Vulkan: posílání příkazů

29. 7. 2021
Doba čtení: 13 minut

Sdílet

 Autor: Depositphotos, Vulkan
Aby zařízení používané s Vulkanem vykonalo jakoukoliv práci, musíme mu zaslat příkazy k provedení. Dnes si ukážeme, jak na to. Našemu zařízení pošleme práci. Počkáme, až ji dokončí a ukončíme aplikaci.

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:

bitcoin_skoleni

// 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.

Autor článku