Почему поэлементное сложение намного быстрее в отдельных циклах, чем в комбинированном цикле?

2073

Предположим, что a1, b1, c1 и d1 указывают на память кучи, и мой числовой код имеет следующий основной цикл.

const int n = 100000;

for (int j = 0; j < n; j++) {
    a1[j] += b1[j];
    c1[j] += d1[j];
}

Этот цикл выполняется 10000 раз через другой внешний цикл for. Чтобы ускорить его, я изменил код на:

for (int j = 0; j < n; j++) {
    a1[j] += b1[j];
}

for (int j = 0; j < n; j++) {
    c1[j] += d1[j];
}

Скомпилированный на MS Visual C++ 10.0 с полной оптимизацией и включенным SSE2 для 32-разрядных на Intel Core 2 Duo (x64), первый пример занимает 5,5 секунды, а пример с двойной петлей занимает всего 1,9 секунды. Мой вопрос: (Пожалуйста, обратитесь к моему перефразированному вопросу внизу)

PS: я не уверен, если это поможет:

Разборка для первого цикла в основном выглядит следующим образом (этот блок повторяется примерно пять раз в полной программе):

movsd       xmm0,mmword ptr [edx+18h]
addsd       xmm0,mmword ptr [ecx+20h]
movsd       mmword ptr [ecx+20h],xmm0
movsd       xmm0,mmword ptr [esi+10h]
addsd       xmm0,mmword ptr [eax+30h]
movsd       mmword ptr [eax+30h],xmm0
movsd       xmm0,mmword ptr [edx+20h]
addsd       xmm0,mmword ptr [ecx+28h]
movsd       mmword ptr [ecx+28h],xmm0
movsd       xmm0,mmword ptr [esi+18h]
addsd       xmm0,mmword ptr [eax+38h]

Каждый цикл в примере с двойным циклом создает этот код (следующий блок повторяется примерно три раза):

addsd       xmm0,mmword ptr [eax+28h]
movsd       mmword ptr [eax+28h],xmm0
movsd       xmm0,mmword ptr [ecx+20h]
addsd       xmm0,mmword ptr [eax+30h]
movsd       mmword ptr [eax+30h],xmm0
movsd       xmm0,mmword ptr [ecx+28h]
addsd       xmm0,mmword ptr [eax+38h]
movsd       mmword ptr [eax+38h],xmm0
movsd       xmm0,mmword ptr [ecx+30h]
addsd       xmm0,mmword ptr [eax+40h]
movsd       mmword ptr [eax+40h],xmm0

Вопрос оказался неактуальным, так как поведение сильно зависит от размеров массивов (n) и кэша ЦП. Так что, если есть дальнейший интерес, я перефразирую вопрос:

Не могли бы вы дать некоторое подробное представление о деталях, которые приводят к разным поведениям кэша, как показано пятью областями на следующем графике?

Также было бы интересно указать на различия между архитектурами ЦП и кэш-памяти, предоставляя подобный график для этих ЦП.

PPS: вот полный код. Он использует TBB Tick_Count для синхронизации с более высоким разрешением, который можно отключить, не определяя TBB_TIMING:

#include <iostream>
#include <iomanip>
#include <cmath>
#include <string>

//#define TBB_TIMING

#ifdef TBB_TIMING   
#include <tbb/tick_count.h>
using tbb::tick_count;
#else
#include <time.h>
#endif

using namespace std;

//#define preallocate_memory new_cont

enum { new_cont, new_sep };

double *a1, *b1, *c1, *d1;


void allo(int cont, int n)
{
    switch(cont) {
      case new_cont:
        a1 = new double[n*4];
        b1 = a1 + n;
        c1 = b1 + n;
        d1 = c1 + n;
        break;
      case new_sep:
        a1 = new double[n];
        b1 = new double[n];
        c1 = new double[n];
        d1 = new double[n];
        break;
    }

    for (int i = 0; i < n; i++) {
        a1[i] = 1.0;
        d1[i] = 1.0;
        c1[i] = 1.0;
        b1[i] = 1.0;
    }
}

void ff(int cont)
{
    switch(cont){
      case new_sep:
        delete[] b1;
        delete[] c1;
        delete[] d1;
      case new_cont:
        delete[] a1;
    }
}

double plain(int n, int m, int cont, int loops)
{
#ifndef preallocate_memory
    allo(cont,n);
#endif

#ifdef TBB_TIMING   
    tick_count t0 = tick_count::now();
#else
    clock_t start = clock();
#endif

    if (loops == 1) {
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++){
                a1[j] += b1[j];
                c1[j] += d1[j];
            }
        }
    } else {
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                a1[j] += b1[j];
            }
            for (int j = 0; j < n; j++) {
                c1[j] += d1[j];
            }
        }
    }
    double ret;

#ifdef TBB_TIMING   
    tick_count t1 = tick_count::now();
    ret = 2.0*double(n)*double(m)/(t1-t0).seconds();
#else
    clock_t end = clock();
    ret = 2.0*double(n)*double(m)/(double)(end - start) *double(CLOCKS_PER_SEC);
#endif

#ifndef preallocate_memory
    ff(cont);
#endif

    return ret;
}


void main()
{   
    freopen("C:\\test.csv", "w", stdout);

    char *s = " ";

    string na[2] ={"new_cont", "new_sep"};

    cout << "n";

    for (int j = 0; j < 2; j++)
        for (int i = 1; i <= 2; i++)
#ifdef preallocate_memory
            cout << s << i << "_loops_" << na[preallocate_memory];
#else
            cout << s << i << "_loops_" << na[j];
#endif

    cout << endl;

    long long nmax = 1000000;

#ifdef preallocate_memory
    allo(preallocate_memory, nmax);
#endif

    for (long long n = 1L; n < nmax; n = max(n+1, long long(n*1.2)))
    {
        const long long m = 10000000/n;
        cout << n;

        for (int j = 0; j < 2; j++)
            for (int i = 1; i <= 2; i++)
                cout << s << plain(n, m, j, i);
        cout << endl;
    }
}

(Показывает FLOP/s для разных значений n.)

Изображение 29

  • 3
    Это может быть операционная система, которая замедляет при поиске физической памяти каждый раз, когда вы обращаетесь к ней, и имеет что-то вроде кэша в случае вторичного доступа к той же самой блокировке.
  • 7
    Вы компилируете с оптимизацией? Это выглядит как много кода ASM для O2 ...
Показать ещё 14 комментариев
Теги:
performance
vectorization
compiler-optimization

10 ответов

1585
Лучший ответ

При дальнейшем анализе этого, я считаю, что это (по крайней мере частично) вызвано выравниванием данных четырех указателей. Это приведет к некоторому конфликту в кеше/пути.

Если я правильно понял, как вы распределяете свои массивы, они , скорее всего, будут выровнены со строкой страницы.

Это означает, что все ваши обращения в каждом цикле будут попадать в один и тот же кеш файл. Однако процессоры Intel на некоторое время имели 8-стороннюю ассоциативность кэша L1. Но на самом деле производительность не полностью единообразна. Доступ к 4-канальным каналам все еще медленнее, чем двухсторонний.

РЕДАКТИРОВАТЬ: на самом деле это похоже на то, что вы выделяете все массивы отдельно. Обычно, когда запрашиваются такие большие распределения, распределитель запрашивает новые страницы из ОС. Поэтому существует высокая вероятность того, что большие выделения будут отображаться с одинаковым смещением от границы страницы.

Здесь тестовый код:

int main(){
    const int n = 100000;

#ifdef ALLOCATE_SEPERATE
    double *a1 = (double*)malloc(n * sizeof(double));
    double *b1 = (double*)malloc(n * sizeof(double));
    double *c1 = (double*)malloc(n * sizeof(double));
    double *d1 = (double*)malloc(n * sizeof(double));
#else
    double *a1 = (double*)malloc(n * sizeof(double) * 4);
    double *b1 = a1 + n;
    double *c1 = b1 + n;
    double *d1 = c1 + n;
#endif

    //  Zero the data to prevent any chance of denormals.
    memset(a1,0,n * sizeof(double));
    memset(b1,0,n * sizeof(double));
    memset(c1,0,n * sizeof(double));
    memset(d1,0,n * sizeof(double));

    //  Print the addresses
    cout << a1 << endl;
    cout << b1 << endl;
    cout << c1 << endl;
    cout << d1 << endl;

    clock_t start = clock();

    int c = 0;
    while (c++ < 10000){

#if ONE_LOOP
        for(int j=0;j<n;j++){
            a1[j] += b1[j];
            c1[j] += d1[j];
        }
#else
        for(int j=0;j<n;j++){
            a1[j] += b1[j];
        }
        for(int j=0;j<n;j++){
            c1[j] += d1[j];
        }
#endif

    }

    clock_t end = clock();
    cout << "seconds = " << (double)(end - start) / CLOCKS_PER_SEC << endl;

    system("pause");
    return 0;
}

Результаты тестов:

EDIT: результаты на реальной архитектуре архитектуры Core 2:

2 x Intel Xeon X5482 Harpertown @3,2 ГГц:

#define ALLOCATE_SEPERATE
#define ONE_LOOP
00600020
006D0020
007A0020
00870020
seconds = 6.206

#define ALLOCATE_SEPERATE
//#define ONE_LOOP
005E0020
006B0020
00780020
00850020
seconds = 2.116

//#define ALLOCATE_SEPERATE
#define ONE_LOOP
00570020
00633520
006F6A20
007B9F20
seconds = 1.894

//#define ALLOCATE_SEPERATE
//#define ONE_LOOP
008C0020
00983520
00A46A20
00B09F20
seconds = 1.993

замечания:

  • 6.206 секунд с одним циклом и 2.116 секунд с двумя циклами. Это точно воспроизводит результаты ОП.

  • В первых двух тестах массивы выделяются отдельно. Вы заметите, что все они имеют одинаковое выравнивание относительно страницы.

  • Во вторых двух тестах массивы упаковываются вместе, чтобы нарушить это выравнивание. Здесь вы заметите, что оба цикла быстрее. Кроме того, второй (двойной) цикл теперь является более медленным, как вы обычно ожидали.

Как отмечает @Stephen Cannon в комментариях, существует очень вероятная вероятность того, что это выравнивание вызывает ложное сглаживание в единицах загрузки/хранения или кеш. Я задумался об этом и обнаружил, что у Intel на самом деле есть аппаратный счетчик для сглаживания парциальных адресов:

http://software.intel.com/sites/products/documentation/doclib/stdxe/2013/~amplifierxe/pmw_dp/events/partial_address_alias.html


5 Регионы - Пояснения

Регион 1:

Это легко. Набор данных настолько мал, что на производительность преобладают накладные расходы, такие как цикл и ветвление.

Регион 2:

Здесь, когда размеры данных увеличиваются, количество относительных накладных расходов уменьшается, а производительность "насыщается". Здесь два цикла медленнее, потому что он имеет в два раза больше потоков и ветвлений.

Я не уверен, что происходит здесь... Выравнивание все равно может сыграть эффект, поскольку Agner Fog упоминает конфликты банковского кэша. (Эта ссылка касается Sandy Bridge, но идея должна быть применима к Core 2.)

Регион 3:

В этот момент данные больше не вписываются в кеш-память L1. Таким образом, производительность ограничена полосой пропускания L1 ↔ L2.

Регион 4:

Снижение производительности в одном цикле - это то, что мы наблюдаем. И, как уже упоминалось, это связано с выравниванием, которое (скорее всего) вызывает блокировки ложных псевдонимов в блоках загрузки/хранения процессора.

Однако для того, чтобы возникло ложное сглаживание, между наборами данных должен быть достаточно большой шаг. Вот почему вы не видите это в регионе 3.

Регион 5:

В этот момент ничего не помещается в кеш. Таким образом, вы связаны пропускной способностью памяти.


Изображение 1165Изображение 1166Изображение 1167

  • 152
    +1: я думаю, что это ответ. Вопреки тому, что говорят все остальные ответы, речь идет не о варианте с одним циклом, имеющим больше пропусков кэша, а о конкретном выравнивании массивов, вызывающих ошибки кеша.
  • 28
    Это; ложный псевдоним киоск является наиболее вероятным объяснением.
Показать ещё 33 комментария
193

ОК, правильный ответ определенно должен что-то сделать с кешем процессора. Но использовать аргумент кеша может быть довольно сложным, особенно без данных.

Есть много ответов, которые привели к большому дискуссию, но пусть сталкиваются с этим: проблемы с кешем могут быть очень сложными и не одномерными. Они сильно зависят от размера данных, поэтому мой вопрос был несправедливым: он оказался очень интересным в графике кэша.

@Мистический ответ убедил многих людей (включая меня), вероятно, потому, что он был единственным, который, казалось, полагался на факты, но это была только одна "точка данных" истины.

Вот почему я объединил его тест (используя непрерывное или отдельное распределение) и ответ @James "Ответ".

На приведенных ниже графиках показано, что большинство ответов и особенно большинство комментариев к вопросу и ответам могут считаться полностью неправильными или истинными в зависимости от конкретного сценария и используемых параметров.

Обратите внимание, что мой первоначальный вопрос был n = 100.000. Эта точка (случайно) проявляет особое поведение:

  • Он обладает наибольшим несоответствием между одной и двумя версиями цикла (почти в три раза)

  • Это единственная точка, где однопетная (а именно, непрерывное распределение) превосходит двухпетлевую версию. (Это сделало возможным Mystical ответ).

Результат с использованием инициализированных данных:

Изображение 1168

Результат с использованием неинициализированных данных (это то, что тестировалось Mystical):

Изображение 1169

И это трудно объяснить: Инициализированные данные, которые выделяются один раз и повторно используются для каждого следующего тестового примера с разным размером вектора:

Изображение 1170

Предложение

Каждый низкоуровневый вопрос, связанный с производительностью, на Qaru должен быть необходим для предоставления информации MFLOPS для всего диапазона размеров данных, соответствующих кешу! Это пустая трата времени для того, чтобы думать о ответах и ​​особенно обсуждать их с другими без этой информации.

  • 17
    +1 Хороший анализ. Я не собирался оставлять данные неинициализированными. Просто так получилось, что распределитель все равно обнулел их. Таким образом, инициализированные данные имеют значение. Я только что отредактировал свой ответ с результатами на реальной машине с архитектурой Core 2, и они намного ближе к тому, что вы наблюдаете. Другое дело, что я протестировал диапазон размеров n и он показывает тот же разрыв производительности для n = 80000, n = 100000, n = 200000 и т. Д.
  • 2
    @Mysticial Я думаю, что ОС внедряет обнуление страниц при каждой передаче новых страниц процессу, чтобы избежать возможного межпроцессного шпионажа.
69

Второй цикл включает в себя намного меньше активности кэша, поэтому процессору легче поддерживать требования к памяти.

  • 1
    Вы говорите, что во втором варианте меньше кешей? Зачем?
  • 2
    @Oli: В первом варианте процессору необходимо одновременно получить доступ к четырем строкам памяти: a[i] , b[i] , c[i] и d[i] Во втором варианте ему нужно всего две. Это делает гораздо более жизнеспособным пополнение этих строк при добавлении.
Показать ещё 13 комментариев
43

Представьте, что вы работаете на машине, где n было правильным значением, чтобы можно было одновременно хранить два ваших массива в памяти, но общего объема доступной памяти посредством кэширования на диске все еще было достаточно для хранения всех четырех.

Предполагая простую политику кэширования LIFO, этот код:

for(int j=0;j<n;j++){
    a[j] += b[j];
}
for(int j=0;j<n;j++){
    c[j] += d[j];
}

будет сначала вызывать загрузку a и b в RAM, а затем работать полностью в RAM. Когда начинается второй цикл, c и d затем загружаются с диска в RAM и работают.

другой цикл

for(int j=0;j<n;j++){
    a[j] += b[j];
    c[j] += d[j];
}

будет выводить два массива и страницу в двух других каждый раз вокруг цикла. Это, очевидно, будет намного медленнее.

Вы, вероятно, не видите кеширование диска в своих тестах, но вы, вероятно, видите побочные эффекты какой-либо другой формы кеширования.


Кажется, здесь есть небольшая путаница/недоразумение, поэтому я попытаюсь немного уточнить на примере.

Скажите n = 2 и мы работаем с байтами. Таким образом, в моем сценарии у нас всего 4 байта оперативной памяти, а остальная часть памяти значительно медленнее (скажем, в 100 раз больше доступа).

Предполагая довольно глупую политику кеширования, если байт не находится в кеше, поместите его туда и получите следующий байт, пока мы на нем, вы получите сценарий примерно такой:

  • С

    for(int j=0;j<n;j++){
     a[j] += b[j];
    }
    for(int j=0;j<n;j++){
     c[j] += d[j];
    }
    
  • кэшируем a[0] и a[1] затем b[0] и b[1] и устанавливаем a[0] = a[0] + b[0] в кэш - теперь в кэше четыре байта, a[0], a[1] и b[0], b[1]. Стоимость = 100 + 100.

  • установить a[1] = a[1] + b[1] в кеше. Стоимость = 1 + 1.
  • Повторите для c и d.
  • Общая стоимость = (100 + 100 + 1 + 1) * 2 = 404

  • С

    for(int j=0;j<n;j++){
     a[j] += b[j];
     c[j] += d[j];
    }
    
  • кэшируем a[0] и a[1] затем b[0] и b[1] и устанавливаем a[0] = a[0] + b[0] в кэш - теперь в кэше четыре байта, a[0], a[1] и b[0], b[1]. Стоимость = 100 + 100.

  • извлеките a[0], a[1], b[0], b[1] из кеша и кеша c[0] и c[1] затем d[0] и d[1] и установите c[0] = c[0] + d[0] в кеше. Стоимость = 100 + 100.
  • Я подозреваю, что вы начинаете видеть, куда я иду.
  • Общая стоимость = (100 + 100 + 100 + 100) * 2 = 800

Это классический сценарий трэша кеша.

  • 8
    Это неверно Ссылка на определенный элемент массива не приводит к тому, что весь массив выгружается с диска (или из не кэшированной памяти); только соответствующая страница или строка кэша выгружаются.
  • 1
    @ Брукс Моисей - если вы пройдете весь массив, как это происходит здесь, то это произойдет.
Показать ещё 6 комментариев
28

Это не из-за другого кода, а из-за кэширования: RAM медленнее, чем регистры процессора, а кэш-память находится внутри ЦП, чтобы избежать записи ОЗУ каждый раз, когда изменяется переменная. Но кеш невелик, так как RAM, следовательно, он отображает только его часть.

Первый код изменяет адреса удаленной памяти, чередуя их в каждом цикле, тем самым требуя постоянно отбрасывать кеш.

Второй код не чередуется: он просто перемещается по смежным адресам дважды. Это заставляет все задания быть завершенными в кеше, аннулируя его только после запуска второго цикла.

  • 0
    Почему это приводит к тому, что кэш постоянно становится недействительным?
  • 1
    @OliCharlesworth: думайте о кеше как о твердой копии непрерывного диапазона адресов памяти. Если вы притворяетесь, что обращаетесь к адресу, не являющемуся их частью, вам необходимо перезагрузить кэш. И если что-то в кеше было изменено, оно должно быть записано обратно в ОЗУ, иначе оно будет потеряно. В примере кода 4 вектора из 100 000 целых чисел (400 КБ), скорее всего, больше, чем емкость кэша L1 (128 или 256 КБ).
Показать ещё 5 комментариев
18

Я не могу повторить результаты, обсуждаемые здесь.

Я не знаю, виноват ли плохой тестовый код, или что, но эти два метода находятся в пределах 10% друг от друга на моей машине, используя следующий код, и один цикл обычно немного быстрее, чем два - как вы ожидать.

Размеры массивов варьировались от 2 ^ 16 до 2 ^ 24 с использованием восьми циклов. Я был осторожен, чтобы инициализировать исходные массивы, чтобы присвоение += не просило FPU добавить мусор памяти, интерпретируемый как double.

Я поиграл с различными схемами, такими как помещение присваивания b[j], d[j] для InitToZero[j] внутри циклов, а также с использованием += b[j] = 1 и += d[j] = 1, и я получил довольно последовательные результаты.

Как и следовало ожидать, инициализация b и d внутри цикла с использованием InitToZero[j] дала комбинированному подходу преимущество, так как они выполнялись вплотную перед присвоением a и c, но все еще в пределах 10%. Пойди разберись.

Аппаратное обеспечение - Dell XPS 8500 с процессором 3 Core i7 @3,4 ГГц и 8 ГБ памяти. Для 2 ^ 16 до 2 ^ 24, используя восемь циклов, совокупное время было 44,987 и 40,965 соответственно. Visual C++ 2010, полностью оптимизирован.

PS: я изменил циклы для обратного отсчета до нуля, и комбинированный метод был немного быстрее. Почесывая голову Обратите внимание на новый размер массива и количество циклов.

// MemBufferMystery.cpp : Defines the entry point for the console application.
//
#include "stdafx.h"
#include <iostream>
#include <cmath>
#include <string>
#include <time.h>

#define  dbl    double
#define  MAX_ARRAY_SZ    262145    //16777216    // AKA (2^24)
#define  STEP_SZ           1024    //   65536    // AKA (2^16)

int _tmain(int argc, _TCHAR* argv[]) {
    long i, j, ArraySz = 0,  LoopKnt = 1024;
    time_t start, Cumulative_Combined = 0, Cumulative_Separate = 0;
    dbl *a = NULL, *b = NULL, *c = NULL, *d = NULL, *InitToOnes = NULL;

    a = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    b = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    c = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    d = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    InitToOnes = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    // Initialize array to 1.0 second.
    for(j = 0; j< MAX_ARRAY_SZ; j++) {
        InitToOnes[j] = 1.0;
    }

    // Increase size of arrays and time
    for(ArraySz = STEP_SZ; ArraySz<MAX_ARRAY_SZ; ArraySz += STEP_SZ) {
        a = (dbl *)realloc(a, ArraySz * sizeof(dbl));
        b = (dbl *)realloc(b, ArraySz * sizeof(dbl));
        c = (dbl *)realloc(c, ArraySz * sizeof(dbl));
        d = (dbl *)realloc(d, ArraySz * sizeof(dbl));
        // Outside the timing loop, initialize
        // b and d arrays to 1.0 sec for consistent += performance.
        memcpy((void *)b, (void *)InitToOnes, ArraySz * sizeof(dbl));
        memcpy((void *)d, (void *)InitToOnes, ArraySz * sizeof(dbl));

        start = clock();
        for(i = LoopKnt; i; i--) {
            for(j = ArraySz; j; j--) {
                a[j] += b[j];
                c[j] += d[j];
            }
        }
        Cumulative_Combined += (clock()-start);
        printf("\n %6i miliseconds for combined array sizes %i and %i loops",
                (int)(clock()-start), ArraySz, LoopKnt);
        start = clock();
        for(i = LoopKnt; i; i--) {
            for(j = ArraySz; j; j--) {
                a[j] += b[j];
            }
            for(j = ArraySz; j; j--) {
                c[j] += d[j];
            }
        }
        Cumulative_Separate += (clock()-start);
        printf("\n %6i miliseconds for separate array sizes %i and %i loops \n",
                (int)(clock()-start), ArraySz, LoopKnt);
    }
    printf("\n Cumulative combined array processing took %10.3f seconds",
            (dbl)(Cumulative_Combined/(dbl)CLOCKS_PER_SEC));
    printf("\n Cumulative seperate array processing took %10.3f seconds",
        (dbl)(Cumulative_Separate/(dbl)CLOCKS_PER_SEC));
    getchar();

    free(a); free(b); free(c); free(d); free(InitToOnes);
    return 0;
}

Я не уверен, почему было решено, что MFLOPS был релевантным показателем. Хотя идея заключалась в том, чтобы сосредоточиться на доступе к памяти, поэтому я попытался минимизировать время вычислений с плавающей запятой. Я ушел в +=, но я не уверен, почему.

Прямое назначение без вычислений было бы более точным тестом времени доступа к памяти и создало бы тест, который был бы равномерным независимо от числа циклов. Может быть, я что-то упустил в разговоре, но об этом стоит подумать дважды. Если плюс не входит в задание, совокупное время почти одинаково и составляет 31 секунда.

  • 1
    Штраф за несоосность, о котором вы здесь упоминаете, - это когда смещенная загрузка отдельного хранилища / хранилища (включая невыгруженную загрузку / хранилище SSE). Но это не тот случай, так как производительность чувствительна к относительному выравниванию различных массивов. На уровне инструкций нет смещений. Каждый отдельный груз / магазин правильно выровнен.
15

Это потому, что у процессора не так много промахов в кеше (где ему приходится ждать, когда данные массива будут поступать из чипов RAM). Было бы интересно настроить размер массивов постоянно, чтобы вы превышали размеры кеша уровня 1 (L1), а затем кеш уровня 2 (L2), вашего процессора и рассчитайте время, затрачиваемое на выполнение кода, против размеров массивов. Граф не должен быть прямой, как и следовало ожидать.

  • 2
    Я не верю, что существует какое-либо взаимодействие между размером кеша и размером массива. Каждый элемент массива используется только один раз и может быть безопасно удален. Впрочем, вполне возможно взаимодействие между размером строки кэша и размером массива, если это приводит к конфликту четырех массивов.
13

Первый цикл чередует запись в каждой переменной. Второй и третий только делают небольшие прыжки размера элемента.

Попробуйте написать две параллельные линии из 20 крестов с ручкой и листом, разделенными 20 сантиметрами. Попробуйте один раз закончить одну, а затем другую линию и попробуйте еще раз, нажимая крест в каждой строке попеременно.

5

Оригинальный вопрос

Почему один цикл намного медленнее двух?


Заключение:

Случай 1 - это классическая проблема интерполяции, которая оказывается неэффективной. Я также думаю, что это было одной из главных причин того, почему многие машинные архитектуры и разработчики закончили создавать и проектировать многоядерные системы с возможностью выполнения многопоточных приложений, а также параллельного программирования.

Рассматривая это с помощью такого подхода, не затрагивая, как аппаратное обеспечение, ОС и компилятор (-ы) работают вместе для выделения кучи, которая включает в себя работу с ОЗУ, кэш-памятью, файлами подкачки и т. Д.; математика, лежащая в основе этих алгоритмов, показывает нам, какой из этих двух вариантов является лучшим решением. Мы можем использовать аналогию, где Boss или Summation, что будет представлять собой For Loop, который должен перемещаться между рабочими A & B, мы можем легко увидеть, что случай 2, по крайней мере 1/2, как быстро, если не немного больше, чем случай 1 из - за разница в расстоянии, необходимом для поездки, и времени, затрачиваемом между рабочими. Эта математика почти виртуально и идеально соответствует как Bench Mark Times, так и разнице в инструкциях по сборке.

Теперь я начну объяснять, как все это работает ниже.


Оценивая проблему

Код ОП:

const int n=100000;

for(int j=0;j<n;j++){
    a1[j] += b1[j];
    c1[j] += d1[j];
}

А также

for(int j=0;j<n;j++){
    a1[j] += b1[j];
}
for(int j=0;j<n;j++){
    c1[j] += d1[j];
}

Рассмотрение

Рассматривая оригинальный вопрос OP о двух вариантах циклов for и его исправленный вопрос о поведении кэшей, а также множество других превосходных ответов и полезных комментариев; Я хотел бы попытаться сделать что-то другое здесь, используя другой подход к этой ситуации и проблеме.


Подход

Учитывая два цикла и все дискуссии о кэшировании и хранении страниц, я бы хотел использовать другой подход, чтобы взглянуть на это с другой точки зрения. Тот, который не включает кеш и файлы подкачки, ни выполнения для выделения памяти, на самом деле этот подход вообще не касается реального оборудования или программного обеспечения.


Перспектива

Посмотрев некоторое время на код, стало совершенно очевидно, в чем проблема и что ее генерирует. Давайте разберем это в алгоритмической задаче и рассмотрим ее с точки зрения использования математических обозначений, а затем применим аналогию к математическим задачам, а также к алгоритмам.


Что мы знаем

Мы знаем, что его цикл будет выполняться 100 000 раз. Мы также знаем, что a1, b1, c1 и d1 являются указателями на 64-битную архитектуру. В C++ на 32-битной машине все указатели имеют размер 4 байта, а на 64-битной машине они имеют размер 8 байтов, поскольку указатели имеют фиксированную длину. Мы знаем, что у нас есть 32 байта для выделения в обоих случаях. Единственное отличие состоит в том, что мы выделяем 32 байта или 2 набора по 2-8 байт на каждую итерацию, тогда как во 2-м случае мы выделяем 16 байтов для каждой итерации для обоих независимых циклов. Таким образом, оба цикла по-прежнему равны 32 байта в общем распределении. Получив эту информацию, давайте продолжим и покажем общую математику, алгоритм и аналогию. Мы знаем, сколько раз один и тот же набор или группа операций должны быть выполнены в обоих случаях. Мы знаем объем памяти, который должен быть выделен в обоих случаях. Мы можем оценить, что общая рабочая нагрузка распределений между обоими случаями будет примерно одинаковой.


Что мы не знаем

Мы не знаем, сколько времени это займет для каждого случая, если только мы не установим счетчик и не проведем тест. Однако контрольные показатели уже были включены в исходный вопрос, а также в некоторые ответы и комментарии, и мы можем видеть существенную разницу между этими двумя вопросами, и в этом заключается полное обоснование этого вопроса для этой проблемы и для ответа на него в начинается с.


Пусть расследовать

Уже очевидно, что многие уже сделали это, взглянув на распределение кучи, тесты производительности, на RAM, Cache и Page Files. Рассмотрение конкретных точек данных и конкретных итерационных индексов также было включено, и различные разговоры об этой конкретной проблеме заставили многих людей начать сомневаться в других связанных с этим вещах. Итак, как нам начать смотреть на эту проблему, используя математические алгоритмы и применяя к ней аналогию? Начнем с того, что сделаем пару утверждений! Затем мы строим наш алгоритм оттуда.


Наши утверждения:

  • Мы позволим нашему циклу и его итерациям быть суммированием, которое начинается с 1 и заканчивается на 100000 вместо того, чтобы начинаться с 0, как в циклах, поскольку нам не нужно беспокоиться о схеме индексации адресации памяти 0, так как нас просто интересует сам алгоритм.
  • В обоих случаях у нас есть 4 функции для работы и 2 вызова функций с 2 операциями, выполняемыми для каждого вызова функции. Таким образом, мы установим их как функции и вызовы функций, чтобы быть F1(), F2(), f(a), f(b), f(c) и f(d).

Алгоритмы:

1-й случай: - только одно суммирование, но два независимых вызова функций.

Sum n=1 : [1,100000] = F1(), F2();
                       F1() = { f(a) = f(a) + f(b); }
                       F2() = { f(c) = f(c) + f(d);  }

2-й случай: - Два суммирования, но у каждого свой вызов функции.

Sum1 n=1 : [1,100000] = F1();
                        F1() = { f(a) = f(a) + f(b); }

Sum2 n=1 : [1,100000] = F1();
                        F1() = { f(c) = f(c) + f(d); }

Если вы заметили, что F2() существует только в Sum где и Sum1 и Sum2 содержат только F1(). Это также станет очевидным позже, когда мы начнем делать вывод, что со вторым алгоритмом происходит своего рода оптимизация.

Итерации в первом случае Sum вызывает f(a), который прибавит к самому себе f(b) затем вызовет f(c), который сделает то же самое, но добавит f(d) к себе для каждых 100000 iterations. Во втором случае мы имеем Sum1 и Sum2 и оба действуют одинаково, как если бы они были одной и той же функцией, вызываемой дважды подряд. В этом случае мы можем рассматривать Sum1 и Sum2 как просто старую Sum2 Sum где Sum в этом случае выглядит следующим образом: Sum n=1: [1,100000] { f(a) = f(a) + f(b); } Sum n=1: [1,100000] { f(a) = f(a) + f(b); } и теперь это выглядит как оптимизация, где мы можем просто считать, что это та же самая функция.


Резюме с аналогией

С тем, что мы видели во втором случае, это выглядит почти так, как будто есть оптимизация, так как оба цикла имеют одинаковую точную сигнатуру, но это не настоящая проблема. Проблема не в работе, которую выполняют f(a), f(b), f(c) и f(d) в обоих случаях, а в сравнении между ними, а в разнице в расстоянии суммирования должен путешествовать в обоих случаях, что дает вам разницу во времени исполнения.

Представьте, что For Loops - это Summations, выполняющие итерации, как Boss который отдает приказы двум людям A & B и что их работа заключается в том, чтобы добывать C & D соответственно, а также собирать с них какую-то посылку и возвращать ее. В аналогии здесь сами итерации цикла или суммирования и проверки условий на самом деле не представляют Boss. То, что на самом деле представляет Boss здесь не непосредственно из фактических математических алгоритмов, а из фактической концепции Scope и Code Block внутри подпрограммы или подпрограммы, метода, функции, единицы перевода и т.д. Первый алгоритм имеет 1 область действия, где 2-й алгоритм имеет 2 последовательных области действия.

В первом случае при каждом вызове Boss идет к A и отдает приказ, а A уходит, чтобы получить пакет B's затем Boss идет к C и отдает приказы сделать то же самое и получать пакет от D на каждой итерации.

Во втором случае Boss работает напрямую с A чтобы пойти и получить пакет B's пока все пакеты не будут получены. Тогда Boss работает с C, чтобы сделать то же самое для получения всех D's пакетов.

Поскольку мы работаем с 8-байтовым указателем и занимаемся распределением кучи, давайте рассмотрим эту проблему здесь. Допустим, Boss находится в 100 футах от A а A - в 500 футах от C Нам не нужно беспокоиться о том, как далеко Boss изначально от C из-за порядка выполнения. В обоих случаях Boss сначала путешествует из A затем в B Эта аналогия не говорит о том, что это расстояние является точным; это всего лишь сценарий использования теста, чтобы показать работу алгоритмов. Во многих случаях при распределении кучи и работе с кешем и файлами подкачки эти расстояния между адресами могут не сильно различаться или могут очень сильно зависеть от характера типов данных и размеров массива.


Тестовые случаи:

Первый случай: на первой итерации Boss должен сначала пройти 100 футов, чтобы отдать ордер A и A уходит и делает свое дело, но затем Boss должен пройти 500 футов к C чтобы дать ему ордер. Затем на следующей итерации и на каждой другой итерации после того, как Boss должен пройти назад и вперед 500 футов между ними.

Второй случай: The Boss должен пройти 100 футов на первой итерации к A, но после этого он уже там и просто ждет, пока A вернется, пока не будут заполнены все промахи. Затем Boss должен пройти 500 футов на первой итерации до C потому что C находится в 500 футах от A так как этот Boss( Summation, For Loop ) вызывается сразу после работы с A а затем просто ждет, как он сделал с A пока все C's сделаны.


Разница в пройденных расстояниях

const n = 100000
distTraveledOfFirst = (100 + 500) + ((n-1)*(500 + 500); 
// Simplify
distTraveledOfFirst = 600 + (99999*100);
distTraveledOfFirst = 600 + 9999900;
distTraveledOfFirst =  10000500;
// Distance Traveled On First Algorithm = 10,000,500ft

distTraveledOfSecond = 100 + 500 = 600;
// Distance Traveled On Second Algorithm = 600ft;    

Сравнение произвольных значений

Мы легко видим, что 600 - это гораздо меньше, чем 10 миллионов. Теперь это не точно, потому что мы не знаем фактической разницы в расстоянии между тем, какой адрес ОЗУ или из какого Cache или файла подкачки каждый вызов на каждой итерации будет вызван многими другими невидимыми переменными, но это просто оценка ситуации, которую нужно знать, и попытка взглянуть на нее с наихудшего сценария.

Таким образом, по этим числам это будет выглядеть так, как будто Алгоритм Один должен быть на 99% медленнее, чем Алгоритм Два; однако, это только часть или ответственность The Boss's за алгоритмы, и она не учитывает фактических работников A, B, C и D а также то, что они должны делать на каждой итерации цикла. Таким образом, работа боссов составляет только около 15-40% всей выполняемой работы. Таким образом, основная часть работы, выполняемой рабочими, оказывает чуть большее влияние на поддержание соотношения разности скоростей примерно до 50-70%.


Наблюдение: - Различия между двумя алгоритмами

В этой ситуации это структура процесса выполняемой работы, и он показывает, что вариант 2 более эффективен как при частичной оптимизации, так и при наличии аналогичного объявления функции и определения, где только переменные различаются по имени., И мы также видим, что общее расстояние, пройденное в случае 1, намного больше, чем в случае 2, и мы можем считать это расстояние пройденным нашим Фактором времени между двумя алгоритмами. Дело 1 требует гораздо больше работы, чем дело 2. Это также было замечено в доказательстве ASM которое было показано между обоими случаями. Даже с учетом того, что уже было сказано об этих случаях, это также не учитывает того факта, что в случае 1 боссу придется ждать, пока оба A & C вернутся, прежде чем он сможет снова вернуться к A на следующей итерации и это также не учитывает тот факт, что если A или B отнимает слишком много времени, то и Boss и другие работники также ожидают простоя. В случае 2 только один бездействует - Boss пока рабочий не вернется. Так что даже это влияет на алгоритм.



Измененные вопросы ОП

РЕДАКТИРОВАТЬ: Вопрос оказался неактуальным, так как поведение сильно зависит от размеров массивов (n) и кэш-памяти ЦП. Так что, если есть дальнейший интерес, я перефразирую вопрос:

Не могли бы вы дать некоторое подробное представление о деталях, которые приводят к разным поведениям кэша, как показано пятью областями на следующем графике?

Также было бы интересно указать на различия между архитектурами ЦП и кэш-памяти, предоставляя подобный график для этих ЦП.


Относительно этих вопросов

Без сомнения, я продемонстрировал, что существует проблема, лежащая в основе еще до того, как аппаратное и программное обеспечение подключается. Теперь что касается управления памятью и кэшированием вместе с файлами подкачки и т.д., Которые все работают вместе в интегрированном наборе систем между: The Architecture {Аппаратное обеспечение, Прошивка, некоторые встроенные драйверы, Ядра и Наборы инструкций ASM}, The OS {Файл и системы управления памятью, драйверы и реестр}, The Compiler {единицы перевода и оптимизации исходного кода} и даже сам Source Code с его набором (-ами) отличительных алгоритмов; мы уже можем видеть, что в первом алгоритме есть узкое место, прежде чем мы даже применим его к любой машине с любой произвольной Architecture, OS и Programmable Language по сравнению со вторым алгоритмом. Таким образом, уже существовала проблема, прежде чем задействовать внутренние особенности современного компьютера.


Конечные результаты

Тем не мение; Нельзя сказать, что эти новые вопросы не важны, потому что они сами по себе и играют роль в конце концов. Они действительно влияют на процедуры и общую эффективность, и это видно из различных графиков и оценок от многих, кто дал свой ответ и/или комментарий (ы). Если вы обратите внимание на аналогию с Boss и двумя работниками A & B которые должны были пойти и получить пакеты из C & D соответственно, и с учетом математических обозначений этих двух алгоритмов, вы можете увидеть, что даже без участия Компьютерный Case 2 примерно на 60% быстрее, чем Case 1 и когда вы смотрите на графики и диаграммы после того, как эти алгоритмы были применены к исходному коду, скомпилированы и оптимизированы и выполнены через ОС для выполнения операций с данным оборудованием, вы даже немного видите большее ухудшение различий в этих алгоритмах.

Теперь, если набор "данных" довольно мал, на первый взгляд может показаться, что разница не такая уж и плохая, но поскольку Case 1 примерно на 60 - 70% медленнее, чем Case 2 мы можем рассматривать рост этой функции как Различия во времени исполнения:

DeltaTimeDifference approximately = Loop1(time) - Loop2(time)
//where 
Loop1(time) = Loop2(time) + (Loop2(time)*[0.6,0.7]) // approximately
// So when we substitute this back into the difference equation we end up with 
DeltaTimeDifference approximately = (Loop2(time) + (Loop2(time)*[0.6,0.7])) - Loop2(time)
// And finally we can simplify this to
DeltaTimeDifference approximately = [0.6,0.7]*(Loop2(time)

И это приближение является средней разницей между этими двумя циклами как алгоритмически, так и машинными операциями, включающими оптимизацию программного обеспечения и машинные инструкции. Поэтому, когда набор данных растет линейно, увеличивается и разница во времени между ними. Алгоритм 1 имеет больше выборок, чем алгоритм 2, что очевидно, когда Boss должен был пройти назад и вперед максимальное расстояние между A & C для каждой итерации после первой итерации, в то время как Алгоритм 2 Boss должен был пройти к A один раз, а затем после того, как это было сделано с A он должен был проехать максимальное расстояние только один раз при переходе от A к C

Поэтому попытка заставить Boss сосредоточиться на двух одинаковых вещах одновременно и жонглировать ими взад и вперед вместо того, чтобы сосредоточиться на похожих последовательных задачах, к концу дня рассердит его, потому что ему пришлось путешествовать и работать вдвое больше., Поэтому не теряйте масштаб ситуации, позволяя вашему боссу попасть в интерполированное узкое место, потому что супруга и дети босса этого не оценят.

  • 0
    Прошло много времени с тех пор, как я опубликовал этот ответ, но я также хотел добавить краткий комментарий, который также может помочь понять это: в моей аналогии с Боссом в качестве цикла for или для суммирования или итераций в цикле, мы могли бы также Рассмотрим этого босса как комбинацию между Stack Frame и Stack Pointer, который управляет областью видимости, переменными стека и адресацией памяти для циклов for.
  • 0
    @PeterMortensen Я учел ваш совет, слегка изменив свой первоначальный ответ. Я считаю, что это то, что вы предлагали.
1

Это может быть старый C++ и оптимизации. На моем компьютере я получил почти такую же скорость:

Один цикл: 1,577 мс

Два цикла: 1,507 мс

Я использую Visual Studio 2015 на процессоре E5-1620 с частотой 3,5 ГГц и 16 ГБ ОЗУ.

Ещё вопросы

Сообщество Overcoder
Наверх
Меню