Чому доповнення з елементами набагато швидше в окремих циклах, ніж у комбінованому циклі?


2246

Припустимо 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) та кешу CPU. Тож якщо є подальший інтерес, я перефразую питання:

Чи можете ви надати глибоке розуміння деталей, які призводять до різної поведінки кешу, як це проілюстровано п'ятьма регіонами на наступному графіку?

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

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

введіть тут опис зображення


4
Можливо, це операційна система, яка сповільнюється під час пошуку фізичної пам'яті кожного разу, коли ви отримуєте доступ до неї, і має щось на зразок кешу у разі вторинного доступу до того ж мемблоку.
AlexTheo

7
Ви збираєтесь з оптимізаціями? Це виглядає як багато ASM-коду для O2 ...
Luchian Grigore

1
Я запитав, що, схоже, було подібним питанням деякий час тому. Це може відповідати цікавою інформацією.
Марк Вілкінс

61
Ці два фрагменти коду не є еквівалентними через потенційно перекриваються покажчики. C99 має restrictключове слово для таких ситуацій. Я не знаю, чи є у MSVC щось подібне. Звичайно, якби це було проблемою, то код SSE був би невірним.
user510306

8
Це може мати щось спільне з псевдонімом пам'яті. Один цикл d1[j]може мати псевдонім a1[j], тому компілятор може відступити від певних оптимізацій пам'яті. Хоча цього не відбувається, якщо ви розділите записи на пам'ять у дві петлі.
rturrado

Відповіді:


1690

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

Якщо я правильно здогадався, як ви розподіляєте масиви, вони , ймовірно, будуть вирівняні до рядка сторінки .

Це означає, що всі ваші звернення в кожному циклі будуть падати в один і той же спосіб кешу. Однак процесори Intel деякий час мали 8-сторону кеш-пам'ять L1. Але насправді вистава не є цілком однаковою. Доступ до 4-х способів все-таки повільніше, ніж, наприклад, двосторонній.

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: Результати на фактичній машині архітектури 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:

У цей момент нічого не вкладається в кеш. Таким чином, ви пов'язані пропускною здатністю пам'яті.


2 x Intel X5482 Harpertown @ 3,2 ГГц Intel Core i7 870 при 2,8 ГГц Intel Core i7 2600K @ 4,4 ГГц


162
+1: Я думаю, що це відповідь. На противагу тому, що всі інші відповіді говорять, мова йде не про варіант одного циклу, який по суті має більше пропусків кешу, а про певне вирівнювання масивів, що викликає промахи кешу.
Олівер Чарлсворт

30
Це; помилкове згладжування стійло є найбільш вірогідним поясненням.
Стівен Канон

7
@VictorT. Я використовував код, з яким пов'язаний ОП. Він генерує .css файл, який я можу відкрити в Excel і зробити з нього графік.
Містичний

5
@Nawaz Сторінка зазвичай становить 4 КБ. Якщо ви подивитеся на шістнадцяткові адреси, які я роздруковую, всі окремо виділені тести мають однаковий модуль 4096. (тобто 32-байт від початку межі 4КБ) Можливо, GCC не має такої поведінки. Це може пояснити, чому ви не бачите відмінностей.
Містичний


224

Гаразд, правильна відповідь, безумовно, повинна щось робити з кешем процесора. Але використовувати аргумент кеша може бути досить складно, особливо без даних.

Є багато відповідей, які призвели до безлічі дискусій, але давайте вирішимо: питання кешу можуть бути дуже складними і не є одновимірними. Вони сильно залежать від розміру даних, тому моє запитання було несправедливим: воно виявилося в дуже цікавій точці графіка кешу.

@ Відповідь Містікаля переконала багато людей (включаючи мене), ймовірно, тому, що вона була єдиною, яка, здавалося, покладалася на факти, але це була лише одна «точка даних» істини.

Ось чому я поєднав його тест (використовуючи безперервне та окреме розподілення) та поради @James 'Answer.

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

Зауважте, що моє початкове запитання було на n = 100.000 . Цей момент (випадково) проявляє особливу поведінку:

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

  2. Це єдиний момент, коли одноконтур (а саме при безперервному розподілі) б'є двоконтурну версію. (Це взагалі зробило можливою відповідь Містікаля.)

Результат з використанням ініціалізованих даних:

Введіть тут опис зображення

Результат із використанням неініціалізованих даних (це те, що перевірено Mysticial):

Введіть тут опис зображення

І це важко пояснити: ініціалізовані дані, що виділяються один раз і повторно використовуються для кожного наступного тестового випадку різного розміру вектора:

Введіть тут опис зображення

Пропозиція

Кожне запитання щодо низької продуктивності щодо переповнення стека повинно вимагати надання інформації MFLOPS для всього діапазону відповідних розмірів кешу! Даремно кожен витрачає час на думки про відповіді та особливо на їх обговорення з іншими без цієї інформації.


18
+1 Хороший аналіз. Я не збирався в першу чергу залишати дані неініціалізованими. Просто так сталося, що розподільник все одно їх нулював. Тож важливими є ініціалізовані дані. Я щойно відредагував свою відповідь результатами на фактичній машині архітектури Core 2, і вони набагато ближче до того, що ви спостерігаєте. Інша справа, що я протестував діапазон розмірів, nі він показує однаковий розрив у продуктивності n = 80000, n = 100000, n = 200000тощо.
Mysticial

2
@Mysticial Я думаю, що ОС реалізовує нульову сторінку, коли надає нові сторінки в процес, щоб уникнути можливого міжпроцесорного шпигування.
v.oddou

1
@ v.oddou: поведінка залежить і від ОС; IIRC, у Windows є потік для фонових звільнених сторінок, які не виводяться, і якщо запит не може бути задоволений з уже нульових сторінок, VirtualAllocвиклик блокує, поки він не зможе вистачити на нуль для задоволення запиту. На відміну від цього, Linux просто відображає нульову сторінку в міру необхідності копіювати при записі, а під час запису копіює нові нулі на нову сторінку, перш ніж записати нові дані. Так чи інакше, з точки зору процесу користувальницького режиму, сторінки нульові, але перше використання неініціалізованої пам'яті зазвичай буде дорожче в Linux, ніж у Windows.
ShadowRanger

81

Другий цикл передбачає набагато меншу активність кешу, тому процесору легше йти в ногу з потребами пам'яті.


1
Ви говорите, що другий варіант має менший обсяг кешу? Чому?
Олівер Чарльворт

2
@Oli: У першому варіанті, процесор повинен отримати доступ до чотирьох лініях пам'яті на Time- a[i], b[i], c[i]і d[i]в другому варіанті, вона повинна тільки два. Це робить набагато більш життєздатним поповнювати ці рядки, додаючи.
Щеня

4
Але поки масиви не стикаються в кеші, кожен варіант вимагає точно такої ж кількості читання та запису з / в основну пам'ять. Отже, висновок (я думаю), що ці два масиви трапляються постійно.
Олівер Чарльворт

3
Я не стежу за цим. На кожну інструкцію (тобто на примірник x += y) є два читання та одна запис. Це справедливо для будь-якого варіанту. Отже, вимога пропускної здатності процесора кеша <-> є однаковою. Поки немає конфліктів, вимога пропускної здатності оперативної пам'яті кеш <-> ОЗУ також однакова ..
Олівер Чарльворт

2
Як зазначається в stackoverflow.com/a/1742231/102916 , апаратний попередній вибір Pentium M може відслідковувати 12 різних потоків вперед (і я б очікував, що пізніше обладнання буде принаймні настільки здатним). Цикл 2 досі зчитує лише чотири потоки, тому добре знаходиться в цій межі.
Брукс Мойсей

50

Уявіть, що ви працюєте на машині, де 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 разів довший доступ).

Якщо припустити досить тупу політику кешування, якщо байт не знаходиться в кеші, помістіть його туди і отримайте наступний байт, поки ми в ньому, ви отримаєте такий сценарій:

  • З

    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

Це класичний сценарій треш-кешу.


12
Це неправильно. Посилання на певний елемент масиву не призводить до того, що весь масив буде заповнено з диска (або з некешованої пам'яті); заподіюється лише відповідна сторінка або рядок кешу.
Брукс Мойсей

1
@Brooks Мойсей - Якщо ви пройдетеся через увесь масив, як це відбувається тут, то це буде.
OldCurmudgeon

1
Ну так, але це відбувається протягом усієї операції, а не те, що відбувається щоразу навколо циклу. Ви заявляли, що друга форма "роздруковуватиме два масиви та кожну іншу сторінку кожного разу навколо циклу", і це я заперечую. Незалежно від розміру загальних масивів, в середині цього циклу ваша ОЗУ буде містити сторінку з кожного з чотирьох масивів, і нічого не вийде на екран, поки не закінчиться цикл, поки цикл не закінчиться.
Брукс Мойсей

У конкретному випадку, коли n було лише правильним значенням для нього, щоб можна було одночасно зберігати два ваших масиви в пам’яті, тоді доступ до всіх елементів чотирьох масивів в одному циклі повинен неодмінно закінчитися обміном.
OldCurmudgeon

1
Чому ви залишаєтеся цією петлею 2 сторінок у цілому a1та b1для першого завдання, а не лише першою сторінкою кожної з них? (Ви припускаєте 5-байтові сторінки, тому сторінка є половиною вашої оперативної пам’яті? Це не просто масштабування, це абсолютно не схоже на реальний процесор.)
Брукс Мойсей,

35

Це не через інший код, а через кешування: ОЗУ повільніше, ніж реєструє процесор, і кеш-пам'ять знаходиться всередині процесора, щоб уникнути запису оперативної пам’яті щоразу, коли змінна змінна. Але кеш-пам'ять не велика, оскільки оперативна пам’ять є, отже, вона відображає лише її частину.

Перший код модифікує віддалені адреси пам'яті, чергуючи їх у кожному циклі, вимагаючи постійної інвалідизації кешу.

Другий код не чергується: він просто перетікає на сусідні адреси двічі. Це змушує завершити всю роботу в кеші, анулюючи її лише після запуску другого циклу.


Чому це призведе до того, що кеш буде постійно недійсним?
Олівер Чарльворт

1
@OliCharlesworth: Розглядайте кеш-пам'ять як копію постійного діапазону адрес пам'яті. Якщо ви робите вигляд, що отримуєте доступ до адреси, яка не є їх частиною, вам доведеться знову завантажити кеш. І якщо щось у кеші було змінено, воно повинно бути записане в оперативній пам'яті, інакше воно буде втрачено. У зразковому коді 4 вектора з 100'000 цілих чисел (400kBytes), швидше за все, більше, ніж ємність кешу L1 (128 або 256K).
Еміліо Гаравалья

5
Розмір кеша не впливає на цей сценарій. Кожен елемент масиву використовується лише один раз, після чого не має значення, чи він виселений. Розмір кешу має значення лише у випадку тимчасової локальності (тобто ви збираєтеся повторно використовувати ті самі елементи в майбутньому).
Олівер Чарльворт

2
@OliCharlesworth: Якщо мені доведеться завантажити нове значення в кеш, і в ньому вже є значення, яке було змінено, я спершу повинен записати його, і це змушує мене чекати, коли запис станеться.
Еміліо Гараваля

2
Але в обох варіантах коду ОП кожне значення змінюється точно один раз. Ви робите однакову кількість відшкодувань у кожному варіанті.
Олівер Чарльворт

22

Я не можу повторити обговорювані тут результати.

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

Розміри масивів варіювали від 2 ^ 16 до 2 ^ 24, використовуючи вісім петель. Я обережно ініціалізував вихідні масиви, щоб +=завдання не просило ФПУ додати сміття пам'яті, інтерпретоване як подвійне.

Я грав з різними схемами, наприклад, поставивши призначення 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). Але це не так, оскільки продуктивність чутлива до відносних вирівнювань різних масивів. На рівні інструкцій немає нерівностей. Кожен товар / склад правильно вирівняний.
Містичний

18

Це тому, що в процесорі немає стільки пропусків кешу (де він повинен чекати, коли дані масиву надійдуть з мікросхем оперативної пам'яті). Вам було б цікаво постійно змінювати розмір масивів, щоб ви перевищували розміри кешу рівня 1 (L1), а потім кеш рівня 2 (L2) вашого процесора та намічали час, який потрібно для вашого коду виконати проти розмірів масивів. Графік не повинен бути прямим, як ви очікували.


2
Я не вірю, що між розміром кешу та розміром масиву немає взаємодії. Кожен елемент масиву використовується лише один раз, а потім може бути безпечно вилучений. Цілком може бути взаємодія між розміром лінії кешу та розміром масиву, хоча це спричиняє конфлікт чотирьох масивів.
Олівер Чарльворт

15

Перший цикл чергує запис у кожній змінній. Другий і третій роблять лише невеликі стрибки розміру елементів.

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


Аналогії діяльності в реальному світі загрожують небезпекою, коли думаєш про речі, такі як інструкції з процесора. Те, що ви ілюструєте, - це ефективно шукати час , який би застосувався, якби ми говорили про читання / запис даних, що зберігаються на спінінг-диску, але в кеш-процесорі (або в оперативній пам'яті, або на SSD) немає часу на пошук. Доступ до розрізнених областей пам’яті не передбачає жодного штрафу порівняно з суміжними доступами.
FeRD

7

Оригінальне запитання

Чому одна петля набагато повільніше, ніж дві петлі?


Висновок:

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

Дивлячись на це з подібного підходу, не включаючи, як апаратне забезпечення, ОС і компілятор (и) працюють разом, щоб робити купі виділення, які передбачають роботу з оперативною пам’яттю, кешем, файлами сторінок тощо; математика, яка лежить в основі цих алгоритмів, показує нам, який із цих двох є кращим рішенням.

Ми можемо використовувати аналогію Bossбуття, Summationяке буде являти собою For Loopте, що має подорожувати між працівниками A& B.

Ми легко можемо побачити, що Випадок 2 принаймні наполовину швидший, якщо не трохи більше, ніж Випадок 1 через різницю відстані, яка потрібна для проїзду, та часу, що забирається між працівниками. Ця математика майже практично і ідеально поєднується як з BenchMark 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-х варіантів циклів для циклів та його виправлене питання щодо поведінки кешів, а також багато інших відмінних відповідей та корисних коментарів; Я хотів би спробувати зробити щось інше тут, застосувавши інший підхід щодо цієї ситуації та проблеми.


Підхід

Враховуючи дві петлі та всю дискусію про кеш-пам'ять та подачу сторінки, я хотів би скористатися іншим підходом до розгляду цього питання з іншої точки зору. Один, який не включає файли кешу та сторінок, ані виконання, щоб виділити пам'ять, насправді цей підхід взагалі не стосується фактичного обладнання або програмного забезпечення.


Перспектива

Подивившись коду на деякий час, стало цілком очевидно, в чому проблема, і що її породжує. Розбимо це на алгоритмічну задачу і розглянемо її з точки зору використання математичних позначень, а потім застосуємо аналогію до математичних задач, а також до алгоритмів.


Що ми знаємо

Ми знаємо, що ця петля запуститься 100 000 разів. Ми також знаємо , що a1, b1, c1іd1 є покажчиками на 64-бітної архітектури. У межах C ++ на 32-бітній машині всі покажчики мають 4 байти, а на 64-бітній машині - розміром 8 байт, оскільки покажчики мають фіксовану довжину.

Ми знаємо, що у нас є 32 байти, які можна виділити в обох випадках. Єдина відмінність полягає в тому, що ми виділяємо 32 байти або 2 набори по 2-8 байт на кожну ітерацію, де у другому випадку ми виділяємо 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від Case1де F1()міститься в Sumз Case1і в обох Sum1і Sum2від Case2. Це стане очевидним пізніше, коли ми почнемо робити висновок про оптимізацію, яка відбувається в рамках другого алгоритму.

Ітерації через Sumвиклики у першому випадку, f(a)які додадуть себе, f(b)потім виклики, f(c)які будуть робити те саме, але додаватимуть f(d)себе для кожної 100000ітерації. У другому випадку ми маємо 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підпрограми, методу, функції, блоку перекладу і т.д.

У першому випадку на кожній виклику виклику Bossпереходить до Aта видає замовлення і Aвідходить на отримання B'sпакета, потім Bossйде Cі дає накази зробити те саме і отримувати пакет з Dкожної ітерації.

У другому випадку, Bossпрацює безпосередньо з пакетом Ago і get, B'sпоки всі пакети не будуть отримані. Тоді Bossроботи з тим, Cщоб зробити те саме, щоб отримати всі D'sпакунки.

Оскільки ми працюємо з 8-байтовим покажчиком і займаємось розподілом купи, розглянемо наступну проблему. Скажімо, що Boss100 футів від, Aі Aце 500 футів від C. Нам не потрібно хвилюватися з приводу того, наскільки далеко Bossйде спочатку Cчерез порядок страт. В обох випадках Bossспочатку рухається Aспочатку, потім B. Ця аналогія не означає, що ця відстань є точною; це просто корисний сценарій тестових випадків, щоб показати роботу алгоритмів.

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


Випробування:

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

Другий випадок: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%повільнішим, ніж Алгоритм Другий; Однак, це тільки 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поки працівник не повернеться. Тож навіть це має вплив на алгоритм.



Поправлені питання щодо ОП

EDIT: Питання виявилося не актуальним, оскільки поведінка сильно залежить від розмірів масивів (n) та кешу CPU. Тож якщо є подальший інтерес, я перефразую питання:

Чи можете ви надати глибоке розуміння деталей, які призводять до різної поведінки кешу, як це проілюстровано п'ятьма регіонами на наступному графіку?

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


Щодо цих питань

Як я вже без сумнівів продемонстрував, існує ще одна проблема, перш ніж апаратне і програмне забезпечення буде залучене.

Тепер щодо управління пам'яттю та кешування разом із файлами сторінок тощо, які працюють разом в інтегрованому наборі систем між наступним:

  • The Architecture {Обладнання, мікропрограмне забезпечення, деякі вбудовані драйвери, ядра та інструкції з набору ASM}.
  • The OS{Системи управління файлами та пам'яттю, драйвери та реєстр}.
  • The Compiler {Одиниці перекладу та оптимізація вихідного коду}.
  • І навіть Source Codeсам із набором (ими) відмінних алгоритмів.

Ми вже бачимо , що є вузьке місце, що відбувається в першому алгоритмі , перш ніж ми навіть застосувати його до будь-якій машині з будь-яким довільним Architecture, OSі по Programmable Languageпорівнянні з другим алгоритмом. Вже існувала проблема, перш ніж залучити до суті сучасного комп’ютера.


Кінцеві результати

Однак; це не означає, що ці нові питання не мають важливого значення, оскільки вони самі є, і вони все-таки грають певну роль. Вони впливають на процедури та загальну ефективність роботи, і це видно з різних графіків та оцінок багатьох, хто дав свої відповіді та коментарі.

Якщо ви звернули увагу на аналогію Bossдвох робітників A&, Bякі повинні були піти і отримати пакунки з C& Dвідповідно та враховуючи математичні позначення двох алгоритмів, про які йдеться; ви можете бачити без залучення апаратних засобів та програмного забезпечення комп'ютера Case 2приблизно 60%швидше, ніжCase 1 .

Якщо ви подивитеся на графіки та діаграми після того, як ці алгоритми були застосовані до деякого вихідного коду, складеного, оптимізованого та виконаного через ОС для виконання своїх операцій над заданим обладнанням, ви навіть можете побачити дещо деградацію між різницями в цих алгоритмах.

Якщо Dataнабір досить малий, спочатку це може здатися не всім поганим. Однак, оскільки 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зосередитись на тому, щоб робити дві подібні речі одразу і жонглювати ними вперед і назад, замість того, щоб зосереджуватись на подібних послідовних завданнях, до кінця дня змусить його злитися, оскільки йому довелося подорожувати та працювати вдвічі більше. Тому не втрачайте масштабів ситуації, не даючи вашому начальнику потрапити в інтерпольоване вузьке місце, оскільки подружжя та діти начальника не оцінюють це.



Поправка: Принципи проектування програмного забезпечення

- Різниця між обчисленнями Local Stackта Heap Allocatedобчисленнями в межах ітеративного циклу та різниця між їх використанням, ефективністю та ефективністю -

Математичний алгоритм, який я запропонував вище, в основному стосується циклів, які виконують операції над даними, виділеними на купі.

  • Послідовні операції стека:
    • Якщо цикли виконують операції над даними локально в межах одного кодового блоку або області, що знаходиться в кадрі стека, він все одно буде застосовуватися, але місця пам'яті набагато ближче там, де вони зазвичай послідовні і різниця в пройденій відстані або в часі виконання майже незначний. Оскільки в купі не проводиться ніяких розподілів, пам'ять не розсіюється, а пам'ять не вибирається через оперативну пам'ять. Пам'ять, як правило, є послідовною і відносно кадру стека та вказівника стека.
    • Коли послідовні операції виконуються на стеці, сучасний процесор кешуватиме повторювані значення та адреси, що зберігають ці значення в локальних регістрах кешу. Час операцій чи інструкцій тут знаходиться в порядку наносекунд.
  • Послідовні операції, що виділяються під час купи:
    • Коли ви починаєте застосовувати розподіли купи, і процесор повинен вибирати адреси пам'яті під час послідовних дзвінків, залежно від архітектури процесора, контролера шини та модулів Ram, час операцій або виконання може бути на порядок мікро мілісекунд. Порівняно з кешованими операціями стеку, вони досить повільні.
    • Процесору доведеться отримувати адресу пам'яті від Ram і зазвичай все, що знаходиться через системну шину, є повільним порівняно з внутрішніми шляхами передачі даних або шинами даних у самому процесорі.

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

Це добре робити з даними, які знаходяться на стеці, оскільки вони часто кешуються, але не для даних, які повинні містити запит адреси пам'яті під час кожної ітерації.

Тут починає грати інженерія програмного забезпечення та архітектура програмного забезпечення. Це вміння знати, як впорядкувати свої дані, знати, коли кешувати свої дані, знати, коли розподілити свої дані в купі, знати, як розробити та реалізувати ваші алгоритми, і знати, коли і куди їх викликати.

Можливо, у вас є той самий алгоритм, що стосується того ж набору даних, але вам може знадобитися одна конструкція реалізації для його варіанту стека та інша для варіанту, виділеного з купою, саме через вищезазначене питання, яке видно з його O(n)складності алгоритму при роботі з купи.

З того, що я помітив протягом багатьох років, багато людей не враховують цей факт. Вони прагнуть розробити один алгоритм, який працює на певному наборі даних, і вони будуть використовувати його незалежно від того, набір даних буде локально кешований на стеку або якщо він був виділений на купі.

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

Ось псевдоприклад: Дві прості структури, один алгоритм.

struct A {
    int data;
    A() : data{0}{}
    A(int a) : data{a}{} 
};
struct B {
    int data;
    B() : data{0}{}
    A(int b) : data{b}{}
}                

template<typename T>
void Foo( T& t ) {
    // do something with t
}

// some looping operation: first stack then heap.

// stack data:
A dataSetA[10] = {};
B dataSetB[10] = {};

// For stack operations this is okay and efficient
for (int i = 0; i < 10; i++ ) {
   Foo(dataSetA[i]);
   Foo(dataSetB[i]);
}

// If the above two were on the heap then performing
// the same algorithm to both within the same loop
// will create that bottleneck
A* dataSetA = new [] A();
B* dataSetB = new [] B();
for ( int i = 0; i < 10; i++ ) {
    Foo(dataSetA[i]); // dataSetA is on the heap here
    Foo(dataSetB[i]); // dataSetB is on the heap here
} // this will be inefficient.

// To improve the efficiency above, put them into separate loops... 

for (int i = 0; i < 10; i++ ) {
    Foo(dataSetA[i]);
}
for (int i = 0; i < 10; i++ ) {
    Foo(dataSetB[i]);
}
// This will be much more efficient than above.
// The code isn't perfect syntax, it's only psuedo code
// to illustrate a point.

Це те, про що я мав на увазі, маючи окремі реалізації для варіантів стека проти варіантів купи. Самі алгоритми не мають великого значення, це петлеві структури, які ви будете використовувати їх для цього.


Минув час, коли я опублікував цю відповідь, але я також хотів додати короткий коментар, який також може допомогти зрозуміти це: За моєю аналогією з Boss як циклом для циклу або підсумками чи ітераціями через цикл, ми також могли б цей бос вважається комбінацією між Stack Frame & Stack Pointer, який керує змінними сфери та стека та адресацією пам'яті циклів.
Френсіс Куглер

@PeterMortensen Я врахував вашу пораду, трохи змінивши свою оригінальну відповідь. Я вважаю, що це ви запропонували.
Френсіс Куглер

2

Це може бути старий C ++ та оптимізація. На своєму комп’ютері я досяг майже однакової швидкості:

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

Дві петлі: 1.507 мс

Я запускаю Visual Studio 2015 на процесорі E5-1620 3,5 ГГц із 16 ГБ оперативної пам’яті.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.