Gpu-оптимізація - прописні істини, мої it-замітки

[Pullquote align = "left | center | right" textalign = "left | center | right" width = "30%"] ядер багато не буває ... [/ pullquote]

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

У даній статті описані основні поняття, для того щоб було легше орієнтуватися в теорії gpu-оптимізації і базові правила, для того щоб до цих понять, доводилося звертатися по-рідше.

Причини по якій GPU ефективні для роботи з великими обсягами даних, що вимагають обробки:

  • у них великі можливості по паралельному виконанню завдань (багато-багато процесорів)
  • висока пропускна здатність у пам'яті

Пропускна здатність пам'яті (memory bandwidth) - це скільки інформації - біт або гігабайт - може може бути передано за одиницю часу секунду або процесорний такт.

Одне із завдань оптимізації - задіяти по максимуму пропускну здатність - збільшити показники throughput (в ідеалі вона повинна бути дорівнює memory bandwidth).

Для поліпшення використання пропускної здатності:

  • збільшити обсяг інформації - використовувати пропускної канал на повну (наприклад кожен потік працює з флоат4)
  • зменшувати латентність - затримку між операціями

Затримка (latency) - проміжок часу між моментами, коли контролер запросив конкретну
осередок пам'яті і тим моментом, коли дані стали доступні процесору для виконання інструкцій.
На саму затримку ми ніяк вплинути не можемо - ці обмеження присутні на апаратному рівні.
Саме за рахунок цієї затримки процесор може одночасно обслуговувати кілька потоків -
поки потік А запросив виділити йому пам'яті, потік Б може щось порахувати, а потік З чекати поки до нього прийдуть запитані дані.

Як знизити затримку (latency) якщо використовується синхронізація:

  • зменшити число потоків в блоці
  • збільшити число груп-блоків

Використання ресурсів GPU на повну - GPU Occupancy

Обчислювальні потужності GPU - це сотні процесорів жадібних до обчислень, при створенні програми - ядра (kernel) - на плечі програміста лягати тягар розподілу навантаження на них. Помилка може привести до того, що велика частина цих дорогоцінних ресурсів може безцільно простоювати. Зараз я поясню чому. Почати доведеться здалеку.

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

Кожна точка синхронізації, кожне розгалуження логіки може породити таку ситуацію простою. Максимальна дивергенція (розгалуження логіки виконання) залежить від розміру варпа. Для GPU від NVidia - це 32, для AMD - 64.

Для того щоб знизити простої мультипроцессора під час виконання варпа:

  • мінімізувати час очікування бар'єрів
  • мінімізувати розбіжність логіки виконання в функції-Кернелі

Для ефективного вирішення даного завдання має сенс розібратися - як же відбувається формування варпа (для випадку з неколько размерностями). Насправді порядок простий - в першу чергу по X, потім по Y і, в останню чергу, Z.

Gpu-оптимізація - прописні істини, мої it-замітки

ядро запускається з блоками розмірністю 64 × 16, потоки розбиваються по варпа в порядку X, Y, Z - тобто перші 64 елемента розбиваються на два варпа, потім другі і т.д.

Gpu-оптимізація - прописні істини, мої it-замітки

Ядро запускається з блоками розмірністю 16 × 64. У перший варп додаються перші і другі 16 елементів, в другій варп - треті і четверті і т.д.

Як знижувати дивергенцію (пам'ятаєте - розгалуження - не завжди причина критичною втрати продуктивності)

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

Як використовувати ресурси GPU по максимуму

Ресурси GPU, на жаль, теж мають свої обмеження. І, строго кажучи, перед запуском функції-Кернел має сенс визначити ліміти і при розподілі навантаження ці ліміти врахувати. Чому це важливо?

  • число блоків / робочих груп потоків має бути кратно кількості потокових процесорів
  • розмір блоку / робочої групи повинен бути кратний розміру варпа

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

В голові всі ці деталі тримати швидко набридає, тому для розрахунок gpu-occupancy NVidia запропонувала несподіваний інструмент - ексельний (!) Калькулятор набитий макросами. Туди можна ввести інформацію по максимальному числу потоків для SM, число регістрів і розмір загальної (shared) пам'яті доступних на потоковому процесорі, і використовувані параметри запуску функцій - а він видає в процентах ефективність використання ресурсів (і ви рвете на голові волосся усвідомлюючи що щоб задіяти всі ядра вам не вистачає регістрів).

GPU і операції з пам'яттю

Проблема йде в наступному - кожен запит повертає у відповідь шматочок даних розміром кратний 128 бітам. А кожен потік використовує лише чверть його (в разі звичайної чотирьох-байтовой змінної). Коли суміжні потоки одночасно працюють з даними розташованими послідовно в осередках пам'яті - це знижує загальне число звернень до пам'яті. Називається це явище - об'єднані операції читання і запису (coalesced access - good! Both read and write) - і при вірній організації коду (strided access to contiguous chunk of memory - bad!) Може відчутно поліпшити продуктивність. При організації свого ядра - пам'ятайте - суміжний доступ - в межах елементів одного рядка пам'яті, робота з елементами стовпчика - це вже не так ефективно. Хочете більше деталей? мені сподобалася ось ця pdf - або гуглити на предмет "memory coalescing techniques".

І, на останок, ще трохи про пам'ять. Колективна пам'ять на мультипроцесорі зазвичай організована у вигляді банків пам'яті містять 32 бітні слова - дані. Число банків за доброю традицією варіюється від одного покоління GPU до іншого - 16/32 Якщо кожен потік звертається за даними в окремий банк - все добре. Інакше виходить кілька запитів на читання / запис до одного банку і ми отримуємо - конфлікт (shared memory bank conflict). Такі конфліктні звернення серіалізуются і відповідно виконуються послідовно, а не паралельно. Якщо до одного банку звертаються все потоки - використовується "широкомовний" відповідь (broadcast) і конфлікту немає. Існує кілька способів ефективно боротися з конфліктами доступу, мені сподобалося опис основних методик щодо позбавлення від конфліктів доступу до банків пам'яті - тут.

Як зробити математичні операції ще швидше? Пам'ятати що:

  • обчислення подвійної точності - це високе навантаження операції з fp64 >> fp32
  • константи виду 3.13 в коді, за замовчуванням, інтерпретується як fp64 якщо явно не вказувати 3.14f
  • для оптимізації математики не зайвим буде впоратися в гайдах - а чи немає якихось прапорців у компілятора
  • виробники включають в свої SDK функції, які використовують особливості пристроїв для досягнення продуктивності (часто - на шкоду переносимості)

Брухт для профілювання:

P. S. Як більш розлогого керівництва по оптимізації, можу порекомендувати гуглити всілякі best practices guide для OpenCL і CUDA.