Чому порядок циклів впливає на продуктивність при ітерації над 2D масивом?


360

Нижче наведено дві програми, які майже однакові, за винятком того, що я перемикав 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; }
   }
}


7
Чи можете ви додати деякі результати орієнтиру?
naught101


14
@ naught101 Тестові показники показуватимуть різницю між показниками продуктивності в будь-якому місці від 3 до 10 разів. Це основний C / C ++, я абсолютно спотикаючись, як за це набрали стільки голосів ...
TC1

12
@ TC1: я не думаю, що це так просто; може бути проміжним. Але не дивно, що "основні" речі, як правило, корисні більшої кількості людей, отже, і безліч результатів. Більше того, це питання, яке важко гугл, навіть якщо воно є "базовим".
LarsH

Відповіді:


595

Як вже говорилося, проблема полягає в магазин в комірку пам'яті в масиві: x[i][j]. Ось трохи розуміння, чому:

У вас є двовимірний масив, але пам'ять у комп'ютері за своєю суттю є одновимірною. Тож поки ви уявляєте свій масив таким чином:

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

У другому прикладі ви отримуєте доступ до масиву, перенісши спочатку 2-е число, тобто:

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 дюймів, (2) модифікація всіх, (3) повторення 4000 * 4000/16 разів. Це приємно і швидко, і у процесора завжди є над чим працювати.

Перший приклад - (1) захопити шматок 16 дюймів, (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 називається «рядком-мажор», а Фортран - «стовпчик-мажор». Як ви бачите, дуже важливо знати, чи мова вашого програмування є рядковою чи основною для стовпців! Ось посилання для отримання додаткової інформації: http://en.wikipedia.org/wiki/Row-major_order


14
Це досить ґрунтовна відповідь; це те, чого мене вчили, коли я мав справу з помилками кешу та управління пам'яттю.
Макото

7
У вас є "перша" та "друга" версії в неправильному напрямку; перший приклад варіює перший індекс у внутрішньому циклі, і буде більш повільним виконанням прикладу.
caf

Чудова відповідь. Якщо Марк хоче прочитати більше про такі нітті зернисті, я рекомендував би книгу на кшталт Write Great Code.
wkl

8
Бонусні бали за те, що C змінив порядок рядків у Fortran. Для наукових обчислень розмір кешу L2 - це все, тому що якщо всі ваші масиви вписуються в L2, то обчислення можна завершити, не заходячи в основну пам'ять.
Майкл Шопсін


68

Нічого спільного зі складанням. Це пов’язано з пропусками кеша .

C багатовимірні масиви зберігаються з останнім розміром як найшвидший. Таким чином, перша версія буде пропускати кеш на кожній ітерації, тоді як друга версія не буде. Отже, друга версія повинна бути значно швидшою.

Дивіться також: http://en.wikipedia.org/wiki/Loop_interchange .


23

Версія 2 запуститься набагато швидше, оскільки вона використовує кеш комп'ютера краще, ніж версія 1. Якщо ви подумаєте про це, масиви - це просто суміжні області пам'яті. Коли ви запитаєте елемент у масиві, ваша ОС, ймовірно, внесе сторінку пам'яті в кеш, який містить цей елемент. Однак, оскільки наступні кілька елементів також знаходяться на цій сторінці (оскільки вони суміжні), наступний доступ уже буде в кеші! Ось що робить версія 2, щоб пришвидшити її.

Версія 1, з іншого боку, має доступ до елементів стовпця з розумом, а не з рядком. Цей тип доступу не є суміжним на рівні пам'яті, тому програма не може так сильно скористатися кешуванням ОС.


За таких розмірів масиву тут, мабуть, відповідає кеш-менеджер в процесорі, а не в ОС.
krlmlr

12

Причина - локальний доступ до даних кешу. У другій програмі ви скануєте лінійно через пам'ять, яка виграє від кешування та попереднього завантаження. Шаблон використання пам'яті вашої першої програми набагато більше розповсюджений і, отже, має гіршу поведінку кешу.


11

Окрім інших відмінних відповідей на сходинки кешу, існує також можлива різниця в оптимізації. Ваш другий цикл, ймовірно, буде оптимізований компілятором у щось еквівалентне:

  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не може, тому є менша користь від її оптимізації. Це також складніше, тому що компілятору потрібно знати і використовувати розмір внутрішнього масиву. І не трапляється так, що часто у внутрішньому циклі у звичайному коді (це відбувається лише для багатовимірних масивів, де останній індекс зберігається постійним у циклі, а другий до останнього - кроковим), тому оптимізація є не пріоритетною .


Я не розумію, що означає, що це означає, що щоразу потрібно стрибнути вказівник "p" з 4000 ".
Ведрак

@Veedrac Покажчик потрібно буде збільшити до 4000 всередині внутрішньої петлі: p += 4000isop++
fishinear

Чому компілятор знайде цю проблему? iвже збільшується на неодиничне значення, враховуючи приріст вказівника.
Ведрак

Я додав більше пояснень
риболовлю

Спробуйте ввести int *f(int *p) { *p++ = 10; return p; } int *g(int *p) { *p = 10; p += 4000; return p; } в gcc.godbolt.org . Ці два, здається, складають однаково.
Ведрак

7

Винуватець цього рядка:

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

Друга версія використовує безперервну пам'ять, таким чином, буде значно швидшою.

Я спробував

x[50000][50000];

а час виконання - 13s для версії1 проти 0,6 для версії2.


4

Я намагаюся дати загальну відповідь.

Тому що 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шматок ітерацію, і між будь-яким фрагментом-итерацией, ви будете мати array_widthітерації тільки sizeof(array_element).

Хоча для старих процесорів x86 це не мало значення, в даний час x86 робить багато попереднього вибору та кешування даних. Ви, ймовірно, створюєте багато пропусків кеш-пам'яті у своєму повільному порядку ітерації.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.