Почему поэлементное сложение намного быстрее в отдельных циклах, чем в комбинированном цикле? - PullRequest
2130 голосов
/ 18 декабря 2011

Предположим, 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 Macro:

#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.)

enter image description here

Ответы [ 10 ]

1627 голосов
/ 18 декабря 2011

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

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

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

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

Вот код теста:

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;
}

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

РЕДАКТИРОВАТЬ: Результаты на фактический 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:

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

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

Регион 3:

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

Регион 4:

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

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

Регион 5:

На данный момент в кеш ничего не помещается. Итак, вы ограничены пропускной способностью памяти.


2 x Intel X5482 Harpertown @ 3.2 GHz Intel Core i7 870 @ 2.8 GHz Intel Core i7 2600K @ 4.4 GHz

213 голосов
/ 18 декабря 2011

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

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

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

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

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

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

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

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

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

Enter image description here

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

Enter image description here

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

Enter image description here

Предложение

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

73 голосов
/ 18 декабря 2011

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

44 голосов
/ 18 декабря 2011

Представьте, что вы работаете на машине, где 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 в ОЗУ, а затем будет полностью обработан в ОЗУ. Когда начинается второй цикл, c и d будут загружены с диска в ОЗУ и запущены.

другой цикл

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

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


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

Скажите 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

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

30 голосов
/ 18 декабря 2011

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

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

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

19 голосов
/ 30 декабря 2012

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

Я не знаю, виноват ли плохой тестовый код, или что, но эти два метода находятся в пределах 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 секунду.

16 голосов
/ 18 декабря 2011

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

13 голосов
/ 17 августа 2012

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

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

5 голосов
/ 30 января 2017

Исходный вопрос

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


Вывод:

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

Взгляд наэто из такого подхода без учета того, как аппаратное обеспечение, ОС и компилятор (-ы) работают вместе для выделения кучи, что включает в себя работу с ОЗУ, кэш-памятью, файлами подкачки и т. д .;математика, лежащая в основе этих алгоритмов, показывает нам, какой из этих двух вариантов является лучшим решением.Мы можем использовать аналогию, где Boss или Summation, который будет представлять For Loop, который должен путешествовать между рабочими A & B, мы можем легко увидеть, что Case 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];
}

Соображение

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


Подход

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


Перспектива

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


Что мы знаем

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


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

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


Давайте исследуем

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


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

  • Мы позволим нашему циклу и его итерациям быть суммированием, которое начинается в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 как просто старый Sum, где Sum в этом случае выглядит следующим образом: 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 примерно на 1340 * медленнее, чем 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 сосредоточиться на двух одинаковых вещах одновременно и подтасовывать их взад и вперед вместо того, чтобы концентрироваться на похожих последовательных задачах, к концу дня заставит его разозлиться, потому что он должен был путешествовать и работать вдвое больше. Поэтому не теряйте масштаб ситуации, позволяя вашему боссу попасть в узкое место, поскольку супруга и дети босса этого не оценят.

1 голос
/ 11 июля 2018

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

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

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

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

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...