Що таке "зручний для кеша" код?


738

Яка різниця між кодом, непривабливим до кешу, та кодом " дружнього до кешу "?

Як я можу переконатися, що я записую кешований код?


28
Це може дати вам підказку: stackoverflow.com/questions/9936132/…
Роберт Мартін

4
Також пам’ятайте про розмір лінії кешу. У сучасних процесорах це часто 64 байти.
Джон Дайблінг

3
Ось ще одна дуже гарна стаття. Принципи застосовуються до програм C / C ++ на будь-якій ОС (Linux, MaxOS або Windows): lwn.net/Articles/255364
paulsm4

4
Питання, пов’язані з цим: stackoverflow.com/questions/8469427/…
Метт

Відповіді:


965

Прелімінарії

На сучасних комп'ютерах лише однієї структури пам'яті найнижчого рівня ( регістри ) можуть переміщувати дані за один тактовий цикл. Однак регістри дуже дорогі, і більшість комп'ютерних ядер мають менше декількох десятків регістрів (всього кілька сотень, а може і тисяча байтів ). На іншому кінці спектру пам'яті ( DRAM ) пам’ять дуже дешева (тобто буквально в мільйони разів дешевша ), але займає сотні циклів після запиту на отримання даних. Щоб подолати цей розрив між супер швидким і дорогим і надто повільним і дешевим - це кеш-пам'ять, названі L1, L2, L3 зі зменшенням швидкості та вартості. Ідея полягає в тому, що більшість виконуючого коду буде часто потрапляти на невеликий набір змінних, а решта (набагато більший набір змінних) нечасто. Якщо процесор не може знайти дані в кеші L1, він виглядає в кеш-пам'яті L2. Якщо ні, то кеш L3, а якщо ні, головна пам'ять. Кожен з цих "промаху" у часі дорогий.

(Аналогія кеш-пам’яті - це системна пам’ять, оскільки системна пам’ять - це занадто зберігання на жорсткому диску. Зберігання жорсткого диска - дуже дешево, але дуже повільно)

Кешування - один з основних методів зниження впливу затримки . Перефразовуючи Herb Sutter (див. Посилання нижче): збільшити пропускну здатність легко, але ми не можемо придбати вихід із затримки .

Дані завжди отримуються за допомогою ієрархії пам'яті (найменший == найшвидший до найповільнішого). Кеш / промах зазвичай відноситься до хіту / промаху в найвищому рівні кеша - пам'яті в CPU - на самому високому рівні , я маю в виду найбільшою == повільним. Швидкість удару кеша є вирішальною для продуктивності, оскільки кожен пропущений кеш приводить до отримання даних з оперативної пам'яті (або ще гірше ...), що вимагає багато часу (сотні циклів для оперативної пам’яті, десятки мільйонів циклів для жорсткого диска). Для порівняння, зчитування даних із кешу (найвищого рівня) зазвичай займає лише кілька циклів.

У сучасних комп'ютерних архітектурах вузькі місця продуктивності залишають процесорний процес (наприклад, доступ до оперативної пам’яті або вище). Це з часом тільки погіршиться. Збільшення частоти процесора наразі вже не має значення для підвищення продуктивності. Проблема - доступ до пам'яті. Тому зусилля з розробки програмного забезпечення в центральних процесорах в даний час в основному зосереджені на оптимізації кеш-пам'яті, попередньої завантаження, конвеєрів та одночасності. Наприклад, сучасні процесори витрачають близько 85% загиблих на кеші і до 99% для зберігання / переміщення даних!

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

Основні поняття для зручного кешу коду

Дуже важливим аспектом коду, кероване кешем, є все про принцип локальності , мета якого - розміщення пов'язаних даних в пам’яті для забезпечення ефективного кешування. Що стосується кеш-процесора, важливо бути в курсі рядків кеша, щоб зрозуміти, як це працює: Як працюють лінії кеша?

Наступні конкретні аспекти мають велике значення для оптимізації кешування:

  1. Тимчасовий локалітет : коли було доступно дане місце пам’яті, цілком ймовірно, що до цього ж місця знову відкриється найближчим часом. В ідеалі ця інформація все ще буде кешована в цей момент.
  2. Просторова локалізація : це стосується розміщення пов'язаних даних поблизу. Кешування відбувається на багатьох рівнях, а не лише в процесорі. Наприклад, коли ви читаєте з оперативної пам’яті, зазвичай виймається більший об’єм пам’яті, ніж те, що було спеціально запитувано, оскільки дуже часто програма потребує цих даних незабаром. Кеші HDD дотримуються тієї ж думки. Зокрема, для кеш-процесорів важливе значення має поняття кеш-ліній .

Використовуйте відповідне контейнери

Простий приклад кеш-пам’яті проти кеш-недружнього 's std::vectorпроти std::list. Елементи a std::vectorзберігаються в суміжній пам'яті, і тому такий доступ до них набагато більш сприятливий для кешу, ніж доступ до елементів в a std::list, який зберігає його вміст у всьому місці. Це пов’язано з просторовою локальністю.

Дуже приємну ілюстрацію цього дає Б'ярн Струструп у цьому відеоролику на YouTube (спасибі @Mohammad Ali Baydoun за посилання!).

Не нехтуйте кешем у структурі даних та дизайні алгоритму

По можливості намагайтеся адаптувати структуру даних та порядок обчислень таким чином, щоб максимально використовувати кеш. Поширеною технікою в цьому плані є блокування кешу (версія Archive.org) , яке має надзвичайно важливе значення у високопродуктивних обчисленнях (див., Наприклад, ATLAS ).

Знати та використовувати неявну структуру даних

Ще один простий приклад, про який багато людей у ​​цій галузі іноді забувають - це головний стовпчик (колишній. ,) проти впорядкування основних рядків (напр. ,) для зберігання двомірних масивів. Наприклад, розглянемо таку матрицю:

1 2
3 4

У порядку впорядкування основних рядків це зберігається в пам'яті як 1 2 3 4; у впорядкуванні основного стовпця це зберігається як 1 3 2 4. Неважко помітити, що реалізації, які не використовують це впорядкування, швидко натраплять на проблеми з кешем (які легко уникнути!). На жаль, такі речі дуже часто я бачу в своєму домені (машинне навчання). @MatteoItalia показав цей приклад більш докладно у своїй відповіді.

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

Для простоти, припустимо, що кеш містить одну лінію кешу, яка може містити 2 елементи матриці, і що коли даний елемент витягується з пам'яті, наступний теж. Скажімо, ми хочемо взяти суму над усіма елементами в прикладі матриці 2x2 вище (давайте назвати це M):

Експлуатування замовлення (наприклад, спочатку зміна індексу стовпців у ):

M[0][0] (memory) + M[0][1] (cached) + M[1][0] (memory) + M[1][1] (cached)
= 1 + 2 + 3 + 4
--> 2 cache hits, 2 memory accesses

Не використовувати замовлення (наприклад, спочатку змінити індекс рядка в ):

M[0][0] (memory) + M[1][0] (memory) + M[0][1] (memory) + M[1][1] (memory)
= 1 + 3 + 2 + 4
--> 0 cache hits, 4 memory accesses

У цьому простому прикладі використання замовлення приблизно подвоює швидкість виконання (оскільки для доступу до пам'яті потрібно набагато більше циклів, ніж обчислення сум). На практиці різниця в продуктивності може бути значно більшою.

Уникайте непередбачуваних гілок

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

Це пояснено дуже добре тут (завдяки @ 0x90 за посилання): Чому обробка відсортованого масиву швидша, ніж обробка несортованого масиву?

Уникайте віртуальних функцій

В контексті , virtualметоди представляють суперечливе питання щодо помилок кешу (існує загальний консенсус, що їх слід уникати, коли це можливо, з точки зору продуктивності). Віртуальні функції можуть спричиняти пропуски кешу під час пошуку, але це відбувається лише в тому випадку, якщо конкретна функція не викликається часто (інакше вона може бути кешована), тому деякі розцінюють це як проблему. Для ознайомлення з цією проблемою перевірте: яка вартість продуктивності наявності віртуального методу в класі C ++?

Поширені проблеми

Поширена проблема в сучасних архітектурах з багатопроцесорними кешами називається помилковим обміном . Це відбувається, коли кожен окремий процесор намагається використовувати дані в іншій області пам’яті та намагається зберігати їх у тій же лінії кешу . Це призводить до того, що рядок кешу, який містить дані, який може використовувати інший процесор, знову і знову перезаписується. Ефективно, різні потоки змушують один одного чекати, викликаючи пропуски кешу в цій ситуації. Дивіться також (спасибі @Matt за посилання): Як і коли вирівняти розмір лінії кешу?

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


27
можливо, ви могли б трохи розширити відповідь, також пояснивши, що в -multithreaded код-дані також можуть бути занадто локальними (наприклад, помилковим спільним доступом)
TemplateRex

2
Кеш може мати стільки рівнів, скільки дизайнери чіпів вважають корисними. Як правило, вони врівноважують швидкість та розмір. Якщо ви можете зробити кеш L1 таким же великим, як L5, і так само швидко, вам знадобиться лише L1.
Рафаель Баптиста

24
Я усвідомлюю, що на StackOverflow відхилені порожні повідомлення про згоду, але це, чесно кажучи, найясніший, найкращий відповідь, який я бачив досі. Відмінна робота, Марк.
Джек Едлі

2
@JackAidley дякую за похвалу! Коли я побачив увагу, яке приділяв цьому питанню, зрозумів, що багатьох людей може зацікавити дещо обширне пояснення. Я радий, що це корисно.
Marc Claesen

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

140

На додаток до відповіді @Marc Claesen, я вважаю, що повчальним класичним прикладом коду, непривабливого до кешу, є код, який сканує двовимірний масив C (наприклад, зображення растрового зображення) замість рядка.

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

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

І все, що потрібно, щоб зіпсувати виставу - це піти

// Cache-friendly version - processes pixels which are adjacent in memory
for(unsigned int y=0; y<height; ++y)
{
    for(unsigned int x=0; x<width; ++x)
    {
        ... image[y][x] ...
    }
}

до

// Cache-unfriendly version - jumps around in memory for no good reason
for(unsigned int x=0; x<width; ++x)
{
    for(unsigned int y=0; y<height; ++y)
    {
        ... image[y][x] ...
    }
}

Цей ефект може бути досить драматичним (кілька порядків швидкості) в системах з невеликими кешами та / або в роботі з великими масивами (наприклад, 10+ мегапікселів на 24 bpp зображення на поточних машинах); з цієї причини, якщо вам доведеться зробити багато вертикальних сканувань, часто краще спочатку повернути зображення на 90 градусів і пізніше виконати різні аналізи, обмеживши кеш-код, непривітний, лише обертанням.


Помилка, це має бути х <ширина?
mowwwalker

13
Сучасні редактори зображень використовують плитку як внутрішнє сховище, наприклад блоки розміром 64x64 пікселів. Це набагато зручніше для кеш-пам'яті для локальних операцій (розміщення даб, запуск фільтра розмиття), оскільки більшість часу сусідні пікселі є близькими в пам'яті в обох напрямках.
максимум

Я спробував встановити подібний приклад на своїй машині, і виявив, що часи були однакові. Хто-небудь ще намагався приурочити його?
gsingh2011

@ I3arnon: nope, перший - кеш-пам'ять, оскільки зазвичай в масивах C зберігаються в порядку основного рядка (звичайно, якщо ваше зображення з якихось причин зберігається в порядку стовпців-мажорних зворотних).
Маттео Італія

1
@Gauthier: так, перший фрагмент - хороший; Я думаю, що коли я писав це, я думав так: "Все, що потрібно [щоб зіпсувати роботу робочого додатку], - це перейти від ... до ..."
Маттео Італія

88

Оптимізація використання кешу значною мірою зводиться до двох факторів.

Місцевість довідника

Перший фактор (на який уже згадували інші) - це місце відліку. Місцевість відліку дійсно має два виміри: простір та час.

  • Просторовий

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

По-друге, ми хочемо, щоб інформація, яка буде оброблятися разом, також знаходилася разом. Типовий кеш працює в "рядках", це означає, що коли ви отримуєте доступ до деякої інформації, інша інформація за сусідніми адресами буде завантажена в кеш з тією частиною, яку ми торкнулися. Наприклад, коли я торкаюся одного байта, кеш може завантажувати 128 або 256 байт поблизу цього. Щоб скористатися цим, ви, як правило, хочете, щоб дані, упорядковані, збільшили максимум ймовірності того, що ви також будете використовувати ті інші дані, які були завантажені одночасно.

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

  • Час

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

Так як ви додали це як C ++, я вкажу на класичний приклад щодо кеш-недружнім дизайн: std::valarray. valarrayПеревантаження найбільш арифметичні оператори, так що я можу (наприклад) сказати a = b + c + d;(де a, b, cі dвсе valarrays) , щоб зробити поелементне складання цих масивів.

Проблема в цьому полягає в тому, що він проходить через одну пару входів, дає результати тимчасові, проходить через іншу пару входів тощо. Маючи велику кількість даних, результат одного обчислення може зникнути з кешу, перш ніж він буде використаний у наступному обчисленні, тому ми закінчуємо читання (записування) даних неодноразово, перш ніж отримуємо наш кінцевий результат. Якщо кожен елемент кінцевого результату буде що - щось на зразок (a[n] + b[n]) * (c[n] + d[n]);, ми зазвичай вважаємо за краще читати кожен a[n], b[n], c[n]і d[n]один раз, робити обчислення, записуємо результат, збільшення nі повторіть «сезам ми зробили. 2

Обмін рядками

Другий головний фактор - уникнення спільного використання ліній. Щоб зрозуміти це, нам, ймовірно, потрібно створити резервну копію та трохи подивитися, як організовано кеші. Найпростіша форма кешу - це пряме відображення. Це означає, що одна адреса в основній пам'яті може зберігатися лише в одному конкретному місці в кеші. Якщо ми використовуємо два елементи даних, які відображають на одне місце в кеші, це працює погано - кожен раз, коли ми використовуємо один елемент даних, інший повинен бути видалений з кешу, щоб звільнити місце для іншого. Решта кешу може бути порожньою, але ці елементи не використовуватимуть інші частини кеша.

Щоб цього не допустити, більшість кеш-пам'яток називають "встановити асоціативний". Наприклад, у кеш-сховищі 4-ходового набору будь-який елемент з основної пам'яті може зберігатися в будь-якому з 4 різних місць кеша. Отже, коли кеш буде завантажувати елемент, він шукає найменш використаний 3 елемент серед цих чотирьох, передає його в основну пам'ять і завантажує новий елемент на своє місце.

Проблема, ймовірно, досить очевидна: для кешу з прямим відображенням два операнди, які трапляються на карті в одне місце кешу, можуть призвести до поганої поведінки. N-шлях кеш-асоціативного кеша збільшує число від 2 до N + 1. Впорядкування кешу в більш "способи" вимагає додаткової схеми і, як правило, працює повільніше, тому (наприклад, 8192-набір асоційованого кеша також рідко є хорошим рішенням.

Зрештою, цей фактор важче контролювати в портативному коді. Ваш контроль над тим, де розміщуються ваші дані, зазвичай досить обмежений. Гірше, точне відображення від адреси до кешу різниться між інакше подібними процесорами. У деяких випадках, однак, варто зробити такі речі, як виділення великого буфера, а потім використовувати лише частини того, що ви виділили, щоб забезпечити обмін даними одними і тими ж лініями кешу (хоча, ймовірно, вам потрібно буде виявити точний процесор і діяти відповідно, щоб це зробити).

  • Неправдивий обмін

Є ще один пов'язаний елемент, який називається "помилковим спільним доступом". Це виникає в багатопроцесорній або багатоядерній системі, де два (або більше) процесорів / ядер мають окремі дані, але потрапляють у ту саму лінію кешу. Це змушує обох процесорів / ядер координувати свій доступ до даних, хоча кожен має свій окремий елемент даних. Особливо, якщо обидві модифікують дані по черзі, це може призвести до значного уповільнення, оскільки дані повинні постійно перетинатися між процесорами. Це неможливо легко вилікувати, організувавши кеш у більш "способи" або щось подібне. Основний спосіб запобігти це - гарантувати, що два потоки рідко (бажано ніколи) не змінюють дані, які, можливо, можуть знаходитися в одній лінії кешу (з однаковими застереженнями щодо складності управління адресами, за якими розподіляються дані).


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

  2. Якщо хтось valarrayзамислюється про те, як це (розроблено спеціально для продуктивності), може бути це дуже неправильно, це зводиться до одного: він був справді розроблений для таких машин, як старі Крейси, які використовували швидку основну пам'ять і не кешували. Для них це справді був майже ідеальним дизайном.

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


1
Мені подобаються зайві відомості у вашій відповіді, зокрема valarrayприклад.
Marc Claesen

1
Нарешті: простий опис заданої асоціативності! EDIT далі: Це одна з найінформативніших відповідей на SO. Дякую.
Інженер

32

Ласкаво просимо у світ Дизайну, орієнтованого на дані. Основна мантра полягає в сортуванні, усуненні гілок, партії, усуненні virtualвикликів - всі кроки до кращого місцевості.

Оскільки ви позначили це питання C ++, ось обов'язковий типовий C ++ Bullshit . Тоні Альбрехт « Підводні камені об’єктно-орієнтованого програмування» також є чудовим вступом у цю тему.


1
що ви маєте на увазі під партією, можна не зрозуміти.
0x90

5
Дозування: замість виконання одиниці роботи над одним об’єктом виконайте його на партії предметів.
аруль

Блокування AKA, блокування регістрів, блокування кешів.
0x90

1
Блокування / неблокування зазвичай стосується того, як об’єкти поводяться в одночасному середовищі.
arul

2
дозування == векторизація
Amro

23

Тільки закладаємо: класичний приклад непридатного до кешу порівняно з кешами зручного коду - це "блокування кеша" матриці множення.

Наївна матриця множення виглядає так:

for(i=0;i<N;i++) {
   for(j=0;j<N;j++) {
      dest[i][j] = 0;
      for( k==;k<N;i++) {
         dest[i][j] += src1[i][k] * src2[k][j];
      }
   }
}

Якщо Nвін великий, наприклад, якщо N * sizeof(elemType)більший розмір кеша, то кожен доступ до нього src2[k][j]буде пропущеним кешем.

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

int itemsPerCacheLine = CacheLineSize / sizeof(elemType);

for(i=0;i<N;i++) {
   for(j=0;j<N;j += itemsPerCacheLine ) {
      for(jj=0;jj<itemsPerCacheLine; jj+) {
         dest[i][j+jj] = 0;
      }
      for( k=0;k<N;k++) {
         for(jj=0;jj<itemsPerCacheLine; jj+) {
            dest[i][j+jj] += src1[i][k] * src2[k][j+jj];
         }
      }
   }
}

Якщо розмір рядка кеш-пам'яті становить 64 байти, а ми працюємо на 32-бітових (4-байтних) поплавках, то в кеш-рядку є 16 елементів. А кількість кеш-пропусків за допомогою цього простого перетворення зменшується приблизно в 16 разів.

Фантастичні перетворення працюють на 2D плитках, оптимізують для декількох кешів (L1, L2, TLB) тощо.

Деякі результати "блокування кешу" Google:

http://stumptown.cc.gt.atl.ga.us/cse6230-hpcta-fa11/slides/11a-matmul-goto.pdf

http://software.intel.com/en-us/articles/cache-blocking-techniques

Приємна відеоанімація оптимізованого алгоритму блокування кешу.

http://www.youtube.com/watch?v=IFWgwGMMrh0

Петля плиткою дуже тісно пов'язана:

http://en.wikipedia.org/wiki/Loop_tiling


7
Людей, які читають це, також може зацікавити моя стаття про множення матриць, де я перевірив ikj-алгоритм "сприятливого до кешу" та недружнього ijk-алгоритму шляхом множення двох матриць 2000x2000.
Мартін Тома

3
k==;Я сподіваюся, що це друкарська помилка?
TrebledJ

13

Сьогодні процесори працюють з багатьма рівнями каскадної області пам'яті. Таким чином у процесора буде купа пам'яті, яка знаходиться на самому чіпі процесора. Він має дуже швидкий доступ до цієї пам'яті. Існують різні рівні кешу, кожен з яких має повільніший доступ (і більший), ніж наступний, доки ви не перейдете до системної пам’яті, яка відсутня в процесорі та є відносно набагато повільнішою для доступу.

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

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

Дружній код кешу намагається зберегти доступ близько до пам’яті, щоб мінімізувати пропуски кешу.

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

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


4

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

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

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

Функція повинна починатися з адреси кешування рядків кешу. Хоча для цього є компілятори компілятора (gcc), майте на увазі, що якщо функції дуже короткі, для кожної з них може бути марно зайняти цілу лінію кешу. Наприклад, якщо три найчастіше використовувані функції вміщуються в один 64-байтний кеш-рядок, це менш марно, ніж якщо кожна з них має свою лінію і призводить до двох ліній кеша, менш доступних для іншого використання. Типове значення вирівнювання може бути 32 або 16.

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


2

Як @Marc Claesen зазначав, що один із способів написати дружній код кеша - це використання структури, в якій зберігаються наші дані. На додаток до цього ще одним способом написання зручного кеш-коду є: змінити спосіб зберігання наших даних; потім напишіть новий код для доступу до даних, що зберігаються в цій новій структурі.

Це має сенс у випадку, як системи баз даних лінеаризують кортежі таблиці та зберігають їх. Є два основні способи зберігання кортежів таблиці, тобто зберігання рядків та стовпців. У магазині рядків, як випливає з назви, кортежі зберігаються рядно. Припустимо, що таблиця з назвою, Productщо зберігається, має 3 атрибути, тобто int32_t key, char name[56]і int32_t price, тому загальний розмір кортежу становить 64байти.

Ми можемо імітувати виконання основних запитів зберігання рядків у основній пам'яті, створивши масив Productструктур розміром N, де N - кількість рядків у таблиці. Такий макет пам'яті також називають масивом структур. Тож структура продукту може бути такою:

struct Product
{
   int32_t key;
   char name[56];
   int32_t price'
}

/* create an array of structs */
Product* table = new Product[N];
/* now load this array of structs, from a file etc. */

Аналогічно ми можемо імітувати дуже базове виконання запиту зберігання стовпців у основній пам'яті, створивши 3 масиви розміром N, один масив для кожного атрибуту Productтаблиці. Таке розташування пам’яті ще називають структурою масивів. Отже, 3 масиви для кожного атрибуту Product можуть виглядати так:

/* create separate arrays for each attribute */
int32_t* key = new int32_t[N];
char* name = new char[56*N];
int32_t* price = new int32_t[N];
/* now load these arrays, from a file etc. */

Тепер після завантаження масиву структур (макет рядків) та 3 окремих масивів (макет стовпців) Productу нас на пам’яті зберігаються рядки і сховища стовпців .

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

SELECT SUM(price)
FROM PRODUCT

Для магазину рядків ми можемо перетворити вищезазначений запит SQL в

int sum = 0;
for (int i=0; i<N; i++)
   sum = sum + table[i].price;

Для магазину стовпців ми можемо перетворити вищезазначений SQL запит у

int sum = 0;
for (int i=0; i<N; i++)
   sum = sum + price[i];

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

Припустимо, що розмір рядка кеша - це 64байти.

У випадку компонування рядків, коли читається рядок кешу, ціна лише 1 ( cacheline_size/product_struct_size = 64/64 = 1) кортежу зчитується, тому що наш розмір структури - 64 байти, і він заповнює весь наш рядок кешу, тому для кожного кортежу пропуск кеша відбувається у випадку макетів рядків

У випадку компонування стовпців при зчитуванні рядка кеша зчитується цінова вартість 16 ( cacheline_size/price_int_size = 64/4 = 16) кортежів, тому що 16 суміжних цін цін, що зберігаються в пам'яті, вносяться в кеш, тому для кожного шістнадцятого кортежу кеш пропускає у випадку макет стовпця

Таким чином, макет стовпців буде швидшим у випадку заданого запиту, і швидшим при таких запитах агрегації на підмножині стовпців таблиці. Ви можете спробувати такий експеримент для себе, скориставшись даними з еталону TPC-H , і порівняти час виконання для обох макетів. Стаття у вікіпедії про системи баз даних, орієнтованих на стовпці, також хороша.

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


1

Майте на увазі, що кеші не просто кешують постійну пам'ять. Вони мають кілька ліній (щонайменше 4), тому розривна пам'ять, що перекривається, часто може зберігатися так само ефективно.

Те, чого не вистачає у всіх вищезазначених прикладах, - це вимірювані орієнтири. Існує багато міфів про продуктивність. Якщо ви її не виміряєте, ви не знаєте. Не ускладнюйте свій код, якщо ви не помітно покращили.

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