Jak poslat příkazy do zařízení? Zkráceně řečeno, požadované příkazy nejprve uložíme do command bufferu (příkazový buffer) a následně odešleme do fronty (queue) ke zpracovnání. Zařízení, kterému patří daná fronta, pak tyto příkazy provede.
Aplikaci si můžeme stáhnout, zkompilovat a spustit. Uvidíme výstup podobný tomuto:
Vulkan devices: GeForce GTX 1050 Radeon(TM) RX 460 Graphics Intel(R) HD Graphics 530 Compatible devices: GeForce GTX 1050 Radeon(TM) RX 460 Graphics Intel(R) HD Graphics 530 Using device: GeForce GTX 1050 Submiting work... Waiting for the work... Done.
Samozřejmě, většina z nás nemá tři grafické karty, ale schválně uvádím tyto tři od různých výrobců, neboť nám v budoucích dílech umožní nahlédnout do různých rozdílů mezi nimi.
Co tedy výpis obsahuje? Vidíme seznam vulkanních zařízení přítomných v tomto počítači. V tomto případě jsou to tři grafické karty. Pak vidíme výpis kompatibilních zařízení, který opět obsahuje všechny tři karty. Nakonec vidíme název vybraného zařízení. Tomuto zařízení pošleme práci. Počkáme, až ji dokončí, a ukončíme aplikaci.
Globální proměnné
Pojďme se nyní podívat na kód v main.cpp
. Hned na začátku souboru nám přibylo množství proměnných, které jsou nyní globální, nikoliv lokální. To nám umožní strukturovat kód do funkcí, aniž bychom museli potřebné proměnné neustále předávat jako parametry do jednotlivých funkcí.
// Vulkan instance // (it must be destructed as the last one) static vk::UniqueInstance instance; // Vulkan handles and objects // (they need to be placed in particular (not arbitrary) order // because they are destructed from the last one to the first one) static vk::PhysicalDevice physicalDevice; static uint32_t graphicsQueueFamily; static vk::UniqueDevice device; static vk::Queue graphicsQueue; static vk::UniqueCommandPool commandPool; static vk::UniqueCommandBuffer commandBuffer; static vk::UniqueFence renderingFinishedFence;
Význam jednotlivých proměnných si vysvětlíme až v kódu. Podstatné také je, že pořadí proměnných není úplně náhodné, neboť definuje, v jakém pořadí se budou objekty likvidovat při ukončování aplikace. Připomeňme, že destruktory nejnižších proměnných jsou volány jako první. Takže například instance je až úplně nahoře, neboť z vulkanních objektů musí být zlikvidována až jako poslední.
Kompatibilní zařízení
Nyní se dostáváme k funkci main
. Kód vytvoření instance je nám již znám. Nicméně hned pod ním nám přibyl nový kód. Ten ze všech fyzických zařízení vybírá ty, které jsou „kompatibilní“ s požadavky naší aplikace:
// find compatible devices // (the device must have a queue supporting graphics operations) vector<vk::PhysicalDevice> deviceList = instance->enumeratePhysicalDevices(); vector<tuple<vk::PhysicalDevice, uint32_t>> compatibleDevices; for(vk::PhysicalDevice pd : deviceList) { // select queue for graphics rendering vector<vk::QueueFamilyProperties> queueFamilyList = pd.getQueueFamilyProperties(); for(uint32_t i=0, c=uint32_t(queueFamilyList.size()); i<c; i++) { if(queueFamilyList[i].queueFlags & vk::QueueFlagBits::eGraphics) compatibleDevices.emplace_back(pd, i); } }
Na prvním řádku do proměnné deviceList
přiřadíme seznam všech vulkanních zařízení na tomto počítači. Na dalším řádku si definujeme proměnnou, kam uložíme pouze kompatibilní zařízení.
Jaké že zařízení je kompatibilní? V následujících dílech se chystáme vytvářet grafickou aplikaci. Budeme tedy vyžadovat pouze zařízení podporující grafické operace nad alespoň jednou z jejich front (queues). Potřebujeme tedy seznam všech zařízení „profiltrovat“. Sledujeme-li tedy dále náš kód, provedeme cyklus přes všechna fyzická zařízení a u každého zařízení se zeptáme na seznam tříd front (queue family list).
Tento seznam projdeme a hledáme v něm třídu front, která podporuje grafické operace. Řečeno prakticky: hledáme třídu, která má v queueFlags nastaven bit vk::QueueFlagBits::eGraphics
. Pokud alespoň jedna ze tříd front podporuje grafické operace, umí daná grafická karta rendrovat. Zařadíme ji tedy do seznamu kompatibilních zařízení. Do tohoto seznamu si uložíme i index do seznamu tříd front, kde jsme našli podporu pro grafické operace. Tento index budeme brzy potřebovat.
Někdo by se mohl zeptat, co je to za nesmysl ptát se, zda grafická karta umí rendrovat? Nicméně není všechno grafická karta, co umí Vulkan. V dnešní době máme výpočetní akcelerátory, které jsou určeny výlučně na počítání, podporují Vulkan a vůbec žádný konektor na připojení monitoru nemají. Tato čistě výpočetní zařízení chceme určitě přeskočit, pokud píšeme klasickou renderovací aplikaci, což je případ našeho tutoriálu.
V dalším kódu si vypíšeme seznam fyzických zařízení a seznam kompatibilních zařízení:
// print devices cout << "Vulkan devices:" << endl; for(vk::PhysicalDevice pd : deviceList) cout << " " << pd.getProperties().deviceName << endl; cout << "Compatible devices:" << endl; for(auto& t : compatibleDevices) cout << " " << get<0>(t).getProperties().deviceName << endl;
Kód neobsahuje nic, s čím bychom se nesetkali již dříve. Proto pojďme na další blok kódu:
// choose device if(compatibleDevices.empty()) throw runtime_error("No compatible devices."); physicalDevice = get<0>(compatibleDevices.front()); graphicsQueueFamily = get<1>(compatibleDevices.front()); cout << "Using device:\n" " " << physicalDevice.getProperties().deviceName << endl;
Potřebujeme vybrat jedno z kompatibilních zařízení. V našem tutoriálu nebudeme vymýšlet žádnou složitost a vezmeme první kompatibilní zařízení. Do proměnné physicalDevice
si tedy uložíme vybrané fyzické zařízení a do proměnné graphicsQueueFamily
index do tříd front, kde jsme našli podporu pro grafické operace.
Logické zařízení
Nyní se dostáváme k přelomovému bodu: vytvoření logického zařízení (logical device). Zde budeme respektovat zaběhané konvence a budeme-li hovořit pouze o zařízení (device), budeme mít na mysli logické zařízení, jak jsem již zmínil v jednom z dřívějších dílů tohoto tutoriálu. Ve Vulkanu obyčejně drtivou většinu času pracujeme s logickými zařízeními, má tedy smysl hovořit zkráceně pouze o zařízeních. A pokud už někdy budeme mít na mysli fyzické zařízení, použijeme obě slova.
Logické zařízení vytvoříme takto:
// create device device = physicalDevice.createDeviceUnique( vk::DeviceCreateInfo{ vk::DeviceCreateFlags(), // flags 1, // queueCreateInfoCount array{ // pQueueCreateInfos vk::DeviceQueueCreateInfo{ vk::DeviceQueueCreateFlags(), // flags graphicsQueueFamily, // queueFamilyIndex 1, // queueCount &(const float&)1.f, // pQueuePriorities }, }.data(), 0, nullptr, // no layers 0, nullptr, // number of enabled extensions, enabled extension names nullptr, // enabled features } );
Jak vidíme, metoda createDeviceUnique
bere jako parametr strukturu vk::DeviceCreateInfo
. Parametr flags (česky přepínače) je rezervován pro budoucí použití, proto jej vytvoříme jeho výchozím konstruktorem. Další parametr je počet front, které budeme používat. V našem případě jedna. Následuje ukazatel na pole front obsahující jedinou strukturu vk::DeviceQueueCreateInfo
.
Pro vytvoření vk::DeviceQueueCreateInfo
předáváme čtyři parametry. První z nich je flags, který opět necháme prázdný. Pak queueFamilyIndex
– ten nastavíme na frontu, kterou jsme si našli dříve a která podporuje grafické operace. Další parametr po nás žádá počet front, které budeme chtít alokovat z této třídy front. Nám stačí jedna. A jako poslední parametr předáme prioritu fronty. Pro detailnější popis parametrů opět odkazuji do dokumentace Vulkanu.
Ze zbývajících parametrů vk::DeviceCreateInfo
nám zůstávají layers (vrstvy), extensions (rozšíření) a features (funkcionality), přičemž žádnou z těchto věcí v tomto díle tutoriálu nevyužijeme. Jejich význam si objasníme v jednom z dalších dílů, až je budeme používat.
Queue (fronta), command pool a command buffer
Frontu (queue) potřebujeme pro odesílání příkazů do logického zařízení. Fronta byla již alokována při vytvoření vk::Device
, takže si na ni pouze vezmeme handle:
// get queues graphicsQueue = device->getQueue(graphicsQueueFamily, 0);
Jen opět upozorňuji, že frontu získáváme funkcí začínající slovem get
, nikoliv create
nebo allocate
. Uvolňování handlu z paměti tedy není naše práce. Handle fronty bylo vytvořeno interně ve funkci vytvářející logické zařízení a bude uvolněno automaticky, až budeme logické zařízení rušit. Má jej tedy ve vlastnictví logické zařízení. Fronta sama nemá metodu destroy
a nic jako vk::UniqueQueue
neexistuje.
Další v pořadí je command pool:
// command pool commandPool = device->createCommandPoolUnique( vk::CommandPoolCreateInfo( vk::CommandPoolCreateFlags(), // flags graphicsQueueFamily // queueFamilyIndex ) );
Command pool je objekt, ze kterého alokujeme command buffery. Slouží pro amortizaci ceny alokace každého command bufferu zvlášť. Command pool může například interně předalokovat určité množství zdrojů a alokace jednotlivých command bufferů je pak podstatně efektivnější.
Podíváme-li se na parametry pro vytvoření command pool, je to struktura vk::CommandPoolCreateInfo
, která potřebuje vyplnit položku flags
a queueFamilyIndex
. Flags necháme prázdné a queueFamilyIndex
nastavíme na třídu naší grafické fronty, kterou jsme alokovali při vytváření logického zařízení.
A nyní už samotná alokace command bufferu:
// allocate command buffer commandBuffer = std::move( device->allocateCommandBuffersUnique( vk::CommandBufferAllocateInfo( commandPool.get(), // commandPool vk::CommandBufferLevel::ePrimary, // level 1 // commandBufferCount ) )[0]);
Command buffer je kus paměti, do které ukládáme příkazy, které pak odešleme k provedení do zařízení. A proč příkazy neposíláme přímo, ale musíme jej nejprve naskládat do bufferu? Jeden z důvodů je, že komunikace se zařízením může být relativně velmi časově náročná. Například s klasickou grafickou kartou musíme komunikovat po PCIe sběrnici a posílání každého příkazu zvlášť by bylo neefektivní. Často totiž můžeme posílat tisíce nebo i sta tisíce příkazů. Takhle je všechny uložíme do bufferu a odešleme naráz.
Nyní k jednotlivým parametrům, které si bere struktura vk::CommandBufferAllocateInfo
. První parametr je command pool, ze kterého bude command buffer naalokován. Protože commandPool je vk::UniqueCommandPool
, použijeme metodu get
pro konverzi na vk::CommandPool
. Druhý parametr je level (úroveň). Command buffer můžeme mít primární nebo sekundární. Primární mohou být odesílány do front k provedení a mohou volat sekundární command buffery. Sekundární mohou být pouze zavolány z primárních. My, než k tomu bude důvod, budeme používat pouze primární. Poslední parametr je počet command bufferů, které chceme alokovat a které budou vráceny funkcí ve std::vector<vk::UniqueCommandBuffer>
.
Nyní ale vzniká problém s návratovou hodnotou. Naše proměnná je typu vk::UniqueCommandBuffer
a návratová hodnota je vektor těchto hodnot. problém vyřešíme převzetím pouze nultého prvku vektoru. Použijeme tedy operátor []
, který vrací referenci na UniqueCommandBuffer
. Zde by však kompilátor hlásil chybu – chtěl by totiž použít copy-assignment, tedy operator=(const vk::UniqueCommandBuffer&)
, který je u všech typů vk::Unique*
úmyslně zakázán. Naopak, všechny typy vk::Unique*
mají move-assignment operátor, tedy operator=(const vk::UniqueCommandBuffer&&)
. Stačí tedy návratovou hodnotu zabalit do funkce std::move
, která způsobí, že se nevolá copy-assignment, ale move-assignment. Hodnota z UniqueCommandBuffer
se tedy korektně přesune z vráceného std::vectoru
do naší proměnné.
Command buffer recording
Nyní je potřeba příkazy do command bufferu nahrát (record). My v tomto díle pouze zahájíme nahrávání příkazem begin
a ukončíme jej příkazem end
. Zadávání jednotlivých příkazů mezi begin
a end
si necháme na příště. Nahrávání tedy provedeme takto:
// begin command buffer commandBuffer->begin( vk::CommandBufferBeginInfo( vk::CommandBufferUsageFlagBits::eOneTimeSubmit, // flags nullptr // pInheritanceInfo ) ); // end command buffer commandBuffer->end();
Metoda begin zavolaná nad command bufferem zahájí nahrávání. Technicky řečeno, zavoláním begin
je command buffer vyprázdněn, pokud již měl z dřívějška nahrané nějaké příkazy, a pak je převeden do stavu „recording“, kdy přijímá příkazy. Parametr funkce je struktura vk::CommandBufferBeginInfo
se dvěma parametry: flags
a pInheritanceInfo
. Flags nastavíme na eOneTimeSubmit
, protože command buffer necháme provést pouze jednou. Tím umožníme driveru provést určité optimalizace. Druhý parametr pInheritanceInfo
se používá u sekundárních command bufferů. My jej tedy směle nastavíme na nullptr
, neboť dle specifikace bude ignorován.
Pro ukončení nahrávání command bufferu pouze zavoláme metodu end
. V tu chvíli je command buffer převeden do stavu „executable“, tedy připraven k provedení.
Odeslání command bufferu k provedení
Dříve, než odešleme naši práci k provedení, potřebujeme si vytvořit Fence, česky plot či překážka. Fence slouží k synchronizaci mezi zařízením a procesorem. Fence má dva stavy: signaled a unsignaled, česky asi signálovaná a nesignálovaná. Typické použití je: Pošleme příkazy do fronty spolu s fence. Na procesoru pak na fence počkáme. Jakmile je fence zasignalizována, znamená to, že příkazy zaslané spolu s fence již byly provedeny. Jednoduchý příklad, jak se s ní pracuje, uvidíme v kódu. Nesignálovanou fence vytvoříme takto:
// fence renderingFinishedFence = device->createFenceUnique( vk::FenceCreateInfo{ vk::FenceCreateFlags() // flags } );
Metoda createFenceUnique
bere jediný parametr, kterým je struktura vk::FenceCreateInfo
, a ta zase jediný parametr flags
, který necháme prázdný.
Nyní odešleme command buffer do fronty. To provedeme zavoláním metody submit
nad frontou:
// submit work cout << "Submiting work..." << endl; graphicsQueue.submit( vk::SubmitInfo( // submits (of vk::ArrayProxy<vk::SubmitInfo> type) 0, nullptr, nullptr, // waitSemaphoreCount, pWaitSemaphores, pWaitDstStageMask 1, &commandBuffer.get(), // commandBufferCount, pCommandBuffers 0, nullptr // signalSemaphoreCount, pSignalSemaphores ), renderingFinishedFence.get() // fence );
Metoda submit
může být výkonnostně relativně náročná, proto se doporučuje odesílat více práce naráz. Tedy ne deset command bufferů odeslat deseti submity, ale provést jediný submit beroucí pole o deseti command bufferech. Jako první parametr vidíme strukturu vk::SubmitInfo
. Ve skutečnosti zde můžeme dát pole struktur vk::SubmitInfo
, protože parametr funkce je typu vk::ArrayProxy<vk::SubmitInfo>
, který provede implicitní konverzi na sebe z typů jako std::array
, std::vector
, std::initializer_list
, ale i z reference na jediný prvek. Pro více informací doporučuji nahlédnout na implementaci ArrayProxy
ve vulkan.hpp
.
Samotná vk::SubmitInfo
bere jako parametry seznam semaforů, na které bude command buffer čekat, než se začne provádět. My zatím žádné semafory nepoužíváme, proto jej nastavíme na nullptr
. Třetí parametr je seznam masek, který se váže k semaforům. Je tedy také nastaven na nullptr
. Konečně čtvrtý a pátý parametr udávají seznam command bufferů k provedení. Šestý a sedmý parametr jsou pak semafory, které budou signalizovány po dokončení provádění předaných command bufferů. Na závěr ještě vidíme druhý parametr funkce submit
, kterým je naše fence, kterou jsme před chvílí vytvořili. Její význam si vyjasníme vzápětí.
V tuto chvíli by se zdálo, že práce byla odeslána do zařízení a že máme odpracováno. Není tomu tak. Než ukončíme aplikaci, musíme počkat na dodělání práce:
// wait for the work cout << "Waiting for the work..." << endl; vk::Result r = device->waitForFences( renderingFinishedFence.get(), // fences (vk::ArrayProxy) VK_TRUE, // waitAll uint64_t(3e9) // timeout (3s) ); if(r == vk::Result::eTimeout) throw std::runtime_error("GPU timeout. Task is probably hanging."); cout << "Done." << endl;
Metoda waitForFences
zavolaná nad logickým zařízením počká na naši fence, než bude signalizovaná. Tuto fence jsme předali při odesílání našeho command bufferu a bude signalizovaná po dokončení jeho provádění. A proč že musíme čekat? Obyčejně si chceme převzít hotové výsledky. I kdybychom je již nechtěli a zkusili zlikvidovat vulkanní objekty při ukončování aplikace, tak se dopustíme nesprávného použití Vulkan API. Dokumentace command pool od nás explicitně žádá, aby při uvolňování command pool nebyl žádný jeho command buffer prováděn. Takže počkáme, než začneme vulkanní objekty likvidovat.
Parametry metody waitForFences
jsou seznam fences, na které se má počkat. Tento parametr je opět vk::ArrayProxy
, tedy vezme jak jednu vk::Fence
, tak i std::array
, std::vector
a std::initializer_list
. Druhý parametr waitAll
říká, zda čekáme až jsou všechny fences ve stavu signalled nebo zda stačí jedna fence ve stavu signalled.
Poslední parametr je timeout
, tedy časový limit, do kterého bude funkce čekat na dokončení práce, než skončí s návratovým kódem vk::Result::eTimeout
. Hodnota je v nanosekundách jako uint64_t
. Jakou hodnotu zvolit? Můžeme tam dát maximální hodnotu uint64_t
, což dá Vulkanu půl tisíciletí na dokončení výpočtu, nebo zvolit nějakou hodnotu v sekundách. Například tři sekundy.
Popravdě řečeno, je špatný nápad posílat jakoukoliv úlohu, která trvá řádově sekundy. Takovou úlohu je lépe rozdělit na více menších úloh. Ovladače i samotný operační systém mohou detekovat zamrzlé úlohy na vulkanních zařízeních. Pokud výpočet běží na grafické kartě déle než určité množství sekund a počítač vypadá zamrzle a možná ani myš se nehýbe, může být driver schopen provést odstranění úlohy z karty. Některé, hlavně starší karty, nemusí tuto funkci podporovat, ale mohou podporovat kompletní reset. Po takovémto resetu pravděpodobně dostaneme návratovou hodnotu vk::Result::eErrorDeviceLost
. Naštěstí nás tyto problémy v real-time renderingu obyčejně netrápí, neboť zde cílíme na výpočet snímku řádově v desítkách milisekund. V každém případě v našich příkladech budeme používat tři sekundy, po které dovolíme aplikaci tvářit se vytuhle, kdybychom náhodou v shaderu omylem napsali nekonečnou smyčku.
Čekání na nečinnost
Na samém konci funkce main
nám přibyl nový kus kódu:
// wait device idle // this is important if there was an exception and device is still busy // (device need to be idle before destruction of buffers and other stuff) if(device) device->waitIdle();
Komentář nám vysvětluje, že při výskytu výjimky může nastat situace, že nějaký výpočet ještě stále běží. V tom případě by nebylo dobré provádět destrukci našich globálních vulkanních objektů. Proto nejprve počkáme, až je práce dokončena, a pak až opustíme funkci main
. Metoda waitIdle()
nemá timeout, ale nemusíme mít strach, že bude blokovat nekonečně dlouho při zatuhlé úloze. Jak jsem již psal, lepší zařízení a operační systémy umí tyto úlohy po chvíli sestřelit a my v aplikaci uvidíme chybu vk::Result::eErrorDeviceLost
.
Shrnutí
Pokud po spuštění tohoto příkladu vidíme na obrazovce „Done.“, můžeme si pogratulovat. Podařilo se nám vytvořit logické zařízení, použít frontu (queue) a command pool, vytvořit command buffer, odeslat jej na zařízení a počkat na jeho provedení. Command buffer byl sice prázdný, ale to napravíme hned příště, kdy uděláme první pokus s renderováním.