Upload
darko-smiljanic
View
49
Download
4
Embed Size (px)
DESCRIPTION
Programiranje u Cudi B
Citation preview
SVEUILITE U MOSTARU
FAKULTET PRIRODOSLOVNO MATEMATIKIH I
ODGOJNIH ZNANOSTI
Programiranje u Cudi
Mostar, rujan 2014.
1. Uvod
U proteklih 40 godina, namjenski grafiki procesori su proli put od istraivakih laboratorija i
simulatora leta do komercijalnih radnih stanica i medicinskih ureaja, a kasnije sve do osobnih
raunala i konzola za zabavu. U nedavno vrijeme poeli su se ugraivati u mobilne telefone i
automobile.
Grafiki procesori slue da ubrzaju razliite zadatke od iscrtavanja teksta i grafike u Internet web
preglednicima do sofisticiranije sinteze trodimenzionalnih slika u raunalnim igrama. Ukratko demo
objasniti prirodu procesiranja potrebnu za 3D slikovnu sintezu koja je osnova za mnoga podruja. Sve
druge primjene grafikih procesora koriste podskup ovih sposobnosti za 3D procesiranje pa tako i
opde-namjensko raunanje na grafikim procesorima(GPGPU).
Kako je broj tranzistora u ovim ureajima poeo nadmaivati broj onih koji se nalaze u CPU, panja je
se usmjerila na primjenu procesne modi prema raunalo intenzivnim problemima koji se ne odnosi ne
grafiko renderiranje.
Rani pristupi u koritenju GPU za opde kalkulacije datiraju jo u vrijeme 2000. Ipak, GPU hardware je
u to vrijeme bio zasnovan na fiksnom protonom sustavu. Svi zadatci su se morali mapirati u grafiku
domenu i u toj domeni se i rjeavati. Ovo je bio dosta iscrpljujudi zadatak jer je programer morao
poznavati sintagme grafike obrade slike i morao je koristiti neki od jezika i API-ja kojim se pristupalo
funkcijama grafikog protonog sustava. Kao odgovor na trend koritenja grafikih procesora u
opdenite svrhe raunanja, Nvidia je pokrenula CUDA arhitekturu i pripadajudi API visoke razine
kako bi razvijateljima aplikacija omogudili to jednostavniji rad u poznatom razvojnom okruenju.
U ovom radu demo opisati evoluciju i arhitekturu grafikog protonog sustava odnosno grafikog
procesora. Dati demo pregled i primjere tehnologije CUDA e koja se koristi u opde namjenskom
raunanju na grafikim procesorima.
2. Razvoj grafikih procesora
Slijedede poglavlje de dati uvod u raunalnu grafiku i grafiki hardver koji formira pozadinu GPGPU.
Tijekom ovoga, biti de nam potrebni osnovni termini raunalne grafike.: geometrijske primitive koje
su predstavljeni jednostavnim atominim geometrijskim objektima kao toke, linije, trokuti i drugi
poligoni. Krajnje toke ovih objekata se nazivaju vrhovima (vertex). Druga osnovna jedinica je
fragment koji je osnova za piksele. Fragmenti sadre vrijednost boje, a takoer sadre druge
informacije koje su potrebne prije nego se piksel nacrta, kao pozicija, dubina, alfa vrijednost (za
transparentnost).
2.1 Grafiki cjevovod Grafiki cjevovod (koji se zove cjevovod renderiranja) je model koji opisuje razliite korake koji se
izvode u renderiranju scene. Koncept cjevovoda se moe usporediti sa CPU instrukcijskim
cjevovodom: pojedinani koraci se rade paralelno, ali su blokirani sve dok se ne zavri posljednji
korak. Jednostavan model za (fiksni) protoni sustav je naslikan na slici 1.
Slika 1. Jednostavni grafiki cjevovod
Aplikacija mijenja scenu primjerice reagirajudi na korisnike inpute. Komponente nove scene se
prosljeuju (model i kamera) prema transformacijama. U ovom koraku, koordinate lokalnih objekata
se transformiraju u globalni koordinatni sustav. Kamera se pozicionira u sceni i koordinatni sustav mu
se prilagouje. Tijekom osvjetljenja, za sve vrhove se raunaju vrijednosti boje koje ovise o poziciji
svijetla i svojstvima odgovarajudih trokuta. U koraku projekcije, 3D scena se mapira u 2D prostor
slike. Tijekom rezanja (clipping), nepotrebne primitive se eliminiraju. U koraku rasterizacije uklanjaju
se skrivene povrine (koristimo Z-buffer algoritam) i scena se transformira u bitmape na nain da se
raunaju vrijednosti boja za svaki piksel.
2.2 Grafiki API
Grafiki API-ji pruaju programerima veliki nivo apstrakcije i pojednostavljuju softverski razvoj
procesa skrivajudi kompleksnosti i mogudnosti grafikog sklopovlja i pogonskih ureaja.
Direct3D je API za crtanje 3D grafike, a najprominentnija komponenta je DirectX API kolekcija za
multimedijalne primjene na Microsoft platformama (Windows i Xbox). Prednost programerima za
koritenje DirectX je velika zastupljenost na tritu koja omoguduje Microsoft-u da definira minimum
hardverskih specifikacija za grafike komponente u suradnji sa proizvoaima grafikih kartica.
Nedostaci su u injenici da je vlasnitvo Microsofta , nisku kompatibilnost sa starijim izdanjima te
kratki ciklus putanja novih verzja. Ipak, zadnja dva argumenta takoer pruaju osnove za inovacije:
Sve do pojave Direct3D 10, najinteresantniji razvoj za GPGPU je uvoenje razliitih shader modela.
OpenGL je meu-platformski API za 2D i 3D raunalnu grafiku i glavna je alternativa Direct3D-u.
OpenGl se razvija preko Khronos grupacije, industrijskog konzorcija koji ukljuuje vie od 100 lanova
AMD, Intel i Nvidij-u. OpenGL kao i DirectX se uvelike oslanja na grafike shadere koji de biti
prezentirane u slijededoj sekciji.
2.3 Grafiki shaderi
U raunalnoj grafici , shaderi su mali programi koji rade na GPU ili drugim procesorima. U poetku
shaderi su oznaili tranziciju sa fiksnog protonog sustava preko konfigurabilnih do programibilnih
sustava. Shaderi su nainili proces renderiranja fleksibilnijim i omogudili su nove grafike efekte.
Isprva su shaderi uvedeni u OpenGl 1.5 i Direct3D 8. Kasnije verzije API-ja su revidirali shader
specifikacije i povedali fleksibilnost, primjerice smanjujudi ogranienja vezanu za maksimalnu koliinu
instrukcija po shaderu. API je specificirao tri tipa shadera koji se koriste za grafiku: vertex shaderi,
geometrijski i piksel shaderi. Vertex shaderi mogu mijenjati koordinate ili vrhove dok su geometrijski
shaderi u mogudnosti generirati ili duplicirati nove geometrijske primitive iz postojedih u cjevovodu
(instanciranje stabala ili znakova). Oba shadera se mogu mapirati u geometrijski korak koji smo naveli
ranije kod prezentacije fiksnog protonog sustava. Pixel shaderi (u OpenGl zvani fragmentni shaderi)
se izvode nakon rasterizacijskog koraka. Operiraju na jednom piksel/fragmentu i izvode promjene
boja ili sjena.
Velika inovacija u grafikim shaderima je uvoenje unificiranih shadera. Unificirani shader model
harmonizira instrukcijski set izmeu razliitih shader tipova. Sa unificiranom shader arhitekturom
svi shaderi imaju istu hardversku podlogu i mogu se koristi dinamino kao vertex, geometrijski ili
pixel shaderi. Nvidia je uvela unifed shader arhitekturu sa GeForce 8 serijom. Slika 2. Opisuje razliku
izmeu arhitektura. Ako pogledamo desnu sliku, ista shader jezgra se upotrebljava za razliite shader
zadatke. Dobrobit ovog pristupa je mogudnost da se dinamiki reagira na razliite piksel/
geometrijske oblike u razliitim scenama kako bi se povedale performanse i efikasnost.
Slika 2. Usporedba diskretnog i unificiranog dizajna shadera
Shaderi se programiraju u razliitim jezicima. Dok se programiranje na niskoj razini radi sa
assemblerom , pristupi visokog nivoa kao High Level Shading Language (HLSL) ciljaju prema Direct3D
programiranju koje je razvio Microsoft ili Open Gl shading Language za OpenGl programiranje.
3. Arhitektura grafikog procesora
Hardverska arhitektura grafike jedinice za obradu se razlikuje od normalnog procesora opde
namjene u nekoliko kljunih aspekata. Ove razlike su uvjetovane potrebama iz podruja real-time
raunalne grafike.
Mnogi objekti kao pikseli i vrhovi se mogu obraivati u izolaciji i nisu meusobno ovisni.
Postoje mnogo nezavisnih objekata (milijuni piksela, tisude vrhova,)
Mnogi objekti zahtijevaju iscrpna raunanja
GPU arhitektura je evoluirala da se suoi sa ovim zahtjevima.
Kako bi bolje razumjeli razlike izmeu CPU i GPU arhitekture poeti demo sa CPU arhitekturom i
uiniti nekoliko kljunih promjena sve dok ne dobijemo arhitekturu nalik na GPU.
3.1. Moderni CPU
Vedina modernih procesora koji se koriste u server i desktop sustavima su strojevi za raunanje. Kako
bi neto mogao izraunati CPU treba:
Da ima mogudnost preuzimanja i dekodiranja instrukcija iz memorije
Jedinicu izvravanja koja zapravo provodi raunanja (ALU, FPU, ..)
Neki vrstu konteksta izvravanja (registri)
Samo sa tim dijelovima CPU bi bio jako spor zbog slijededih efekata:
Memorijske latencije: Dohvatanje podataka iz glavne memorije je veoma vremenski
rastrona operacija. Ako neka izvrna jedinica treba podatke za kalkulacije ona eka sve dok
podaci ne budu dostupni, a to ekanje moe potencijalno biti jako dugo.
Sub-optimalni programski tok: Nain na koji program koristi izvrne jedinice CPU-a moe biti
neefikasno i ostaviti neku izvrnu jedinicu u stanju ekanja.
CPU sadri nekoliko dodatnih kompleksnih podsustava kako bi prevladao ove probleme i ubrzao
performanse:
Out of order izvravanje: Instrukcije su reorganizirane kako bi bolje iskoristili jedinicu
izvravanja na CPU-u. Na primjer cjelobrojna aritmetika (ALU) i floating point operacije (FPU)
se izvravaju paralelno. Nakon izvravanja izvorni red treba biti sauvan bududi je ova
optimizacija transparentna programu.
Predikcija grananja: Kada se program grana CPU ne zna koja je iduda instrukcija i koji podatak
je potreban sve dok se ne izvri uvjet grananja. Na taj nain CPU bi bio osuen na ekanje dok
se ne dohvate instrukcije i podaci nakon grananja. Kako bi limitirao utjecaj i udar grananja na
performanse CPU pokuava predvidjeti koja grana de nastaviti sa izvoenjem te preuzeti
instrukcije i podatke za to grananje. Ako je predikcija ispravna zastoj u cjevovodu se
izbjegava, ako je pogrean izvravanje programa se zaustavlja i ne moe se nastaviti sve dok
zahtijevani podatak i instrukcija nije moguda.
Memorijski pre-dohvat : Na osnovi karakteristika programa CPU preuzima podatke iz glavne
memorije koji bi bili potrebni u slijededim instrukcijama.
Ke hijerarhija : Kako bi reducirali memorijsku latenciju CPU koristi nekoliko tipova ke
memorije.
Svrha svih ovih optimizacija je da unaprijedi performanse za jednu struju ili tok instrukcija. Bududi
CPU tradicionalno izvrava jedan program/proces u trenutku vremena dugo su vremena jedini
zahtjevi za optimiziranje bili fokusirani na performanse jednog toka izvravanja.
No CPU arhitektura se optimizira na nain da se viestruki programi izvode koristedi vremensko
multipleksiranje. Primjerice posjeduju jedinicu koja de upravljati memorijom i prenositi podatke u
meuspremnik i tako efikasno implementirati virtualnu memoriju. Tijekom zadnjih godina dodavan
je hardver koji rukuje sa drugom instrukcijskom strujom (drugi Fetch & Decode blok). Ako se jedna
instrukcijska struja zaustavi zbog pogreno predvienog grananja druga struja moe hraniti jedinicu
izvravanja to rezultira sveukupno boljem iskoritenju svih jedinca izvravanja.
Ovaj pristup je prvo uveo Intel sa Netburst arhitekturom (Pentium 4 HT) i zvao se HyperThreding.
Netburst arhitektura je posjedovala dugi cjevovod i zbog toga su zastoji u cjevovodu uvelike
degradirali performanse. HyperThread je smanjio negativni utjecaj zastoja , ali je zahtijevao podrku
od operacijskog sustava pa je to donekle limitiralo korisnost ove optimizacije. Niagara arhitektura
(UlstraSparc T1 ) Sun mikrosustava je ak i unaprijedila ovu ideju. Automatski upravlja sa 4 dretve/niti
po jezgri i svaki ciklus izvrava instrukcije razliitih dretve. Ovo ispreplitanje uva/skriva memorijsku
latenciju. Kasnije verzije arhitekture su i povedale broj konkurentnih dretvi za 8 ili 16.
3.2. Fokus nije na performansama zasnovanim na jednom instrukcijskom toku
Zadade u raunalnoj grafici obino nude dobar potencijal za paralelizaciju. Mnoge operacije se
trebaju uiniti na svim pikselima slike ili na svim vrhovima scene. Ove operacije se uobiajeno
raunaju vie ili manje nezavisno od drugih . Sa hardverske toke gledita ova vrsta opteredenja se
moe distribuirati na vie jezgri i ne zahtjeva striktne sekvencijalne operacije kao to rade mnogi CPU
algoritmi.
Kako bi postigli veliki broj jezgri ove jezgre moraju biti jednostavne. Na taj nain se reduciraju na
apsolutni minimum :
Instrukcija fetch i decode (preuzmi i dekodiraj)
Jedinica izvravanja
Kontekst izvravanja
Efektivno smo otklonili svu logiku koja ubrzava jedan tok performansi, ali smo
dobili mogudnost da stavimo vie jezgri na ip. Ovo povedava potencijalnu
raunalnu mod nae arhitekture. Na primjer, sada moemo staviti 16
jednostavnih jezgri koje zajedno obrauju i procesiraju 16 instrukcijskih tokova
paralelno.
3.3. Dijeljenje instrukcija izmeu struja /tokova
Neka imamo program koji izvrava istu instrukciju nad
pikselima. Ista instrukcija se moe primijeniti na veoma
veliku koliinu podataka. Kad bi imali sliku dimenzija
1280*1024, program bi se izvrao 1.310.720 puta jer
imamo upravo toliko piksela. U naem trenutnom 16
jezgrenom dizajnu ove piksele demo procesirati u
blokovima po 16 niti.
Vedina od ovih 1.310.720 instrukcijskih tokova rade
uglavnom isto. Izvravaju istu instrukciju na razliitim
podacima. Ovo daje priliku za drugu optimizaciju
arhitekture: single instruction multiple data (SIMD)
procesiranje. Umjesto jedne decode and fetch jedinice po instrukcijskom toku mi demo ponovno
upotrijebiti decode and fetch za nekoliko instrukcijskih tokova. Sa ovom optimizacijom naa nova
SIMD procesorska jezgra sadri jednu decode $ fetch jedinicu i vedi broj izvrnih jedinica, svaka sa
pripadajudim kontekstom izvravanja. Na primjer, SIMD procesna jezgra iroka osam jedinica
sadrava jednu decode & fetch jedinicu , 8 izvrnih jedinica i 8 konteksta izvravanja.
Ako svih 8 izvrnih jedinica dijele istu instrukciju sve dobro funkcionira i mi dobijamo 8 puta bolje
performanse bez velikog organizacijskog overhead-a. Ali ako dijeljena instrukcijska struja sadri
instrukcije grananja moe se dogoditi da svih 8 izvrnih jedinica ne nastavi sa istom instrukcijom.
Instrukcija grananja kao i uvjetni skok nastavlja upravljanje tokovima programa na razliitim
pozicijama ovisno o uvjetima run-time (vrijednost varijable ili koordinata piksela). Sa takvom
instrukcijom moe se dogoditi da odreena izvrna jedinica jedne SIMD jezgre treba nastaviti
izvravanje na drugoj poziciji nego ostatak izvrnih jedinica. U tom sluaju moramo izvriti instrukcije
za obje grane. Izvrne jedinice koje nisu u trenutno izvrnom grananju trebaju ignorirati ove
instrukcije.
Koliina grananja u dijeljenim instrukcijskim strujama ograniava korisnost SIMD optimizacije. to se
pojavi vie grananja unutar iste SIMD jezgre to su nie SIMD performanse.
Ako auriramo nau 16 jezgrenu arhitekturu tada mi dobivamo 16 SIMD procesnih jezgri, te svaka
ima mogudnosti da izvodi istu operaciju na 8 podatkovnih tokova. U optimalnom sluaju mi moemo
procesirati 16*8=128 programa paralelno. Moe se primijetiti da smo u mogudnosti rukovati jednim
jedinstvenim instrukcijskim tokom po SIMD jezgri bez gubitka performansi bududi su SIMD jezgre
neovisne od drugih. Ako ipak kontrola toka unutar jedne SIMD jezgre varira mi gubimo performanse
bududi SIMD jezgra mora izvriti instrukcije svake grane sekvencijalno.
3.4. Utjecaj SIMD programskog modela
Postoji dva razliita naina za prezentiranje SIMD arhitekture programerima:
Eksplicitno koritenjem vektorskih instrukcija
Implicitno predstavljajudi svaku izvrnu jedinicu kao dretvu
Oba pristupa se esto koriste i imaju svoje prednosti. EksplicitnI SIMD je najedi na CPU u formi SSE
instrukcija. Jedna instrukcija moe operirati na 4 floating point vrijednosti jednostruke preciznosti.
Na GPU ovaj eksplicitni pristup se koristi preko INTEL Larrabee tehnologije sa 16 komponentnim
vektorima. Veliki nedostatak ovoga je to nije transparentan za programere . Dok pie izvorni kod
programer mora koristiti vektorske podatkovne tipove i instrukcije ili uopde nede modi iskoristiti
izvrne jedinice osim standardno jedne.
Implicitni SIMD s druge strane je transparentan za programera. Iako je ovo obino prikladnije
opasnost lei u iluziji da svaka dretva moe slijediti svoju kontrolu toka. Programer mora biti svjestan
karakteristike arhitekture, ali ne mora eksplicitno koristiti vektorske podatkovne tipove i instrukcije.
3.5. Ispreplitanje tokova u skrivanju(maskiranju) latencije
U normalnim grafikim raunalnim aplikacijama se mora obraditi veliki broj nezavisnih objekata
(pikseli ,vrhovi,). Sa tisudama ili milijunima instrukcijskih tokova koji su nam na raspolaganju
moemo koristiti konkurentnost tih tokova za maskiranje memorijske latencije. Ako jedan
instrukcijski tok ne moe nastaviti zbog nedostatka podataka prespajamo se na drugi mogudi
instrukcijski tok. Ako se i ta struja zaustavi prespajamo se na tredi tok i tako dalje. Sa dovoljno
velikim brojem instrukcijskih tokova izvrna jedinica se moe u potpunosti iskoristiti sve dok se
podatak za prvi zaustavljeni instrukcijski tok ne oslobodi.
Zavrni instrukcijski tok se moe proizvoljno dugo izvravati (ovisno koliko esto se zaustavi), ali kada
gledamo sve tokove zajedno sveukupna propusnost se maksimizira.
Prebacivanje izmeu instrukcijskih tokova mora biti veoma brzo kako bi se ovo efektivno
implementiralo. Ovo ostvarujemo tako da uvamo vie od jednog izvrnog konteksta u izvrnoj
jedinici. Umjesto mijenjanja (swapanja) registara pri svakoj promjeni konteksta jednostavno
sauvamo sve kontekste izvravanja u registrima. Ovo zahtjeva veliku koliinu registara, ali zato
omoguduje instantno prebacivanje izmeu instrukcijskih tokova.
Ova arhitektura ima zanimljive posljedice: Jednostavni
program zahtjeva samo mali izvrni kontekst (malo registara)
za operiranje. Zbog toga se vie ovih izvrnih konteksta moe
uvati na ipu te instrukcijski tokovi mogu biti bolje
isprepleteni , veoma efektivno sakrivajudi memorijsku
latenciju. Vie kompleksnih programa zahtjeva vedi kontekst
izvravanja (vie registara) za operiranje sa umanjenim
ispreplitanjem. Obino optimizacije dovode do kompleksnijih
programa i zbog toga u nekim sluajevima jednostavni
programi se mogu bre izvoditi kada govorimo o memorijskom
pristupa nego oni optimiziraniji.
3.6. Memorijski pristup
Moderni CPU koriste sofisticiranu hijerarhiju kea da rijee veliku latenciju prema glavnoj radnoj
memoriji. Ako je traeni podataka prisutan u jednom od keeva moe mu se pristupiti relativno brzo.
Sam CPU sadri ke i brine se za prebacivanje izmeu kea i glavne memorije. Bududi programi
uobiajeno pokazuju jednu vrstu memorijske lokalnosti u ponaanju (paternima) ovi su keevi za
vedinu zadada dosta efikasni. Hardver se brine za ove optimizacije pa su one transparente za
programere. Za aplikacije visokih performansi moe biti korisno poznavanje semantike ke hijerarhije
osobito kada se radi o viestrukim dretvama bududi one mogu traiti da se keevi sinkroniziraju to
moe naruiti performanse(negativan uinak).
GPU s druge strane obino uopde ne operira sa glavnom memorijom. GPU je najede spojena sa
vlastitom off chip memorijom koja se koristi za teksture. Veliina ove memorije varira, ali povijesno je
uvijek bila za etvrtinu ili polovinu manja od veliine glavne memorije. Prije nego GPU moe zapoeti
sa radom prethodno se podaci moraju premjestiti na grafiku memoriju. Brzina ove operacije ovisi i
od konekcije izmeu glavne memorije i grafike kartice. Zbog toga uvelike varira, ali ugrubo da
dobijemo neku perspektivu je oko 5GiByte/s.
GPU programi koji rade na grafikoj memoriji su bili relativno kratki u ranim danima.
Nije bilo lokalnosti u pristupu memorijskim lokacijama osim kod pristupa teksturama. Ako jedan
piksel prikazuje teksturni podatak na specifinoj poziciji velike su anse da i slijededi piksel takoer
prikazuje teksturni podatak blizu iste te lokacije. GPU ne prua sofisticiranu ke hijerarhiju te umjesto
toga nudi veliki bandwidth prema grafikoj memoriji i prua teksturni ke. Teksturni keevi u osnovi
predstavljaju overlay na specifine blokove u grafikoj memoriji. Pristup takvom memorijskom bloku
se keira sa specijalnim instrukcijama (teksturni dohvati). Ovo reducira latenciju kada se radi sa
teksturnim podacima.
Memorijski pristup koji se ne odvija kroz teksturne keeve pati od potpune latencije i kanjenja.
U tom sluaju moramo se osloniti na ispreplitanje instrukcijskog toka. Memorijska sabirnica GPU-a je
organizirana na nain da efikasno rukuje sa velikim skupom i blokovima podataka. Uobiajeno je blok
dovoljno velik da nahrani sve izvrne jedinice SIMD jezgre sa jednom float vrijednosti ( najedi tip
podataka u raunalnoj grafici danas). Ako program pristupa memoriji na nain da sve izvrne
jedinice u SIMD jezgri mogu dobiti zahtijevane podatke sa jednim masivnim transferom onda
moemo iskoristiti cijeli memorijski pojas (bandwidth) grafike memorije. Trenutno je to oko
150GiByte/s. Meutim ako program pokazuje vie nasumini /random memorijski pristup ovi
memorijski transferi se ne mogu efikasno koristiti.
Kako bi prevaziao ova ogranienje u memorijskom pristupu vedina GPU programskih API-ja pruaju
jednu vrstu lokalne ip memorije (uobiajeno registri). Program moe koristiti ovu memoriju kao
runo upravljani ke. Naime podaci se prethodno prebace i alju iz grafike memorije prema ip
lokalnoj memoriji i ta se memorija koristi u svim daljnjim kalkulacijama. Ovo limitira utjecaj latencije
grafike memorije na jedan pristup u trenutku startanja programa. Ipak memorija na ipu nije velika,
uobiajeno je rije o najvie nekoliko KiByte po SIMD jezgri.
Nedostatak ovog pristupa je da se ip-lokalna memorija uobiajeno koristi da se uvaju dodatni
konteksti izvravanja isprepletenih instrukcijskih tokova. to se vie ip lokalne memorije koristi kao
runo upravljani ke to se manje instrukcijskih tokova moe ispreplitati da se sakrije memorijska
latencija. Zbog ovoga programer mora balansirati koritenje ip lokalne memorije.
3.7. Usporedba izmeu CPU i GPU arhitekture
CPU i GPU arhitekture dijele isti osnovni model izvravanja. Posjeduju preuzmi i dohvati instrukciju,
koju trebaju izvriti te koriste neki kontekst izvravanja ili registre. Ali GPU arhitektura se razlikuje
od CPU arhitekture u trima kljunim konceptima koje smo objasnili:
Fokus nije na performansama jednog toka instrukcija
Dijeljenje instrukcija izmeu tokova (SIMD)
Ispreplitanje tokova kako bi se sakrila latencija
Ovi koncepti su proizili iz posebnih uvjeta raunalne grafike domene. Ipak neke od ideja iza ovih
koncepata se mogu pronadi dananjim modernim CPU-ima.
MMX i kasniji SEE instrukcijski set su takoer temeljeni na SIMD (single instruction multiple data).
SEE doputa simultani rad na vie integer ili floating point vrijednosti jednostruke preciznosti. Ove
instrukcije su dodane kako bi se omogudilo bre procesiranje video i audio podataka. U tom sluaju
sirovi volumen multimedijskih podataka je forsirao nove optimizacije na CPU arhitekturi.
Ipak vedina CPU programa ne koriste ove optimizacije kao standardne postavke. Obino se
programski jezici fokusiraju na dobro poznate razvojne paradigme. Vektorizacija ili podatkovni
paralelizam nije tako dobro poznata paradigma i obino se usvaja kada je zaista potrebna jer dodaje
kompleksnost programu. Zbog toga vedina softvera ne koriste stil SIMD instrukcija ak i kada bi
problemska domena imala od toga koristi. To osobito do izraaja onda kada se vrijeme razvoja
smatra veoma skupim, a performanse nisu toliko bitne da bi opravdale optimizacije.
Ovo SIMD instrukcijama na CPU-u daje prizvuk i karakteristiku dodatne mogudnosti ili add-on.
SIMD arhitektura na GPU je usvojene iz nude i potrebe inae bi sa tako velikom koliinom podataka
bilo jako teko rukovati. Takoer, izbjegava se dodavanje fetch i decode jedinice na svaku izvrnu
jedinicu. Ovo uva prostor i ini ip kompaktnijim i jeftinijim za proizvodnju (vie ipova po
silikonskom waferu).
Iz sasvim drugih motiva CPU i GGPU arhitektura se pomakla u istom pravcu to se tie SIMD
optimizacije.
Druga slinosti izmeu CPU i GPU je trenutni razvojni trend prema viejezgrenim i mnogo jezgrenim
CPU-ima. Zbog fizikih ogranienja (brzine svjetlosti, curenje napona u veoma malim krugovima) CPU
ne moe vie povedavati performanse jednog instrukcijskog toka. CPU frekvencija i brzina instrukcija
po sekundi se ne mogu povedavati u nedogled. Povedana potronja struje na visokim frekvencijama
oteduje krugove i zahtjeva skupo hlaenje. Netburts arhitektura (Pentium 4) je dizajnirana za visoke
frekvencije do 10 Ghz. Ipak zbog ekscesivne pretjerane potronje struje je limitirana na frekvencije
izmeu 3 i 4 Ghz. Frekvencije do 7 Ghz su se mogle dobiti, ali u veoma specijalnim uvjetima hlaenja
za veoma kratak period (do 1 minute, nakon toga CPU pregori)
Ovo nije ostavilo CPU proizvoaima izbora nego da skaliraju horizontalno. Dodavanjem dodatnih
jezgri teoretski se multipliciraju performanse. Kako bi se iskoristile performanse svih jezgri za jedan
zadatak program mora koordinirati nekoliko instrukcijskih tokova na razliitim jezgrama. Ipak ovo
obino zahtjeva da se dretve sinkroniziraju u razliitim prigodama to ini programiranje i debagiranje
teim.
Kako bi se integriralo sve vie i vie jezgri na jedan CPU, brzina i kompleksnost jedne jezgre se
uobiajeno reducira. Zbog toga stari jedno jezgreni procesori obino ima bolje performanse za jedan
instrukcijski tok nego moderni quad core CPU. Ovo je slino ideji koritenoj u GPU arhitekturi: mnogo
jednostavnih procesnih jezgri je efikasnije nego jedna velika jezgra. Iako su CPU jezgre mnogo
kompleksnije nego GPU jezgre, s vremenom se mogu razviti u jednostavnije sklopove kako bi
omogudile bolje skaliranje. GPU jezgre s druge strane mogu evoluirati u kompleksnije kako bi bile
vie user friendly prema programerima. Ideja horizontalnog skaliranja je prisutna na obje
arhitekture.
4. CUDA
Kao odgovor na trend koritenja grafikih procesora u opdenite svrhe raunanja, Nvidia je
pokrenula CUDA-u, arhitekturu i pripadajudi API visoke razine kako bi razvijateljima omogudila to
jednostavniji rad u poznatom razvojnom okruenju. CUDA (eng. Compute Unified Device
Architecture) je arhitektura grafikih kartica za paralelno raunanje koja omogudava pristup
grafikom sklopovlju i razvoj programske potpore za GPU pomodu programskog jezika C te radi na
svim Nvidijinim grafikim karticama od serije G8X .
CUDA uvodi neke mogudnosti koje joj daju prednost nad dotadanjim GPGPU arhitekturama . CUDA
omogudava raspreno itanje memorije tj. itanje iz proizvoljnih adresa u grafikoj memoriji, uvodi
potpunu potporu za operacije nad cjelobrojnim podacima i nad bitovima te implementira napredno
upravljanje dretvama na grafikom ureaju.
Dretvama koje se izvode na GPU omogudava pristup dijeljenoj memoriji koja moe posluiti kao
priruna memorija ime se ostvaruje veda propusnost nego bi to bilo mogude koristedi dohvatanje
podataka iz globalne memorije, a podran je i mehanizam sinkronizacije dretvi.
4.1. Sklopovska implemenatcija
Temelj arhitekture Tesla je podesivo polje viedretvenih viestrukih procesora toka (eng. streaming
multiprocesor, skradeno SM) *5+. Na slici (Slika 4) prikazan je GPU s 14 SM-ova. Svaki SM sadri 8
manjih jezgri procesora toka (eng. streaming processor, skradeno SP), dvije jedinice za posebne
funkcije (eng. special function unit, skradeno SFU) te viedretvenu instrukcijsku jedinicu (eng.
multithreaded instruction unit, skradeno MT IU).
SM procesori takoer imaju skup 32-bitnih registara po svakom SP procesoru, dijeljenu memoriju
(eng. shared memory) te prirunu memoriju za konstante i prirunu memoriju za teksture iz kojih
moe samo itati.
SP jedinice mogu obavljati osnovne aritmetike i logike operacije nad cjelobrojnim brojevima te
brojevima s pominim zarezom dok SFU jedinice slue za izvoenje nekih sloenijih operacija kao to
su trigonometrijske funkcije i logaritmi. MT IU se brine o dohvatanju i izvravanju instrukcija
pojedinih skupina dretvi. SM procesori grupirani su u parove pri emu svaki par ima svoju jedinicu za
uzorkovanje tekstura (eng. texture unit) koja teksture iz glavne memorije dohvada preko L1
prirune memorije ime se ubrzava itanje tekstura. SM procesori takoer mogu itati i pisati u
glavnu DRAM memoriju, ali je pristup istoj mnogo sporiji od pristupa dijeljenoj memoriji i registrima.
Kada raunalo domadin eli dodijeliti neki posao grafikom procesoru, prvo se prenesu potrebni
podaci i instrukcije za njihovu obradu iz glavne memorije na raunalu u memoriju na grafikom
ureaju. Nakon to CPU pokrene izvravanje, na GPU se istovremeno pokrede veliki broj dretvi, a
jedinica za raspodjelu posla na CWD (eng. compute work distribution) na GPU ih dinamiki
rasporeuje po dostupnim SM procesorima.
Svaka od istovremeno pokrenutih dretvi na SM procesoru ima rezervirane vlastite registre u kojima
se pohranjuje trenutni kontekst izvravanja pa je prebacivanje izvravanja s jedne dretve na drugu
vrlo brzo jer nema zamijene konteksta kao to je to sluaj kod CPU-a.
Osim toga, jedan SM moe koristedi sve SP jezgre istovremeno izvriti odreenu instrukciju nad
cijelom skupinom dretvi (veliine 8 u ovom sluaju).
Mogu se primijetiti oite slinosti izmeu arhitekture SM procesora i SIMD paradigme, meutim
Nvidia ovakav pristup naziva SIMT (eng. single instruction, multiple thread) jer SIMD model
proiruje naprednim upravljanjem dretvama o emu de vie rijei biti u sljededem poglavlju.
4.2. Logika organizacija
Kako bi se pojednostavio problem istovremenog izvravanja velikog broja dretvi, CUDA uvodi
poseban nain hijerarhijskog grupiranja dretvi koji olakava raspodjelu cjelokupnog posla unutar
GPU-a.
Dretve su prije svega rasporeene u blokove. Blok je skup dretvi ija je veliina odreena s jednom
dvije ili tri dimenzije te unutar kojeg se dretve mogu sinkronizirati i meusobno komunicirati
pomodu dijeljene memorije. Dretve unutar pojedinog bloka uvijek se izvode na istom SM procesoru,
ali jednom SM procesoru moe biti dodijeljeno vie blokova.
Blokovi su rasporeeni u dvodimenzionalnu reetku (eng. grid) koja predstavlja logiku raspodjelu
jednog zadatka odreenog jezgrenom funkcijom. To znai da sve dretve u blokovima unutar jedne
reetke izvravaju istu jezgrenu funkciju. Dretve iz razliitih blokova ne mogu meusobno
komunicirati niti se uskladiti pri izvravanju.
Programer mora organizirati posao po blokovima i reetkama te u glavnom programu odrediti
njihove dimenzije pri pokretanju jezgrene funkcije. GPU zatim instancira jezgrenu funkciju na
reetku paralelnih blokova dretvi. Svaka dretva unutar bloka izvrava instancu jezgrene funkcije te
ima svoj ID koji oznaava njezinu poziciju u bloku. Blok takoer ima svoj ID unutar reetke.
Ovakva organizacija dretvi omogudava prilagodljivu raspodjelu poslova na GPU. Na primjer ako
podijelimo reetku na 8 blokova, grafiki ureaj s dvije jezgre moe svakoj dodijeliti 4 bloka, dok bi
grafiki ureaj s 4 jezgre svakoj mogao dodijeliti 2 bloka. Programer dakle treba samo logiki
organizirati posao, dok de stvarnu raspodjelu posla po jezgrama ovisno o dostupnim sredstvima
obaviti GPU, preciznije CWD jedinica
Paralelno izvravanje i upravljanje dretvama obavlja se automatski. Stvaranjem dretvi, vremenskim
upravljanjem i prekidom izvravanja rukovodi CUDA sustav izravno na sklopovlju i programer se o
tome ne mora brinuti.
4.3. Memorijski model
Tijekom izvravanja, dretve mogu pristupiti razliitim memorijskim prostorima. Svaka dretva na
raspolaganju ima odreeni broj registara, lokalnu memoriju, dijeljenu memoriju bloka te pristup
memoriji za konstante, memoriji za teksture i globalnoj memoriji. Lokalna memorija koristi se za
pomodne varijable koje ne stanu u registre dretve. Dijeljena memorija bloka vidljiva je svim dretvama
u bloku i obino ima mnogo manje vrijeme kanjenja (eng. latency) od globalne memorije pa se
koristi kao priruna memorija bloka te moe posluiti za ubrzavanje izvravanja i uinkovitu
komunikaciju meu dretvama bloka. Dretve u pojedinom bloku mogu se sinkronizirati pozivom
ugraenih funkcija za sinkronizaciju ime se osigurava da nijedna dretva nede nastaviti s
izvravanjem dok sve dretve nisu dole do sinkronizacijske granice. Sinkronizacija je neophodna pri
koritenju dijeljene memorije. Nakon prolaza sinkronizacijske granice, sve dretve mogu u dijeljenoj
memoriji bloka vidjeti memorijske zapise ostalih dretvi bloka koje su napravljene prije sinkronizacije
i na taj nain mogu meusobno komunicirati. Globalna memorija zajednika je dretvama svih
blokova u reetki i koristi se za pribavljanje ulaznih podataka i zapisivanje krajnjih rezultata.
Memorijski prostor za konstante, teksture te lokalna i globalna memorija fiziki se nalaze u DRAM
memoriji na grafikom ureaju, ali konstantama i teksturama se pristupa preko prirune memorije te
se tako ubrzava njihovo dohvadanje. Dretve koje se izvode na GPU iz memorija za konstante i
teksture mogu samo itati dok u ostale memorije mogu i pisati.
Raunalo domadin moe pomodu programskog suelja itati i pisati u globalnu memoriju te u
memoriju za konstante i teksture.
Multiprocesri imaju veliki broj 32-bitnih registara: 8k za ureaje raunalne sposobnosti 1.0 i
1.1 16k za ureaje raunalne sposobnosti 1.2 i 1.3 i 32k za ureaje raunalne sposobnosti 2.0
ili vie. U tablici se nalazi opis razliitih vrste memorije dostupne na GPU.
Registri Registri su najbra memorija, sa pristupom bez ikakve latencije na svakom ciklusu takta, kao i na regularnom CPU. Registri dretve se ne mogu dijeliti sa drugim dretvama.
Dijeljena memorija Dijeljena memorija je usporediva sa L1 ke memorijom na CPU. Lei blizu multiprocesora i ima veoma kratak pristup vremena. Dijeljena memorija se dijeli meu svim dretvama na zadanom bloku.
Globalna memorija Globalna memorija lei na ureaju, ali van ipa multiprocesora, tako da je vrijeme pristupa do 100 puta vede nego na dijeljenoj memoriji.
Lokalna memorija Specifina memorija dretvi gdje se uva globalna memorija. Varijable se uvaju u lokalnoj memoriji dretvi ako kompajler odlui da nema dovoljno registara da uvaju podatke dretvi. Ova memorija je spora, iako se zove lokalna
Konstantna memorija 64k konstante memorije se uva van ipa multiprocesora i memorija je koja se samo ita. Host kod pie u konstantu memoriju prije lansiranja kernela, a kernel moe itati ovu memoriju. Konstantna memorija je keirana. Svaki multiprocesor moe keirati do 8k konstantne memorije tako da slijedna itanja sa kontantne memorije mogu biti veoma brza. Sve dretve imaju pristup konstantnoj memoriji.
Teksturna memorija Specijalizirana memorija za povrinsko mapiranje tekstura
Primjeri
Programski primjer 1.
CUDA zahtjeva od programera da razdvoji kod koji de se izvravati na grafikom procesoru u posebne
funkcije koje se razlikuju od standardnih funkcija u C-u. Te funkcije nazivamo jezgrene funkcije ili
kerneli. U slijededem primjeru imamo jednostavni primjer Hello World programa namijenjenog za
CUDA model.
#include #include "cuda_runtime.h" #include "device_launch_parameters.h" #define NUM_BLOCKS 4 #define BLOCK_WIDTH 32 __global__ void hello() { printf("Hello world! Ja sam dretva %d u bloku %d\n", threadIdx.x, blockIdx.x); } int main(int argc,char **argv) { // Pokrecemo jezgrenu funkciju hello(); // cudaDeviceSynchronize eka da kernel zavri cudaDeviceSynchronize(); printf("Kraj izvodjenja!\n"); return 0; }
Nakon definicije konstante imamo funkciju void hello () koja predstavlja jezgrenu funkciju ovog
programa. Ona se ne razlikuje puno od standardnih funkcija bez povratne vrijednosti osim po
jezinom konstruktu __global__. Konstrukt __global__predstavlja deklaracijski specifikator preko
kojeg kompajler zna da je rije o kodu koji de se izvravati na ureaju.
Druga uoljiva razlika je u samom pozivu jegrene funkcije hello. Uoljivo je da se razlikuje od obinog
poziva funkcije po trostrukom znaku manje () izmeu kojih se nalazi
konfiguracija parametara. Parametrima odreujemo koliko dretvi de se pokrenuti te kako de se
dretve organizirati po blokovima. Prvi parametar oznaava broj blokova, dok drugi odgovara broju
dretvi. Nakon toga slijede obine zagrade u kojima moemo prenijeti argumente funkcije. U ovom
sluaju ih nemamo.
Nakon to kompajliramo program imati demo (4*32) 128 paralelno izvedenih jezgrenih funkcija.
Svaka dretva ima svoju kopiju kernela kojeg izvrava. Tako da demo imati pri ispisu kernela imati
ukupno 128 dretvi koji izvravaju funkciju printf. Svaka pokrenuta dretva dobije jedinstveni broj i
preko varijable threadIdx taj broj moemo saznati. Na slian nain moemo i doznati u kojem bloku
se nalazi dretva preko varijable blockIdx jer se svakom bloku dodijeli jedinstvena vrijednost odnosno
id bloka.
Varijable threadIdx i blockIdx su strukture tipa dim3. To su C strukture koje imaju 3 lana (x,y,z). Svaki
lan strukture predstavlja jednu dimenziju tako da dretve kao i blokovi mogu biti jedno, dvo i
trodimenzionalni. Na taj nain veoma lako moemo pokrenuti primjerice matrice koje su
dvodimenzionalne. Zadada programera je da organizira dretve po blokovima vodedi rauna da svaki
blok moe imati maksimalno 512 dretvi za ureaje raunalne mogudnosti ispod 2.0 dok za one
iznad mogude je i 1024 dretve po bloku.
U naem sluaju imamo jednostavan sluaj od 32 dretve po jednom bloku tako da ukupno imamo
128 dretvi koje se izvravaju paralelno. Bududi smo kao parametar kernela proslijedili jednostavne
cjelobrojne vrijednosti kompajler podrazumijeva da je rije o jednodimenzionalnoj strukturi dim3 te
stoga imamo samo x lanove za dretve i blokove(threadIdx.x i blockIdx.x).
Valja napomenuti da se dretve izvravaju paralelno i na ispisu moemo vidjeti da se one ne moraju
izvriti po redu i ne znamo kojim redoslijedom de se oni izvriti.
Na slijededoj slici vidimo isjeak ispisa programa.
Programski primjer 2.
U prolom primjeru vidjeli smo kako se izvodi jednostavni program koji ispisuje i javlja jedinstveni
broje dretve i bloka u kojem se dretva nalazi. Primjedujemo da nije uraen nikakav proraun
odnosno program nije obradio nikakve ulazne podatke.
Tipian program za GPU operacije izgleda ovako. U kodu koji izvrava standardni procesor prethodno
se moraju alocirati odreeni podaci na grafikom procesoru. Nakon toga se ti podaci kopiraju iz
radne memorije standardnog opde-namjenskog procesora u memoriju na grafikom procesoru. Tek
kada se podaci prebace i pohrane u memoriji GPU-a grafiki procesor moe izvriti zadani proraun.
Nakon zavretka rada rezultat se prebacuje iz memorije na grafikom procesoru u radnu memoriju
raunala. Moe se primijetiti da imamo dva koraka u kojem se miu podaci iz jedne u drugu
memoriju. Generalno ako de se esto micati podaci s jedne na drugu memoriju ,a obrada nad tim
podacima relativno mala tada CUDA tehnologija i grafiki procesor nede dodi do izraaja , ak je
mogude da bude loija izvedba u odnosu na klasinu obradu na CPU.
CUDA tehnologija najbolje dolazi do izraaja kod aplikacija koje rade malo transfera izmeu memorija
i kada trebamo izvravati mnogo kalkulacija. Kaemo da imaju visok odnos procesiranja naspram
komunikacije.
U slijededem primjeru kojim demo izraunati kvadrate brojeva demo pokazati kako izgleda tipian
program za GPGPU.
Zapoeti demo sa main() rutinom, u njoj se nalazi kod koji de se izvravati. Prvo demo postaviti dvije
konstante kojim demo definirati broj elemenata niza i veliinu memorije u bajtovima za niz.
Konstante u ARRAY_SIZE demo iskoristiti kod deklarianje niza h_array kojeg demo i inicijalizirati na
slijededi nain:
int main(int argc, char ** argv) { const int ARRAY_SIZE = 64; const int ARRAY_BYTES = ARRAY_SIZE * sizeof(float); // generiramo ulazni niz na host-u float h_in[ARRAY_SIZE]; for (int i = 0; i < ARRAY_SIZE; i++) { h_in[i] = float(i); } float h_out[ARRAY_SIZE];
Ono to je vrijedno napomenuti je konvencija imena. Imena varijabli namijenjenih izvravanju na CPU zapoinju sa h, dok za izvravanje na GPU zapoinju sa d. Deklariramo GPU memorijske pokazivae, te alociramo memoriju na GPU ureaju koritenjem cudaMalloc poziva.
// dekariramo GPU pokzaivae na prostor u memoriji float * d_in; float * d_out; // alociramo GPU memoriju cudaMalloc((void**) &d_in, ARRAY_BYTES); cudaMalloc((void**) &d_out, ARRAY_BYTES); // prebacujemo niz na GPU cudaMemcpy(d_in, h_in, ARRAY_BYTES, cudaMemcpyHostToDevice);
Kako bi se uvjerili da su podaci uistinu na memoriji grafikog procesora, a ne na CPU pogledajmo slijedede dvije linije koda. Koristimo cudaMalloc sa dva argumenta; pokaziva na niz i broj bajtova za alociranje. Poziv cudaMalloc oznaava alokaciju podataka na GPU to je analogno klasinom pozivu malloc za dinamiko alociranje podataka za standardnom C programu. Nakon alokacije moramo kopirati podatke iz niza h_in u niz kojeg smo maloprije alocirali u grafikoj memoriji d_in. To radimo sa pozivom cudaMemcpy koji je slian regularnom pozivu memcpy osim to prima etiri argumenta umjesto tri. Prva tri argumenta su ista kao i regularni C memcpy (odredite, izvor, broj bajtova). etvrti argument govori o smjeru prijenosa gdje imamo tri izbora:
cudaMemcpyHostToDevice kopira podatke sa host-a na ureaj
cudaMemcpyDeviceToHost kopira podatke sa ureaja na host
cudaMemcpyDeviceToDevice kopira podatke sa ureaja na ureaj Nakon svih ovih poziva imamo sve potrebne preduvjete da pokrenemo kernel ili jezgrenu funkciju na GPU. U tu svrhu koristimo operator pokretanja kojeg oznaavamo sa > znakovima. Izmeu trostukih manje i vie znakova ubacujemo konfiguraciju parametara kojom odreujemo koliko dretvi demo pokrenuti te kako demo organizirati dretve po blokovima. U prolom primjeru smo prenijeli jednostavne cjelobrojne vrijednosti kao parametre jezgrene funkcije. Ovaj put demo navesti eksplicitnu konfiguraciju preko dim3 strukture koja je sastavljena od 3 cjelobrojne vrijednosti bez predznaka.
struct dim3 { unsigned int x, y, z; ... }; dim3 block_size; block_size.x = 128; block_size.y = 1; // configure a two dimensional grid as well dim3 grid_size; dim3 grid_size.x = 1;
square(d_out, d_in);
Ovom linijom koda smo prenijeli u konfiguraciji dvije dim3 strukture grid_size i block_size. One zapravo govore da se na GPU pokrede kernel na mrei blokova sastavljenog od jednog bloka, a drugi parametar block_size predstavlja veliinu bloka po dretvama. Odnosno koliko de biti dretvi u jednom bloku. Moemo primijetiti da smo za y dimenziju strukture block_size dodijelili vrijednost 1 dok za y dimenziju strukture grid_size nismo nita dodjeljivali. Treba istaknuti da se pri samoj deklaraciji dim3 strukture sve vrijednosti odnosno dimenzije (x,y,z) inicijaliziraju na jedan. Tako da je linija koda block_size.y=1 zapravo suvina jer se podrazumijeva. Jezgrena funkcija izgleda ovako:
__global__ void square(float * d_out, float * d_in){ int i = threadIdx.x; d_out[i] = d_in[i] *d_in[i]; }
Za razliku od prolog primjera u ovoj jezgrenoj funkciji imamo dva argumenta; pokaziva prema ulaznom i izlaznom nizu. Oba pokazivaa se moraju alocirati na GPU inae de se program sruiti i upravo to spada u najede greke. Bududi je kernel tipa void tj. funkcija ne vrada nikakvu povratnu vriednost onda se izlazni rezultat upisuje u niz d_out koji predstavlja argument jezgrene funkcije. Potomo izvravamo kvadiriranje brojeva u liniji. CPU ima ulogu da pokrene 64 kopija kernela na 64 dretvi. Kao argumenti u jezgrenoj funkciji proslijeuju se pokazivai na nizove d_in i d_out. Za parametre jezgrene funkcije moemo pozvati samo podatke alocirane na GPU memoriji. Kada zavri naa jezgrena funkcija sa proraunom rezultati se nalazi se u nizu d_out odnosno u memoriji na ureaju stoga sa naredbom cudaMemcpy je potrebno u obrnutom smjeru izvriti transfer podataka sa memorije na ureaju na memoriju u host-u.
cudaMemcpy(h_out, d_out, ARRAY_BYTES, cudaMemcpyDeviceToHost);
Nakon ovog poziva dobiveni rezultati de se nalaziti u nizu h_out kojeg ispisujemo na izlaz. Nakon to se izvri proraun potrebno je osloboditi alociranu memoriju na memoriju u grafikom procesoru to ostvarujemo sa naredbom cudaFree().
#include __global__ void square(float * d_out, float * d_in){ int i = threadIdx.x; d_out[i] = d_in[i] *d_in[i]; } int main(int argc, char ** argv) { const int ARRAY_SIZE = 64; const int ARRAY_BYTES = ARRAY_SIZE * sizeof(float); // generiramo ulazni niz na host-u float h_in[ARRAY_SIZE]; for (int i = 0; i < ARRAY_SIZE; i++) { h_in[i] = float(i); } float h_out[ARRAY_SIZE]; // dekariramo GPU pokzaivae na prostor u memoriji float * d_in; float * d_out; // alociramo GPU memoriju cudaMalloc((void**) &d_in, ARRAY_BYTES); cudaMalloc((void**) &d_out, ARRAY_BYTES); // prebacujemo niz na GPU cudaMemcpy(d_in, h_in, ARRAY_BYTES, cudaMemcpyHostToDevice); // pokreemo kernel square(d_out, d_in); // kopiramo rezultat nazad na CPU cudaMemcpy(h_out, d_out, ARRAY_BYTES, cudaMemcpyDeviceToHost); // printamo rezultirajui niz for (int i =0; i < ARRAY_SIZE; i++) { printf("%f", h_out[i]); printf(((i % 4) != 3) ? "\t" : "\n"); } cudaFree(d_in); cudaFree(d_out); return 0; }
Programski primjer 3.
Svaka dretva ima pristup svojim registrima koji su privatni za ovu dretvu . Dretva moe itati i pisati iz registara u dretvenom bloku imaju takoer pristup neemu to se zove dijeljena memorija. Stoga sve dretve u dretvenom bloku mogu pisati i itati po blokovima dijeljenu memoriju. Ovo je mala koliina memorije koja se nalazi direktno na SM-u. Konano tu je i globalna memorija, svaka dretva u cijelom sustavu u bilo koje vrijeme moe itati i pisati prema globalnoj memoriji. Stoga, dretve u jednom kernelu mogu itati i pisati iz nje, dretve u kasnijem kernelu mogu takoer itati pisati sa nje. Da rekapituliramo svaka dretva ima pristup svoji registrima, ima pristup dijeljenoj memoriji koja je dostupna ostalim dretvama u bloku te ima pristup globalnoj memoriji koja je dostupna svim dretvama. Cpu ima pristup svojoj memoriji koju nazovamo host memorijom ili memorijom domadina. U slijededem programskom primjeru prikazati demo kako programer moe koristiti razliite tipove
memorije koje prua GPU. Meutim prije toga redi demo neto o pojmu sinkronizacije.
Sinkronizacija
Znamo da dretve mogu imati pristup rezultatima jedni drugih. Oni se dijele u globalnoj memoriji. Ovo
znai da rade zajedno na raunanju, ali postoji problem. to ako dretva pokua itati rezultat prije
nego druga dretva ima priliku da zapie rezultat ili ga uopde prorauna. Ovo znai da trebamo
sinkronizaciju. Dretve se trebaju sinkronizirati sa jedno drugim kako bi se izbjegla ova situacija. Ova
potreba za sinkronizacijom je jedna od najosnovnijih problema u paralelnom raunanju.
Neka imamo slijededi odsjeak jezgrene funkcije kojim pomjeramo vrijednosti u niz za jedno mjesto u
lijevo.
int idx=threadIdx.x; __shared__ int niz[128]; niz[idx]=threadIdx.x; if(idx
Pogledajmo sada kernel i kod koji operira sa dijeljenom memorijom.
__global__ void use_shared_memory_GPU(float *array) { // lokalne varijable, privatne za svaku nit int i, index = threadIdx.x; float average, sum = 0.0f;
// __shared__ variable su vidljive svim dretvama u bloku i imaju isti ivotni vijek kao i blok dretvi
__shared__ float sh_arr[128]; // kopiramo podatke iz "array" u globalnoj memoriji u sh_arr u dijeljenoj memoriji. // ovdje, svaka nit je odgovorna za kopiranje jednog elementa sh_arr[index] = array[index]; __syncthreads(); // osigurati da su sva pisanja u dijeljenu memoriju zavrena // sh_arr je popunjena. Pronaimo prosjek svih prijanjih elemenata for (i=0; i average) { array[index] = average; } // slijedei kod NEMA EFEKTA, modificira dijeljenu memoriju, ali rezultirajui modificirani podatak se nikad ne kopira u glovalnu memoriju i nestaje im za vri blok dretvi sh_arr[index] = 3.14; }
Sam poziv kernela koji koristi lokalnu, dijeljenu i globalnu memoriju se nalaze su slijededem kodu.
// Koritenje razliitih memorisjkih prostora u CUDA #include /********************** * koritenje lokalne mmeorije* **********************/ // __device__ ili __global__ funkcija se izvodi na GPU __global__ void use_local_memory_GPU(float in) { float f; // varijabla "f" u lokalnoj memoriji, privatna za svaku nit f = in; // parametar "in" je u lokalnoj memoriji i privatan za svaku nit } /********************** * koristenje globalne memorije * **********************/ // __global__ function izvodi se na GPU i poziva se sa host-a __global__ void use_global_memory_GPU(float *array) { // "array" pokzaivac u globalnu memoriju na ureaju array[threadIdx.x] = 2.0f * (float) threadIdx.x; } /********************** * dijeljena memorija * **********************/ __global__ void use_shared_memory_GPU(float *array) { // lokalne varijable, privatne za svaku nit int i, index = threadIdx.x; float average, sum = 0.0f;
// __shared__ variable su vidljive svim dretvama u bloku i imaju isti ivotni vijek kao i blok dretvi
__shared__ float sh_arr[128]; // kopiramo podatke iz "array" u globalnoj memoriji u sh_arr u dijeljenoj memoriji. // ovdje, svaka nit je odgovorna za kopiranje jednog elementa sh_arr[index] = array[index]; __syncthreads(); // osigurati da su sva pisanja u dijeljenu memoriju zavrena // sh_arr je popunjena. Pronaimo prosjek svih prijanjih elemenata for (i=0; i average) { array[index] = average; } // slijedei kod NEMA EFEKTA, modificira dijeljenu memoriju, ali rezultirajui modificirani podatak se nikad ne kopira u glovalnu memoriju i nestaje im za vri blok dretvi sh_arr[index] = 3.14; } int main(int argc, char **argv)
{ /* * First, call a kernel that shows using local memory */ use_local_memory_GPU(2.0f); /* * Potom, pozivamo kernel koji koristi globalnu memoriju */ float h_arr[128]; // konvencija: h_ variable ive na host float *d_arr; // konvencija: d_ variable ive na device (GPU global mem) // alociramo globalnu memoriju na ureaju, rezultat smjetamo u "d_arr" cudaMalloc((void **) &d_arr, sizeof(float) * 128); // kopiramo podateke iz host memorije "h_arr" u memoriju ureaja "d_arr" cudaMemcpy((void *)d_arr, (void *)h_arr, sizeof(float) * 128, cudaMemcpyHostToDevice); // pozivamo kernel (1 blok od 128 dretvi) use_global_memory_GPU(d_arr); // modificiramo sadraj niza u d_arr // kopiramo modificirani niz nazad na host, prepisujui sadraj od h_arr cudaMemcpy((void *)h_arr, (void *)d_arr, sizeof(float) * 128, cudaMemcpyDeviceToHost); /* * Potom, pozivamo kernel koji koristi dijeljenu memoriju */ // kao i prije proslijedimo pokaziva u globalnu memoriju use_shared_memory_GPU(d_arr); // kopiramo modificirani niz nazad u host cudaMemcpy((void *)h_arr, (void *)d_arr, sizeof(float) * 128, cudaMemcpyHostToDevice); return 0; }
Imamo 128 elemenata i stoga demo imati i 128 dretvi. Argument jezgrene funkcije je float *array niz.
Deklariramo par lokalnih varijabli koje su privatne za svaku dretvu.
Potom deklariamo varijablu koja se nalazi u dijeljenoj memoriji ja konstruktom __shared__. Cijeli
smisao dijeljene memorije da su vidljive svim varijablama u bloku dretvi. ivotni ciklus je onoliki
koliko traje obrada.
Prije toga deklariramo lokalne varijable i, index, average, sum. Potom kopiramo podatke iz globalne
memorije niza array u dijeljeni niz sh_arr. Naravno potrebno je osigurati da se podaci u cjelosti
kopiraju prije nego ponemo s njima baratati pa koristimo barijeru u vidu poziva syncthreads().
Kada smo osigurali da su podaci u nizu sh_arr. Pristupamo raunaju prosjeka svih elemenata niza. U
tu svrhu koristimo for petlju u rasponu od 0 do vrijednosti broja dretvi.
Nakon prorauna prosjeka provjeravamo u petlji da li su elementi u nizu vedi od prosjeka . Ako jesu
onda pripadajudem elementu sa pozicijom indeks pridruujemo vrijednost prosjeka.
Nakon toga imamo dio koda sh_arr[index]=3.14 . Ovom linijom kodu nita nedemo promijeniti u globalnoj memoriji , a kako je dijeljena memorija ivi koliko ivi i blok dretvi onda de ovaj niz vrijednosti posve ieznuti, a mogude je da de ga i sam kompajler zanemariti.
Programski primjer 4.
Ved smo ranije govorili o probleme sinkronizacije . Ovaj put imamo primjer kada mnogo dretvi ita i
upisuje u iste memorijske lokacije.
Ispod pomodne funkcije print_array koje demo koristiti za ispis napisan je kernel increment_naive
koji sadri kao argument pokaziva na cjelobrojni niz. Svaka dretva de imati svoj broj indeksa i poziciju
u bloku. Cilj zadatka je da svaka dretva uveda vrijednost elementa niza za jedan. Bududi imamo
miilijun dretvi, a deset elemenata u nizu zaduiti demo po sto tisuda dretvi za svaki element. Kako bi
to uradili mnoiti demo po modulu indeks dretve sa veliinom niza.
injenica da imamo milijun dretvi koje piu u samo 10 elemenata znai da nakon to svaka dretva
doda jedan odgovarajudi element u niz zavriti demo sa 10 elemenata u nizu koji svi sadre vrijednost
od 100 000.
Takoer mjeriti de se vrijeme izvoenja kernela i u tu svrhu definira se struktura GpuTimer.
Promotrimo kod i ispis rezultata.
#include #include "gputimer.h" #define NUM_THREADS 1000000 #define ARRAY_SIZE 100 #define BLOCK_WIDTH 1000 void print_array(int *array, int size) { printf("{ "); for (int i = 0; i < size; i++) { printf("%d ", array[i]); } printf("}\n"); } __global__ void increment_naive(int *g) { // koja je ovo dretva? int i = blockIdx.x * blockDim.x + threadIdx.x; // svaka nit inkrementira uzastopne elemente, do veliine ARRAY_SIZE i = i % ARRAY_SIZE; g[i] = g[i] + 1; } __global__ void increment_atomic(int *g) { // koja je ovo dretva? int i = blockIdx.x * blockDim.x + threadIdx.x; // svaka nit inkrementira uzastopne elemente, do veliine ARRAY_SIZE i = i % ARRAY_SIZE; //g[i]++;
atomicAdd(& g[i], 1); } int main(int argc,char **argv) { GpuTimer timer; printf("%d pokrenutih dretvi u %d blokova koji pisu %d elemenata niza\n", NUM_THREADS, NUM_THREADS / BLOCK_WIDTH, ARRAY_SIZE); // dekariramo i alociramo memoriju int h_array[ARRAY_SIZE]; const int ARRAY_BYTES = ARRAY_SIZE * sizeof(int); // deklariramo, alociramo, i inicijaliziramo na GPU int * d_array; cudaMalloc((void **) &d_array, ARRAY_BYTES); cudaMemset((void *) d_array, 0, ARRAY_BYTES); // pokreemo jezgru timer.Start(); //naivna i atomina implementacija jezgrene funkcije increment_naive(d_array); // increment_atomic(d_array); timer.Stop(); // kopiramo nazad polje sume iz GPU te ispisujemo cudaMemcpy(h_array, d_array, ARRAY_BYTES, cudaMemcpyDeviceToHost); print_array(h_array, ARRAY_SIZE); printf("Time elapsed = %g ms\n", timer.Elapsed()); // oslobaamo GPU mememoriju i izlazimo cudaFree(d_array); return 0; }
#ifndef __GPU_TIMER_H__ #define __GPU_TIMER_H__ struct GpuTimer { cudaEvent_t start; cudaEvent_t stop; GpuTimer() { cudaEventCreate(&start); cudaEventCreate(&stop); } ~GpuTimer() { cudaEventDestroy(start); cudaEventDestroy(stop); } void Start() { cudaEventRecord(start, 0);
} void Stop() { cudaEventRecord(stop, 0); } float Elapsed() { float elapsed; cudaEventSynchronize(stop); cudaEventElapsedTime(&elapsed, start, stop); return elapsed; } }; #endif /* __GPU_TIMER_H__ */
Nakon to pokrenemo nekoliko puta program vidimo da ne dobivamo oekivani rezultat od po
100000 u svakom elementu niza. Osim toga primijeti se kako je rezultat svaki put drugaiji odnosno
imamo nedeterministiko izvravanje.
Uzrok ovakvog ponaanja se nalazi u dijelu kodu koji inkrementira g[i]=g[i]+1. Ova operacija se ne
odvija u jednom koraku ved zapravo u tri koraka. Prvo se mora proitati vrijednost iz lokacije i , pa
potom modificirati te na istu lokaciju prepisati rezultat. Potrebno je odreeno vrijeme da svaka
dretva proita vrijednost, inkrementira je te spremi rezultat. Zbog ovog vremenskog kanjenja mnoge
dretve koje se simultano izvode de u meuvremenu proitati staru ne-inkrementiranu vrijednost te
de se jedna te ista vrijednost uzastopice zapisivati. Takoer neke dretve koje kasnije ponu sa
izvravanjem de prepisati rezultat preko onih ranije zavrenih pa onda nije ni udo to imamo
nekonzistente rezultate.
Ovaj problem smo imali ved kod sinkronizacije i moe se rijeiti barijerama. Meutim u ovom sluaju
demo koristiti atomine operacije. Ideja je slijededa. Bududi je inkrementiranje operacija koja
ukljuuje vie koraka potrebno je omoguditi da se ova operacija izvede u jednom koraka. Moramo
osigurati da ova operacija bude atomina. CUDA osigurava atomine memorijske operacije koje
spadaju u posebne instrukcije koje implementira GPU. Najkoritenije su atomicAdd, atomicMin,
atomicXor itd. Neka imamo istovjetan kernel kao u prolom primjeru samo je razlika to umjesto
jednostavnog zbrajanja koristimo funkciju atomicAdd.
__global__ void increment_atomic(int *g) { // koja je ovo dretva? int i = blockIdx.x * blockDim.x + threadIdx.x; // svaka nit inkrementira uzastopne elemente, do veliine ARRAY_SIZE i = i % ARRAY_SIZE; //g[i]++; atomicAdd(& g[i], 1); }
Funkcija atomicAdd kao parametar uzima pokaziva na niz te broj koji se dodaje. U naem sluaju je
rije o broju 1. Kad ponovo pokrenemo program vidimo da dobivamo rezultat koji zapravo i
oekujemo.
S druge strane moe se primijetiti da se ove operacije izvravaju sporije nego standardne operacije pa
ih valja koristiti mudro.
Atomine operacije imaju mnoga ogranienja. Prvenstveno rije je o tome da su samo odreene
operacije i odreeni tipovi podrani. Podrani su samo jednostavni operacije kao oduzmi, dodaj,
minimum, XOR operacija ili slino.
Programski primjer 5.
Iako je matrica kao pravokutno polje brojeva veoma jednostavna ona predstavlja najkorisniji i
najosnovniji objekt u znanstvenom raunanju. Primjene su mnogobrojne i ukljuuju raunalnu
grafiku, rjeavanje sustava jednadbi, usporeivanje sekvenci DNA, modeliranje elektrinih krugova
raunalnih mrea itd. Kao matematiki objekti matrice mogu se dodavati, oduzimati mnoiti i
ponekad dijeliti. Ovdje demo se interesirati samo za mnoenje.
Implementacija Multipliciranja matrice
Sada smo u poziciji pisati kernel koji doputa da se host kod umnoka matrica prebaci na GPU.
Matricu demo prikazati kao strukturu sastavljenu od tri lana. Prvi lan width predstavlja irinu
matrice odnosno broj elemenata u jednom retku(broj stupaca). lan height predstavlja visinu matrice
ili broj elemenata u stupcu (broj redaka). Tredi lan elements de nam posluiti za odreivanje ukupne
veliina matrice u bajtovima to se lako odredi mnoedi broj elemenata stupca i retka sa veliinom
float tipa podatka.
typedef struct { int width; int height; float* elements; } Matrix;
Sada kada smo definirali strukturu koja de predstavljati matricu u main programu demo se pobrinuti
za deklaraciju i odreivanje dimenzija matrica s kojima demo izvriti mnoenje. Trebati de nam tri
matrice. Matrica A, B i rezultirajuda matrica C.
int main(int argc, char* argv[]){ Matrix A, B, C;
printf("Molimo unesite dimenzije matrice A:\nBroj elemenata retka:"); scanf("%d",&A.height); printf("Broj elemenata stupca:"); scanf("%d",&A.width); printf("Molimo unesite dimenzije matrice B:\nBroj elemenata stupca:"); scanf("%d",&B.width); B.height=A.width; A.elements = (float*)malloc(A.width * A.height * sizeof(float)); B.elements = (float*)malloc(B.width * B.height * sizeof(float)); C.height = A.height; C.width = B.width; C.elements = (float*)malloc(C.width * C.height * sizeof(float));
Nakon toga demo inicijalizirati elemente matrica A i B koristedi jednostavni pseudo-generator rand()
u rasponu od 0 do 10.
for(int i = 0; i < A.height; i++) for(int j = 0; j < A.width; j++) A.elements[i*A.width + j] = (float)(rand() % 10); for(int i = 0; i < B.height; i++) for(int j = 0; j < B.width; j++) B.elements[i*B.width + j] = (float)(rand() % 10);
Ovaj dio koda nam je posebno zanimljiv iz razloga to kod inicijalizacije ne koristimo
dvodimenzionalne nizove ved se sluimo jednodimenzionalnim nizom elements. Zapisi u radnoj
memoriji su jednodimenzionalni to moemo iskoristiti kako bi zapisali matricu preko niza brojeva.
Najdede se koristi row major zapis gdje se u memoriju slijedno upisuju retci matrica.
Poslije inicijalizacije pokrede se funkcija MatMul sa argumentima matrica A,B i C. U ovoj funkciji se
definiraju sve predradnje za izvravanje na ureaju.
MatMul(A, B, C);
Vratiti demo se kanije na ovu funkciju. Poslije nje uslijedit de ispis rezultata izvravanja matrice A, B i reuzultirajude matrice C. // Ispisujemo dio matrice do 10x10 elemenata for(int i = 0; i < min(10, A.height); i++){ for(int j = 0; j < min(10, A.width); j++) printf("%f ", A.elements[i*A.width + j]); printf("\n"); } printf("\n");
for(int i = 0; i < min(10, B.height); i++){ for(int j = 0; j < min(10, B.width); j++) printf("%f ", B.elements[i*B.width + j]); printf("\n"); } printf("\n"); for(int i = 0; i < min(10, C.height); i++){ for(int j = 0; j < min(10, C.width); j++) printf("%f ", C.elements[i*C.width + j]); printf("\n"); } printf("\n"); }
void MatMul(const Matrix A, const Matrix B, Matrix C)
Definirati demo matrice d_A, d_B i d_C, te kopirati vrijednosti odgovarajudih matrica koje smo
prenijeli kao argumente funkcije.
Nakon toga alociramo memoriju na ureaju na siguran nain. Naime vedina poziva koji se tiu
alociranja, kopiranja, inicijaliziranja na memoriji ureaja mogu vratiti povratnu vrijednost tipa
cudaError. Na ovaj nain se moe provjeriti da li je kod navedenih operacija dolo do greki, te koja je
greka u pitanju.
void MatMul(const Matrix A, const Matrix B, Matrix C) { // Uitavamo A i V memoriju ureaja Matrix d_A; d_A.width = A.width; d_A.height = A.height; size_t size = A.width * A.height * sizeof(float); cudaError_t err = cudaMalloc(&d_A.elements, size); printf("CUDA malloc A: %s\n",cudaGetErrorString(err)); err = cudaMemcpy(d_A.elements, A.elements, size, cudaMemcpyHostToDevice); printf("Copy A to device: %s\n",cudaGetErrorString(err)); Matrix d_B; d_B.width = B.width; d_B.height = B.height; size = B.width * B.height * sizeof(float); err = cudaMalloc(&d_B.elements, size); printf("CUDA malloc B: %s\n",cudaGetErrorString(err)); err = cudaMemcpy(d_B.elements, B.elements, size, cudaMemcpyHostToDevice); printf("Copy B to device: %s\n",cudaGetErrorString(err)); // Alociramo C u memoriju ureaja Matrix d_C; d_C.width = C.width; d_C.height = C.height; size = C.width * C.height * sizeof(float); err = cudaMalloc(&d_C.elements, size); printf("CUDA malloc C: %s\n",cudaGetErrorString(err));
Nakon obavljenih memorijskih transakcija pristupamo konfiguraciji jezgrene funkcije. Koristiti demo
dvodimenzionalnu konfiguraciju bududi je rije o matrici.
Definirajmo dim3 strukture.
// Pozivamo jezgru dim3 dimBlock(BLOCK_SIZE, BLOCK_SIZE); dim3 dimGrid((B.width + dimBlock.x - 1) / dimBlock.x, (A.height + dimBlock.y - 1) / dimBlock.y); MatMulKernel(d_A, d_B, d_C); err = cudaThreadSynchronize(); printf("Izvodimo kernel: %s\n", cudaGetErrorString(err)); // itamo C iz memorije ureaja err = cudaMemcpy(C.elements, d_C.elements, size, cudaMemcpyDeviceToHost); printf("Kopiramo C off of device: %s\n",cudaGetErrorString(err)); // Oslobaamo memoriju ureaja cudaFree(d_A.elements); cudaFree(d_B.elements); // cudaFree(d_C.elements); }
U dimBlock funkciji definiramo broj dretvi po bloku. Imamo dvodimenzionalnu konfiguraciju gdje je
broj dretvi po x jednak BLOCK_SIZE ,isto kao i po y dimenziji. Nakon to smo ustvrdili veliinu bloka
potrebno je vidjeti koliko de nam trebati blokova odnosno dimenzije mree. Kako dimenzije mree
ovisi o dimenzijama samih matrica ne moemo jednostavno odrediti i upisati konstantnu vrijednost.
Bududi su matrice dvodimenzionalne u dimGrid funkciju za x dimenziju demo imati B.width + dimBlock.x - 1) / dimBlock.x, dok (A.height + dimBlock.y - 1) / dimBlock.y za y
dimenziju.
Sada smo u poziciji pozvati jezgrenu funkciju koja je definirana na slijededi nain.
__global__ void MatMulKernel(Matrix A, Matrix B, Matrix C) { // Svaka nit rauna jedan element od C // akumuliramo rezultate u Cvalue float Cvalue = 0.0; int row = blockIdx.y * blockDim.y + threadIdx.y; int col = blockIdx.x * blockDim.x + threadIdx.x; if(row > A.height || col > B.width) return; for (int e = 0; e < A.width; ++e) Cvalue += (A.elements[row * A.width + e]) * (B.elements[e * B.width + col]); C.elements[row * C.width + col] = Cvalue; }
Mnoenje matrica radimo na klasian nain tako da mnoimo odgovarajude elemente retka matrice
A sa elementima stupca matrice B, te ih meusobno zbrojimo. Postupak ponavljamo sa svim retcima
i stupcima dok ne dobijemo rezultirajudu matricu. Ilustracija je prikazana na slici.
Definirati demo varijabla Cvalue koja de posluiti kao akumulator (sumator) produkta elemenata reda
i stupca stoga je inicijaliziramo na 0.
Slijedede linije koda pomau dretvi da otkriju redak i kolonu unutar matrice.
U if grananju se nalazi uvjet po kojem se terminira dretvu ako je indeks tj pozicija u redu ili stupcu
izvan granica produkta matrice.
Slijedede dvije linije vrte u petlji elemente retka matrice A i elemente stupca B ( stupac i redak
matrica su iste veliine) koji su potrebne za raunanje produkta zapisa (redak,stupac) zapis i sume
ovih produkata akumuliranih u Cvalue varijablu. Matrice A i B se uvaju u globalnoj memoriji u row
major redoslijedu to znai da je matrica sauvana kao jednodimenzionalno polje sa prvim redom
kojeg sukcesivno slijedi drugi red itd.
Jednodimenzionalni zapis u memoriji
Bududi koristimo sukcesivni linearni zapis ne moemo pristupati elementima na uobiajeni nain (i,j )
preko matrica gdje i predstavlja redak , a j stupac. Na taj nain pozicija elementa sa vrijednosti 7 bi
bila odreena parom i,j kao (2,3) ili u C jeziku kao A*1+*2+. Meutim u jednodimenzinalnom nizu ne
moemo pristupati elementima na takav nain. Primjedujemo na slici da svaki red ima fiksno
odreenu veliinu odnosno broj elemenata u retku koja je u naem primjeru odreena sa varijablom
width. Stoga moemo iskoristiti zapis (i*width +j). Odnosno bududi je u naem sluaju irina retka
(width) jednaka etiri 4 poziciju elementa sa vrijednosti 7 bi pronali kao (2*4+3) to je jednako 11 u
jednodimenzionalnom polju. U C jeziku, jer brojanje poinje od nule, bi isto bilo prikazano kao
A*1*4+2+ to je jednako A*6+.
Konano, zadnja linija kernela kopira ovaj meusobni produkt retka i stupca u odgovarajude
elemente matrice C u globalnoj memoriji ureaja.
Kod:
#include #include #include #include "cuda_runtime.h" #include "device_launch_parameters.h" // Matrice se uvaju u rednom poretku // M(row, col) = *(M.elements + row * M.width + col) typedef struct { int width; int height; float* elements; int stride; } Matrix; // Veliina bloka #define BLOCK_SIZE 16 __global__ void MatMulKernel(Matrix A, Matrix B, Matrix C); // Mnoenje matrica - Host kod // Dimenzije matrica se pretpostavlja da su umnoci od BLOCK_SIZE void MatMul(const Matrix A, const Matrix B, Matrix C) { // Uitavamo A i V memoriju ureaja Matrix d_A; d_A.width = A.width; d_A.height = A.height; size_t size = A.width * A.height * sizeof(float); cudaError_t err = cudaMalloc(&d_A.elements, size); printf("CUDA malloc A: %s\n",cudaGetErrorString(err)); err = cudaMemcpy(d_A.elements, A.elements, size, cudaMemcpyHostToDevice); printf("Copy A to device: %s\n",cudaGetErrorString(err)); Matrix d_B; d_B.width = B.width; d_B.height = B.height; size = B.width * B.height * sizeof(float); err = cudaMalloc(&d_B.elements, size); printf("CUDA malloc B: %s\n",cudaGetErrorString(err)); err = cudaMemcpy(d_B.elements, B.elements, size, cudaMemcpyHostToDevice); printf("Copy B to device: %s\n",cudaGetErrorString(err));
// Alociramo C u memoriju ureaja Matrix d_C; d_C.width = C.width; d_C.height = C.height; size = C.width * C.height * sizeof(float); err = cudaMalloc(&d_C.elements, size); printf("CUDA malloc C: %s\n",cudaGetErrorString(err)); // Pozivamo jezgru dim3 dimBlock(BLOCK_SIZE, BLOCK_SIZE); dim3 dimGrid((B.width + dimBlock.x - 1) / dimBlock.x, (A.height + dimBlock.y - 1) / dimBlock.y); MatMulKernel(d_A, d_B, d_C); err = cudaThreadSynchronize(); printf("Izvodimo kernel: %s\n", cudaGetErrorString(err)); // itamo C iz memorije ureaja err = cudaMemcpy(C.elements, d_C.elements, size, cudaMemcpyDeviceToHost); printf("Kopiramo C off of device: %s\n",cudaGetErrorString(err)); // Oslobaamo memoriju ureaja cudaFree(d_A.elements); cudaFree(d_B.elements); // cudaFree(d_C.elements); } // Jezgra umnoka matrica koja se poziva MatMul() __global__ void MatMulKernel(Matrix A, Matrix B, Matrix C) { // Svaka nit rauna jedan element od C // akumuliramo rezultate u Cvalue float Cvalue = 0.0; int row = blockIdx.y * blockDim.y + threadIdx.y; int col = blockIdx.x * blockDim.x + threadIdx.x; if(row > A.height || col > B.width) return; for (int e = 0; e < A.width; ++e) Cvalue += (A.elements[row * A.width + e]) * (B.elements[e * B.width + col]); C.elements[row * C.width + col] = Cvalue; } int main(int argc, char* argv[]){ Matrix A, B, C; int a1, a2, b1, b2; printf("Unesite dimenzije matrice A:\nBroj elemenata retka:"); scanf("%d",&A.height); printf("Broj elemenata stupca: "); scanf("%d",&A.width); A.stride=A.width; printf("Unesite dimenzije matrice B:\nBroj elemenata stupca:"); scanf("%d",&B.width); B.height=A.width; B.stride=B.width; A.elements = (float*)malloc(A.width * A.height * sizeof(float)); B.elements = (float*)malloc(B.width * B.height * sizeof(float)); C.height = A.height; C.width = B.width; C.elements = (float*)malloc(C.width * C.height * sizeof(float)); for(int i = 0; i < A.height; i++) for(int j = 0; j < A.width; j++) A.elements[i*A.width + j] = (float)(rand() % 3);
for(int i = 0; i < B.height; i++) for(int j = 0; j < B.width; j++) B.elements[i*B.width + j] = (float)(rand() % 2); MatMul(A, B, C); for(int i = 0; i < min(10, A.height); i++){ for(int j = 0; j < min(10, A.width); j++) printf("%f ", A.elements[i*A.width + j]); printf("\n"); } printf("\n"); for(int i = 0; i < min(10, B.height); i++){ for(int j = 0; j < min(10, B.width); j++) printf("%f ", B.elements[i*B.width + j]); printf("\n"); } printf("\n"); for(int i = 0; i < min(10, C.height); i++){ for(int j = 0; j < min(10, C.width); j++) printf("%f ", C.elements[i*C.width + j]); printf("\n"); } printf("\n"); }
Literatura
[1] Hochberg, R.,Matrix Multiplication with CUDA - A basic introductionto the CUDA programming
model,2012http://www.shodor.org/media/content//petascale/materials/UPModules/matrixMultipli
cation/moduleDocument.pdf
[2] The NVIDIA Corporation. The CUDA C Best Practices Guide v4.0. NVIDIA Corporation,2011.
[3] The NVIDIA Corporation. The CUDA C Programming Guide v4.0. NVIDIA Corporation,2011.
[4]Zibula, A General Purpose Computation on Graphics Processing Units (GPGPU) using CUDA, 2009
[5] Blythe, D., Rise of the Graphics Processor, Proceedings of the IEEE, Vol. 96, No. 5, May 2008
[6] Rumpf M., Graphics Processor Units: New Prospects for Parallel Computing, Numerical Solution of
Partial Differential Equations on Parallel Computers, 2005.