Почему порядок циклов влияет на производительность при итерации по 2D-массиву?

Ниже приведены две почти идентичные программы, за исключением того, что я переключил переменные i и j. Они оба бегут в разное количество времени. Может кто-нибудь объяснить, почему это происходит?

Версия 1

#include <stdio.h>
#include <stdlib.h>

main () {
  int i,j;
  static int x[4000][4000];
  for (i = 0; i < 4000; i++) {
    for (j = 0; j < 4000; j++) {
      x[j][i] = i + j; }
  }
}

Версия 2

#include <stdio.h>
#include <stdlib.h>

main () {
  int i,j;
  static int x[4000][4000];
  for (j = 0; j < 4000; j++) {
     for (i = 0; i < 4000; i++) {
       x[j][i] = i + j; }
   }
}

Ответы

Ответ 1

Как говорили другие, проблема заключается в сохранении в ячейке памяти в массиве: x[i][j]. Вот немного понимания, почему:

У вас есть двумерный массив, но память в компьютере по своей сути 1-мерная. Поэтому, когда вы представляете свой массив следующим образом:

0,0 | 0,1 | 0,2 | 0,3
----+-----+-----+----
1,0 | 1,1 | 1,2 | 1,3
----+-----+-----+----
2,0 | 2,1 | 2,2 | 2,3

Ваш компьютер сохраняет его в памяти как одну строку:

0,0 | 0,1 | 0,2 | 0,3 | 1,0 | 1,1 | 1,2 | 1,3 | 2,0 | 2,1 | 2,2 | 2,3

Во втором примере вы получаете доступ к массиву, сначала перебирая второй номер, т.е.:

x[0][0] 
        x[0][1]
                x[0][2]
                        x[0][3]
                                x[1][0] etc...

Это означает, что вы все в порядке. Теперь посмотрим на 1-ю версию. Вы делаете:

x[0][0]
                                x[1][0]
                                                                x[2][0]
        x[0][1]
                                        x[1][1] etc...

Из-за того, как C выложил 2-мерный массив в памяти, вы просите его прыгать повсюду. Но теперь для кикера: Почему это имеет значение? Все обращения к памяти одинаковы, правильно?

Нет: из-за кешей. Данные из вашей памяти доводятся до ЦПУ в маленьких кусках (так называемые "линии кэша" ), как правило, 64 байта. Если у вас есть 4-байтовые целые числа, это означает, что вы получаете 16 последовательных целых чисел в аккуратном небольшом пакете. На самом деле это довольно медленно, чтобы получить эти куски памяти; ваш процессор может выполнять большую работу за время, затрачиваемое на загрузку отдельной строки кэша.

Теперь оглянитесь на порядок доступа: Второй пример: (1) захват фрагмента из 16 ints, (2) изменение всех из них, (3) повтор 4000 * 4000/16 раз. Это хорошо и быстро, и у процессора всегда есть над чем работать.

Первый пример: (1) захватить фрагмент из 16 ints, (2) изменить только один из них, (3) повторить 4000 * 4000 раз. Это потребует 16-кратное количество "выборки" из памяти. Вашему процессору на самом деле придется тратить время на сидение, ожидая появления этой памяти, и пока она сидит вокруг, вы тратите драгоценное время.

Важное примечание:

Теперь, когда у вас есть ответ, вот интересная заметка: нет причин, по которым ваш второй пример должен быть быстрым. Например, в Фортране первый пример будет быстрым, а второй медленным. Это потому, что вместо того, чтобы расширять вещи в концептуальные "строки", такие как C, Fortran расширяется в "столбцы", то есть:

0,0 | 1,0 | 2,0 | 0,1 | 1,1 | 2,1 | 0,2 | 1,2 | 2,2 | 0,3 | 1,3 | 2,3

Макет C называется "row-major", а Fortran называется "column-major". Как вы можете видеть, очень важно знать, является ли ваш язык программирования значительным или крупным! Здесь ссылка для получения дополнительной информации: http://en.wikipedia.org/wiki/Row-major_order

Ответ 2

Ничего общего с сборкой. Это связано с пропуском кеша.

Многомерные массивы C сохраняются с последним измерением как самым быстрым. Таким образом, первая версия пропустит кеш на каждой итерации, тогда как вторая версия не будет. Поэтому вторая версия должна быть значительно быстрее.

Смотрите также: http://en.wikipedia.org/wiki/Loop_interchange.

Ответ 3

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

Версия 1, с другой стороны, обращается к столбцам элементов, а не к ряду. Этот вид доступа не соприкасается с уровнем памяти, поэтому программа не может использовать кэширование ОС как можно больше.

Ответ 4

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

Ответ 5

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

  for (j=0; j<4000; j++) {
    int *p = x[j];
    for (i=0; i<4000; i++) {
      *p++ = i+j;
    }
  }

Это менее вероятно для первого цикла, потому что ему нужно каждый раз увеличивать указатель "p" на 4000.

EDIT: p++ и даже *p++ = .. можно скомпилировать в одну инструкцию процессора в большинстве процессоров. *p = ..; p += 4000 не может, поэтому в оптимизации его меньше. Это также сложнее, потому что компилятор должен знать и использовать размер внутреннего массива. И это не происходит часто во внутреннем цикле в нормальном коде (это происходит только для многомерных массивов, где последний индекс поддерживается постоянным в цикле, а второй - последним), поэтому оптимизация меньше приоритета,

Ответ 6

Эта строка виновника:

x[j][i]=i+j;

Вторая версия использует непрерывную память, поэтому будет значительно быстрее.

Я пробовал с помощью

x[50000][50000];

а время исполнения - 13 секунд для версии 1 против 0,6 для версии 2.

Ответ 7

Я пытаюсь дать общий ответ.

Потому что i[y][x] является сокращением для *(i + y*array_width + x) в C (попробуйте стильный int P[3]; 0[P] = 0xBEEF;).

Когда вы перебираете y, вы перебираете куски размером array_width * sizeof(array_element). Если у вас это в вашем внутреннем цикле, у вас будут array_width * array_height итерации по этим фрагментам.

Перевернув порядок, вы будете иметь только array_height chunk-итераций, и между любой итерацией блоков вы будете иметь array_width итераций только sizeof(array_element).

В то время как на действительно старых x86-процессорах это не имело особого значения, в настоящее время "x86" делает много предварительной выборки и кэширования данных. Вероятно, вы получаете много пропусков кеша в своем более медленном итерационном порядке.