Seštevanje vektorjev1
Začnimo s preprostim primerom seštevanja dveh vektorjev. Problem bomo rešili na dva načina. Najprej bomo začeli dokaj naivno in se osredotočili le na osnove programiranja GPE ter na korake na gostitelju, ki so potrebni, da naš prvi program zaženemo na GPE. V drugem načinu se bomo nato osredotočili na izboljšavo ščepca, ki se izvaja na GPE.
Spodnja koda prikazuje implementacijo funkcije v jeziku C za seštevanje dveh vektorjev, katerih elementi so realna števila, predstavljena z zapisom v plavajoči vejici z enojno natančnostjo (float
).
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
vecA
, vecB
, vecC
) ter število elementov v vektorjih (iNumElements
). Vektorje seštevamo tako, da seštejemo vse istoležne elemente v vhodnih vektorjih vecA
in vecB
ter rezultat shranimo v istoležni element vektorja vecC
. Ker imajo vektorji iNumElements
elementov, bomo seštevanje vektorjev opravili v zanki, ki ser bo ponovila iNumElements
krat.
V zanki posamezne elemente vektorja indeksiramo s celoštevilskim indeksom iGID
. Ime smo izbrali namenoma (ang. Global Index), razlog za tako izbiro nam bo znan kmalu.
Naiven poskus seštevanja vektorjev
Funkcijo VectorAdd()
bi sedaj radi izvajali na GPE. Spoznali smo, da so GPE idealne za izvajanje nalog, kjer imamo veliko podatkovnega paralelizma - v našem primeru je to še kako res, saj isto operacijo (seštevanje) izvajamo nad vsemi elementi vektorjev. Povrh pa so te operacije medseboj neodvisne (elemente vektorjev lahko seštejemo v poljubnem vrstnem redu) in jih lahko tako seštevamo vzporedno.
Program za napravo - ščepec
V prejšnjih poglavjih smo spoznali, da programe za GPE (ščepce) pišemo tako, da jih bodo lahko (hkrati) izvajale vse niti iz prostora NDRange (tega bomo seveda še definirali v nadaljevanju) - to so vse niti, ki jih bomo na GPE pognali. Zato moramo delo, ki ga opravi funkcija VectorAdd()
, čim bolj eneakomerno razdeliti med niti. V našem primeru je to dokaj preprosta naloga, saj bomo za začetek delo razdelili tako, da bo vsaka nit seštela le istoležna elementa. Zato bomo na GPE pognali toliko niti, koliko je elementov v vektorjih - v našem primeru je to iNumElements
niti.
Vseh iNumElements
niti, ki jih bomo pognali, tvori prostor NDRange. Omenili smo že, da je ta prostor lahko eno-, dvo-, ali tri-dimenzionalen in da razsežnost prostora prilagajamo podatkom. Ker so naši vektorji enodimenzionalna polja, bomo prostor NDRange v našem primeru organizirali v eni dimenziji (X). Globalna velikost tega prostora bo kar iNumElements
. Zato bo globalni indeks vsake niti, ki se bo izvajala na GPE, enolično določen iz tega prostora in bo pravzaprav ustrezal indeksu elementov, ki jih nit sešteva. Ščepec, ki ga bodo vse niti izvajale na GPE, je prikazan v spodnji kodi:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
Vidimo, da je koda ščepca zelo podobna kodi funkcije VectorAdd()
. Vsaka funkcija, ki jo želimo izvajati na GPE, mora biti deklarirana kot ščepec (__kernel
). Ščepec smo poimenovali vecadd_naive()
, njegovi argumenti pa so naslovi vektorjev ter število elementov v vektorjih. Naslovi vektorjev imajo tokrat specifikator __global
, s katerim določimo, da se ti naslovi nanašajo na globalni pomnilnik na GPE. Vektorje, ki jih želimo seštevati na GPE, moramo namreč hraniti na GPE, saj računske enote na GPE ne morejo naslavljati glavnega pomnilnika na gostitelju. Ker so vektorji lahko precej veliki, je globalni pomnilnik edini ustrezen pomnilniški prostor za hranjenje vektorjev. Povrh pa vse računske enote lahko naslavljajo globalni pomnilnik in bodo zato elementi vektorjev dostopni vsem nitim, ne glede na to, na kateri računski enoti se bo posamezna nit izvajala (na to itak nimamo vpliva).
Vsaka nit najprej ugotovi svoj globalni indeks iGID
v prostoru NDRange s funkcijo get_global_id(0)
. Argument 0 pri klicu funkcije določa, da zahtevamo indeks v dimenziji X. Nato vsaka nit sešteje istoležna elementa v vektorjih, katerih indeks je enak njenemu indeksu, vendar le, če je njen indeks manjši ali enak številu elementov v vektorjih (zato je v ščepcu if
stavek). Na ta način zagotovimo dvoje:
- podatki, s katerimi dela posamezna nit, so za vsako nit enolično določeni in ni možnosti, da bi morebiti dve niti skušali pisati v isti element vektorja
vecC
, - pomnilniške dostope združujemo v segmente, kjer vse niti v istem snopu dostopajo do zaporednih 8-, 16-, 24- ali 32 bitnih podatkov v pomnilniku (ang. memory coalescing).
Sedaj nam preostane le še, da si pogledamo, kako tak ščepec prevedemo za izbrano GPE napravo ter kako ga prenesemo na GPE in tam poženemo.
Podroben opis za OpenCL C programski jezik najdemo na spletni strani The OpenCL C Specification.
Program na gostitelju
Ko smo napisali ščepec, ga moramo najprej prevesti. Ščepce prevajamo za izbrano napravo, zato moramo najprej izbrati platformo,napravo v platformi, ustvariti kontekst in ukazno vrsto za vsako napravo kot smo to spoznali v prejšnjem poglavju. S tem delo programa na gostitelju še zdaleč ni končano. Ta mora sedaj opraviti še naslednje naloge:
- rezervirati prostor v glavnem pomnilniku in inicializirati podatke na gostitelju,
- rezervirati prostor v globalnem pomnilniku naprave, kjer bomo hranili naše vektorje,
- prebrati program za napravo iz datoteke v pomnilnik,
- prevesti program za napravo - program za napravo se bo prevajal med izvajanjem programa na gostitelju,
- iz prevedenega programa ustvariti ščepec, ki ga bomo zagnali na izbrani napravi,
- prenesti podatke iz gostitelja na napravo,
- nastaviti argumente za ščepec,
- nastaviti velikost in organizacijo prostora niti NDRange,
- zagnati ščepec na napravi ter
- prebrati podatke iz globalnega pomnilnika na napravi po končanem izvajanju ščepca.
Zgornja slika prikazuje vse korake, ki jih mora izvesti program na gostitelju pred in po zaganjanju ščepcev na GPE naparavi.
Inicializacija podatkov na gostitelju
Program na gostitelju mora inicializirati vse podatke, ki so potrebni za računanje na napravi. Zavedati se moramo, da gostitelj dostopa lahko le do svojega glavnega pomnilnika in bodo zaenkrat vsi podatki, ki jih gostitelj inicializira, v glavnem pomnilniku. V našem primeru moramo rezervirati prostor za vse tri vektorje ter inicializirati vektorja, ki ju želimo sešteti:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
Imenom vektorjev, ki jih hranimo na gostitelju smo dodali _h
, da poudarimo, da so to podatki na gostitelju (ang. host). Vse elemente vhodnih vektorjev na gostitelju smo nastavili na vrednost 1.
Rezervacija prostora v globalnem pomnilniku naprave
Sedaj moramo rezervirati prostor v globalnem pomnilniku naprave, kjer bomo hranili vse vektorje, ki nastopajo pri računanju. Za rezervacijo prostora v globalnem pomnilniku naprave uporabljamo funkcijo clCreateBuffer()
. Ta rezervira prostor velikosti datasize
. Pri rezervaciji prostora v globalnem pomnilniku navedemo še, ali bo naprava iz rezerviranega prostora samo brala (CL_MEM_READ_ONLY
), vanj samo pisala (CL_MEM_WRITE_ONLY
) ali pa bo imela bralne in pisalne dostope (CL_MEM_READ_WRITE
). Funkcija clCreateBuffer()
vrne rokovalnik (tipa cl_mem
) na pomnilniški objekt (rezervirani prostor) v globalnem pomnilniku naprave. Ne pozabimo, da gostitelj (CPE) nima neposrednega dostopa do tega pomnilnika zato nikoli ne poskusite uporabiti tega rokovalnika kot kazalca in ga dereferencirati na gostitelju!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
|
Branje programa za napravo - ščepca
Programe za napravo pišemo v datotekah s končnico .cl
. V našem primeru bo datoteka kernel.cl
vsebovala le ščepec vecadd_naive()
. V splošnem bi datoteka kernel.cl
lahko vsebovala poljubno število ščepcev in funkcij, ki jih ti ščepci kličejo. Program moramo prevesti za izbrano napravo, zato to počnemo takrat, ko ima gostitelj že vse informacije o platformi in napravah. Pred samim prevajanjem, moramo program iz datoteke kernel.cl
prebrati v pomnilnik gostitelja in nato iz njega ustavriti programski objekt, primeren za prevajanje. To storimo s spodnjo kodo.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
|
Najprej odpremo datoteko kernel.cl
in iz nje preberemo kodo ter jo kot zaporedje znakov shranimo v znakovni niz programBuffer
. Šele nato s funkcijo clCreateProgramWithSource()
preberemo znakovni niz programBuffer
in iz njega ustvarimo ustrezen programski objekt cpProgram
, primeren za prevajanje.
Prevajanje programa za izbrano GPE napravo
Prevajanje prebranega programa, ki ga hranimo v objektu cpProgram
, prevedemo s funkcjo clBuildProgram()
, kot to prikazuje spodnja koda.
1 2 3 4 5 6 7 8 9 10 11 12 |
|
Zavedati se moramo, da se šele sedaj, torej v času izvajanja programa na gostitelju, prevede program za napravo, ki smo ga prebrali iz datoteke kernel.cl
. Kakršnekoli napake v kodi v kernel.cl
se bodo pokazale šele sedaj, med prevajanjem, in ne takrat, ko prevajamo program za gostitelja. Zato moramo v primeru napak med prevajanjem (pri funkciji clBuildProgram()
), le te shraniti v podatkovni niz v glavnem pomnilniku ter jih po potrebi prikazati. To lahko storimo s funkcijo clGetProgramBuildInfo()
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
|
Funkcijo clGetProgramBuildInfo()
kličemo dvakrat. Prvič, da ugotovimo dolžino sporočila o napaki, in drugič, da preberemo celotno sporočilo o napaki. Spodaj je prikazan primetr izpisa napake v primeru, ko smo v ščepcu uporabili nedeklarirano ime za vektor B:
Error: Failed to build program executable!
<kernel>:9:35: error: use of undeclared identifier 'vecBB'; did you mean 'vecB'?
vecC[myID] = vecA[myID] + vecBB[myID];
^~~~~
vecB
<kernel>:2:33: note: 'vecB' declared here
__global float* vecB,
^
Ustvarjanje ščepca iz prevedenega programa
V splošnem bi lahko naš prevedeni program vseboval več ščepcev. Zato sedaj iz programa ustvarimo le ščepec vecadd_naive()
, ki ga želimo zagnati na napravi. To storimo s funkcijo clCreateKernel()
.
1 2 3 4 5 6 7 8 9 10 11 |
|
Prenos podatkov na napravo
Vektorja, ki smo ju inicializirali v prvem koraku, prenesemo iz glavnega pomnilnika gostitelja v prej ustvarjena pomnilniška objekta v globalnem pomnilniku naprave. Prenos podatkov pravzaprav sprožimo s pisanjem ustreznega ukaza v ukazno vrsto cmdQueue
naprave. To storimo s funkcijo clEnqueueWriteBuffer()
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
|
Nastavljanje argumentov ščepca
Da bi pognali izbrani ščepec vecadd_naive()
, mu moramo nastaviti še argumente. Za to uporabimo funkcijo clSetKernelArg()
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
|
Drugi argument funkcije clSetKernelArg()
označuje vrstni red argumenta pri klicu ščepca.
Nastavitev in organizacija prostora NDRange
Smo tik pred tem, da zaženemo niti, ki bodo izbrani ščepec izvajale na napravi GPE. Določiti moramo še koliko niti bomo pognali na napravi GPE, kako bodo te niti organizirane v delovne skupine ter kako bodo niti in delovne sklupine organizirane v prostoru NDRange. Temu pravimo nastavitev prostora NDRange. Zaenkrat bomo delali v enodimenzionalnem prostoru NDRange, zato njegovo velikost in organizacijo določimo z naslednjima spremenljivkama szLocalWorkSize
in szGlobalWorkSize
:
1 2 3 |
|
V zgornji kodi smo določili, da želimo imeti 128 niti v delovni skupini in da želimo pognati toliko niti, kolikor so dolgi vektorji (iNumElements
).
Zagon ščepca na izbrani GPE napravi
Ščepec na izbrani napravi zaženemo tako, da v ukazno vrsto cmdQueue
zapišemo ukaz za poganjanje ščepca ter opis prostora NDRange. Oboje storimo s funkcijo clEnqueueNDRangeKernel()
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
V zgornji kodi smo rekli, da želimo zagnati ščepec ckKernel
, da ima prostor NDRange razsežnost 1, da je njegova globalna velikost szGlobalWorkSize
in da so niti organizirane v delovne skupine velikosti szLocalWorkSize
.
Na tem mestu je potrebno opozoriti, da ko v ukazno vrsto ustavimo nek ukaz (na primer za prenos podatkov ali zaganjanje ščepca), nimamo nobenega vpliva na to, kdaj se bo ukaz dejansko izvedel. Funkciji clEnqueueWriteBuffer()
in clEnqueueNDRangeKernel()
sta neblokirajoči in se zaključita takoj. Tako pravzaprav ne vemo, kdaj bodo podatki prenešeni, kdaj se ščepec začne in konča izvajati na napravi. Vrsta nam zagotavlja le vrstni red izvajanja ukazov v njej. Tako smo lahko prepiričani, da se ščepec zagotovo ne bo začel izvajati, preden se podatki ne prenesejo iz gostitelja v napravo.
Branje podatkov iz GPE naprave
Po končanem izvajanju ščepca prenesemo vektor z vsotami iz globalnega pomnilnika naprave v glavni pomnilnik gostitelja.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
clFinish()
, s katero blokiramo izvajanje glavnega programa, dokler se ukazna vrsta cmdQueue
ne sprazni.
Brisanje pomnilnika na gostitelju
Na koncu programa moramo še sprostiti ves rezervirani pomnilniški prostor na gostitelju.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
Celotno kodo iz tega poglavja najdete v mapi 02-vector-add-short
tukaj.
Seštevanje poljubno dolgih vektorjev
Ščepec vecadd_naive()
, ki smo ga uporabili za seštevanje vektorjev ima eno pomanjklivost - vsaka nit, ki bo ščepec vecadd_naive()
izvedla, bo izračunala le eno vsoto. Spomnimo se, da je število niti, ki tvorijo delovno skupino omejeno in da je prav tako omejeno število niti, ki se hkrati izvajajo na eni računski enoti. Pri napravi Tesla K40 je največje število niti, ki se hkrati izvajajo na eni računski enoti, 2048. Ker ima naprava Tesla K40 le 15 računskih enot, je največje število niti, ki se lahko naenkrat izvajajo 30.720. Če poženemo več kot toliko niti, bo moral gonilnik serializirati njihovo izvajanje na GPE. Zato je veliko bolje, da maksimalno število niti, ki jih zaženemo, določimo iz tega, koliko niti bomo dali v eno delovno skupino, koliko skupin se lahko hkrati izvaja na eni računski enoti in koliko je računskih enot na GPE. V tem primeru bomo verjetno imeli manjše število niti kot je dolžina vektorjev. Problem rešimo s spodnjim ščepcem.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
Sedaj bo vsaka nit najprej ugotovila svoj globalni index iGID
ter globalno velikost prostora NDRange iGS
(to je pravzaprav število vseh aktivnih niti). Nato bo istoležne elemente seštevala v zanki while
tako, da bo v eni iteraciji seštela dva istoležna elementa in nato povečala iGID
za toliko, kolikor je vseh aktivnih niti. Tako se bo premaknila na naslednja dva istoležna elementa in ju seštela. Zanka se zaključi, ko indeks iGID
preseže velikost vektorjev.
Zagotovo se sprašujete, zakaj ena nit ne bi seštela sosednji par istoležnih elementov ali 4 sosednje pare istoležnih elementov? Razlog tiči v načinu dostopa do pomnilnika! Spomnimo se, da se do pomnilnika dostopa po segmentih. Da bi zagotovili usklajen dostop do pomnilnika, morata dve sosednji niti v snopu dotopati do dveh sosednjih pomnilniških besed.
Program na gostitelju tokrat ostane enak - le drugi ščepec je treba naložiti. Lahko se tudi sami poskusite poigrati z različnimi velikostmi vektorjev ter z različnimi nastavitvami prostora NDRange.
Celotno kodo iz tega poglavja najdete v mapi 04-vector-add-arb
tukaj.
-
© 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. ↩