DMA
V nasledujúcom jednoduchom príklade si vyskúšame spomínaný DMA prenos. Keď chce programátor na SPE dopraviť väčšie množstvo dát ako jedno číslo v parametri argp, DMA sa nevyhne, preto je dobré ukázať jeho použitie (vlastne má ešte jednu možnosť – použiť schránky, cez ktoré sa dajú medzi PPE a SPE vymieňať 32bitové hodnoty, ale bavíme sa teraz o dátach o objeme desiatok bytov).
Vytvoríme si štruktúru, ktorá bude obsahovať 6 dlhých čísel (unsigned long long) – 5 ako vstup a jedno ako výstup. Takúto štruktúru si SPE pomocou DMA prenesie do lokálnej pamäte (LS), všetky čísla zráta dohromady a výsledok uloží pomocou DMA na pripravené miesto v hlavnej pamäti.
Začneme zdieľanou štruktúrou pre dáta:
typedef struct add_data{ unsigned long long a; unsigned long long b; unsigned long long c; unsigned long long d; unsigned long long e; unsigned long long result; } add_data_t;
Tu asi nie je nič zaujímavé, obyčajný obal pre 6 64-bitových čísel.
Prejdeme ku kódu pre SPE, začneme funkciou main().
int main(unsigned long long speid, unsigned long long argp, unsigned long long envp){ add_data_t local_data __attribute__((aligned(16))); get_data_to_LS(argp, &local_data); local_data.result =local_data.a + local_data.b + local_data.c + \ local_data.d + local_data.e; put_data_to_EA(argp, &local_data); return 0; }
Od predošlého príkladu sa nám líši signatúra funkcie main – pribudli 3 parametre, speid, ktorý identifikuje kontext, argp, kde sa predáva príslušný parameter z volania spe_context_run a envp, kde sa takisto predá hodnota envp parametru zo spe_context_run).
Na prvom riadku deklarujeme lokálnu premennú local_data a zariadime, aby bola zarovnaná na 16 bytov, kvôli DMA prenosu. Táto premenná je vytvorená v adresovom priestore SPE, fyzicky sa nachádza v LS SPU, na ktorom kontext beží.
Po DMA prenose, ktorý si ukážeme o malú chvíľu, zrátame obsah premennej, uložíme výsledok k vstupným dátam a zaistíme DMA prenos späť do hlavnej pamäte.
Metóda vykonávajúca samotný DMA prenos môže vyzerať nasledovne:
void get_data_to_LS(unsigned long long ea, add_data_t* local_data){ spu_mfcdma64(local_data, mfc_ea2h(ea), mfc_ea2l(ea), sizeof(add_data_t), TAG, MFC_GET_CMD); spu_writech(MFC_WrTagMask, 1 << TAG); spu_mfcstat(MFC_TAG_UPDATE_ALL); }
Ako parametre dostane adresu v hlavnej pamäti (efektívnu adresu) a adresu lokálnych dát. Volanie spu_mfcdma64 vytvorí DMA príkaz, ktorý je odoslaný kanálom do MFC jednotky na vykonávajúcom SPE. Uviedli sme adresu v LS, horných 32 bitov efektívnej adresy (makro mfc_ea2h), spodných 32 bitov efektívnej adresy (makro mfc_ea2l), veľkosť prenášaných dát, tag – naše označenie tohto príkazu a samotný príkaz, ktorý od MFC chceme aby vykonala – v tomto prípade to bude GET, t.j. prenos dát z hlavnej pamäte do LS.
Teraz by sme mohli vykonávať nejakú inú prácu, keďže prenosom dát sa zaoberá MFC jednotka a SPU je voľný. My sa ale hneď dotážeme na stav prenosu, prípadne počkáme na jeho dokončenie. Najprv pošleme kanálom do MFC tag príkazu (v podobe bitovej masky), ktorý budeme skúmať (spu_writech). Potom zavoláme funkciu spu_mfcstat, ktorej povieme, že má počkať, kým budú updatované všetky príkazy pod aktuálnym tagom (v našom prípade sa MFC jednotka zaoberá len jediným príkazom) – updatované znamená dokončené, MFC „updatne“ tag po dokončení príkazu t.j. po prenesení požadovaných dát.
Druhá funkcia, ktorá prenáša dáta opačným smerom, vyzerá takmer navlas rovnako
void put_data_to_EA(unsigned long long ea, add_data_t* local_data){ spu_mfcdma64(local_data, mfc_ea2h(ea), mfc_ea2l(ea), sizeof(add_data_t), TAG, MFC_PUT_CMD); spu_writech(MFC_WrTagMask, 1 << TAG); spu_mfcstat(MFC_TAG_UPDATE_ALL); }
Jediný rozdiel v tomto prípade je v poslednom parametri funkcie spu_mfcfma64(), kde od MFC jednotky budeme požadovať príkaz PUT, nie GET.
Spomínané funkcie a makrá použité na DMA sú deklarované v hlavičkovom súbore spu_mfcio.h. Uvedené funkcie zapíšte do jedného súboru, nainkludujte spomínaný spu_mfcio.h (a hlavičkový súbor, kde ste definovali typ add_data_t) a kód pre SPE máme pripravený na preklad.
Pre použitie DMA platí niekoľko pravidiel, ktoré som v príklade zatajil. MFC jednotka je schopná prenášať dáta o násobkoch 16 bytov, pri dodržaní istých podmienok aj veľkosti 1, 2 ,4 a 8 bytov, o maximálnej veľkosti 16 kB. Adresy prenosov musia byť zarovnané na 16 bytov, optimálny výkon dosiahneme pri zarovnaní na 128 bytov. V prípade, že chceme prenášať dáta veľkosti menšej ako 16 bytov, musíme dodržať ešte dve podmienky:
-
adresy musia byť zarovnané na veľkosť prenášaných dát
-
posledné 4 bity adries (t.j. offset od 16 bytov) musia byť rovnaké
V našom príklade ale smerom naspäť do hlavnej pamäte prenášame celú štruktúru (48 bytov). Keby sme chceli preniesť len výsledok, potrebovali by sme urobiť prídavné opatrenia, aby sme zabezpečili spomínané dve podmienky pre prenos dat menších ako 16 bytov.
Odbočka:
S DMA sa otvára možnosť použitia techniky tzv. „double-buffering“. Pri spracovaní objemných dát na SPE použijeme v kóde pre SPE dva buffery. Na začiatku výpočtu naplníme prvý buffer, pošleme MFC príkaz na naplnenie druhého a spustíme výpočet nad prvým, naplneným bufferom. Po skončení výpočtu nad prvým bufferom by už mal byť naplnený druhý buffer (ak nie, tak stačí počkať, kým DMA prenos MFC ukončí). Pošleme do MFC príkaz na uloženie spracovaných dát z prvého bufferu a jeho znovunaplnenie a začneme výpočet nad druhým, naplneným. Takto prelievame výpočet medzi dvoma buffermi s minimálnymi zdržaniami pri čakaní na prenos dát. Ilustruje to nasledujúci obrázok.
Pre úplnosť príkladu ešte uvediem kód pre PPU. Uvidíme, že sa veľmi nelíši od predošlého príkladu. Pribudne akurát inicializácia zdieľanej dátovej štruktúry, potom nasleduje vykonanie programu na SPE a výpis výsledku.
#include <stdlib.h> #include <libspe2.h> #include "common.h" extern spe_program_handle_t adder_handle; add_data_t* init_data(){ add_data_t* data = (add_data_t*)malloc(sizeof(add_data_t)); if (data){ data->a = 0x1111ull; data->b = 0x3333ull; data->c = 0x5555ull; data->d = 0x7777ull; data->e = 0x9999ull; return data; } return 0; } int main(void){ add_data_t* data = init_data(); if (!data){ return 1; } printf("Data sent to SPE:\n0x%llx\n0x%llx\n0x%llx\n0x%llx\n0x%llx\n", data->a, data->b, data->c, data->d, data->e); unsigned long long expected = data->a + data->b + data->c +\ data->d + data->e; int retval; unsigned int entry_point = SPE_DEFAULT_ENTRY; spe_context_ptr_t my_context; my_context = spe_context_create(0, NULL); spe_program_load(my_context, &adder_handle); do { retval = spe_context_run(my_context, &entry_point, 0, (void*)data, NULL, NULL); } while (retval > 0); spe_context_destroy(my_context); printf("Expected result: 0x%llx\nResult from SPE: 0x%llx\n", expected, data->result); return 0; }
Všimnite si štvrtý parameter funkcie spe_context_run, v ktorom sa predá SPE adresa našej štruktúry v hlavnej pamäti. Ďalej tu asi nie je čo vysvetľovať, takže prejdeme priamo k prekladu a spusteniu.
# make spu-gcc adder.c -o adder.spe ppu-embedspu adder_handle adder.spe adder.spe.o ppu-gcc spe_runner.c adder.spe.o -lspe2 -o adder # ./adder Data sent to SPE: 0x1111 0x3333 0x5555 0x7777 0x9999 Expected result: 0x1aaa9 Result from SPE: 0x1aaa9
Vyzerá to, že nám program funguje.
Viacvláknové aplikácie na SPE
Vieme teda dáta preniesť na SPU pomocou DMA, vieme z PPU spustiť výpočet na SPU, k dokonalosti chýba posledný krok – využiť všetky dostupné SPE v našom systéme.
Najprv niečo k tomu, ako zistíme nejaké informácie o hardware. Na to nám poslúži funkcia spe_cpu_info_get() z knižnice libspe2.
#include <stdio.h> #include <libspe2.h> int main(void){ int cpu_nodes = spe_cpu_info_get(SPE_COUNT_PHYSICAL_CPU_NODES, -1); int physical_spes = spe_cpu_info_get(SPE_COUNT_PHYSICAL_SPES, -1); int usable_spes = spe_cpu_info_get(SPE_COUNT_USABLE_SPES, -1); printf("System information:\n" "CPU nodes: %d\n" "Physical SPEs: %d\n" "Usable SPEs: %d\n", cpu_nodes, physical_spes, usable_spes); return 0; }
Po preložení a zlinkovaní (stačí gcc, nie je nutné použiť ppu-gcc) dostaneme na PS3 nasledujúci výpis:
# gcc info.c -lspe2 -o info # ./info System information: CPU nodes: 1 Physical SPEs: 6 Usable SPEs: 6
Máme k dispozícii 6 SPE, ktoré nám umožňuje knižnica libspe2 využívať.
Predstavme si situáciu, kedy potrebujeme spracovať netriviálne množstvo vzájomne nezávislých dát. Tieto dáta môžeme rozdeliť na vhodne veľké časti a spustiť nezávislé výpočty na SPE. Program na PPE len počká, kým všetky SPE skončia, a prezentuje výsledok.
„Vhodne veľké časti“ znamená rozdeliť vstupné dáta na taký počet kusov, aký máme počet dostupných SPE a spustiť na každom SPE jedno vlákno spracujúce jednu časť dát. Je to kvôli tomu, že SPE nemajú v láske prepínanie kontextov ale skôr výpočty. Keďže pamäť dostupná na SPE (Local Store Area – LS) je obmedzená na 256 kB, je výhodné skombinovať programovanie s vláknami s technikou double-bufferingu za účelom optimalizácie rýchlosti výpočtov (t.j. rozbehnúť jedno vlákno na každom SPE, každému dať adresu a veľkosť jeho časti vstupu a program na SPU použije popisovaný double-buffering s cieľom čo najmenej prerušovať výpočet kvôli prenosom dát).
V poslednom príklade si ukážeme prácu, ako zaťažiť všetky SPE v systéme. Keďže máme 6 SPE, spustíme 6 vláken a necháme ich dookola vypisovať nejaký identifikátor. Záťaž SPE potom zistíme z výpisu spu-top.
Program pre SPU nič zložité vykonávať nebude – jeho jedinou prácou je posielať na výstup číslo, ktoré dostane v parametri argp. Bude teda generovať stop&signal inštrukciu a bude žiadať PPE o I/O.
#include <stdio.h> int main(unsigned long long speid, unsigned long long argp, unsigned long long envp){ for(;;){ printf("Thread with SPE context 0x%llx, passed value 0x%llx\n", speid, argp); } return 0; }
Program na PPU použije na vytváranie vláken systémovú knižnicu pthread. Vytvorí toľko vláken, koľko je SPE v systéme a každé z vláken ako svoju funkciu dostane funkciu, ktorá vytvorí SPU kontext a vykoná SPE program. Operačný systém zariadi, aby boli jednotlivé spustené SPU kontexty naplánované na dostupné SPE.
#include <stdlib.h> #include <stdio.h> #include <pthread.h> #include <libspe2.h> extern spe_program_handle_t worker_handle; void *spe_function(void *data); int get_spe_count(){ int usable_spes = spe_cpu_info_get(SPE_COUNT_USABLE_SPES, -1); return usable_spes; } int main(void){ int usable_spes = get_spe_count(); pthread_t* threads; threads = (pthread_t*)malloc(usable_spes*sizeof(pthread_t)); int i, retval; for (i =0; i<usable_spes; i++)="" retval="pthread_create(&threads[i]," null,="" spe_function,="" (void*)(unsigned="" long="" long)i);="" if="" (retval)="" {="" fprintf(stderr,="" error="" creating="" thread\n="" );="" exit(1);="" }="" for="" (i="0;" i="">< usable_spes; i++) { retval = pthread_join(threads[i], NULL); } return 0; } void *spe_function(void *data) { int retval; unsigned int entry_point = SPE_DEFAULT_ENTRY; spe_context_ptr_t my_context; my_context = spe_context_create(0, NULL); spe_program_load(my_context, &worker_handle); int size = spe_ls_size_get (my_context); printf("Size of local store is %d bytes\n", size); do { retval = spe_context_run(my_context, &entry_point, 0, data, NULL, NULL); } while (retval > 0); pthread_exit(NULL); }
Po riadkoch s include direktívami vidíme starú známu handle na SPE program. Funkcia get_spe_count() nám vracia počet dostupných SPE v systéme (je vynechaná kontrola návratovej hodnoty). Main funkcia zistí, koľko máme SPE, vytvorí odpovedajúci počet vláken a čaká až všetky vytvorené vlákna skončia. Každé vlákno vytvorí SPU kontext, ako argp predá do SPU index cyklu, v ktorom bolo vlákno vytvorené. Následne kontext spustí a čaká, kým skončí (čo je v našom prípade nikdy…).
V tejto chvíli by sme mali mať za sebou dostatok teoretických znalostí o Cell procesoroch a takisto nejaké menšie praktické príklady. S touto výbavou sa môžeme smelo vydať na cestu experimentov s touto netradičnou architektúrou.
Veľa ďalších a podrobnejších informácií je možné nájsť v zozname literatúry, patrí tam napríklad detailná technická špecifikácia procesoru, ktorej sme sa venovali v prvom dieli seriálu. Takisto som len okrajovo spomenul signály a schránky, keďže som sa zaujímavejšiu tému považoval DMA.
Literatúra
1 – SPE Runtime Management Library Version 2.3