Оригинальный вопрос
Почему один цикл намного медленнее двух?
Вывод:
Дело 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];
}
Рассмотрение
Рассматривая оригинальный вопрос ОП о двух вариантах циклов for и его исправленный вопрос о поведении кэшей, а также множество других превосходных ответов и полезных комментариев; Я хотел бы попытаться сделать что-то другое здесь, используя другой подход к этой ситуации и проблеме.
Подход
Учитывая два цикла и все дискуссии о кэшировании и хранении страниц, я бы хотел использовать другой подход, чтобы взглянуть на это с другой точки зрения. Тот, который не использует кеш и файлы подкачки, ни выполнения для выделения памяти, на самом деле, этот подход вообще не касается реального оборудования или программного обеспечения.
Перспектива
Посмотрев некоторое время на код, стало совершенно ясно, в чем проблема и что ее генерирует. Давайте разберем это в алгоритмической задаче и посмотрим на это с точки зрения использования математических обозначений, а затем применим аналогию к математическим задачам, а также к алгоритмам.
Что мы знаем
Мы знаем, что этот цикл будет выполняться 100 000 раз. Мы также знаем , что a1
, b1
, c1
иd1
являются указателями на 64-битной архитектуре. В C ++ на 32-разрядной машине все указатели имеют размер 4 байта, а на 64-разрядной машине они имеют размер 8 байтов, поскольку указатели имеют фиксированную длину.
Мы знаем, что у нас есть 32 байта для выделения в обоих случаях. Единственное отличие состоит в том, что мы выделяем 32 байта или 2 набора по 2-8 байт на каждую итерацию, тогда как во втором случае мы выделяем 16 байтов для каждой итерации для обоих независимых циклов.
Оба цикла по-прежнему равны 32 байта в общем распределении. С этой информацией давайте теперь продолжим и покажем общую математику, алгоритмы и аналогию этих понятий.
Мы знаем, сколько раз один и тот же набор или группа операций должны быть выполнены в обоих случаях. Мы знаем количество памяти, которое нужно выделить в обоих случаях. Мы можем оценить, что общая рабочая нагрузка распределений между обоими случаями будет примерно одинаковой.
Что мы не знаем
Мы не знаем, сколько времени это займет для каждого случая, если только мы не установим счетчик и не проведем тест производительности. Тем не менее, критерии уже были включены из исходного вопроса, а также из некоторых ответов и комментариев; и мы можем видеть существенную разницу между этими двумя понятиями, и в этом вся суть этого предложения по этой проблеме.
Давайте расследовать
Уже очевидно, что многие уже сделали это, взглянув на распределение кучи, тесты производительности, на RAM, Cache и Page Files. Рассмотрение конкретных точек данных и конкретных итерационных индексов также было включено, и различные разговоры об этой конкретной проблеме заставили многих людей начать сомневаться в других связанных с этим вещах. Как мы начинаем смотреть на эту проблему, используя математические алгоритмы и применяя к ней аналогию? Начнем с того, что сделаем пару утверждений! Затем мы строим наш алгоритм оттуда.
Наши утверждения:
- Мы позволим нашему циклу и его итерациям быть суммированием, которое начинается с 1 и заканчивается на 100000 вместо того, чтобы начинаться с 0, как в циклах, поскольку нам не нужно беспокоиться о схеме индексации адресации памяти 0, поскольку нас просто интересует сам алгоритм.
- В обоих случаях у нас есть 4 функции для работы и 2 вызова функций с 2 операциями, выполняемыми для каждого вызова функции. Мы установим эти меры , как функции и вызовы функций , как:
F1()
, F2()
, f(a)
, f(b)
, f(c)
и f(d)
.
Алгоритмы:
1-й случай: - только одно суммирование, но два независимых вызова функций.
Sum n=1 : [1,100000] = F1(), F2();
F1() = { f(a) = f(a) + f(b); }
F2() = { f(c) = f(c) + f(d); }
2-й случай: - Два суммирования, но у каждого свой вызов функции.
Sum1 n=1 : [1,100000] = F1();
F1() = { f(a) = f(a) + f(b); }
Sum2 n=1 : [1,100000] = F1();
F1() = { f(c) = f(c) + f(d); }
Если вы заметили , F2()
существует только в Sum
от Case1
где F1()
содержится в Sum
из Case1
и в обоих Sum1
и Sum2
от Case2
. Это станет очевидным позже, когда мы начнем делать вывод, что во втором алгоритме происходит оптимизация.
Итерации по первым Sum
вызовам case 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
соответственно , и , чтобы забрать некоторые пакет из них и вернуть его. В этой аналогии сами циклы for или итерации суммирования и проверки условий фактически не представляют Boss
. То, что на самом деле представляет, Boss
не непосредственно из фактических математических алгоритмов, а из фактической концепции Scope
и Code Block
внутри подпрограммы или подпрограммы, метода, функции, единицы перевода и т. Д. Первый алгоритм имеет 1 область действия, где 2-й алгоритм имеет 2 последовательных области действия.
В первом случае в каждом вызове квитанция Boss
отправляется A
и дает заказ и A
уходит, чтобы получить B's
пакет, а затем Boss
отправляется вC
и дает приказы сделать то же самое и получить пакет D
на каждой итерации.
Во втором случае Boss
работает непосредственно, A
чтобы пойти и получить B's
пакет, пока все пакеты не будут получены. Затем Boss
работает, C
чтобы сделать то же самое для получения всехD's
пакетов.
Поскольку мы работаем с 8-байтовым указателем и имеем дело с распределением кучи, давайте рассмотрим следующую проблему. Скажем, Boss
это 100 футов от A
и это A
500 футов от C
. Нам не нужно беспокоиться о том, как далеко они Boss
изначально находятся из- C
за порядка выполнения. В обоих случаях Boss
изначально путешествует изA
затем к B
. Эта аналогия не говорит о том, что это расстояние является точным; это просто полезный сценарий тестового примера, демонстрирующий работу алгоритмов.
Во многих случаях при распределении кучи и работе с кешем и файлами подкачки эти расстояния между адресами могут не сильно отличаться или могут значительно различаться в зависимости от характера типов данных и размеров массива.
Тестовые случаи:
Первый случай: на первой итерацииBoss
он должен сначала пройти 100 футов, чтобы сдать ордер,A
иA
уйти ивыполнитьсвое дело, но затемBoss
он должен пройти 500 футов,C
чтобы дать ему промах. Затем на следующей итерации и на каждой другой итерации послеBoss
нее нужно идти вперед и назад на 500 футов между ними.
Второй случай: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
пока работник не вернется. Так что даже это влияет на алгоритм.
Измененные вопросы ОП
РЕДАКТИРОВАТЬ: Вопрос оказался неактуальным, так как поведение сильно зависит от размеров массивов (n) и кэш-памяти ЦП. Так что, если есть дальнейший интерес, я перефразирую вопрос:
Не могли бы вы дать некоторое четкое представление о деталях, которые приводят к разным поведениям кэша, как показано пятью областями на следующем графике?
Также было бы интересно указать на различия между архитектурами ЦП и кэш-памяти, предоставляя подобный график для этих ЦП.
Относительно этих вопросов
Без сомнения, я продемонстрировал, что существует основная проблема еще до того, как в нее вступят аппаратное и программное обеспечение.
Теперь что касается управления памятью и кэшированием вместе с файлами подкачки и т. Д., Которые все работают вместе в интегрированном наборе систем между следующими:
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, и обычно все, что происходит по системной шине, медленное по сравнению с внутренними путями данных или шинами данных внутри самого ЦП.
Поэтому, когда вы работаете с данными, которые должны быть в куче, и проходите через них в циклах, более эффективно хранить каждый набор данных и соответствующие ему алгоритмы в пределах своего отдельного цикла. Вы получите лучшую оптимизацию по сравнению с попыткой выделить последовательные циклы, поместив несколько операций различных наборов данных, находящихся в куче, в один цикл.
Это можно делать с данными, находящимися в стеке, поскольку они часто кэшируются, но не для данных, для которых адрес памяти запрашивается при каждой итерации.
Именно здесь вступают в игру инженерия программного обеспечения и проектирование архитектуры программного обеспечения. Это способность знать, как организовать ваши данные, знать, когда кэшировать ваши данные, знать, когда размещать ваши данные в куче, знать, как разрабатывать и реализовывать ваши алгоритмы, и знать, когда и где их вызывать.
У вас может быть тот же алгоритм, который относится к одному и тому же набору данных, но вам может потребоваться один дизайн реализации для его варианта стека, а другой - для его варианта с распределенной кучей только из-за вышеуказанной проблемы, которая видна из-за 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.
Это то, что я имел в виду, имея отдельные реализации для стековых и кучных вариантов. Сами алгоритмы не имеют большого значения, это циклические структуры, в которых вы будете их использовать.