Skoči na vsebino

Anatomija grafičnih procesnih enot1

Da bomo lažje razumeli delovanje in zgradbo sodobnih grafičnih enot, si bomo v nadaljevanju ogledali poenostavljen opis ideje, ki je privedla do njihovega nastanka. GPE so nastale v želji, da bi programsko kodo, ki ima veliko število relativno enostavnih in ponavljajočih se operacij, izvajali na velikem številu manjših procesnih enot in ne na veliki, kompleksni ter energijsko požrešni centralni procesni enoti. V množico problemov, ki so jim sodobne GPE namenjene, sodijo: procesiranje slik in videoposnetkov, operacije nad velikimi vektorji in matrikami, globoko učenje in podobno.

Sodobne CPE so zelo kompleksna digitalna vezja, ki špekulativno izvajajo ukaze v večih cevovodih. Ukazi pa podpirajo veliko število raznovrstnih operacij (manj ali bolj kompleksnih). CPE imajo v cevovodih vgrajene enote za napovedovanje vejitev in ukazne vrste (ang. trace cache), v katerih shranjujejo mikrokode ukazov, ki čakajo na izvajanje. Sodobne CPE imajo več jeder in vsakemu jedru pripada vsaj prvonivojski predpomnilnik L1. Jedra sodobnih CPE skupaj s predpomniniki zaradi prej naštetega zasedajo veliko prostora na čipu in porabijo veliko energije.

Pri vzporednem računalništvu težimo k čim večjemu številu preprostejših procesnih enot, ki so majhne in energijsko učinkovite. Te manjše procesne enote običajno delajo s taktom, ki je nekajkrat nižji od takta ure CPE. Zato manjše procesne enote potrebujejo nižjo napajalno napetost in tako bistveno zmanjšajo porabo energije.

Zmanjšanje moči

Zgornja slika prikazuje, kako je možno v paralelnem sistemu zmanjšati moč in porabo energije. Na levi strani slike je CPE, ki procesira s frekvenco f. Za delo s frekvenco f potrebuje energijo, ki jo dobi iz napajalne napetosti V. Notranja kapacitivnost (to je nekakšna vzrtrajnost, ki se upira hitrim spremembam napetosti na digitalnih priključkih) take CPE je odvisna predvsem od njene velikosti na čipu in je označena s C. Moč, ki jo za svoje delovanje potrebuje CPE, je proporcionalna frekvenci ure, kvadratu napajalne napetosti in kapacitivnosti.

Na desni strani slike isti problem rešujemo z dvema procesnima enotama CPE', ki sta vezani vzporedno. Predpostavimo, da se naš problem da razbiti na dva popolnoma enaka podproblema, ki ju lahko rešujemo ločeno, vsakega na svoji CPE'. Predpostavimo tudi, da sta procesni enoti CPE' za pol manjši od CPE v smislu velikosti čipa in da delata s frekvenco f/2. Ker delata s polovično frekvenco, potrebujeta tudi manj energije. Izkaže se, da če v digitalnem sistemu razpolovimo frekvenco ure, za delovanje sistema potrebujemo le 60 % napajalne napetosti. Ker sta CPE' za polovico manjši, je tudi njuna kapacitivnost le C/2. Moč P', ki jo za svoje delovanje sedaj potrebuje tak vzporedni sistem, je le 0,36 P.

Evolucija GPE

Predpostavimo, da želimo s spodnjo funckijo vectorAdd() v jeziku C sešteti dva vektorja vecA in vecB ter rezultat shraniti v vektor vecC. Vsi vektorji so dolžine 128.

1
2
3
4
5
6
7
void vectorAdd( float *vecA, float *vecB, float *vecC ) {
    int tid = 0; 
    while (tid < 128) {
        vecC[tid] = vecA[tid] + vecB[tid];
        tid += 1; 
    }
}

V zgornji kodi smo namenoma uporabili zanko while, v kateri seštevamo vse iztoležne elemente vektorjev. Indeks trenutnega elementa smo namenoma zapisali s tid, kar je okrajšava od thread index. Zakaj smo izbrali ravno takšno ime, izvemo v nadaljevanju.

Izvajanje na eni CPE

Predpostavimo, da želimo funkcijo vectorAdd() izvajati na eni preprosti CPE, ki je prikazana na spodnji sliki. CPE ima logiko za prevzem in dekodiranje ukazov, aritmetično-logično enoto ter množico registrov, v katerih so operandi za aritmetično-logično enoto.

Preprosta CPE

Poleg CPE je na zgornji sliki prikazana psevdo-zbirniška koda funkcije vectorAdd(). Ne bomo se spuščali v njene podrobnosti, poudarimo le, da se koda v zanki L1 ponovi 128-krat.

Izvajanje na dveh CPE

Sedaj predpostavimo, da želimo funkcijo vectorAdd() izvajati na dveh enakih CPE kot prej. Na spodnji sliki sta prikazani dve CPE ter psevdo-zbirniški kodi, ki se izvajata na vsaki od teh dveh CPE. Spet se ne bomo spuščali v podrobnosti zbirniške kode. Poudarimo le, da se koda v zanki L1 tokrat ponovi le 64 krat, saj vsaka CPE tokrat sešteje le polovico istoležnih elementov v vektorjih (leva CPE sešteva prvih 64 elementov, medtem ko desna CPE sešteva zadnjih 64 elementov). Čez palec lahko ocenimo, da smo izvajanje funkcije vectorAdd() tako dvakrat pohitrili. Pravimo tudi, da se sedaj na dveh CPE hkrati izvajata dve vzporedni niti.

Dve CPE

Računska enota in procesni elementi

Še večjo zmogljivost lahko dosežemo z nadaljnjim dodajanjem enot ALE in kontekstov izvajanja, kot je prikazano na spodnji sliki. Namesto kloniranja celotnega jedra procesorja lahko kopiramo samo ALE in kontekst izvajanja, pri čemer si logiko prevzema/dekodiranja (ukazov) delijo vsi ALE-e. Ker je logika prevzema/dekodiranja deljena, morajo vse enote ALE izvesti isto operacijo, vendar lahko uporabljajo različne vhodne podatke.

Računska enota

Zgornja slika prikazuje jedro z osmimi enotami ALE, osmimi izvajalnimi konteksti in skupno logiko prevzema/dekodiranja. Poleg tega takšno jedro običajno implementira dodaten prostor za podatke, ki si jih nato delijo niti. Z enim samim ukazom lahko na takem jedru vzporedno dodamo osem sosednjih vektorskih elementov. Navodila se zdaj delijo med niti z enakimi programskimi števci (PC) in se izvajajo sočasno - tj. vsako posamezno navodilo se izvaja v zaklenjenem koraku na različnih podatkih. Tako je za vsako nit na voljo en ALE in en kontekst izvajanja. Vsaka nit mora zdaj uporabljati svoj ID (tid) za identifikacijo podatkov, ki se uporabljajo v ukazih.

Na zgornji sliki je predstavljena tudi psevdozbirna koda, ki se izvaja na takem jedru. Ko se pridobi prvi ukaz, se pošlje vsem osmim enotam ALE znotraj procesorskega jedra. Spomnimo se, da ima vsak ALE svoj nabor registrov (kontekst izvajanja), zato bi vsak ALE v svoj register r2 dodal svoj tid. Enako velja tudi za drugo in vsa naslednja navodila v toku ukazov. Na primer, ukaz lfp f1,r3(vecA) se izvede v vseh enotah ALE hkrati. Ta ukaz naloži element iz vektorja vecA na naslov vecA+r3. Ker vrednost v r3 temelji na indeksu niti (tid), bo vsak ALE deloval na drugem elementu iz vektorja vecA. Večina sodobnih grafičnih procesorjev uporablja ta pristop, pri katerem jedra izvajajo skalarne ukaze. Kljub temu se en tok ukazov deli med več niti.

Opazimo pa lahko, da ima tak procesor še vedno le eno funkcijo za prevzem/dekodiranje, ki se deli med osem enot ALU - to pomeni, da lahko prevzame, dekodira in izvrši le en ukaz v eni urini periodi! Zato tak procesor pošlje isti ukaz vsem aritmetično-logičnim enotam hkrati in ukaze izvede v zaklenjenem slogu. Tokrat so operandi v ukazih vektorji dolžine 8, kar nam omogoča, da v eni urini periodi (hkrati) seštejemo osem zaporednih elementov vektorjev in zanko ponovimo le 16-krat. Takšno računsko enoto NVIDIA imenuje streaming multiprocessor (SM) . Računska enota ali SM lahko hkrati izvede eno operacijo nad veliko količino podatkov. Ta način izvajanja se imenuje SIMD (Single Instruction Multiple Data). Ker SM izvaja vsak ukaz v osmih aritmetično-logičnih enotah hkrati, je videti, kot da se izvajajo različna niti. Zato se takšno izvajanje imenuje tudi SIMT (Single Instruction Multiple Threads). V NVIDIA slovarčku ALE enote, ki izvajajo isti ukaz nad različnimi operandi, imajo ime streaming processors (SP). Spodnja slika prikazuje izvajanje ukazov v SIMD (SIMT) slogu.

SIMD (SIMT)

Registri v procesnih elementih so praviloma privatni za vsak procesni element posebej, kar pomeni, da drugi procesni elementi ne morejo dostopati do podatkov v registrih. Računske enote imajo zato poleg procesnih elementov še manjši skupni pomnilnik (ang. shared memory), preko katerega si niti lahko delijo podatke. Zanka L1 se tokrat ponovi le 16- krat!

Grafična procesna enota

Gremo še en korak naprej. Namesto ene računske enote v sistemu uporabimo kar 16 računskih enot, kot prikazuje spodnja slika.

Računska enota

Sedaj nam ni treba zanke ponoviti 16-krat, temveč vsaki računski enoti dodelimo le eno iteracijo zanke. Zgornja slika prikazuje poenostavljeno zgradbo grafičnih procesnih enot, ki vsebuje 16 SMs. S 16 SM lahko z enim tokom ukazov vzporedno dodajamo 128 sosednjih vektorskih elementov. Vsak SM izvede delček kode (predstavljen na vrhu slike). Ta delček kode predstavlja eno nit***. Predpostavimo, da izvajamo 128 niti in da ima vsaka nit svoj ID, tid, pri čemer je tid v območju 0 . . . 127. Prva dva ukaza naložita ID niti tid v r3 in ga pomnožita s 4 (da dobimo pravilno zamikanje v vektorju s plavajočo vejico). Zdaj register r3 (spomnimo se, da so registri zasebni za vsako nit) vsebuje odmik elementa vektorja, do katerega bo dostopala določena nit. Vsaka nit nato sešteje dva sosednja elementa vecA in vecB ter rezultat shrani v ustrezen element vecC. Ker vsak SM vsebuje osem SP (skupaj 128 SP), zanka ni potrebna.

Upamo, da zdaj razumete osnovno zamisel, ki se uporablja pri gradnji grafičnih procesorjev: uporabimo čim več enot ALE (SP) in pustimo, da SP izvajajo iste ukaze na principu lock-step, tj. istočasno izvajajo isti ukaz, vendar na različnih podatkih.

Povzetek

Grafične procesne enote so sestavljene iz večjega števila medseboj neodvisnih računskih enot. Računske enote so sestavljene iz velikega števila procesnih elementov. Malce poenostavljeno lahko vzamemo, da vse računske enote, v nadaljevanju jih bomo označevali s SM, izvajajo isti program (ščepec), procesni elementi znotraj ene računske pa istočasno iste ukaze. Pri tem lahko različne procesne enote v nekem trenutku izvajajo različne dele ščepcev. Pravimo tudi, da računske enote izvajajo skupine niti, procesni elementi pa izvajajo posamezne niti.




  1. © Patricio Bulić, Univerza v Ljubljani, Fakulteta za računalništvo in informatiko. Gradivo je objavljeno pod licenco Creative Commons Priznanje avtorstva-Nekomercialno-Deljenje pod enakimi pogoji 4.0 Mednarodna