Множення матриці: мала різниця у розмірі матриці, велика різниця в термінах


77

У мене є код матричного множення, який виглядає так:

Тут розмір матриці представлений dimension. Тепер, якщо розмір матриць 2000, для запуску цього фрагмента коду потрібно 147 секунд, тоді як якщо розмір матриць 2048, то це займає 447 секунд. Тож, хоча різниці в ні. множення становить (2048 * 2048 * 2048) / (2000 * 2000 * 2000) = 1,073, різниця в термінах складає 447/147 = 3. Хтось може пояснити, чому це трапляється? Я очікував, що масштаб буде лінійним, чого не відбувається. Я не намагаюся зробити найшвидший матричний код множення, просто намагаюся зрозуміти, чому це відбувається.

Технічні характеристики: двоядерний вузол AMD Opteron (2,2 ГГц), 2 ГБ оперативної пам'яті, gcc v 4.5.0

Програма складена як gcc -O3 simple.c

Я також запустив це на компіляторі icc від Intel і бачив подібні результати.

РЕДАГУВАТИ:

Як запропоновано в коментарях / відповідях, я запустив код із розмірністю = 2060, і це займає 145 секунд.

Ось повна програма:


9
Можливо, ключовим для вашого розуміння є те, що множення матриць не масштабується лінійно, ваш код має порядок O(n^3).
brc

6
Можливо, кешування пов’язане, враховуючи потужність двох років 2048 року?
Крістіан Рау

12
@brc Я не знаю, як це якось пов'язано з його проблемою. Він повністю усвідомлює складність свого алгоритму. Ви навіть читали питання?
Крістіан Рау

3
Спробуйте перевірити, наприклад, з розміром = 2060 - це покаже вам, чи проблема пов’язана, наприклад, з розміром кешу, чи це проблема супервирівнювання, наприклад, обробка кешу або обробка TLB.
Paul R

2
Зверніть увагу, що транспонування однієї з матриць (можна виконати її на місці) призведе до кращих результатів для цих типових розмірів (точка беззбитковості може змінюватися). Дійсно, транспонування - це O (n ^ 2) (проти множення O (n ^ 3)), і доступ до пам'яті здійснюється послідовно для обох матриць, що призводить до кращого використання кешу.
Alexandre C.

Відповіді:


84

Ось моя дика здогадка: кеш

Можливо, ви можете помістити doubleв кеш 2 ряди по 2000 с. Що трохи менше, ніж кеш-пам’ять L1 на 32 кб. (поки залишаєте місце інші необхідні речі)

Але коли ви збільшуєте його до 2048 року, він використовує весь кеш (а ви проливаєте трохи, тому що вам потрібно місце для інших речей)

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

Інша можливість - асоціативність кеш-пам'яті завдяки силі двох. Хоча я думаю, що процесор є двостороннім асоціативним L1, тому я не думаю, що це має значення в цьому випадку. (але я все одно викину ідею)

Можливе пояснення 2: Пропущено кеш конфлікту через надмірне вирівнювання в кеші L2.

Ваш Bмасив повторюється в стовпці. Тож доступ обмежений. Загальний обсяг даних 2k x 2kстановить приблизно 32 МБ на матрицю. Це набагато більше, ніж ваш кеш L2.

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

Однак, коли дані ідеально вирівняні (2048), усі ці стрибки потраплять на один і той самий "шлях кешування" і значно перевищують вашу асоціативність кешу L2. Тому доступні рядки кеш-пам’яті Bне залишатимуться в кеш-пам'яті для наступної ітерації. Натомість їх потрібно буде витягнути з барана.


3
Я згоден із підозрою на кеш. Ви можете провести набір експериментів і побудувати графік часу виконання та розміру. Якщо це кеш-пам’ять, ви побачите лінійність в околиці подібних розмірів, з деякими різкими точками руйнування, де ви отримаєте великий крок і зміну лінійного нахилу.
TJD

2
Не просто розмір кешу - коли матриці супер вирівняні, як у випадку 2048 року, тоді ви можете побачити проблеми з обміном кешу, обміном TLB тощо. Спробуйте, наприклад, 2060 і подивіться, що станеться ...
Paul R

Я запустив його з розміром = 2060, і це зайняло 145 секунд. Дивлячись на пояснення 2, це теж має призвести до бідного просторового розташування. Для розміру> = 2048 рядки кеш-пам'яті B потрібно буде отримати з оперативної пам'яті, так?
jitihsk

2
@AhmedMasud І я також не думаю, що використання timesпояснює його проблему.
Крістіан Рау

4
Через спосіб роботи кеш-пам’яті, N-сторонній кеш може вміщувати не більше N кеш-ліній з однаковою адресою за модулем великої потужності двох. (Я не знаю точного числа, якщо ви не скажете, яка модель процесора у вас є.) Коли N = 2048, кеш-лінії, до яких отримують доступ bусі, мають адресу з однаковим модулем над силою двох. Тож вони будуть конфліктувати. (Google: "Conflict Cache Miss")
Містичний

34

Ви точно отримуєте те, що я називаю кеш- резонансом . Це схоже на псевдонім , але не зовсім те саме. Дозволь пояснити.

Кеші - це апаратні структури даних, які витягують одну частину адреси та використовують її як індекс у таблиці, на відміну від масиву в програмному забезпеченні. (Насправді ми апаратно називаємо їх масивами.) Масив кешу містить рядки кешу даних та теги - іноді один такий запис на індекс у масиві (пряме відображення), іноді кілька таких (асоціативність набору N-шляхів). Другу частину адреси витягують і порівнюють із тегом, що зберігається в масиві. Разом індекс та тег однозначно ідентифікують адресу пам’яті кеш-лінії. Нарешті, решта адресних бітів визначає, які байти в рядку кешу адресуються, разом із розміром доступу.

Зазвичай індекс і тег - це прості бітові поля. Тож адреса пам'яті виглядає так

(Іноді індекс і тег є хешами, наприклад, кілька XOR інших бітів у біти середнього діапазону, які є індексом. Набагато рідше, іноді індекс і рідше тег, - це такі речі, як прийняття адреси рядка кешу за модулем a Просте число. Ці більш складні обчислення індексу - це спроби боротися з проблемою резонансу, яку я поясню тут. Усі страждають певною формою резонансу, але найпростіші схеми вилучення бітового поля мають резонанс на загальних шаблонах доступу, як ви вже знайшли.)

Отже, типові значення ... існує безліч різних моделей "Opteron Dual Core", і я не бачу тут нічого, що вказує, яка саме у вас є. Вибравши навмання, останнє керівництво, яке я бачу на веб-сайті AMD, Посібник розробника Bios і ядра (BKDG) для сімейних моделей AMD 15h 00h-0Fh , 12 березня 2012 р.

(Сімейство 15h = сімейство бульдозерів, найновіший процесор високого класу - BKDG згадує двоядерність, хоча я не знаю номер продукту, який саме ви описуєте. Але, як би там не було, однакова ідея резонансу стосується всіх процесорів, просто такі параметри, як розмір кешу та асоціативність, можуть дещо відрізнятися.)

З стор.33:

Процесор AMD Family 15h містить 16-Кбайтний 4-провідний кеш-пам'ять L1 з двома 128-бітними портами. Це кеш-пам'ять, що підтримує до двох 128 байтних навантажень за цикл. Він розділений на 16 банків, кожен шириною 16 байт. [...] Тільки одне завантаження може бути виконане з даного банку кешу L1 за один цикл.

Підсумовуючи:

  • 64-байтовий рядок кешу => 6 зміщених бітів у рядку кешу

  • 16KB / 4-way => резонанс 4KB.

    Тобто біти адреси 0-5 є зміщенням лінії кешу.

  • Рядки кешу 16KB / 64B => 2 ^ 14/2 ^ 6 = 2 ^ 8 = 256 рядків кешу в кеші.
    (Виправлення: спочатку я прорахував це як 128. що я виправив усі залежності.)

  • 4 способи асоціативного => 256/4 = 64 індексу в масиві кешу. Я (Intel) називаю ці "набори".

    тобто ви можете розглядати кеш як масив із 32 записів або наборів, кожен запис містить 4 рядки кешу та їх теги. (Це складніше, ніж це, але це нормально).

(До речі, терміни "набір" та "спосіб" мають різні визначення .)

  • є 6 бітів індексу, біти 6-11 за найпростішою схемою.

    Це означає, що будь-які рядки кешу, які мають абсолютно однакові значення в бітах індексу, бітах 6-11, будуть зіставлені з однаковим набором кешу.

А тепер подивіться на свою програму.

Цикл k - це внутрішній цикл. Основний тип - подвійний, 8 байт. Якщо розмір = 2048, тобто 2K, то послідовні елементи, до яких B[dimension*k+j]звертається цикл, будуть складати 2048 * 8 = 16K байт. Всі вони зіставляться з одним і тим же набором кешу L1 - вони всі матимуть однаковий індекс у кеші. Це означає, що замість того, щоб у кеші було 256 рядків кешу, доступних для використання, буде лише 4 - "4-напрямкова асоціативність" кешу.

Тобто ви, мабуть, будете пропускати кеш кожні 4 ітерації навколо цього циклу. Не добре.

(Насправді все трохи складніше. Але вищевказане є першим хорошим розумінням. Адреси записів B, згадані вище, є віртуальними адресами. Отже, можуть бути дещо інші фізичні адреси. Більше того, Bulldozer має спосіб передбачуваного кешу, ймовірно, використовуючи біти віртуальних адрес, щоб йому не довелося чекати перекладу віртуальної на фізичну адресу. Але, у будь-якому випадку: ваш код має "резонанс" 16K. Кеш даних L1 має резонанс 16K. Не добре .)]

Якщо ви трохи зміните розмір, наприклад, на 2048 + 1, тоді адреси масиву B будуть розподілені по всіх наборах кешу. І ви отримаєте значно менше помилок кешу.

Це досить поширена оптимізація підкладання масивів, наприклад, для зміни 2048 на 2049, щоб уникнути цього srt-резонансу. Але "блокування кешу - це ще важливіша оптимізація. Http://suif.stanford.edu/papers/lam-asplos91.pdf


Окрім резонансу лінії кеш-пам'яті, тут діють і інші речі. Наприклад, кеш-пам'ять L1 має 16 банків, кожен шириною 16 байт. Якщо розмірність = 2048, послідовний доступ до B у внутрішньому циклі завжди надходитиме до одного і того ж банку. Отже, вони не можуть пройти паралельно - і якщо доступом A стане той самий банк, ви програєте.

Я не думаю, дивлячись на це, що це таке велике, як резонанс кешу.

І так, можливо, може відбуватися псевдонім. Наприклад, STLF (Store To Load Forwarding buffers), можливо, порівнює лише за допомогою невеликого бітового поля і отримує помилкові збіги.

(Насправді, якщо задуматися, резонанс у кеші схожий на псевдонім, пов’язаний із використанням бітових полів. Резонанс викликаний кількома лініями кешу, що відображають один і той же набір, а не поширюються по колу. біт.)


Загалом, моя рекомендація щодо тюнінгу:

  1. Спробуйте заблокувати кеш без подальшого аналізу. Я кажу це, тому що блокувати кеш легко, і дуже ймовірно, що це все, що вам потрібно було б зробити.

  2. Після цього використовуйте VTune або OProf. Або Cachegrind. Або ...

  3. А ще краще - скористатися добре налаштованою бібліотечною процедурою для множення матриць.


2
Дуже цікава відповідь (+1), але жахливе форматування та редагування :) Я зробив все, щоб трохи його вдосконалити.
UncleZeiv

Приємно. маленька друкарська помилка: 256 рядків кеш-пам’яті замість 128.
Тей,

Дякуємо, що зрозуміли це: 2 ^ 8 = 256. Я спробую виправити, але, впевнений, я не вловлюю всіх залежностей. Ще коли я працював у Intel, я написав невеличку "Таблицю безкоштовного тексту", яка дозволяла розміщувати формули в тексті: вводити нове число, і виправлення розповсюджувалось. (Я писав це в
Крейзі Глі

17

Є кілька можливих пояснень. Одне з можливих пояснень полягає в тому, що пропонує Mysticial : вичерпання обмеженого ресурсу (кеш-пам’яті або TLB). Ще однією ймовірною можливістю є помилковий зрив псевдонімів, який може статися, коли послідовні звернення до пам'яті відокремлюються кратним кількома степенями двох (часто 4 КБ).

Ви можете почати звужувати дію, будуючи графік часу / розміру ^ 3 для діапазону значень. Якщо ви продули кеш або вичерпали охоплення TLB, ви побачите більш-менш рівну ділянку, за якою різко підніметься між 2000 і 2048 роками, а потім ще одну плоску секцію. Якщо ви бачите кіоски, пов’язані з псевдонімами, ви побачите більш-менш плоский графік з вузьким стрибком вгору в 2048 році.

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


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

10

Я знаю, що це занадто давно, але я перекушу. Це (як уже було сказано) проблема з кешем, що спричиняє уповільнення приблизно на рівні двох. Але з цим є ще одна проблема: це занадто повільно. Якщо ви подивитесь на ваш цикл обчислень.

Найбільш внутрішній цикл змінює k на 1 кожну ітерацію, це означає, що ви отримуєте доступ лише до 1 подвоєного від останнього елемента, який ви використовували з A, але цілий "вимір" подвоюється від останнього елемента B. Це не використовує жодних переваг кешування елементів B.

Якщо ви зміните це на:

Ви отримуєте абсолютно однакові результати (по модулю помилки асоціативності подвійного додавання), але це набагато зручніше кеш-пам’яті ( локальне ). Я спробував, і це дає суттєві покращення. Це можна узагальнити як

Не множте матриці за визначенням, а скоріше за рядками


Приклад прискорення (я змінив ваш код, щоб взяти розмір як аргумент)


Як бонус (і що пов’язано з цим питанням) є те, що цей цикл не страждає від попередньої проблеми.

Якщо ви вже все це знали, то я перепрошую!


+1 Кращий алгоритм завжди має більшу різницю - незалежно від того, який тип кешу (або навіть якщо він є) це швидше.
Джеррі Єремія

9

Кілька відповідей згадували проблеми L2 Cache.

Ви можете перевірити це за допомогою моделювання кешу . Це може зробити інструмент кешгринда Valgrind .

Встановіть параметри командного рядка так, щоб вони відповідали параметрам L2 вашого процесора.

Перевірте його з різними розмірами матриці, ви, ймовірно, побачите раптове збільшення коефіцієнта пропуску L2.

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