Какой порядок вложенных циклов для перебора двумерного массива более эффективен

Какой из следующих порядков вложенных циклов для итерации по двумерному массиву более эффективен с точки зрения времени (производительности кэша)? Почему?

int a[100][100];

for(i=0; i<100; i++)
{
   for(j=0; j<100; j++)
   {
       a[i][j] = 10;    
   }
}

or

for(i=0; i<100; i++)
{
   for(j=0; j<100; j++)
   {
      a[j][i] = 10;    
   }
}

person Sachin Mhetre    schedule 27.03.2012    source источник
comment
должна быть какая-то разница при хранении массива..   -  person Sachin Mhetre    schedule 27.03.2012
comment
Вы их написали. Теперь вы можете увеличить цифры и проверить их самостоятельно. Бьюсь об заклад, что разницы не будет из-за оптимизаций компилятора, или в худшем случае первый будет быстрее, потому что он перебирает непрерывную область памяти.   -  person Rafał Rawicki    schedule 27.03.2012
comment
Небольшое примечание: используйте ++i вместо i++. Это быстрее (хотя для чисел разница с i++ очень мала, не как для итераторов STL).   -  person Raxillan    schedule 27.03.2012
comment
@Raxillan - это уже не так с современными процессорами и компиляторами во всех случаях, в зависимости от фактического языка.   -  person    schedule 27.03.2012
comment
@Raxillan, это просто неправильно. Они будут одинаково эффективны, если только вы не используете компилятор 70-х годов.   -  person Luchian Grigore    schedule 27.03.2012
comment
@LuchianGrigore Итак, i++ не делает копию i?   -  person Raxillan    schedule 27.03.2012
comment
@Raxillan в данном случае нет. Оптимизатор достаточно умен, чтобы знать, что ему не нужна копия.   -  person Luchian Grigore    schedule 27.03.2012
comment
@Raxillan Почему это должно быть так? Ни новое, ни старое значение не используются в одном и том же выражении, и компилятор знает об этом. Так почему это должно иметь значение?   -  person glglgl    schedule 27.03.2012
comment
Закрытие этого как дубликата, согласно meta.stackoverflow.com/questions/380911/   -  person Lundin    schedule 08.03.2019


Ответы (10)


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

Первый метод:

[ ][ ][ ][ ][ ] ....
^1st assignment
   ^2nd assignment
[ ][ ][ ][ ][ ] ....
^101st assignment

Второй метод:

[ ][ ][ ][ ][ ] ....
^1st assignment
   ^101st assignment
[ ][ ][ ][ ][ ] ....
^2nd assignment
person MByD    schedule 27.03.2012
comment
так вы имеете в виду, что доступ к ним быстрее, чем к другому?? - person Sachin Mhetre; 27.03.2012
comment
Это означает, что вы получите меньше промахов кеша, а процессор сможет лучше угадать, к какой памяти будет осуществляться следующий доступ. - person tchap; 27.03.2012
comment
У Рэймонда Чена в блоге есть похожий пост с картинками и хорошим объяснением: blogs.msdn.com/b/oldnewthing/archive/2005/08/05/448073.aspx. Думайте о растровом изображении как о большем массиве. - person chris; 27.03.2012
comment
Забавный тест: на моем конкретном компьютере и компиляторе (процессор i5, Linux, gcc -O3) и с гораздо большей матрицей первый метод занял 2 секунды, а второй — 19 секунд. - person Thomas Padron-McCarthy; 27.03.2012
comment
Тесты на моем компьютере также показали, что первый метод более эффективен. - person Jesse Good; 27.03.2012
comment
@ThomasPadron-McCarthy, это очень специфический тест, который почти не имеет смысла, в большинстве реальных сред (с более значительным телом цикла) разница будет намного меньше или даже не будет существовать. - person KillianDS; 27.03.2012
comment
@KillianDS: я не согласен. Проверьте большинство реализаций библиотек матриц или изображений (или других библиотек, обрабатывающих большие массивы данных). Обычно они идут на многое, чтобы избежать доступа к массиву в непоследовательном порядке, поскольку промахи кэша могут быть очень дорогими. - person Leo; 27.03.2012
comment
@Leo: если у вас слишком быстрый внутренний цикл, да, иначе: нет. Дело в том, что обращения во втором случае все еще очень предсказуемы (шагают), за исключением переходов по столбцам, любой современный процессор будет предварительно выбирать эти строки кэша до того, как они вам понадобятся. - person KillianDS; 27.03.2012
comment
@KillianDS: О, я согласен с этим. Единственный случай, когда порядок доступа будет иметь большое значение, — это когда массивы достаточно велики, чтобы не поместиться в кэш. С другой стороны, это обычно те случаи, когда тело цикла будет довольно маленьким и промахи кеша будут очень заметны. При работе с большими массивами обычно лучше перестраховаться, чем сожалеть, и убедиться, что доступ осуществляется как можно более последовательно (если только производительность не имеет значения). - person Leo; 27.03.2012

  1. Для array[100][100] - они оба одинаковы, если кэш L1 больше, чем 100*100*sizeof(int) == 10000*sizeof(int) == [обычно] 40000. Примечание в Sandy Bridge — 100*100 целых чисел должно быть достаточно, чтобы увидеть разницу, поскольку кэш L1 составляет всего 32 КБ.

  2. Компиляторы, вероятно, все равно оптимизируют этот код.

  3. Предполагая, что компилятор не оптимизировал и матрица не помещается в кеш L1 - первый код лучше из-за производительности кеша [обычно]. Каждый раз, когда элемент не найден в кеше — вы получаете промах кеша — и вам нужно перейти в оперативную память или Кэш L2 [который намного медленнее]. Перемещение элементов из ОЗУ в кеш [заполнение кеша] выполняется блоками [обычно 8/16 байтов] — так что в первом коде вы получаете максимум процент промахов 1/4 [при условии 16-байтового блока кеша, 4 байта ints], а во втором коде она неограничена, и может быть даже 1. Во втором снапе кода — элементы, которые уже были в кеше [вставлены в кеш заливки для соседних элементов] — вынимаются, и получается избыточный кеш-промах.

    • This is closely related to the principle of locality, which is the general assumption used when implementing the cache system. The first code follows this principle while the second doesn't - so cache performance of the first will be better of those of the second.

Вывод: Для всех известных мне реализаций кеша - первая будет не хуже второй. Они могут быть одинаковыми - если кеша нет вообще или весь массив полностью помещается в кеш - или из-за оптимизации компилятора.

person amit    schedule 27.03.2012
comment
К.... Так это означает, что первый всегда эффективен... Мы можем получить доступ к элементам намного быстрее... - person Sachin Mhetre; 27.03.2012
comment
@SachinMhetre: Для всех известных мне реализаций кеша - первая будет не хуже второй. Они могут быть одинаковыми - если кеша нет вообще или весь массив помещается в кеш. - person amit; 27.03.2012
comment
Вероятно, стоит отметить, что есть две проблемы: сколько времени требуется памяти, чтобы добраться из кеша L2 в регистры, и пропускная способность между кешем L2 и регистрами. Если бы это было просто вопросом задержки, то предварительная выборка (программная или аппаратная) могла бы устранить большую часть различий между двумя способами доступа к данным. Жестким ограничением здесь, однако, является пропускная способность; поскольку при каждом доступе к памяти считывается вся строка кэша, а не одно целое число, то с вашими предположениями один шаблон доступа должен считывать в четыре раза больше памяти в целом. - person ; 28.02.2015
comment
@amit Не могли бы вы объяснить, как вы оценили эти показатели промахов 1/4 и 1? - person sgnsajgon; 17.01.2017

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

person Luchian Grigore    schedule 27.03.2012
comment
Я бы проголосовал за это, если бы кто-то действительно продемонстрировал реальную платформу, где первая версия была медленнее второй. Да, это микрооптимизация. Да, пожалуй, особой разницы нет. Нет, вам не следует тратить время на переписывание циклов, если только профилировщик не укажет, что они критичны для производительности. Но если вам нужно выбирать между двумя одинаково простыми, понятными и действенными способами написания фрагмента кода, и вы просто знаете эмпирическое правило, которое гласит, что один из них по крайней мере не медленнее другого, то почему бы не выбрать не медленнее? - person Ilmari Karonen; 28.03.2012
comment
@IlmariKaronen Я проголосовал за ваш комментарий. Но обратите внимание, что это, по крайней мере, зависит от языка. Fortran, например, размещает массив в памяти наоборот, поэтому для Fortran первая версия, скорее всего, будет медленнее второй. - person fishinear; 19.01.2017

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

( ((y) * (row->width)) + (x) ) 

Рассмотрим упрощенный кэш L1, в котором достаточно места только для 50 строк нашего массива. За первые 50 итераций вы неизбежно заплатите за 50 промахов кэша, но что потом? Для каждой итерации с 50 по 99 вы все равно будете промахиваться в кэше и должны получать из L2 (и/или ОЗУ и т. д.). Затем x изменяется на 1, а y начинается заново, что приводит к еще одному промаху кеша, потому что первая строка вашего массива была вытеснена из кеша, и так далее.

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

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

person Michael Foukarakis    schedule 27.03.2012

Учитывая, что C++ является основным, я считаю, что первый метод будет немного быстрее. В памяти 2D-массив представлен в одномерном массиве, и производительность зависит от доступа к нему либо с использованием основных строк, либо основных столбцов.

person Habib    schedule 27.03.2012

Это классическая задача о cache line bouncing

В большинстве случаев первый лучше, но я думаю, что точный ответ: ЭТО ЗАВИСИТ, другая архитектура может привести к другому результату.

person llj098    schedule 27.03.2012

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

person Parag    schedule 30.03.2012

В вашем случае (заполните все значения массива 1) это будет быстрее:

   for(j = 0; j < 100 * 100; j++){
      a[j] = 10;
   }

и вы все еще можете рассматривать a как двумерный массив.

EDIT: как упомянул Биньямин Шарет, вы можете сделать это, если ваш a объявлен таким образом:

int **a = new int*[100];
for(int i = 0; i < 100; i++){
    a[i] = new int[100];
}
person IProblemFactory    schedule 27.03.2012
comment
Вам нужно сделать это с помощью указателя, вы не можете напрямую назначить этот путь. - person MByD; 27.03.2012
comment
Извините, что я PITA, но вам нужно дважды разыменовать его :) - person MByD; 27.03.2012

В общем, лучшая локация (отмеченная большинством респондентов) — это только первое преимущество для производительности петли №1.

Второе (но родственное) преимущество заключается в том, что для циклов, подобных #1, компилятор обычно способен эффективно автоматически векторизовать код с шаблоном доступа к памяти stride-1 (stride -1 означает, что существует непрерывный доступ к элементам массива один за другим на каждой следующей итерации). Напротив, для таких циклов, как #2, автовекторизация обычно не будет работать нормально, потому что нет последовательного итеративного доступа с шагом 1 к смежным блокам в памяти.

Ну, мой ответ общий. Для очень простых циклов, таких как № 1 или № 2, могут использоваться еще более простые агрессивные оптимизации компилятора (оценка любой разницы), а также компилятор обычно может автоматически векторизовать # 2 с шагом-1 для внешнего цикла (особенно с #pragma simd или подобным).

person zam    schedule 03.10.2013

Первый вариант лучше, так как мы можем сохранить a[i] in a temp variable внутри первого цикла, а затем искать в нем индекс j. В этом смысле можно сказать, что это кэшированная переменная.

person Himanshu Goel    schedule 25.03.2015