Saturday, March 16, 2024

Графічний процесор - Стиснення буферів кольору (DCC)

На цій сторінці, я очікую, що ви вже знаєте основні поняття про графічний процесор та його внутрішню роботу. Якщо ви цього не знаєте, це відео роз'яснить цю тему українською мовою :) https://www.youtube.com/watch?v=2huFN60RIJQ&t=697s

У графічних процесорах, ми постійно використовуємо буфери кольорів або рендер-цілі/render targets. Це буфер, де ми зберігаємо наші кадри, які ігри потім використовують у післяобробку/post-processing та різних інших рендер-ефектів. Однак ці буфери займають дуже багато байтів, а читання даних з пам'яті це дуже повільна операція. 

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

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

Делта стиснення кольору

Графічні процесори вже вміють зберігати текстури у стисненому форматі. Ці формати називаються BCn формати, але вони відрізняються від тих, що використовуються для буферів кольору. Основна відмінність полягає в тому, що BCn формати стискають кольори з втратами (lossy compression), тоді як метод стиснення, який використовує процесор для рендер-цілей, стискає без втрат (lossless compression).

Причина того, що рендер-ціль має бути стиснутою без втрат, полягає втому, що користувачі DX11/OpenGL/DX12/Vulkan навіть не знають, що таке стиснення відбувається. Графічний процесор робить це все за спиною користувача, тому результат повинен бути ідентичним до результату без стиснення. 

Метод, який використовують графічні процесори для стиснення рендер-цілей, називається дельта стиснення кольору/delta color compression (DCC). Його суть полягає втому, що колір у кожному пікселі наближений до кольору його сусідів у натуральному зображенні. Оскільки більшість кадрів, які ми рендеримо в іграх, мають природні характеристики, такі рендер-цілі можна ефективно стиснути (іх дані не є випадковими/random).

Щоб стиснути дані, графічний процесор розділяє рендер-ціль на блоки пікселів (можна уявити, що це блоки 8х8 пікселів), і зберігає колір лише першого пікселя в кожному блоці. Потім, він обчислює різницю між коліром кожного іншого пікселя у блоці та першим пікселем і зберігає цю різницю замість звичайного кольору. Як наслідок, у блоці отримуємо різні значення, які переважно є невеликими у натуральних зображеннях (оскільки різниця між сусідними пікселями зазвичай невелика). Тому процесор може зберегти ці значення, застосовуючи менше бітів. Наприклад, якщо у нас є рендер-ціль у форматі RGBA16, процесор може зберегти не 64 бітів для кожного кольору, а скажімо, 24, 32, 42 бітів або іншу кількість, не впливаючи на кінцевий колір зображення :)

Малі значення використовують менше біти

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

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

А що відбувається, коли дані у буфері кольору не можна стиснути, оскільки вони за випадкові? У такому випадку процесор зберігає інформацію у метаданих, що цей блок пікселів не стиснений, і просто зберігає нестиснені кольори для цього блоку. Це рідко трапляється, але таким чином завжди можна зберегти який небудь блок пікселів у DCC форматі. 

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

Щодо того, що саме міститься у мета-дані та в яких конкретних форматах можуть бути закодовані кольори у блоках - залишається невідомим. Кожен графічний процесор має свої власні формати, розміри блоків пікселів та алгоритми для виконання стиснення кольорів, і компанїї, такі як AMD та NVIDIA, не публікують цю інформацію. Якщо ви працюєте з консолями/consoles, можливо, ви маєте доступ до документації щодо їхніх DCC форматів, але через те, що ці формати постійно змінюються з кожними новими графічними процесорами, це насправді не важливо нам знати, як докладно вони стискають дані. Головне - це розуміти, що ця операція прискорює наші програми :)

DCC і графічний конвеєр

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

Коли ми викликуємо Device->CreateResource у DX12 або vkCreateImage у Vulkan для створення рендер-цілі, спочатку вона не містить жодної корисної інформації. Якщо вона покриває попередньо вживану частину пам'яті, то може навіть зберігати ці попередні дані, які переважно відображатимуться як випадкові кольори у пікселях (сміття :P). Щоб правильно ініцювати цю рендер-ціль, маємо дві опції:

1) Викликати функцію Discard у DX12 або vkCmdBeginRenderPass з VkRenderPass з loadOp = LOAD_OP_DONT_CARE та initialLayout = VK_IMAGE_LAYOUT_UNDEFINED у Vulkan. Ця операція ініціює метадані для кожного блоку пікселів, хоча вона не змінює дані у цих пікселях, тому там може залишитися сміттєві значення. Цю функцію використовується, якщо ви знаєте що подальший прохід рендерингу/render pass перезапише кожен піксель у цьому буфері або перезапише колір лише у тих пікселях, які ваша аплікація буде читати (операції перезаписування кольорів передбачають, що метадані рендер-цілей ініціалізовані).

2) Викликати функцію ClearRenderTargets у DX12 або vkCmdBeginRenderPass з VkRenderPass з loadOp = LOAD_OP_CLEAR у Vulkan. Ця операція ініцює метадані як і Discard, а також перезаписує кожен піксель якимось кольором.

Якщо ви не ініціюєте ваші рендер-цілі, очікуйте отаких результатів :)

Файна дискотека XD

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

Після того, як ми ініцюємо рендер-ціль, ми можемо малювати на неї трикутники або записувати з обчислювального шейдера/compute shader. Давайте розглянемо, як стиснена рендер-ціль впливає на ці операції:

Малювання: 

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

Обчислювальний шейдер: 

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

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

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

Завантаження рендер-цілей: 

А що відбувається, коли ми читаємо зі стисненного буфера, наприклад, у піксельному або обчислюваному шейдері? Як бачимо у вищому образі, L2 кеш містить блок стиснення та розшифрування DCC, який також розшифровує DCC дані, перед тим, як шейдер їх отримує. У такий спосіб, блоки піскелів залишаються стисненими у L2 кеші, а у нижчих кешах, вони вже розшифорвані, і шейдер може читати їх як звичайні пікселі.

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

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

Практичні переваги DCC

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

У проходах рендерингу/render passes, де малюємо геометрію (наприклад GBuffer, освітлення) або де обчислюємо післяобробку/post-processing, стиснення рендер-ціль може прискорити їх на 5-10%. Більш того, якщо основним обмеженням продуктивності у проходах рендерингу є пропускна здатність пам'яті, то це стиснення може і навіть більше допомогти. 

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

Тут приведемо приклад очищення рендер-ціль розміром 3840x2160 та форматом R8G8B8A8_UNORM на процесорі RX 580:

Тут очищуємо без стиснення, ця операція забирає 219 мікросекунд для виконання

Тут очищуємо стиснений буфер. Операція лише змінює метадані, і через це виконується за 8 мікросекунд (це понад 27 разів швидше!)

Як знати коли рендер ціль стисненний?

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

На графічних процесорах AMD слід захоплити кадр/frame capture за допомогою Radeon Developer Tools Suite (RDTS). Відкрийте Radeon Developer Panel (RDP), встановіть його на режим Profiling/Профілювання, та захопіть кадр. Потім, відкрийте захоплену кадр у Radeon GPU Profiler (RGP), де будуть дані про обчислення одної кадри. У Overview -> Render/depth targets, побачите всі рендер-цілі, що використовуються у цьому кадрі, а також інформацію про те, які буфери стисненні, а які - ні. Хоча у цьому блозі я говорив лише про стиснення буферів кольору, у цій аплікації побачите, що навіть буфери глибини можуть бути стиснуті, але це вже інша тема.

На NVIDIA процесорах, я не впевнений, як можна отримати цю інформацію. Можливо, NSIGHT Graphics десь ховає цю інформацію, але я не моху знайти в Інтернеті, як перевірити, чи рендер-ціль стиснена. Їхні процесори також стискають буфери кольору, але вони, переважно, не надають багато інформації про те, як це все працює.

Деталі над якими слід важати

Є різні деталі, які враховуються, щоб визначити, чи буде рендер-ціль стисненою:

1) На старших процесорах (перед RDNA1-2), якщо рендер-ціль створена з позначкою ALLOW_UNORDERED_ACCESS, то стиснення буде вимкнено. 

2) Якщо формат буфера кольору є TYPELESS/без типу (наприклад R16G16B16A16_TYPELESS), то стиснення буде вимкнено, оскільки процесор не може визначити, чи дані у пікселі представлені числом з рухомою комою, цілим числом, чи іншим типом. Можливо, новші або майбутні процесори не матимуть цієї проблеми, але як я раніше казав, це відрізняється між процесорами :).

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

До того ж, є деякі деталі, яких варто враховувати, коли рендер-ціль стиснена:

1) При очищенні рендер-цілі, очищуйте до значення 0 або 1, щоб ця операція проводилася швидше (fast clear/швидке очищування). Метадані не мають можливості зберігати різноманітні кольори очищення, які користувач може бажати застосовувати, тому вони переважно обмежені значеннями 0 або 1.

2) Коли зберігаєте кольори від обчислювального шейдера до стисненого рендер-цілі, слід перемінювати всі пікселі у DCC блоку. Якщо шейдер пише до різних випадкових пікселів, то це зберігання може навіть повільніше здійснюватись коли буфер стисненний, оскільки в не стисненному буфері, ми просто переписуємо колір пікселя, а у стисненному, процесор оновлює мета-дану, оновлює стисненний формат блоку тощо. Розмір блоку переважно не відомий, але процесори вибирають цей розмір так, щоб група потіків виконання з розміром 8х8 переписувала весь блок. Тому якщо кожен потік виконання пише до одного пікселя у цьому блоці, то ви ефективно використовуватиме блок розшифрування DCC.

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

Кінцеві слова

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

Джерела

https://gpuopen.com/learn/dcc-overview/ (тут також пояснюється, у яких випадках бар'єр зможе розшифувати весь буфер кольору, оскільки якась майбутня операція може не мати змоги читати стиснуті блоки)

https://gpuopen.com/learn/rdna-performance-guide/ (шукайте за compression тутай)

https://gpuopen.com/presentations/2019/Surfing_the_Wavefronts.pdf - починаючи з сторінки 53

https://www.amd.com/system/files/documents/polaris-whitepaper.pdf - сторінка 10 показує на скільки може приспішити DCC пропускну здатність

https://www.anandtech.com/show/10325/the-nvidia-geforce-gtx-1080-and-1070-founders-edition-review/8 

https://www.es.ele.tue.nl/~heco/courses/ECA/GPU-papers/GeForce_GTX_1080_Whitepaper_FINAL.pdf - сторінка 12

https://gpuopen.com/wp-content/uploads/2019/08/RDNA_Architecture_public.pdf - сторінка 30

Графічний процесор - Стиснення буферів кольору (DCC)

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