Почему порядок циклов влияет на производительность при итерации по 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" делает много предварительной выборки и кэширования данных. Вероятно, вы получаете много пропусков кеша в своем более медленном итерационном порядке.