Чому транспортування матриці 512x512 набагато повільніше, ніж транспонування матриці 513x513?


218

Провівши кілька експериментів на квадратних матрицях різної величини, вийшов візерунок. Незмінно переміщення матриці розміру 2^nвідбувається повільніше, ніж транспонування розміру2^n+1 . Для малих значень n, різниця не є основною.

Однак великі відмінності виникають над значенням 512. (принаймні, для мене)

Відмова: Я знаю, що функція насправді не переносить матрицю через подвійний підміна елементів, але це не має ніякої різниці.

Дотримується коду:

#define SAMPLES 1000
#define MATSIZE 512

#include <time.h>
#include <iostream>
int mat[MATSIZE][MATSIZE];

void transpose()
{
   for ( int i = 0 ; i < MATSIZE ; i++ )
   for ( int j = 0 ; j < MATSIZE ; j++ )
   {
       int aux = mat[i][j];
       mat[i][j] = mat[j][i];
       mat[j][i] = aux;
   }
}

int main()
{
   //initialize matrix
   for ( int i = 0 ; i < MATSIZE ; i++ )
   for ( int j = 0 ; j < MATSIZE ; j++ )
       mat[i][j] = i+j;

   int t = clock();
   for ( int i = 0 ; i < SAMPLES ; i++ )
       transpose();
   int elapsed = clock() - t;

   std::cout << "Average for a matrix of " << MATSIZE << ": " << elapsed / SAMPLES;
}

Зміна MATSIZEдозволяє нам змінити розмір (так!). Я розмістив дві версії на ideone:

У моєму середовищі (MSVS 2010, повна оптимізація) різниця схожа:

  • розмір 512 - середній 2,19 мс
  • розмір 513 - середній 0,57 мс

Чому це відбувається?


9
Ваш код мені виглядає недобросовісно кешем.
— CodesInChaos

7
Це в значній мірі те ж питання , як це питання: stackoverflow.com/questions/7905760 / ...
— Mysticial

Хочете розробити, @CodesInChaos? (Або хто-небудь інший.)
— corazza

@Bane Як щодо читання прийнятої відповіді?
— CodesInChaos

4
@nzomkxia Нічого не міряти нічого без оптимізацій. Якщо оптимізація вимкнена, генерований код буде засмічений стороннім сміттям, яке приховуватиме інші вузькі місця. (наприклад, пам'ять)
— Mysticial

Відповіді:


197

Пояснення походить від Agner Fog в програмі «Оптимізація програмного забезпечення в C ++» і зводиться до способу доступу та зберігання даних у кеші.

Терміни та детальну інформацію дивіться у вікі-статті про кешування , я скорочу її тут.

Кеш організований по наборах і рядках . Одночасно використовується лише один набір, з якого можна використовувати будь-який з рядків, які він містить. Лінія пам'яті, яка може відображати кількість разів, дає нам кількість кешу.

Для конкретної адреси пам'яті ми можемо обчислити, який набір повинен відображати її за формулою:

set = ( address / lineSize ) % numberOfsets

Така формула в ідеалі дає рівномірний розподіл по наборах, оскільки кожна адреса пам'яті, швидше за все, буде прочитана (я сказав в ідеалі ).

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

Я спробую дещо наслідувати приклад з Агнера:

Припустимо, кожен набір має 4 рядки, кожен вміщує 64 байти. Спочатку ми намагаємося прочитати адресу 0x2710, яка йде в комплекті 28. І тоді ми також спробувати прочитати адреси 0x2F00, 0x3700, 0x3F00і 0x4700. Всі вони належать до одного набору. Перед читанням 0x4700усі рядки в наборі були б зайняті. Читаючи, що пам'ять вилучає існуючий рядок у наборі, рядок, який спочатку утримувався 0x2710. Проблема полягає в тому, що ми читаємо адреси, які є (для цього прикладу) 0x800один від одного. Це критичний крок (знову ж таки, для цього прикладу).

Критичний крок також можна розрахувати:

criticalStride = numberOfSets * lineSize

Змінні, розташовані на відстані criticalStrideабо множинні один від одного, змагаються за однакові лінії кеша.

Це частина теорії. Далі, пояснення (також Агнер, я пильно стежу за цим, щоб уникнути помилок):

Припустимо, матриця 64х64 (пам’ятайте, ефекти змінюються залежно від кешу) з кеш-пам'яттю 8 кб, 4 рядки на набір * розміром рядка 64 байти. Кожен рядок може містити 8 елементів у матриці (64-бітові int).

Критичним кроком буде 2048 байт, що відповідає 4 рядкам матриці (що є безперервним у пам'яті).

Припустимо, ми обробляємо рядок 28. Ми намагаємося взяти елементи цього рядка і поміняти їх елементами з стовпця 28. Перші 8 елементів рядка складають кеш-рядок, але вони перейдуть у 8 різних рядки кеша в стовпці 28. Пам'ятайте, критичний крок розташований на 4 ряди (4 послідовних елемента в стовпці)

Коли елемент 16 буде досягнутий у стовпці (4 рядки кешу на набір та 4 ряди один від одного = проблема), елемент ex-0 буде вилучений із кеша. Коли ми досягнемо кінця стовпця, всі попередні рядки кешу втратили б і потребували перезавантаження при доступі до наступного елемента (весь рядок буде перезаписано).

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

Ще одна відмова від відповідальності - я просто взяв голову навколо пояснення і сподіваюся, що я його прибив, але я можу помилитися. У будь-якому разі я чекаю відповіді (або підтвердження) від Mysticial . :)


Ой і наступного разу. Просто пінг мені прямо через Лаунж . Я не знаходжу кожного екземпляра імені на SO. :) Я бачив це лише через періодичні сповіщення електронною поштою.
— Містичне

@Mysticial @Luchian Grigore Один з моїх друзів каже мені, що його Intel core i3ПК працює на Ubuntu 11.04 i386демонструє майже таку ж продуктивність з gcc 4.6. І так само для мого комп'ютера Intel Core 2 Duoз mingw gcc4.4 , який працює на windows 7(32). Він показує велику різницю, коли Я складаю цей сегмент з трохи старшим ПК intel centrinoз gcc 4.6 , який працює на ubuntu 12.04 i386.
— Hongxu Chen

Також зауважте, що доступ до пам'яті, де адреси відрізняються по кратності 4096, має помилкову залежність від процесорів сімейства Intel SnB. (тобто таке зміщення в межах сторінки). Це може зменшити пропускну здатність, коли деякі операції зберігаються, особливо суміш вантажів і магазинів.
— Пітер Кордес

which goes in set 24ти замість цього мав на увазі "у наборі 28 "? А ви припускаєте 32 набори?
— Руслан

Ви маєте рацію, це 28. :) Я також двічі перевірив зв'язаний папір, для оригінального пояснення ви можете перейти до організації кешу 9.2
— Luchian Grigore

78

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

Ваш алгоритм:

for (int i = 0; i < N; i++) 
   for (int j = 0; j < N; j++) 
        A[j][i] = A[i][j];

що просто жахливо для сучасного процесора. Одне рішення - знати деталі щодо вашої кеш-системи та налаштувати алгоритм, щоб уникнути цих проблем. Чудово працює, доки ви знаєте ці деталі .. не особливо портативні.

Чи можемо ми зробити краще? Так, ми можемо: загальний підхід до цієї проблеми - це алгоритми, що не враховують кеш, які, як видно з назви, уникають залежності від конкретних розмірів кешу [1]

Рішення виглядатиме так:

void recursiveTranspose(int i0, int i1, int j0, int j1) {
    int di = i1 - i0, dj = j1 - j0;
    const int LEAFSIZE = 32; // well ok caching still affects this one here
    if (di >= dj && di > LEAFSIZE) {
        int im = (i0 + i1) / 2;
        recursiveTranspose(i0, im, j0, j1);
        recursiveTranspose(im, i1, j0, j1);
    } else if (dj > LEAFSIZE) {
        int jm = (j0 + j1) / 2;
        recursiveTranspose(i0, i1, j0, jm);
        recursiveTranspose(i0, i1, jm, j1);
    } else {
    for (int i = i0; i < i1; i++ )
        for (int j = j0; j < j1; j++ )
            mat[j][i] = mat[i][j];
    }
}

Трохи складніший, але короткий тест показує щось досить цікаве на моєму стародавньому e8400 з випуском VS2010 x64, тестовий код для MATSIZE 8192

int main() {
    LARGE_INTEGER start, end, freq;
    QueryPerformanceFrequency(&freq);
    QueryPerformanceCounter(&start);
    recursiveTranspose(0, MATSIZE, 0, MATSIZE);
    QueryPerformanceCounter(&end);
    printf("recursive: %.2fms\n", (end.QuadPart - start.QuadPart) / (double(freq.QuadPart) / 1000));

    QueryPerformanceCounter(&start);
    transpose();
    QueryPerformanceCounter(&end);
    printf("iterative: %.2fms\n", (end.QuadPart - start.QuadPart) / (double(freq.QuadPart) / 1000));
    return 0;
}

results: 
recursive: 480.58ms
iterative: 3678.46ms

Редагувати: Про вплив розміру: Це набагато менш виражено, хоча все ще помітно певною мірою, це тому, що ми використовуємо ітеративне рішення як вузол листів, а не повторюється до 1 (звичайна оптимізація для рекурсивних алгоритмів). Якщо ми встановимо LEAFSIZE = 1, кеш не впливає на мене [ 8193: 1214.06; 8192: 1171.62ms, 8191: 1351.07ms- це всередині межі помилки, коливання знаходяться в області 100 мс; цей "орієнтир" - це не те, що мені було б занадто комфортно, якби ми хотіли абсолютно точних значень])

[1] Джерела цього матеріалу: Ну, якщо ви не можете прочитати лекцію від того, хто працював з Лейзерсоном та співпрацював над цим. Ці алгоритми все ще описані досить рідко - CLR має єдину виноску про них. Все-таки це чудовий спосіб здивувати людей.


Редагувати (зауважте: я не той, хто опублікував цю відповідь; я просто хотів додати це):
Ось повна версія C ++ вищевказаного коду:

template<class InIt, class OutIt>
void transpose(InIt const input, OutIt const output,
    size_t const rows, size_t const columns,
    size_t const r1 = 0, size_t const c1 = 0,
    size_t r2 = ~(size_t) 0, size_t c2 = ~(size_t) 0,
    size_t const leaf = 0x20)
{
    if (!~c2) { c2 = columns - c1; }
    if (!~r2) { r2 = rows - r1; }
    size_t const di = r2 - r1, dj = c2 - c1;
    if (di >= dj && di > leaf)
    {
        transpose(input, output, rows, columns, r1, c1, (r1 + r2) / 2, c2);
        transpose(input, output, rows, columns, (r1 + r2) / 2, c1, r2, c2);
    }
    else if (dj > leaf)
    {
        transpose(input, output, rows, columns, r1, c1, r2, (c1 + c2) / 2);
        transpose(input, output, rows, columns, r1, (c1 + c2) / 2, r2, c2);
    }
    else
    {
        for (ptrdiff_t i1 = (ptrdiff_t) r1, i2 = (ptrdiff_t) (i1 * columns);
            i1 < (ptrdiff_t) r2; ++i1, i2 += (ptrdiff_t) columns)
        {
            for (ptrdiff_t j1 = (ptrdiff_t) c1, j2 = (ptrdiff_t) (j1 * rows);
                j1 < (ptrdiff_t) c2; ++j1, j2 += (ptrdiff_t) rows)
            {
                output[j2 + i1] = input[i2 + j1];
            }
        }
    }
}

2
Це було б актуально, якщо ви порівнювали часи між матрицями різного розміру, а не рекурсивними та ітераційними. Спробуйте рекурсивний розчин на матриці вказаних розмірів.
— Лучіан Григоре

@Luchian Оскільки ви вже пояснювали, чому він бачить свою поведінку, я вважав, що цілком цікавим є взагалі одне рішення цієї проблеми.
— Voo

Тому що я запитую, чому більша матриця займає коротший час для обробки, не шукаючи більш швидкого алгоритму ...
— Лучіан Григоре

@ Luuchian Різниці між 16383 та 16384 роками складають .. 28 проти 27 мс для мене тут, або приблизно 3,5% - не дуже важливі. І я був би здивований, якби це було.
— Voo

3
Це може бути цікаво пояснити, що recursiveTransposeробить, тобто, що він не заповнює кеш настільки, працюючи на невеликих плитках ( LEAFSIZE x LEAFSIZEрозмірності).
— Матьє М.

60

Як ілюстрацію до пояснення у відповіді Лухіяна Григора , ось як виглядає присутність кешового матриця для двох випадків матриць 64x64 та 65x65 (детальну інформацію про числа див. За посиланням вище).

Кольори в анімації нижче означають наступне:

  • білі - не в кеші,
  • світло-зелений - у кеші,
  • яскраво-зелений - кеш-хіт,
  • помаранчевий - просто читайте з оперативної пам’яті,
  • червоний - пропустіть кеш.

Корпус 64x64:

анімація присутності кеша для матриці 64x64

Зауважте, як майже кожен доступ до нового рядка призводить до пропуску кешу. А тепер, як це виглядає у звичайному випадку, матриці розміром 65x65:

анімація присутності кеша для матриці 65x65

Тут ви бачите, що більшість звернень після початкового розминки - це кешові звернення. Ось як кеш процесора повинен працювати в цілому.


Код, який генерував кадри для вищезазначених анімацій, можна побачити тут .


Чому звернення до кеша вертикального сканування не зберігаються в першому випадку, але у другому випадку? Схоже, що до даного блоку доступ доступний рівно один раз для більшості блоків в обох прикладах.
— Йосія Йодер

Я бачу з відповіді @ LuchianGrigore, що це тому, що всі рядки в стовпці належать до одного набору.
— Джосія Йодер

Так, чудова ілюстрація. Я бачу, що вони з однаковою швидкістю. Але насправді це не так, чи не так?
— келалака

@kelalaka так, FPS-анімація така ж. Я не імітував уповільнення, тут важливі лише кольори.
— Руслан

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