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

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

const int n = 100000;

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

этот цикл выполняется 10 000 раз через другой внешний 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]

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

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) и кэш процессора. Поэтому, если есть дальнейший интерес, я перефразирую вопрос:

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

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

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

(показывает флоп/с для различных значений n.)

enter image description here

10 ответов


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

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

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

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

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

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: результаты на фактический машина архитектуры ядра 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 секунд С двумя петлями. Это воспроизводит результаты OP именно так.

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

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

Как @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:

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


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


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

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

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

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

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

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

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

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

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

Enter image description here

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

Enter image description here

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

Enter image description here

предложение

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


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


представьте, что вы работаете на машине, где 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];
}

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

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


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

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

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

  • С

    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] и set a[0] = a[0] + b[0] в кэше-теперь есть четыре байта в кэше,a[0], a[1] и b[0], b[1]. Стоимость = 100 + 100.

  • set 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] и set 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] и set c[0] = c[0] + d[0] в кэш. Стоимость = 100 + 100.
  • я подозреваю, что вы начинаете видеть, где я нахожусь идущий.
  • общая стоимость = (100 + 100 + 100 + 100) * 2 = 800

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


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

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

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


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

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

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

я играл с различными схемами, например, поставив задание b[j], d[j] to 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 секунду каждый.


Это потому, что у CPU не так много пропусков кэша (где он должен ждать, пока данные массива придут из чипов RAM). Вам было бы интересно постоянно регулировать размер массивов, чтобы вы превышали размеры


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

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


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

почему один цикл намного медленнее, чем два цикла?


Оценка Проблемы

код операции:

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 о 2 вариантах циклов for и его измененного вопроса к поведению кэшей вместе со многими другими отличными ответами и полезными комментариями; я хотел бы попытаться сделать что-то другое здесь, взяв другой подход к этой ситуации и проблеме.


Подход

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


Точки Зрения

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


Что Мы Знаем

мы знаем, что его цикл будет работать в 100 000 раз. Мы также знаем, что a1, b1, c1 & d1 являются указателями на 64-разрядную архитектуру. В C++ на 32-разрядной машине все указатели имеют размер 4 байта, а на 64-разрядной-8 байт, поскольку указатели имеют фиксированную длину. Мы знаем, что у нас есть 32 байта, в котором выделить в обоих случаях. Единственный разница в том, что мы выделяем 32 байта или 2 набора 2-8bytes на каждой итерации, где во втором случае мы выделяем 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 соответственно и забрать у них какой-то пакет и вернуть его. В аналогии здесь цикл for или суммирование итерации и проверки условий сами по себе фактически не представляют Boss. Что на самом деле представляет собой Boss здесь не из фактических математических алгоритмов напрямую, а из фактического понятия Scope и Code Block в рутине или подпрограмме, методе, функции, блоке перевода и т. д. Первый алгоритм имеет 1 область, где 2-й алгоритм имеет 2 последовательных области.

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

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

так как мы работаем с 8 байтовый указатель и работа с распределением кучи рассмотрим эту проблему здесь. Скажем, что Boss в 100 футах от A и A находится в 500 метрах от C. Нам не нужно беспокоиться о том, как далеко Boss изначально от C из-за порядка казни. В обоих случаях Boss изначально едет из потом 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 миллионов. Теперь это не точно, потому что мы не знаем фактическую разницу в расстоянии между тем, какой адрес ОЗУ или из какого кэша или файла страницы каждый вызов на каждой итерации будет связан со многими другими невидимыми переменными, но это просто оценка ситуации, которую нужно знать и пытаться смотреть на нее из худшего сценария.

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


Замечание: - различия между двумя алгоритмами

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


вывод:

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

так что даже глядя на него с таким подходом без даже в том, как аппаратное обеспечение, ОС и компилятор работают вместе, чтобы делать выделения кучи, которые включают работу с ОЗУ, кэшем, файлами страниц и т. д.; математика, стоящая за этим, уже показывает нам, какое из этих двух является лучшим решением, используя приведенную выше аналогию, где Boss или Summations данный For Loops что пришлось путешествовать между рабочими A & B. Мы можем легко видеть, что корпус 2 по крайней мере, как 1/2 так быстро, если не немного больше, чем случае 1 из-за разницы в пройденном расстоянии и времени. И эта математика почти виртуально и идеально согласуется как со временем, так и с разницей в количестве инструкций по сборке.



в OPs изменен вопрос(ы)

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

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

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


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

как я продемонстрировал без сомнения, существует основная проблема еще до того, как аппаратное и программное обеспечение становится вовлеченным. Теперь что касается управления памятью и кэшированием вместе с файлами страниц и т. д. который все работает вместе в интегрированном наборе систем между: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 to C.

так пытается иметь


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

один цикл: госпожа 1.577 две петли: 1.507 ms

Я запускаю VS2015 на процессоре E5-1620 3.5 Ghz с 16GB ram