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

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

int a[100][100];

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

или

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

Ответ 1

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

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

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

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

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

Ответ 2

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

  • Компиляторы, вероятно, будут оптимизировать этот код все равно

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

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

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

Ответ 3

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

Ответ 4

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

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

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

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

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

Ответ 5

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

Ответ 6

Это классическая проблема о cache line bouncing

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

Ответ 7

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

Ответ 8

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

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

и вы все равно можете рассматривать a как 2-мерный массив.

ИЗМЕНИТЬ: Как упоминал Биньямин Sharet, вы можете сделать это, если ваш a объявлен таким образом:

int **a = new int*[100];
for(int i = 0; i < 100; i++){
    a[i] = new int[100];
}

Ответ 9

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

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

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

Ответ 10

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