10. cvičenie – CUDA prúdy a udalosti

Cvičenie čerpá z knihy Hands-On GPU Programming with Python and CUDA: Explore high-performance parallel computing with CUDA (Dr. Brian Tuomanen).

V minulom cvičení sme sa stretli s konkurenciou v rámci jedného kernelu, kedy bol tento kernel spúšťaný vo viacerých vláknach. Rozhranie CUDA tiež poskytuje ďalšiu úroveň konkurencie a to beh viacerých kernelov a pamäťových operácií naraz. Keďže niektoré z tých operácií môžu byť na sebe závislé (napríklad kopírovanie vstupnej matice do pamäte GPU a následné spustenie kernelu, ktorý s ňou pracuje), musia byť použité určité synchronizačné mechanizmy.

CUDA prúd (angl. stream) je sekvencia operácií, ktorá je spúšťaná na GPU nezávisle od iných prúdov.

CUDA udalosť (angl. event) je objekt, ktorý umožňuje oznámiť hostovi, že sa v kerneli stala nejaká udalosť (napr. dosiahla sa určitá časť kódu). Taktiež umožňuje presné časovanie kernelov.

1 Synchronizácia zariadenia

Pri programovaní CUDA pomocou modulu numba sme sa zatiaľ nestretli so synchronizáciou zariadenia, keďže bola robená automaticky za nás po každej operácii na grafickej karte. Pozrime sa preto na kód napísaný v CUDA C, aby sme videli, kde všade sa synchronizácia používa:

V kóde je možné vidieť, že po každej GPU operácii robíme synchronizáciu. Ak by sme ale mali kernely a dáta, ktoré navzájom nesúvisia, mohli by sme ich spúšťať nezávisle na seba a nemuseli by sme čakať, kým predchádzajúca operácia skončí. V príkladoch, ktoré nasledujú sa na to pozrieme.

2 Prúdy

2.1 Jednoduchý príklad

Prvý program vytvorí niekoľko polí s náhodným obsahom, potom každé pole spracuje jednoduchým kernelom a na záver skopíruje výsledok späť na hosta. Najprv si ukážeme verziu, v ktorej idú operácie za sebou v jednom prúde a následne program upravíme tak, aby jednotlivé kernely bežali vo viacerých prúdoch naraz. Zároveň odmeriame získané vylepšenie vo výkone.

2.1.1 Sekvenčný program

2.1.2 Použitie prúdov

Pre každú maticu vytvoríme jeden prúd. Kopírovanie dát a vykonávanie kernelov bude asynchrónne vzhľadom na iné prúdy.

Použitím prúdov sme dosiahli skoro trojnásobné zlepšenie výkonu. Je to vďaka tomu, že jednotlivé operácie (presun polí na GPU, spustenie kernelu a presun dát späť do hosta) bežali v jednotlivých prúdoch asynchrónne.

2.2 Zložitejší príklad – Game of Life

Teraz ukážeme použitie prúdov na zložitejšom príklade. Bude ním hra života od amerického matematika Johna Conwaya.

2.2.1 Implementácia

Nasleduje implementácia pre jeden bežiaci kernel.

2.2.2 Konkurentná Game of Life

Pokračujeme verziou, v ktorej budú konkurentne prebiehať štyri hry. Na to vytvoríme štyri prúdy a taktiež vytvoríme štyri dvojice matíc.

3 Udalosti

V poslednej časti cvičenia sa pozrieme na využitie udalostí. Udalosti sú objekty, ktorými môžeme zistiť, v akom stave je spracovanie prúdu. Zavolaním metódy record() vložíme do aktuálneho alebo zvoleného prúdu míľnik. Táto metóda neblokuje, ale po spracovaní všetkých operácií v prúde bude udalosť nastavená ako hotová. Ak v programe nastavíme dve udalosti, môžeme zistiť časový rozdiel medzi nimi.

3.1 Jednoduchý príklad

V tomto príklade spustíme náš jednoduchý kernel z predchádzajúcich príkladov na jednej matici s veľkým počtom prvkov (skoro 82 miliónov). Vytvoríme dve udalosti, prvú umiestnime pred začiatkom výpočtu kernelu a druhú po jeho skončení.

V prvej verzii programu sa pozrieme, či boli udalosti v prúde zaznamenané ako ukončené. Potom sa pozrieme na celkový čas výpočtu kernelu, k čomu bude potrebná synchronizácia druhej udalosti.

Ak necháme zakomentované riadky v príklade, môžeme vidieť asynchrónnosť udalostí. Prvá udalosť je označená za hotovú, ale druhá ešte nie, keďže sa jednalo len o umiestnenie udalosti do prúdu a grafická karta nestihla potvrdiť dokončenie udalosti.

Po odkomentovaní synchronizačného riadku, v ktorom počkáme na ukončenie udalosti end_event môžeme vypočítať celkový čas uplynutý medzi ukončením oboch udalostí – v tomto prípade sa jedná o čas behu kernelu.

3.2 Udalosti a prúdy

V tejto ukážke použijeme udalosti spoločne s prúdmi. Jedna udalosť patrí jednému prúdu, preto musíme vytvoriť toľko udalostí, koľko máme prúdov. Pre 200 prúdov budeme mať 400 udalostí, keďže chceme časovať dĺžku výpočtu kernelu.

Prvú udalosť daného prúdu umiestnime hneď pred začiatok kernelu. Zvyšok udalostí umiestnime v samostatnom cykle po tom, čo sú všetky kernely spustené. Je to práve z toho dôvodu, aby sme nezdržiavali spustenia kernelov.

Vo výpise vidíme, že zatiaľ čo celkový čas výpočtu bol 4.6 sekundy, beh jedného kernelu trval okolo 2 sekúnd. Štandardná odchýlka s hodnotou 0.2 sekundy je oproti dĺžke behu kernelu malá, čo hovorí o dobrom využití zdrojov GPU. Ak by bola štandardná výchylka väčšia, mali by sme upraviť parametre výpočtu pre lepšie využitie grafickej karty.