Skalarni produkt dveh vektorjev1
Poglejmo si sedaj skalarni produkt vektorjev. Začeli bomo s preprosto različico, da prikažemo osnovno idejo. Nato bomo analizirali učinkovitost preproste različice in jo razširili na različico, ki uporablja skupni pomnilnik.
Osnovna različica skalarnega produkta
The complete program for the naïve version of dot product is: Celotna koda programa za izračun skalarnega produkta
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 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
Rešitev je praktično enaka kot pri seštevanju dveh vektorjev, le da smo operacijo seštevanja nadomestili s produktom. V drugem koraku seštejemo vse produkte. Ko se ščepec zaključi, mora gostitelj prebrati vektor produktov iz naprave in sešteti vse njegove elemente v zanki:
75 76 77 78 79 |
|
Zgornja koda se nahaja v mapi repozitorija delavnice skupaj z navodili za prevajanje in zagon 04-dot-product-naive
.
Optimizacija skalarnega produkta: skupni pomnilnik in redukcija
Težava prejšnje implementacijo je, da gostitelj izračuna vsoto produktov parov. Pri zelo dolgih vektorjih lahko to vzame precej časa.
Zato bomo predstavili boljšo rešitev za seštevanje delnih produktov. Spoznali bomo, kako lahko niti uporabljajo skupni pomnilnik in sodelujejo pri seštevanju. Izračun skalarnega produkta bo sestavljen iz treh korakov:
n
niti bomo razdelili vb
blokov niti sl
nitmi na blok. Vsaka nit v bloku bo izračunala produkte istoležnih elementov vektrojev in jih seštela v svoj delni skalarni produkt v skupnem pomnilniku. Ker imamol
niti v bloku, bo na koncu tega koraka v deljenem pomnilniku shranjenihl
delnih produktov.- Vse niti bloka bodo nato sodelovale pri združevanju vsote
l
delnih skalarnih produktov. Ena izmed niti v bloku bo to vsoto shranila v globalni pomnilnik naprave. Zab
blokov bomo imeli na koncu tega korakab
vsot v globalnem pomnilniku naprave. - Sedaj bomo prenesli
b
vsot iz globalnega pomnilnika naprave na gostitelja in jih tam sešteli. Ker je število blokovb
veliko manjše od velikosti vektorjevn
, bo gostitelj moral prenesti bistveno manj podatkov iz naprave. Poleg tega bo gostitelj imel veliko manj dela pri končnem izračunu skalarnega produkta.
1. Korak: Izračun delnih skalarnih produktov
Spodnja koda prikazuje ščepec, ki zmnoži istoležne elemente, jih sešteje in delni skalarni produkt shrani v skupni pomnilnik (polje buf
).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
buf
se hrani v skupnem pomnilniku in ima velikost BLOCK_SIZE. BLOCK_SIZE je konstanta in je enaka številu niti v bloku. S tem zagotovimo, da bo imela vsaka nit znotraj bloka prostor, kamor bo lahko zapisala delni skalarni produkt. Za rezervacijo skupnega pomnilnika preprosto uporabimo kvalifikator shared pri deklaraciji spremenljivke:
4 |
|
Sledi inicializacija tabele buf
z ničlami, saj se podatkovne strukture v skupnem pomnilniku ne inicializirajo samodejno. Tu, vsaka nit bloka inicializira svojo lokacijo v skupnem pomnilniku.
7 |
|
buf
:
Sedaj lahko vsaka nit zmnoži istoležne elemente in akumulira vsoto produktov na ustrezno mesto v tabeli buf
.
11 12 13 14 15 16 |
|
18 |
|
Ta klic zagotovi, da so vse niti znotraj bloka zaključile vse ukaze do tega trenutka in šele potem dovoli, da niti nadaljujejo z izvajanjem ukazov, ki sledijo. Temu ukazu rečemo prepreka (angl. barrier). Na splošno je prepreka sinhronizacijski konstrukt, ki ga uporabimo na določenem mestu v kodi in poskrbi, da nobena nit ne napreduje prek tega konstrukta, dokler vse niti (bloka) ne prispejo do njega. Ko vse niti dosežejo prepreko, se izvajanje nalednjih ukazov v kodi lahko nadaljuje. Če določene niti iz nekega razloga prepreke nikoli ne dosežejo, bodo preostale niti, ki so prepreko dosegle, preprosto obstale na prepreki. Prišlo bo do smrtnega objema (angl. dead-lock).
2. Korak: Redukcija delnih vsot
Zdaj, ko smo zagotovili, da je tabela buf
napolnjena z delnimi skalarnimi produkti, lahko seštejemo vrednosti v njej. Proces, kjer vzamemo vhodno tabelo in izvedemo nekaj izračunov, ki proizvedejo manjše polje rezultatov, imenujemo redukcija. Naivni način izvedbe te redukcije bi bil, da uporabimo eno, ki se sprehodi preko tabele buf
in računa tekočo vsoto. Časovna zahtevnost takega pristopa bi bila sorazmerna z dolžino polja. V bloku imamo na voljo lahko na stotine niti in lahko tako redukcijo izvedemo vzporedno dosežemo časovno zahtevnost, ki je sorazmerna z logaritmom dolžine polja.
Naslednja slika prikazuje postopek vzporedne redukcije:
Idea je, da vsaka nit vzame dve vrednosti iz tabele buf
, ju sešteje in rezultat shrani nazaj v buf
. Ker vsaka nit združi po dve vrednosti v eno, prvo stopnjo naredimo s polovico niti, s katerimi smo začeli. V naslednjem koraku isto stvar ponovimo s polovico niti iz prvega koraka. Nadaljujemo, dokler nimamo vsote vseh vrednosti tabele buf
v prvem elementu.
Recimo, da imamo 16 elementov v polju buf
in 16 niti v bloku. V prvem koraku vsaka od prvih osmih niti v bloku sešteje element z indeksom threadIdx.x
in element z indeksom threadIdx.x + 8
ter rezultat zapiše v element z indeksom threadIdx.x
. Spomnimo se, da je threadIdx.x
indeks niti znotraj bloka. Preostalih osem niti iz bloka ne izvede ničesar. Nato vse niti počakajo pri prepreki, preden nadaljujejo z drugim korakom. S tem poskrbimo, da so vsa seštevanja zaključena, preden ponovno združujemo vrednosti.
V drugem koraku le prve štiri niti seštevajo elemente. Skupaj seštejemo elementa z indeksom threadIdx.x
in indeksom threadIdx.x + 4
, medtem ko druge niti ne izvedejo ničesar. Pred vstopom v tretji korak bodo vse niti znova počakale pri prepreki. Postopek nadaljujemo, dokler ne ostane le ena nit z indeksom 0, ki sešteje elementa z indeksom 0 in indeksom 1. Postopek redukcije, ki smo ga pravkar opisali, je prikazan v spodnjem izseku kode:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
Pomembno je opozoriti, da morajo vse niti v bloku klicati funkcijo __syncthreads()
. Če se prepreka uporabi znotraj pogojnega stavka, moramo zagotoviti, da vse niti v bloku vstopijo v pogojni stavek. Na primer, spodnja koda prikazuje nepravilno uporabo prepreke, ker vse niti bloka ne vstopijo v pogojni stavek:
1 2 3 4 5 6 |
|
Niti z indeksom threadIdx.x
, večjim ali enakim i
, ne bodo nikoli izvedle klica __syncthreads()
. Posledično bo del niti, za katere je bil pogoj izpolnjen ostal na prepreki v nedogled. To povzroči, da se ščepec "obesi". Tak ščepec bo povzročil, da se bo GPE se bo na neki točki prenehala odzivati in program bomo morali prisilno končati.
Po zaključku zanke while
ima vsak blok niti na prvem mestu v tabeli buf
seštevek vseh delnih skalarnih produtkov izračunanih znotraj bloka.
Rezultat moramo sedaj zapisati v globalni pomnilnik, da ga bomo lahko kasneje prenesli na gostitelja. Ker moramo prenesti samo eno vrednost, uporabimo eno nit, ki vrednost zapiše na ustrezno mesto. To je določeno z indeksom bloka blockIdx.x
:
1 2 3 4 5 |
|
3. Korak: Izračun končnega rezultata na gostitelju
Zadnji korak zahteva, da na gostitelju seštejemo delne skalarne produkte, ki so jih izračunali posamezni bloki niti na napravi. Ker je polje c
relativno majhno si to lahko brez težav privoščimo.
Spodaj je prikazan koda celotnega programa za izračun skalarnega produkta s pomočjo skupnega pomnilnika in vzporedne redukcije.
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 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 |
|
Zgornja koda se nahaja v mapi repozitorija delavnice skupaj z navodili za prevajanje in zagon 05-dot-product-reduction
.
Merjenje časa izvajanja s pomočjo dogodkov CUDA
Napisali smo dve različici za izračun skalarnega produkta v okolju CUDA. Kako ugotovimo, katera različica je hitrejša? To lahko ugotovimo s pomočjo dogodkov CUDA v tokovih (angl. CUDA Streams).
Tok CUDA se nanaša na zaporedje operacij CUDA, ki se izvajajo na napravi v vrstnem redu, določenim s kodo gostitelja. Te operacije lahko vključujejo prenos podatkov med gostiteljem in napravo, zagon ščepca in večino drugih ukazov, ki jih izda gostitelj, vendar jih obdela GPU-naprava. Skozi to delavnico je tipičen vzorec v programiranju:
- Premakni vhodne podatke iz gostitelja na napravo.
- Izvedi ščepec na napravi.
- Premakni rezultat iz naprave nazaj na gostitelja.
Prenosi podatkov med gostiteljem in napravo s cudaMemcpy
so sinhroni (ali blokirajoči). Sinhroni prenosi podatkov se ne začnejo, dokler niso bile zaključene vi prejšnji klici funkcij CUDA. Poleg tega se noben naslednji klic CUDA ne more začeti, dokler sinhroni prenos ni zaključen. Zagoni ščepcev pa so neblokirajoči. Ko ščepec zaženemo se izvajanje na CPE nadaljuje takoj in ne čaka na dokončanje ščepca. Prav blokirajoča narava prenosa podatkov preko funkcije cudaMemcpy
zagotavlja, da se ščepec zaključi preden se izvrši prenos.
Dogodek CUDA je v bistvu časovni žig v toku CUDA, ki se zabeleži ob določenem trenutku med izvajanjem operacij v toku. Programski vmesnik CUDA nudi funkcije, ki vam omogočajo, da vstavite dogodke na katero koli mesto v toku in tudi preverite ali se je dogodek zgodil. Dogodek, ki ga vstavimo v nek tok, se bo zgodil šele, ko bodo vse predhodne operacije v istem toku zaključene. Uporaba dogodkov je relativno enostavna, saj zajemanje časovnega žiga vključuje le dva koraka: ustvarjanje dogodka in beleženje dogodka.
Dogodek dodamo v tok CUDA s pomočjo funkcije:
cudaError_t cudaEventRecord(cudaEvent_t event, cudaStream_t stream = 0);
Da se dogodek zgodi potem počakamo s pomočjo funkcije:
cudaError_t cudaEventSynchronize(cudaEvent_t event);
Zgornja fukcija bo blokirala izvajanje programa na gostitelju, dokler se vse predhodne operacije v nekem toku CUDA ne zaključijo.
S pomočjo sledeče funkcije lahko izmerimo čas med dvema dogodkoma v toku CUDA:
cudaError_t cudaEventElapsedTime(float* ms, cudaEvent_t start, cudaEvent_t stop);
Funkcija vrne pretečeni čas med dvema dogodkoma v milisekundah. This function returns the time elapsed between the events start and stop in milliseconds.
Spodnja koda prikazuje primer uporabe dogodkov za merjenje časa izvajanja ščepca dotProduct
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
|
Zgornja koda se nahaja v mapi repozitorija delavnice skupaj z navodili za prevajanje in zagon 06-dot-product-reduction-profile
.
-
© Patricio Bulić, Davor Sluga, 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. ↩