Оригінальне запитання
Чому одна петля набагато повільніше, ніж дві петлі?
Висновок:
Випадок 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
працює безпосередньо з пакетом A
go і get, 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 футів між ними.
Другий випадок: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.
Це те, про що я мав на увазі, маючи окремі реалізації для варіантів стека проти варіантів купи. Самі алгоритми не мають великого значення, це петлеві структури, які ви будете використовувати їх для цього.