Poglavlje 08: GPU Arhitektura -- Osnove
Zasto ovo poglavlje menja sve
Zamislite sledece: imate scenu u Unreal Engine-u koja radi na 15 FPS. Otvorite profajler, vidite da je GPU usko grlo, i sada gledate u brdo brojeva koji vam nista ne govore. Znate da treba nesto da optimizujete, ali sta tacno? I zasto bas to?
Ovo poglavlje je odgovor na to pitanje.
Vecina tutoriala o optimizaciji vam kaze sta da radite -- smanjite rezoluciju tekstura, koristite LOD, izbegavajte prozirne materijale. Ali retko ko objasni zasto to funkcionise. A bez razumevanja "zasto", vi samo slepo pratite pravila koja ne razumete, i cim naidjete na situaciju koja nije pokrivena tim pravilima, ostajete bez odgovora.
GPU arhitektura je taj "zasto".
Kada zaista razumete kako GPU fizicki radi -- kako izvrsava instrukcije, kako pristupa memoriji, kako se nosi sa grananjem u kodu -- onda vam optimizacija prestaje da bude crna magija. Postaje logicna, predvidljiva, skoro ocigledno.
U ovom poglavlju cemo proci kroz:
- Fundamentalnu razliku izmedju CPU-a i GPU-a (i zasto je ta razlika bitna)
- SIMD/SIMT model izvrsavanja -- srce GPU-a
- Warp-ove i wavefront-e -- grupisanje niti i problem divergencije
- Occupancy -- zasto vise posla u redu cekanja znaci brze izvrsavanje
- Latency hiding -- genijalnu strategiju GPU-a za skrivanje spore memorije
- VRAM i memorijsku hijerarhiju -- gde zive vasi podaci
- Memory bandwidth -- najcesci bottleneck o kome se premalo prica
- Compute Unit-e i Streaming Multiprocessor-e -- gradivne blokove GPU-a
- Kako se logicki rendering pipeline mapira na fizicki hardver
- Prakticne implikacije za optimizaciju u Unreal Engine 5
Svaki od ovih koncepata direktno utice na to kako pisete materijale, kako postavljate scene, i kako donosite odluke o performansama. Ovo nije akademsko znanje -- ovo je znanje koje cete koristiti svakodnevno.
Krenimo.
8.1 CPU vs GPU -- Dva Potpuno Razlicita Pristupa
Analogija koja menja perspektivu
Zamislite da gradite zid od cigle.
CPU pristup: Imate 8 visoko kvalifikovanih zidara. Svaki od njih je majstor -- moze da cita nacrte, donosi odluke na licu mesta, prilagodjava se nepredvidjenim situacijama, radi kompleksne proracune. Ako naidje na problem -- na primer, cev koja prolazi kroz zid -- on zastane, razmisli, prilagodi plan, i nastavi. Svaki zidar radi nezavisno, na svom delu posla, i svaki moze da radi potpuno razlicit zadatak.
GPU pristup: Imate 10.000 radnika. Svaki od njih zna da uradi jednu stvar -- stavi ciglu na odredjeno mesto. Ne donose odluke, ne citaju nacrte, ne prilagodjavaju se. Ali kada im kazete "stavite ciglu na svoju poziciju" -- svih 10.000 to uradi istovremeno. Jedan red cigle -- gotov za sekund.
Ovo je fundamentalna razlika. CPU je dizajniran za slozenost -- mali broj jezgara, ali svako jezgro je neverovatno mocno i fleksibilno. GPU je dizajniran za paralelizam -- ogroman broj jednostavnih procesora koji rade isti posao na razlicitim podacima.
Brojke koje ilustruju razliku
Pogledajmo tipican moderan CPU i GPU iz 2024/2025. godine:
| Karakteristika | CPU (npr. Ryzen 9 7950X) | GPU (npr. RTX 4080) |
|---|---|---|
| Broj jezgara | 16 (32 thread-a) | 9.728 CUDA jezgara |
| Takt (clock speed) | 4.5 - 5.7 GHz | 2.2 - 2.5 GHz |
| L1 Cache po jezgru | 32 KB data + 32 KB instruction | ~128 KB (shared memory + L1 po SM) |
| L2 Cache | 16 MB | ~4 MB |
| L3 Cache | 64 MB | Nema zasebnog L3 |
| Kontrola toka | Kompleksna (branch prediction, speculative execution, out-of-order) | Minimalna |
| Kontekst prebacivanje | Skupo (stotine ciklusa) | Gotovo besplatno (1 ciklus) |
Pogledajte te brojke pazljivo. CPU ima 16 jezgara koja rade na skoro 6 GHz. GPU ima skoro 10.000 jezgara koja rade na samo 2.5 GHz. Ali svako GPU jezgro je dramaticno jednostavnije.
CPU jezgro ima ogroman deo silicijuma posvecen kontroli -- branch prediction (predvidjanje grananja), speculative execution (spekulativno izvrsavanje), out-of-order execution (izvrsavanje van redosleda), veliki keshevi. Sve to postoji da bi jedno jezgro moglo da radi sto brze na jednom thread-u.
GPU jezgro nema nista od toga. Nema branch prediction. Nema speculative execution. Ima minimalan kes. Umesto toga, sav taj silicijum je iskoriscen za -- vise jezgara.
Zasto je GPU pristup savrsen za grafiku
Razmislite o tome sta rendering zapravo radi:
-
Vertex processing: Imate 100.000 vertex-a. Na svaki treba primeniti istu transformaciju (World-View-Projection matricu). To je isti posao na razlicitim podacima.
-
Rasterizacija: Imate milione piksela. Za svaki treba odrediti koji trougao ga pokriva. Opet -- isti posao, razliciti podaci.
-
Pixel/Fragment shading: Za svaki piksel treba izracunati boju -- primeniti iste teksture, iste jednacine osvetljenja. Isti posao, razliciti podaci.
Vidite obrazac? Rendering je masovno paralelan problem. Imate milione nezavisnih elemenata (verteksa, piksela) na koje treba primeniti istu operaciju. Ovo je bukvalno ono za sta je GPU dizajniran.
CPU bi za ovo bio uzasan. 16 jezgara, ma koliko mocnih, ne moze da obradi 2 miliona piksela 60 puta u sekundi (to je 120 miliona piksela u sekundi za Full HD). Ali 10.000 GPU jezgara moze -- jer svako jezgro obradjuje svoj piksel, sva istovremeno.
Gde CPU i dalje vlada
Bitno je razumeti da GPU nije bolji od CPU-a -- on je drugaciji. Za zadatke koji zahtevaju kompleksnu logiku, donosenje odluka, rad sa nepredvidivim podacima -- CPU je daleko bolji.
Na primer, game logic u Unreal Engine-u: AI donosenje odluka, fizika kolizije, stablo ponasanja (Behavior Tree), animacioni state machine -- sve to radi na CPU-u, jer svaki objekat moze zahtevati potpuno drugaciji tok izvrsavanja.
Ovo je razlog zasto optimizacija igara uvek zahteva razumevanje obe strane. Ponekad je CPU bottleneck (Poglavlje 12 -- CPU optimizacija), ponekad GPU (ovo poglavlje i naredno). Ali da biste znali gde je problem, morate razumeti kako oba rade.
8.2 SIMD/SIMT -- Jedno Naredjenje, Hiljade Radnika
Single Instruction, Multiple Data
SIMD (Single Instruction, Multiple Data) je koncept koji je stariji od GPU-a -- postoji i u CPU-ima (SSE, AVX instrukcije). Ali GPU ga je doveo do ekstrema.
Ideja je prosta: umesto da svaki procesor dobije svoju instrukciju, jedan kontroler instrukcija salje istu instrukciju svim procesorima istovremeno. Svaki procesor tu instrukciju primenjuje na svoje podatke.
Zamislite vojnu paradu. Komandir kaze "korak levo" i svih 1.000 vojnika istovremeno koraci levo. Komandir ne mora da svakom vojniku pojedinacno kaze sta da radi -- jedna komanda, hiljadu izvrsenja.
U GPU terminologiji:
// Ovo je shader kod koji se izvrsava na GPU-u
float4 PixelShader(float2 uv : TEXCOORD) : SV_Target
{
float4 color = Texture.Sample(Sampler, uv); // 1. Dohvati teksturu
color.rgb *= LightIntensity; // 2. Pomnoži sa svetlom
color.rgb = pow(color.rgb, 1.0/2.2); // 3. Gamma korekcija
return color; // 4. Vrati boju
}
Ovaj shader se izvrsava za svaki piksel na ekranu. Ali GPU ne izvrsava svaki piksel pojedinacno -- on uzme grupu piksela (recimo 32) i kaze:
- "Svi dohvatite teksturu sa svojih UV koordinata"
- "Svi pomnozite svoju boju sa intenzitetom svetla"
- "Svi primenite gamma korekciju na svoju boju"
- "Svi vratite svoju boju"
Cetiri komande -- 32 piksela obradjena. Ista cetiri komande -- sledecih 32 piksela. I tako dalje, dok svi pikseli ne budu gotovi.
SIMT -- GPU-ova varijacija
GPU zapravo koristi malo modifikovan model koji se zove SIMT (Single Instruction, Multiple Threads). Razlika izmedju SIMD i SIMT je suptilna ali vazna:
-
SIMD (klasican): Jedna instrukcija, vise podataka. Podaci su obicno vektori (float4, na primer). Programer eksplicitno pise vektorske operacije.
-
SIMT (GPU): Jedna instrukcija, vise niti (thread-ova). Svaka nit ima svoj programski brojac (program counter), svoje registre, svoje podatke. Ali hardverski, grupa niti izvrsava istu instrukciju u istom taktu.
Razlika je u tome sto SIMT model izgleda kao da svaka nit radi nezavisno -- vi pisete shader kao da obradjujete samo jedan piksel. GPU hardver je taj koji "ispod haube" grupise te niti i izvrsava ih paralelno. Ovo je ogromna prednost za programera, jer ne morate razmisljati o vektorizaciji.
Ali -- i ovo je kljucno -- ta apstrakcija ima svoju cenu. Zato sto se niti izvrsavaju u grupama, ako razlicite niti u grupi treba da rade razlicite stvari (na primer, razlicite grane if naredbe), nastaje problem. O tome cemo detaljno u sledecem odeljku.
Kako ovo izgleda u praksi za Unreal Engine
Kada u Unreal Engine-u napravite Material i primenite ga na mesh, Unreal prevodi vas Material Graph u HLSL shader kod. Taj kod se kompajlira u GPU instrukcije. Kada se mesh renderuje, GPU pokrece taj shader za svaki piksel koji mesh pokriva na ekranu.
Recimo da imate mesh koji pokriva 500.000 piksela. GPU ce kreirati 500.000 niti -- po jednu za svaki piksel. Te niti se grupisu u warp-ove (NVIDIA) ili wavefront-e (AMD) od 32 ili 64 niti, i izvrsavaju se paralelno.
Na RTX 4080 sa 9.728 CUDA jezgara, GPU moze da izvrsava stotine warp-ova istovremeno. To znaci da tih 500.000 piksela moze biti obradjen veoma brzo -- ali samo ako sve niti rade isti posao.
8.3 Warp-ovi i Wavefront-i -- Lockstep Izvrsavanje
Sta je Warp?
Warp je grupa niti (thread-ova) koje se izvrsavaju u lockstep-u -- to jest, sve niti u grupi izvrsavaju istu instrukciju u istom takt ciklusu. Ovo je najmanji nivo paralelizma na GPU-u.
| GPU proizvodjac | Naziv | Velicina |
|---|---|---|
| NVIDIA | Warp | 32 niti |
| AMD | Wavefront (Wavefront64) | 64 niti (ili 32 kod RDNA arhitekture -- Wavefront32) |
| Intel (Arc) | EU thread | 8/16 niti |
Zamislite warp kao grupu vojnika koji marsiraju u formaciji. Svi moraju da idu istim korakom, istom brzinom, u istom smeru. Ako jedan vojnik treba da skrene levo dok ostali idu pravo -- svi moraju da zastanu, sacekaju da on skrene levo, pa onda nastave pravo. Ili obrnuto.
Ovo je fundamentalno ogranicenje GPU-a i jedan od najvaznijih koncepata za razumevanje performansi.
Branch Divergence -- Kada niti odu razlicitim putem
Evo klasicnog primera divergencije u shader-u:
if (uv.x > 0.5)
{
// Put A: kompleksna kalkulacija
color = ExpensiveCalculation(uv);
}
else
{
// Put B: jednostavna kalkulacija
color = SimpleCalculation(uv);
}
Zamislite da imate warp od 32 niti. 16 niti obradjuje piksele gde je uv.x > 0.5 (Put A), a 16 niti obradjuje piksele gde je uv.x <= 0.5 (Put B).
Sta GPU radi u ovom slucaju?
-
Prvo, svih 32 niti ulazi u
ifblok (Put A). Ali samo 16 niti zaista izvrsava kod -- ostalih 16 je maskirano (deaktivirano). One trose takt cikluse ali ne rade nista korisno. -
Zatim, svih 32 niti ulazi u
elseblok (Put B). Sada tih drugih 16 niti radi, a prvih 16 je maskirano.
Rezultat? GPU izvrsava oba puta za ceo warp. Umesto da se izvrsava samo jedan put (sto bi bilo idealno), izvrsavaju se oba, i efektivna iskoriscenost je samo 50%.
Ovo je branch divergence (divergencija grananja) i to je jedan od najvecih ubica performansi na GPU-u.
Koliko je divergencija zaista skupa?
Hajde da napravimo racunicu:
- Put A traje 100 takt ciklusa
- Put B traje 20 takt ciklusa
- Bez divergencije: niti na putu A zavrse za 100, niti na putu B zavrse za 20
- Sa divergencijom unutar istog warp-a: SVE niti zavrsavaju za 100 + 20 = 120 takt ciklusa
Ali to je najgori slucaj. Ako svi pikseli u jednom warp-u idu istim putem -- nema divergencije. Problem nastaje samo kada se niti unutar istog warp-a razilaze.
Ovo je razlog zasto lokalna koherentnost (susedni pikseli idu istim putem) pomaze performansama. Pikseli koji su blizu jedan drugom na ekranu obicno zavrsavaju u istom warp-u, pa ako svi pikseli u jednom regionu biraju isti put -- nema divergencije.
Prakticne posledice za Unreal Engine materijale
U Unreal Engine materijalu, svaki if node, svaki Switch, svaki Custom node sa branch-om moze da izazove divergenciju.
Klasican primer je kvalitetni LOD u materijalu:
// LOSA PRAKSA: branch divergence na granici prelaza
if (DistanceToCamera < 1000.0)
{
// Visok kvalitet: 4 texture sample-a, kompleksan lighting
color = HighQualityShading(uv);
}
else
{
// Nizak kvalitet: 1 texture sample, jednostavan lighting
color = LowQualityShading(uv);
}
Na granici od 1000 unita, neki pikseli u istom warp-u ce biti blize, neki dalje. To znaci divergenciju. I umesto da daleki pikseli profitiraju od jeftinijeg shadera, oni placaju cenu oba shadera.
Bolje resenje je koristiti lerp (linearna interpolacija) umesto if:
// BOLJA PRAKSA: bez divergencije
float blend = saturate((DistanceToCamera - 900.0) / 200.0);
float4 highQ = HighQualityShading(uv);
float4 lowQ = LowQualityShading(uv);
color = lerp(highQ, lowQ, blend);
Da, ovo uvek racuna oba kvaliteta, sto zvuci gore. Ali nema divergencije, pa su sve niti u warp-u zauzete -- i cesto je ovo brze nego if sa divergencijom, posebno na velikim povrsinama gde je granica prelaza izrazena.
Naravno, ako mozete da garantujete da ceo mesh koristi samo jedan put (na primer, ceo mesh je daleko od kamere), onda if moze da pomogne jer ce svi warp-ovi ici istim putem i GPU nece morati da izvrsava oba puta. Pogledajte Poglavlje 14 (Materijali i Shader Optimizacija) za vise detalja o tome kako donositi ovu odluku.
Ugnjezdjena divergencija -- umnozavanje problema
Divergencija postaje eksponencijalno gora sa ugnezdenim grananjima:
if (condition1) // 50% niti -> A, 50% -> B
{
if (condition2) // Unutar A: 50% -> AA, 50% -> AB
{
// Put AA
}
else
{
// Put AB
}
}
else
{
if (condition3) // Unutar B: 50% -> BA, 50% -> BB
{
// Put BA
}
else
{
// Put BB
}
}
U najgorem slucaju, GPU mora da prodje kroz sve cetiri puta, a svaka nit radi korisno u samo jednom od njih. Iskoriscenost: 25%. Tri cetvrtine GPU-ovog vremena je bacaceno.
Ovo je razlog zasto kompleksni materijali sa mnogo uslova mogu biti izuzetno skupi -- ne samo zato sto imaju vise instrukcija, vec zato sto divergencija dramaticno smanjuje efektivnu iskoriscenost hardvera.
8.4 Occupancy -- Sto Vise Posla, Bolje Performanse
Paradoks GPU-a
Evo neceg sto na prvi pogled ne zvuci logicno: GPU radi brze kada ima vise posla. Dajte mu premalo posla, i on ce zapravo raditi sporije nego sto bi mogao.
Zasto? Odgovor lezi u konceptu koji se zove occupancy (zauzetost).
Sta je Occupancy?
Occupancy je odnos izmedju broja aktivnih warp-ova na jednom Streaming Multiprocessor-u (SM) i maksimalnog broja warp-ova koje taj SM moze da podrzi.
Occupancy = Aktivni warp-ovi / Maksimalni warp-ovi
Na primer, ako jedan SM moze da podrzi do 48 warp-ova, a trenutno ima 24 aktivna warp-a, occupancy je 50%.
Zasto je Occupancy vazan?
Da bismo ovo razumeli, moramo prvo razumeti jedan kljucan fakt: pristup memoriji na GPU-u je spor. Kada warp treba da procita teksturu iz VRAM-a, to moze da traje stotine takt ciklusa. Sta GPU radi dok ceka?
Ako ima samo jedan warp -- nista. Samo ceka. Svi procesori sede besposleni dok podaci ne stignu iz memorije.
Ali ako ima 48 warp-ova -- GPU jednostavno prebaci na drugi warp koji ima podatke spremne za obradu. Dok warp #1 ceka na teksturu, GPU izvrsava instrukcije na warp-u #2. Kada warp #2 zatrazi memoriju, prebaci na warp #3. I tako dalje.
Ovo je kao restoran sa jednim kuvarom. Ako kuvar ima samo jedno jelo u pripremi, svaki put kad stavi nesto u rernu mora da ceka 30 minuta ne radeci nista. Ali ako ima 10 jela u raznim fazama pripreme -- dok jedno jelo ceka u rerni, on sece povrce za drugo, servira trece, priprema sos za cetvrto. Nikad ne stoji besposleni.
Sta ogranicava Occupancy?
Svaki SM ima ogranicene resurse koje warp-ovi dele:
-
Registri (Register File): Svaki SM ima odredjen broj registara (na primer, 65.536 na modernim NVIDIA GPU-ima). Svaka nit u warp-u zauzima odredjen broj registara u zavisnosti od kompleksnosti shader-a. Sto je shader kompleksniji, vise registara koristi, manje warp-ova staje na SM.
-
Deljena memorija (Shared Memory): Svaki SM ima ogranicenu kolicinu deljene memorije (obicno 48-100 KB). Compute shader-i koji koriste shared memory mogu da smanje occupancy.
-
Maksimalan broj warp-ova: Postoji hardversko ogranicenje na broj warp-ova po SM-u, cak i ako ima dovoljno registara i shared memory.
-
Maksimalan broj niti po bloku: Za compute shader-e, postoji ogranicenje na velicinu thread group-a.
Evo prakticnog primera:
SM ima:
- 65.536 registara
- Maksimalno 48 warp-ova (1.536 niti)
Shader A koristi 32 registra po niti:
- 32 registra × 32 niti/warp × 48 warp-ova = 49.152 registara
- 49.152 < 65.536, dakle moze svih 48 warp-ova
- Occupancy: 100%
Shader B koristi 64 registra po niti:
- 64 registra × 32 niti/warp × 48 warp-ova = 98.304 registara
- 98.304 > 65.536! Ne moze svih 48!
- Maksimalno: 65.536 / (64 × 32) = 32 warp-ova
- Occupancy: 32/48 = 66.7%
Shader C koristi 128 registara po niti:
- Maksimalno: 65.536 / (128 × 32) = 16 warp-ova
- Occupancy: 16/48 = 33.3%
Vidite trend? Sto je shader kompleksniji (koristi vise registara), to je occupancy nizi, i GPU ima manje warp-ova izmedju kojih moze da prebacuje, sto znaci da manje efikasno skriva latenciju memorije.
Da li je 100% Occupancy uvek cilj?
Ne. I ovo je cest nesporazum.
Occupancy je vazan za latency-bound shader-e -- one koji provode mnogo vremena cekajuci podatke iz memorije. Za te shader-e, veci occupancy znaci bolje skrivanje latencije.
Ali za compute-bound shader-e (ALU-bound) -- one koji vecinu vremena provode racunajuci, a ne cekajuci memoriju -- visok occupancy moze zapravo skoditi performansama. Zasto? Zato sto veci broj warp-ova znaci manje registara po niti, sto moze dovesti do register spilling -- kada shader nema dovoljno registara, GPU mora da privremeno cuva podatke u sporijem memorijskom prostoru.
Generalno pravilo:
- Za vecinu rendering shader-a (pixel shaderi sa texture fetch-om): occupancy je vazan, ciljajte bar 50%
- Za matematicki intenzivne compute shader-e: occupancy od 25-50% moze biti sasvim ok
- Za texture-heavy shader-e (mnogo texture sample-ova): occupancy je kriticno vazan
U Unreal Engine kontekstu, vi ne kontrolisete occupancy direktno. Ali kontrolisete kompleksnost vasih materijala, sto direktno utice na broj registara po niti, sto utice na occupancy. Kompleksniji materijal = vise registara = nizi occupancy = (potencijalno) losije performanse, cak i ako sam shader nije mnogo sporiji po instrukciji.
Poglavlje 14 (Materijali i Shader Optimizacija) detaljnije pokriva kako proceniti i kontrolisati kompleksnost materijala.
8.5 Latency Hiding -- Genijalnost GPU Dizajna
Problem: Memorija je spora
Evo jednog neprijatnog fakta iz sveta racunarstva: memorija je mnogo sporija od procesora. I ova razlika se samo povecava iz generacije u generaciju.
Na modernom GPU-u:
| Operacija | Latencija (takt ciklusi) |
|---|---|
| Aritmeticka operacija (sabiranje, mnozenje) | 4-6 ciklusa |
| Pristup shared memory / L1 cache | 20-30 ciklusa |
| Pristup L2 cache | 100-200 ciklusa |
| Pristup VRAM (texture fetch, buffer read) | 400-800 ciklusa |
Procitajte to ponovo. Pristup VRAM-u je 100-200 puta sporiji od aritmeticke operacije. To znaci da za svaki Texture.Sample() poziv u vasem shader-u, GPU bi mogao da uradi 100-200 matematickih operacija u istom vremenu.
Kako CPU resava ovaj problem
CPU koristi veliku hijerarhiju kes memorije: L1 (mali, brz), L2 (srednji), L3 (veliki, sporiji). Kada CPU zatrazi podatak iz memorije, a taj podatak je u L1 kesu -- odgovor stize za 3-4 ciklusa. Ako je u L2 -- 10 ciklusa. L3 -- 30 ciklusa. Ako podatak nije ni u jednom kesu (cache miss), CPU mora da ceka stotine ciklusa na RAM.
Dodatno, CPU koristi i spekulativno izvrsavanje (speculative execution) -- nastavlja da izvrsava instrukcije "na slepo" dok ceka podatke, nadajuci se da ce predvidjanje biti tacno. Ako bude pogresno, baca taj rad i pocinje ispocetka.
Sve ovo zahteva ogroman deo silicijuma (fizickog prostora na cipu) -- i upravo zato CPU ima samo 8-16 jezgara. Ostatak cipa je kes i kontrolna logika.
Kako GPU resava ovaj problem -- potpuno drugacije
GPU nema velike kes memorije. Nema spekulativno izvrsavanje. Umesto toga, koristi strategiju koja je genijalana u svojoj jednostavnosti:
"Dok jedni cekaju, drugi rade."
Evo kako to funkcionise korak po korak:
- GPU ima, recimo, 48 warp-ova na jednom SM-u
- Warp #1 pocinje da se izvrsava:
color = Texture.Sample(...)-- trazi podatak iz VRAM-a - Podatak nece stici 400+ ciklusa. GPU ne ceka. Umesto toga, prebacuje na Warp #2
- Warp #2 se izvrsava:
normal = NormalMap.Sample(...)-- i on trazi memoriju - GPU prebacuje na Warp #3, #4, #5...
- U medjuvremenu, podaci za Warp #1 stizu! GPU se vraca na Warp #1 i nastavlja izvrsavanje
Zamislite to ovako: imate 48 lonaca na stednjaku. Dok se supa u loncu #1 kuva, vi mesate sos u loncu #2, secete povrce za lonac #3, dodajete zacine u lonac #4. Nikad ne stojite i gledate u lonac cekajuci da provri -- uvek imate nesto drugo da radite.
Kljuc je u tome sto prebacivanje izmedju warp-ova na GPU-u je besplatno (ili gotovo besplatno -- 1 takt ciklus). Ovo je radikalno drugacije od CPU-a, gde prebacivanje konteksta kosta stotine ciklusa.
Zasto je prebacivanje besplatno? Zato sto svaki warp ima svoje registre koji su vec alocirani na SM-u. Nema potrebe da se bilo sta cuva ili ucitava -- svi podaci su vec tu, GPU samo pocinje da cita instrukcije iz drugog programa counter-a.
Matematika skrivanja latencije
Hajde da napravimo konkretnu racunicu:
Pretpostavke:
- Latencija VRAM-a: 400 ciklusa
- Shader izvrsava 20 instrukcija izmedju dva memory fetch-a
- Svaka instrukcija traje ~4 ciklusa
- Dakle, korisno vreme izmedju fetch-ova: 20 × 4 = 80 ciklusa
- Vreme cekanja: 400 - 80 = 320 ciklusa besposlenog rada (per warp)
Za potpuno skrivanje latencije:
- Potrebno je dovoljno warp-ova da popune 400 ciklusa
- Svaki warp daje 80 ciklusa korisnog rada
- 400 / 80 = 5 warp-ova minimum
Sa 5 aktivnih warp-ova, dok warp #1 ceka, GPU izvrsava #2, #3, #4, #5,
i kada se vrati na #1, podaci su tu.
Sa samo 2 warp-a: 2 × 80 = 160 ciklusa korisnog rada
Praznog hoda: 400 - 160 = 240 ciklusa -- GPU stoji besposlen 60% vremena!
Ovaj proracun pokazuje zasto je occupancy vazan: vise warp-ova = bolje skrivanje latencije = manja besposlica = bolje performanse.
Implikacije za Unreal Engine
Ovo objasnjava nekoliko optimizacionih "pravila" koja se cesto citiraju ali retko objasne:
-
Zasto je bolje imati nesto vise matematike nego dodatni texture fetch: Jedan texture fetch kosta 400+ ciklusa latencije. Dodavanje 20-30 matematickih operacija kosta 80-120 ciklusa. Ako mozete da izracunate neku vrednost umesto da je procitate iz teksture -- cesto je to brze, jer ne dodajete memorijsku latenciju.
-
Zasto je kompleksnost materijala vazna cak i kad shader nije "spor": Kompleksniji shader koristi vise registara, smanjuje occupancy, i time smanjuje sposobnost GPU-a da skrije latenciju. Shader mozda sam po sebi nije sporiji, ali zbog nizeg occupancy-ja, GPU provodi vise vremena cekajuci.
-
Zasto je overdraw posebno skup (detalji u Poglavlju 10 -- Overdraw i Fill Rate): Svaki preklopljeni piksel trazi iste teksture ponovo, dodajuci memorijsku latenciju. I svaki takav piksel zauzima warp slot koji bi mogao raditi na vidljivom pikselu.
8.6 VRAM -- Memorija Koja Hrani GPU
Sta je VRAM?
VRAM (Video RAM) je memorija koja se nalazi fizicki na grafickoj kartici, direktno povezana sa GPU-om putem brze memorijske magistrale. Za razliku od sistemskog RAM-a koji komunicira sa CPU-om preko PCIe ili memory bus-a, VRAM je dizajniran za ogroman propusni opseg (bandwidth) koji GPU zahteva.
Zamislite sistemski RAM kao autoput sa 4 trake koji vodi od skladista do fabrike. VRAM je kao autoput sa 256 ili 384 traka koji vodi direktno od skladista do proizvodne linije -- neuporedivo vise materijala moze da stigne u istom vremenskom periodu.
VRAM vs Sistemski RAM
| Karakteristika | Sistemski RAM (DDR5) | VRAM (GDDR6X) | VRAM (HBM3) |
|---|---|---|---|
| Tipican kapacitet | 16-64 GB | 8-24 GB | 24-80 GB |
| Bandwidth | 50-80 GB/s | 500-1000 GB/s | 2000-3000+ GB/s |
| Latencija | ~80-100 ns | ~100-150 ns | ~100 ns |
| Sirina magistrale | 128-bit (dual channel) | 256-384 bit | 4096-6144 bit |
| Cena po GB | Niska | Visoka | Veoma visoka |
Pogledajte bandwidth brojke. Tipican GPU ima 10-15 puta veci bandwidth od sistemskog RAM-a. I cak taj enormni bandwidth je cesto nedovoljan za sve sto GPU treba da uradi.
Sta se cuva u VRAM-u?
U kontekstu Unreal Engine renderinga, VRAM sadrzi:
-
Teksture: Ovo je obicno najveci korisnik VRAM-a. Svaka tekstura u sceni -- diffuse, normal map, roughness, metallic, emissive -- sve to zivi u VRAM-u. Mip-map-ovi takodje (Poglavlje 13 -- Teksture i Streaming).
-
Vertex i Index Buffer-i: Geometrija svakog mesh-a koji se renderuje -- pozicije verteksa, normale, UV koordinate, indeksi trouglova.
-
Framebuffer: Sama slika koja se renderuje. Za Full HD sa HDR (16-bit float, 4 kanala), to je: 1920 × 1080 × 8 bajtova = ~16 MB po render target-u. Sa G-Buffer-om (deferred rendering) koji ima 4-6 render target-a, to je 64-96 MB samo za jedan frame.
-
Depth Buffer (Z-Buffer): Informacija o dubini za svaki piksel. Obicno 32-bit float: 1920 × 1080 × 4 = ~8 MB.
-
Constant Buffer-i: Parametri shader-a -- matrice transformacije, parametri materijala, svetlosne informacije.
-
Structured Buffer-i: Instancing podatci, GPU-driven rendering podatci, Nanite podatci.
-
Compute Buffer-i: Privremeni buffer-i za post-processing, Lumen podatci, particle simulation.
Za tipicnu Unreal Engine 5 scenu na 1080p, upotreba VRAM-a moze izgledati ovako:
Teksture: 2-6 GB (zavisi od broja i rezolucije)
Geometry buffer-i: 200-800 MB
G-Buffer: ~100 MB
Depth buffer: ~8 MB
Shadow maps: 200-500 MB
Lumen podatci: 300-800 MB
Nanite podatci: 200-500 MB
Post-process: 100-300 MB
Ostalo: 200-500 MB
--------------------------------------
UKUPNO: 3-9 GB (tipicno)
Ove brojke se dramaticno povecavaju na visim rezolucijama. Na 4K (3840 x 2160), framebuffer-i i render target-i su 4 puta veci. Na 1440p ultrawide, slicno.
Memorijska hijerarhija na GPU-u
Bas kao sto CPU ima hijerarhiju L1 → L2 → L3 → RAM, GPU ima svoju memorijsku hijerarhiju:
┌─────────────────────────────────┐
│ Registri (per nit) │ Najbrži, najmanja kapaciteta
│ ~256 registara/nit │ Latencija: 0-1 ciklus
├─────────────────────────────────┤
│ Shared Memory / L1 Cache │ Deljeno u okviru SM-a
│ 48-100 KB po SM │ Latencija: ~20-30 ciklusa
├─────────────────────────────────┤
│ L2 Cache │ Deljeno za ceo GPU
│ 4-6 MB │ Latencija: ~100-200 ciklusa
├─────────────────────────────────┤
│ VRAM │ Glavni memorijski prostor
│ 8-24 GB │ Latencija: ~400-800 ciklusa
└─────────────────────────────────┘
Svaki nivo je veci ali sporiji od prethodnog:
Registri: Najbrza memorija na GPU-u. Svaka nit ima svoje privatne registre -- na modernim NVIDIA GPU-ima, do 255 registara po niti. Pristup je prakticki instantan (0-1 ciklus). Ali kapacitet je mali -- svaki registar je 32-bit (4 bajta), dakle svaka nit ima oko 1 KB privatne memorije. Ovo je gde shader cuva privremene promenljive (float4 color, float3 normal, itd.).
Shared Memory / L1 Cache: Ovo je mali ali brz memorijski prostor (48-100 KB) koji dele sve niti u okviru jednog SM-a. Latencija je oko 20-30 ciklusa. Shared memory je posebno vazan za compute shader-e gde niti trebaju da dele podatke -- na primer, pri racunanju blur efekta, susedni pikseli trebaju pristup istim podacima. U kontekstu rendering shader-a, ovaj prostor se uglavnom koristi kao L1 cache za texture fetch-ove.
L2 Cache: Ovo je veci kes (4-6 MB) koji dele svi SM-ovi na celom GPU-u. Latencija je 100-200 ciklusa. Kada vise SM-ova pristupa istim teksturama (sto je cesto u renderingu), L2 cache znacajno smanjuje pritisak na VRAM bandwidth.
VRAM: Glavni memorijski prostor. Ogroman kapacitet (8-24 GB) ali latencija od 400-800 ciklusa. Ovde zive sve teksture, buffer-i, framebuffer-i.
Texture Cache i zasto je lokalizacija bitna
GPU ima specijalizovan texture cache koji koristi cinjenicu da susedni pikseli obicno citaju susedne texel-e iz teksture. Ovo se zove prostorna lokalizacija (spatial locality).
Teksture u VRAM-u su organizovane u tile-ove (blokove) umesto linearno -- to jest, texel-i koji su blizu na 2D teksturi su blizu i u memoriji. Kada GPU ucita jedan texel, automatski ucitava i okolne texel-e u cache, jer je velika verovatnoca da ce biti potrebni za susedne piksele.
Ovo je razlog zasto tri-linear i anisotropno filtriranje tekstura nije dramaticno skuplje od point filtriranja -- susedni texel-i su verovatno vec u cache-u.
Ali ovo takodje objasnjava zasto neke operacije ubijaju texture cache performanse:
- Nasumicni pristup teksturi (npr. koriscenjem proceduralno generisanih UV koordinata): susedni pikseli citaju potpuno razlicite delove teksture, kes je beskoristan.
- Veoma velike teksture sa malim mip level-om: veliki delovi teksture se ucitavaju ali koristi se samo mali deo, sto "trosi" cache prostor (vise o ovome u Poglavlju 13 -- Teksture i Streaming).
8.7 Memory Bandwidth -- Najcesci Bottleneck
Sta je Memory Bandwidth?
Memory bandwidth (propusni opseg memorije) je kolicina podataka koju GPU moze da procita ili zapise u VRAM u jednoj sekundi. Ovo je jedan od najvaznijih specifikacija GPU-a, a istovremeno jedan od najcesce zanemarenih.
Zamislite VRAM kao ogromno skladiste, a bandwidth kao broj kamiona koji mogu da voze izmedju skladista i fabrike u isto vreme. Mozete imati ogromno skladiste, ali ako imate samo 2 kamiona -- fabrika ce cesce cekati na materijal nego sto ce raditi.
Moderni GPU-ovi imaju impresivan bandwidth:
| GPU | Memory Bandwidth |
|---|---|
| RTX 3060 | 360 GB/s |
| RTX 4070 | 504 GB/s |
| RTX 4080 | 717 GB/s |
| RTX 4090 | 1.008 GB/s |
| RX 7900 XTX | 960 GB/s |
Zvuci mnogo, zar ne? Ali hajde da izracunamo koliko je zapravo potrebno.
Koliko bandwidth-a trosi rendering?
Uzmimo primer: renderujemo Full HD (1920x1080) na 60 FPS sa deferred rendering-om.
G-Buffer citanje/pisanje po frame-u:
- GBuffer A (World Normal + Metallic): 1920 × 1080 × 4 bajta = 8.29 MB (pisanje)
- GBuffer B (Base Color + Specular): 1920 × 1080 × 4 bajta = 8.29 MB (pisanje)
- GBuffer C (Roughness + AO + ...): 1920 × 1080 × 4 bajta = 8.29 MB (pisanje)
- Depth Buffer: 1920 × 1080 × 4 bajta = 8.29 MB (pisanje)
- Ukupno pisanje G-Buffer-a: ~33 MB
Lighting pass (citanje G-Buffer-a + pisanje scene color):
- Citanje svih G-Buffer-a: ~33 MB
- Pisanje scene color (HDR, float16): 1920 × 1080 × 8 = 16.6 MB
- Ukupno za lighting: ~50 MB
Texture fetch-ovi:
- Prosecno 4-8 texture sample-a po pikselu (diffuse, normal, roughness, metallic, AO...)
- Svaki sample: ~4-16 bajtova (zavisno od formata i filtriranja)
- Za 2 miliona piksela × 6 sample-a × 8 bajtova = ~96 MB
Shadow maps:
- Citanje shadow map tekstura: 20-100+ MB (zavisno od broja svetala i shadow kaskada)
Post-processing:
- Bloom, tone mapping, DoF, motion blur: svaki pass cita ceo framebuffer i pise novi
- Svaki pass: ~25-40 MB (citanje + pisanje)
- 5-10 post-process pass-ova: 125-400 MB
Screen-space efekti (SSAO, SSR):
- Citanje depth-a, normala: ~16 MB
- Pisanje rezultata: ~8 MB
- Po efektu: ~24 MB
Ukupno po frame-u (gruba procena):
G-Buffer: ~33 MB
Lighting: ~50 MB
Teksture: ~96 MB
Shadows: ~50 MB
Post-process: ~200 MB
Screen-space: ~50 MB
Ostalo: ~50 MB
---------------------------------
UKUPNO: ~529 MB po frame-u
Na 60 FPS: 529 MB × 60 = ~31.7 GB/s
To je samo za Full HD! Na 4K, vecina ovih brojki se ucetvorostruci:
Na 4K (3840×2160) na 60 FPS: ~127 GB/s
A ovo je konzervativna procena! Sa Lumen-om, Nanite-om, VSM-om (Virtual Shadow Maps), TSR-om, i svim ostalim UE5 feature-ima, realan zahtev moze biti 2-3x veci.
Na RTX 4070 sa 504 GB/s bandwidth-a, rendering na 4K sa svim UE5 feature-ima aktiviranim moze zaista postati bandwidth-limited.
Bandwidth i overdraw
Overdraw -- kada se isti piksel renderuje vise puta jer ga pokrivaju vise objekata -- direktno umnozava bandwidth zahteve. Ako svaki piksel u proseku ima overdraw od 2x, potreban bandwidth se dupla.
Ovo je jedan od razloga zasto je depth prepass (Early-Z) vazan: ako GPU zna unapred koji objekti su ispred, ne mora da obradjuje (i samim tim trosi bandwidth za) piksele koji ce svakako biti prekriveni. Vise o ovome u Poglavlju 09 (Rendering Pipeline) i Poglavlju 10 (Overdraw i Fill Rate).
Kompresija -- GPU-ov odgovor na bandwidth krizu
Moderni GPU-ovi imaju hardversku kompresiju framebuffer-a koja moze znacajno smanjiti bandwidth zahteve. Ovo se zove Delta Color Compression (NVIDIA) ili Delta Color Compression / Infinity Cache (AMD).
Ideja je da se framebuffer podaci kompresuju pre nego sto se zapisu u VRAM, i dekompresuju kada se citaju. Kompresija je lossless (bez gubitka) i radi dobro na "glatkim" regionima slike gde se susedni pikseli malo razlikuju.
Ali kompresija ne radi dobro na regionima sa mnogo detalja, ostrih ivica, ili buke (noise). U tim slucajevima, kompresija moze potpuno da otkaze i GPU pada na pun, nekompresovani bandwidth.
Ovo je jos jedan razlog zasto su neke rendering tehnike efikasnije od drugih -- one koje proizvode "glatke" izlaze bolje koriste bandwidth kompresiju.
8.8 Compute Units i Streaming Multiprocessors
Gradivni blokovi GPU-a
Do sada smo pricali o GPU-u kao monolitnoj masini. Ali GPU je zapravo sastavljen od mnogo manjih, relativno nezavisnih jedinica. NVIDIA ih naziva Streaming Multiprocessors (SM), AMD ih naziva Compute Units (CU), a Intel ih naziva Execution Units (EU) grupisane u Slices.
Zamislite GPU kao veliku fabriku. Fabrika se ne sastoji od jedne ogromne proizvodne linije -- umesto toga, ima mnogo manjih radionica (SM-ova/CU-ova), svaka sa sopstvenom opremom i radnicima. Direktor fabrike (GPU scheduler) rasporedjuje posao po radionicama.
Sta je unutar jednog SM-a (NVIDIA arhitektura kao primer)?
Pogledajmo strukturu jednog SM-a na modernoj NVIDIA arhitekturi (Ada Lovelace, RTX 40xx serija):
┌──────────────────────────────────────────────────────────┐
│ Streaming Multiprocessor │
│ │
│ ┌───────────────┐ ┌───────────────┐ │
│ │ Warp │ │ Warp │ │
│ │ Scheduler #1 │ │ Scheduler #2 │ ... (#3, #4) │
│ └───────┬───────┘ └───────┬───────┘ │
│ │ │ │
│ ┌───────┴──────────────────┴───────┐ │
│ │ │ │
│ │ FP32 Jedinice (CUDA Cores) │ │
│ │ 128 komada po SM-u │ │
│ │ │ │
│ │ INT32 Jedinice │ │
│ │ 64 komada po SM-u │ │
│ │ │ │
│ │ FP64 Jedinice (opciono) │ │
│ │ 2 komada (gaming GPU-ovi) │ │
│ │ │ │
│ │ SFU (Special Function Units) │ │
│ │ sin, cos, sqrt, log, exp │ │
│ │ 16 komada │ │
│ │ │ │
│ │ LD/ST Jedinice (Load/Store) │ │
│ │ 32 komada │ │
│ │ │ │
│ │ Tensor Cores │ │
│ │ 4 komada (za AI/ML) │ │
│ │ │ │
│ │ RT Cores │ │
│ │ 1 komad (za ray tracing) │ │
│ │ │ │
│ └───────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────┐ │
│ │ Register File: 65.536 × 32-bit │ │
│ │ = 256 KB │ │
│ └───────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────┐ │
│ │ Shared Memory / L1 Cache │ │
│ │ 128 KB (konfigurisano) │ │
│ └───────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────┐ │
│ │ Texture Units: 4 │ │
│ │ (texture fetch + filtriranje) │ │
│ └───────────────────────────────────┘ │
│ │
│ Maks warp-ova: 48 (= 1.536 niti) │
│ Maks thread blokova: 16-32 │
└──────────────────────────────────────────────────────────┘
Hajde da razjasnimo svaku komponentu:
CUDA Cores (FP32 ALU-ovi)
Ovo su "radni konji" GPU-a -- aritmeticko-logicke jedinice (ALU) koje izvrsavaju osnovne matematicke operacije na floating-point brojevima: sabiranje, oduzimanje, mnozenje, fused multiply-add (FMA). FMA je posebno vazan za grafiku jer se a × b + c operacija pojavljuje svuda -- u transformacijama matrica, racunanju osvetljenja, interpolaciji boja.
Na Ada Lovelace arhitekturi, svaki SM ima 128 FP32 jedinica. RTX 4080 ima 76 SM-ova, dakle ukupno 128 × 76 = 9.728 CUDA jezgara. RTX 4090 ima 128 SM-ova = 16.384 CUDA jezgara.
Bitna napomena: kada NVIDIA kaze "CUDA jezgra", to je pojedinacna FP32 ALU. To nije isto sto i CPU jezgro -- jedno CPU jezgro je znatno mocnije od jednog CUDA jezgra. Ne uporedjujte ove brojke direktno.
INT32 Jedinice
Izvrsavaju celobrojne (integer) operacije. Koriste se za proracune adresa, indeksiranje, bitske operacije. Na modernim arhitekturama, INT32 jedinice mogu da rade paralelno sa FP32 jedinicama, sto povecava efektivnu propusnost.
SFU (Special Function Units)
Ove jedinice izvrsavaju transendetalne matematicke funkcije: sin(), cos(), sqrt(), log(), exp(), pow(). Ove funkcije su mnogo kompleksnije od prostog sabiranja ili mnozenja i zahtevaju specijalizovan hardver.
Svaki SM ima samo 16 SFU-ova (u poredjenju sa 128 FP32 jedinica). To znaci da su ove operacije sporije -- GPU moze da izvrsi jednu SFU operaciju za svake 8 FP32 operacije.
Ovo je prakticno vazno: u vasim Unreal Engine materijalima, operacije poput sin(), cos(), pow() su skuplje nego add() ili multiply(). Ako imate materijal sa mnogo trigonometrijskih funkcija (na primer, za animaciju vode), imajte na umu da SFU moze postati bottleneck.
Texture Units
Svaki SM ima 4 teksturne jedinice (TMU -- Texture Mapping Units). Ove jedinice su specijalizovane za:
- Adresni proracun: Konvertovanje UV koordinata u memorijsku adresu u VRAM-u
- Filtriranje: Bilinearno, trilinearno, anizotropno filtriranje (interpolacija izmedju texel-a)
- Dekompresija: On-the-fly dekompresija kompresovanih tekstura (BC/DXT formati)
Texture Unit prima UV koordinatu, odlazi u VRAM (ili cache) po podatke, primenjuje filtriranje, i vraca rezultujucu boju. Ceo proces traje mnogo takt ciklusa (zbog memorijske latencije), ali pipeline-ovanjem, svaki TMU moze da vraca po jedan rezultat svakog ciklusa (throughput od 1 texel/ciklus po TMU).
Sa 4 TMU-a po SM-u i 76 SM-ova na RTX 4080, to je 304 texture fetch-a po ciklusu. Na 2.5 GHz, to je teorijski ~760 giga-texel-a po sekundi (GTexel/s). Naravno, memorijski bandwidth ogranicava stvarne performanse daleko ispod ovog teorijskog maksimuma.
Load/Store Jedinice
Ove jedinice se bave citanjem i pisanjem podataka u/iz memorije (VRAM, shared memory, registri). Svaki SM ima 32 LD/ST jedinica, sto znaci da moze da izda 32 memorijske operacije po ciklusu.
Tensor Cores
Ovo su specijalizovane jedinice za matricno mnozenje, originalno dizajnirane za AI/ML trening. U kontekstu igara, NVIDIA ih koristi za DLSS (Deep Learning Super Sampling) -- AI-bazirano upscaling resenje. Unreal Engine 5 podrzava DLSS kroz plugin.
Tensor Core-ovi nisu direktno relevantni za tradicionalni rendering, ali DLSS je izuzetno vazan za performanse (Poglavlje 17 -- Upscaling Tehnologije).
RT Cores (Ray Tracing Cores)
Specijalizovane jedinice za akceleraciju ray tracing-a -- konkretno, za BVH (Bounding Volume Hierarchy) traversal i ray-triangle intersection testove. Ove operacije su srce ray tracing-a i bez hardverske akceleracije bi bile previse spore za real-time upotrebu.
Lumen u Unreal Engine 5 koristi hardware ray tracing (kada je dostupan) za globalnu iluminaciju i refleksije. Bez RT Core-ova, Lumen koristi software ray tracing ili screen-space fallback-ove, sto je manje precizno (vise o ovome u buducem poglavlju o Lumen-u).
Register File -- Skriveni heroj
Register file je mozda najvaznija komponenta SM-a sa stanovista performansi, a gotovo nikad se ne pominje u marketinskim materijalima.
Svaki SM ima 65.536 registara od 32 bita = 256 KB register file. Ovo je ogromno u poredjenju sa CPU-om (koji ima obicno 16-32 opstenamenska registra po jezgru).
Zasto toliko registara? Zato sto svi warp-ovi na SM-u dele ovaj register file. Ako imate 48 warp-ova × 32 niti/warp = 1.536 niti, svaka nit dobija 65.536 / 1.536 = ~42 registra. Ako shader koristi vise registara, manje warp-ova moze da bude aktivno (smanjuje se occupancy).
Ovo je direktna veza izmedju kompleksnosti vaseg materijala u Unreal Engine-u i GPU performansi:
Jednostavan materijal (20 registara/nit):
→ 48 warp-ova × 32 niti × 20 reg = 30.720 registara (od 65.536)
→ Occupancy: 100%
→ Odlicno skrivanje latencije
Kompleksan materijal (80 registara/nit):
→ 65.536 / (80 × 32) = 25 warp-ova
→ Occupancy: 25/48 = 52%
→ Pristojno skrivanje latencije
Veoma kompleksan materijal (200 registara/nit):
→ 65.536 / (200 × 32) = 10 warp-ova
→ Occupancy: 10/48 = 20.8%
→ Slabo skrivanje latencije → moguc bottleneck
Kako GPU raspodjeljuje posao medju SM-ovima
GPU ima globalni scheduler (rasporedivac) koji deli posao na grupe i rasporedjuje ih po SM-ovima. Za rendering:
-
Vertex processing: GPU deli vertekse na grupe od 32 (warp size). Svaka grupa se salje na jedan SM. SM izvrsava vertex shader na svim verteksima u grupi istovremeno.
-
Rasterizacija: Specijalizovan hardver (ROP-ovi -- Raster Operators) pretvara trouglove u fragmente (piksele). Ovaj proces nije programabilan.
-
Pixel/Fragment processing: Fragmenti se grupisu u warp-ove od 32 i salju na SM-ove. Svaki SM izvrsava pixel shader na svojoj grupi piksela.
Vazna stvar: GPU ne garantuje redosled izvrsavanja izmedju SM-ova. SM #5 moze da zavrsi pre SM #1. Ovo je razlog zasto shader-i ne mogu da komuniciraju izmedju razlicitih piksela (sem kroz mehanizme kao sto su atomske operacije ili barrier-i u compute shader-ima).
8.9 Unified Shader Arhitektura -- Kako Logicki Pipeline Mapira na Fizicki Hardver
Stari nacin: Fiksne shader jedinice
U starijim GPU-ima (pre 2007. godine, pre NVIDIA G80 / ATI R600), vertex shader-i i pixel shader-i su se izvrsavali na fizicki odvojenim jedinicama:
STARA ARHITEKTURA (pre-unified):
Verteksi → [Vertex Shader Jedinice] → Rasterizer → [Pixel Shader Jedinice] → Framebuffer
(fiksni broj) (fiksni broj)
Problem sa ovim pristupom je ocigledan: ako scena ima mnogo geometrije ali jednostavne materijale, vertex shader jedinice su preopterecene dok pixel shader jedinice sede besposlene. Obrnuto, ako imate malo geometrije ali kompleksne materijale, pixel shader jedinice su preopterecene.
U oba slucaja, deo GPU-a je neiskoriscen. To je kao da imate fabriku gde se u jednom delu cigle prave, a u drugom farbaju -- i ne mozete da prebacite radnike iz farbanja u pravljenje cigli cak i kad farbari nemaju posla.
Novi nacin: Unified Shaders (od 2007. do danas)
Moderna GPU arhitektura ima unificirane shader jedinice -- isti fizicki hardver moze da izvrsava vertex shadere, pixel shadere, geometry shadere, tessellation shadere, compute shadere, mesh shadere, ray tracing shadere. Sve.
MODERNA ARHITEKTURA (unified):
┌─────────────────────┐
Verteksi ─────────→ │ │
│ │
Pikseli ─────────→ │ Unified Shader │ ─────→ Framebuffer
│ Pool (SM-ovi) │
Compute ─────────→ │ │
│ │
Ray Trace ────────→ │ │
└─────────────────────┘
Sada, GPU dinamicki rasporedjuje SM-ove prema potrebi. Ako scena ima mnogo geometrije -- vise SM-ova radi na vertex shader-ima. Ako ima malo geometrije ali kompleksne materijale -- vise SM-ova radi na pixel shader-ima. Iskoriscenost hardvera je dramaticno bolja.
Kako rendering pipeline radi na unified arhitekturi
Pogledajmo korak po korak kako jedan frame prolazi kroz GPU na unified arhitekturi:
Korak 1: Command Processing
CPU (kroz Unreal Engine RHI -- Rendering Hardware Interface) salje komande GPU-u kroz Command Buffer. Svaka komanda kaze GPU-u sta da radi: "izvrsi draw call sa ovim vertex buffer-om, ovim shader-om, ovim teksturama."
GPU-ov command processor cita ove komande i rasporedjuje posao.
Korak 2: Vertex Processing (Input Assembly + Vertex Shader)
GPU-ov Input Assembler cita vertex podatke iz vertex buffer-a i index buffer-a u VRAM-u. Verteksi se grupisu u warp-ove od 32 i salju na dostupne SM-ove.
SM izvrsava vertex shader na svakom verteksu: primenjuje World-View-Projection transformaciju, racuna per-vertex podatke (normale, tangente, UV koordinate za sledeci stage).
Korak 3: Primitive Assembly i Culling
Transformisani verteksi se sklapaju u primitive (trouglove). U ovoj fazi, GPU moze da izvrsi hardverski culling:
- Backface Culling: Trouglovi okrenuti od kamere se odbacuju
- Frustum Culling: Trouglovi van vidnog polja se odbacuju
- Small Triangle Culling: Trouglovi manji od jednog piksela se (ponekad) odbacuju -- ali ovo ima ogranicenja, kao sto cemo videti u odeljku o optimizaciji
Sa Nanite-om u UE5, ovaj proces je fundamentalno drugaciji -- Nanite ima sopstveni software rasterizer koji radi mnogo agresivniji culling (detaljno u Poglavlju o Nanite-u).
Korak 4: Rasterizacija
Rasterizer je fiksna funkcija (nije programabilan) koja pretvara trouglove u fragmente. Za svaki trougao, rasterizer odredjuje koji pikseli na ekranu su pokriveni tim trouglom.
Rasterizer radi u blokovima od 2×2 piksela koji se zovu quad. Ovo je vazno -- cak i ako trougao pokriva samo 1 piksel, GPU obradjuje ceo quad od 4 piksela (3 piksela ce biti maskirani i nece pisati u framebuffer, ali ce trositi compute resurse). O tome vise u odeljku o prakticnim implikacijama.
Korak 5: Early-Z Test (opciono)
Pre nego sto se pokrene pixel shader, GPU moze da proveri depth buffer: ako je piksel iza vec renderovanog piksela, nema smisla pokretati shader za njega. Ovo je ogromna usteda -- pixel shader je obicno najskuplji deo pipeline-a.
Ali Early-Z ne radi uvek. Ako pixel shader menja dubinu (depth write) ili koristi discard/clip (za alpha testing), GPU mora da pokrene shader pre nego sto zna da li piksel treba da se nacrta. U ovim slucajevima, Early-Z je iskljucen za taj draw call.
Ovo je razlog zasto su materijali sa maskiranjem (Masked materials) u Unreal Engine-u skuplji od opaque materijala -- oni koriste clip() za prozirnost, sto iskljucuje Early-Z.
Korak 6: Pixel/Fragment Shading
Fragmenti se grupisu u warp-ove i salju na SM-ove. SM izvrsava pixel shader -- dohvata teksture, racuna osvetljenje, primenjuje efekte materijala.
Ovo je obicno najskuplji deo pipeline-a, jer:
- Piksela je mnogo vise od verteksa (za vecinu scena)
- Pixel shader obicno ima vise texture fetch-ova (svaki kosta memorijsku latenciju)
- Pixel shader je obicno kompleksniji (lighting model, specijalni efekti)
Korak 7: Output Merger (ROP-ovi)
Konacno, Raster Operations (ROP) jedinice izvrsavaju:
- Depth test (ako Early-Z nije uradjen)
- Stencil test
- Blending (za transparentne objekte)
- Pisanje konacne boje u framebuffer
ROP-ovi su fiksna funkcija i obicno nisu bottleneck, osim u slucajevima teskog blending-a (transparentni objekti, particles).
Async Compute -- Simultano izvrsavanje razlicitih tipova posla
Moderna GPU arhitektura podrzava asinhrono izvrsavanje -- GPU moze istovremeno da izvrsava graficki posao (rendering) i compute posao (compute shader-i) na razlicitim SM-ovima.
Ovo je korisno jer rendering pipeline ima faze gde ne koristi sve SM-ove (na primer, tokom rasterizacije, pixel shader SM-ovi cekaju). Async compute dozvoljava da se u tim "rupama" izvrsava drugi posao -- na primer, post-processing ili particle simulacija.
Unreal Engine 5 koristi async compute za razlicite efekte: Lumen compute, SSAO, volumetric fog, i druge. Ovo je razlog zasto ponekad dodavanje compute-heavy efekta ne kosta onoliko FPS-a koliko biste ocekivali -- on se parcijalno izvrsava u vremenu kada bi GPU inace bio besposlen.
8.10 Prakticne Implikacije za Optimizaciju u UE5
Sada dolazimo do najvaznijeg dela ovog poglavlja -- kako sve sto smo naucili direktno utice na performanse u Unreal Engine 5. Ovo je deo gde se teorija pretvara u praksu.
Problem malih trouglova (Small Triangle Problem)
Ovo je mozda najvaznija prakticna posledica GPU arhitekture za game developere.
Secate se da smo rekli da rasterizer radi u quad-ovima od 2×2 piksela? Evo sta to znaci u praksi:
Zamislite trougao koji pokriva samo 1 piksel na ekranu. Rasterizer kreira quad od 2×2 = 4 piksela. Pixel shader se pokrece za sva 4 piksela, ali samo 1 ce zapravo pisati u framebuffer. Iskoriscenost: 25%.
Sada zamislite mesh koji je toliko daleko od kamere da svaki trougao pokriva samo 1-2 piksela. Ako mesh ima 10.000 trouglova, GPU ce pokrenuti pixel shader za 40.000 piksela, ali ce korisno obraditi samo 10.000-20.000. Efektivno, trosimo 2-4x vise GPU resursa nego sto je potrebno.
Ali problem je zapravo jos gori od toga.
Svaki warp na GPU-u sadrzi 32 niti (piksela). Ako trougao pokriva samo par piksela, warp ce imati mnogo maskiranih (neaktivnih) niti. U ekstremnom slucaju, trougao koji pokriva 1 piksel ce zauzeti warp od 32 niti ali samo 1 nit ce raditi korisno -- iskoriscenost od 3.1%.
A setite se jos jedne stvari: za svaki takav trougao, GPU mora da izvrsi vertex shader na 3 verteksa, primitive assembly, rasterizaciju -- sav taj overhead za svega 1-2 piksela rezultata.
Ovo je eksplicitno razlog zasto Nanite postoji u Unreal Engine 5. Nanite-ov software rasterizer efikasno obradjuje sitne trouglove jer:
- Ne koristi quad-ove od 2×2 piksela
- Moze efikasno da rasterizuje trouglove manje od piksela
- Ima agresivan per-triangle culling
Za mesheve koji ne koriste Nanite, trebate se pobrinuti da trouglovi na tipicanom rastojanju posmatranja budu dovoljno veliki -- minimum 8-16 piksela po trouglu je pravilo palca. LOD sistem (Poglavlje 11) je vaš alat za ovo.
Zasto su texture fetch-ovi skupi
Svaki Texture.Sample() poziv u shader-u pokrece lanac dogadjaja:
- GPU izracunava UV adresu
- Salje zahtev za podatke iz VRAM-a (ili, sa srecom, cache-a)
- Ceka 400-800 ciklusa na odgovor (ako nije u cache-u)
- Prima podatke, primenjuje filtriranje
- Vraca rezultat
Jedan texture fetch nije problem -- GPU skriva latenciju prebacivanjem na druge warp-ove. Ali svaki dodatni fetch:
- Povecava zahtev za bandwidth-om
- Zahteva vise registara za cuvanje rezultata (smanjuje occupancy)
- Povecava pritisak na texture cache
Tipican Unreal Engine material moze imati:
- Base Color texture: 1 fetch
- Normal Map: 1 fetch
- Roughness/Metallic/AO (packed): 1 fetch
- Emissive: 1 fetch (opciono)
- Detail texture: 1 fetch (opciono)
- Ukupno: 3-5+ texture fetch-ova po pikselu
Svaki dodatni fetch iznad 4-5 pocinje da bude primetljivo skup. Ovo je razlog zasto se preporucuje:
- Pakovanje vise kanala u jednu teksturu (Channel Packing): Roughness u R, Metallic u G, AO u B kanalu jedne teksture -- jedan fetch umesto tri
- Koriscenje manjih tekstura gde je moguce (manje podataka = brzi fetch)
- Izbegavanje nepotrebnih tekstura (mozete li konstantnu boju umesto teksture?)
Vise o optimizaciji tekstura u Poglavlju 13 (Teksture i Streaming) i Poglavlju 14 (Materijali i Shader Optimizacija).
Zasto branching boli -- prakticni primeri
Objasnili smo branch divergence u teoriji, ali evo konkretnih Unreal Engine primera:
Primer 1: Materijal sa uslovnim slojevima
// SKUP materijal -- divergencija na granicama
if (HeightBlend > 0.5)
{
Fetch snow textures (3 fetcha)
Calculate snow lighting
}
else
{
Fetch rock textures (3 fetcha)
Calculate rock lighting
}
Na granicama izmedju snega i stene, pikseli u istom warp-u ce se razilaziti. GPU izvrsava obe grane. To je 6 texture fetch-ova umesto 3 -- dvostruka cena.
Primer 2: Switch na Material Instance parametar
// POTENCIJALNO SKUP materijal
switch (MaterialType)
{
case 0: MetalShading(); break;
case 1: FabricShading(); break;
case 2: SkinShading(); break;
case 3: HairShading(); break;
}
Ako razliciti delovi mesh-a koriste razlicite tipove materijala, divergencija je neizbezna. Bolje resenje je da svaki tip bude zaseban materijal na zasebnom mesh section-u (jer razliciti draw call-ovi ne dele warp-ove).
Primer 3: Proceduralni efekti sa uslovima
// Proceduralni materijal za teren
if (slope > 45_degrees)
ApplyCliffMaterial();
else if (height > 2000)
ApplySnowMaterial();
else if (moisture > 0.7)
ApplyGrassMaterial();
else
ApplyDirtMaterial();
Ovo je ugnjezdena divergencija. U najgorem slucaju (na preseku sva cetiri tipa terena), GPU izvrsava sve cetiri grane. Koristite lerp sa tezinskim mapama umesto if naredbi.
Fill Rate i Overdraw
Fill rate je brzina kojom GPU moze da "popuni" piksele na ekranu -- to jest, koliko piksela po sekundi moze da obradi kroz pixel shader i zapise u framebuffer.
Fill rate je ogranicen sa:
- Brojem SM-ova (pixel shader processing)
- Brojem ROP-ova (framebuffer write)
- Memory bandwidth-om (texture reads + framebuffer writes)
Overdraw -- kada isti piksel bude obradjivan vise puta -- direktno smanjuje efektivni fill rate. Ako imate overdraw od 3x, potrebno vam je 3x veci fill rate da biste odrzali isti framerate.
Najgori krivci za overdraw u Unreal Engine-u su:
- Transparentni objekti: Ne mogu da koriste depth buffer za eliminaciju prekrivih piksela (ne mogu da pisu u Z-buffer jer su prozirni)
- Particle sistemi: Mnogo overlapping quad-ova
- Vegetacija: Mnogo overlapping listova (leaf cards)
- Nesortirani opaque objekti: Ako se daleki objekti renderuju pre bliskih, daleki pikseli prolaze kroz ceo shader pa ih bliski pikseli prepisuju
Za (4), Unreal Engine koristi depth prepass i front-to-back sortiranje da minimizira overdraw za opaque objekte. Ali za (1), (2), i (3), overdraw je teze izbecci i zahteva pazljivu optimizaciju (Poglavlje 10 -- Overdraw i Fill Rate).
Shader Complexity i registarski pritisak
Kompleksnost shader-a utice na performanse na vise nacina:
- Direktno: Vise instrukcija = vise takt ciklusa za izvrsavanje
- Indirektno (registri): Kompleksniji shader koristi vise registara → manji occupancy → losije skrivanje latencije
- Indirektno (instruction cache): Duzi shader moze da ne stane u instruction cache SM-a, sto dovodi do dodatnog ucitavanja instrukcija iz memorije
U Unreal Engine Material Editor-u, postoji "Shader Complexity" vizualizacioni mod koji boja piksele prema broju instrukcija u shader-u. Ovo je koristan ali nepotpun alat -- ne uzima u obzir registarski pritisak niti memory bandwidth. Piksel moze biti "zeleni" (malo instrukcija) ali opet skup ako ima mnogo texture fetch-ova.
Za detaljniju analizu shader performansi, koristite GPU profajlere (RenderDoc, NVIDIA Nsight Graphics, AMD Radeon GPU Profiler). Vise o profilisanju u Poglavlju 18 (Profajliranje i Analiza).
Compute Shader Bottleneck-ovi
Compute shader-i (koje UE5 intenzivno koristi za Lumen, Nanite, VSM, post-processing) imaju svoje specificne bottleneck-ove:
-
Thread Group Size: Ako thread group ima manje niti od warp size-a (32 za NVIDIA), neke CUDA jezgra ce biti neiskoriscena. Thread group treba da bude umnozak warp size-a.
-
Shared Memory: Compute shader-i koji koriste puno shared memory smanjuju occupancy. Ali shared memory je cesto neophodan za efikasan rad (komunikacija izmedju niti).
-
Barrier Synchronization:
GroupMemoryBarrier()zahteva da sve niti u grupi stignu do iste tacke pre nego sto nastave. Ako neke niti zavrse ranije, one cekaju -- sto je cist gubitak. -
Atomske operacije:
InterlockedAdd(),InterlockedMax()i slicne atomske operacije su serializovane -- ako vise niti pokusa da pristupi istoj memorijskoj lokaciji, one se izvrsavaju jedna po jedna. Ovo moze dramaticno da uspori shader.
Wave/Warp Intrinsics -- napredne optimizacije
Moderna GPU-ovi i shader modeli (SM 6.0+) podrzavaju wave intrinsics -- operacije koje rade na nivou celog warp-a umesto pojedinacne niti. Na primer:
WaveActiveSum(): Sabira vrednosti svih niti u warp-u bez koriscenja shared memoryWaveReadLaneFirst(): Cita vrednost iz prve aktivne niti u warp-uWaveActiveBallot(): Vraca bitmask aktivnih niti
Ove operacije su mnogo brze od alternativa koje koriste shared memory i barrier-e. Unreal Engine 5 ih koristi interno, i napredni korisnici ih mogu koristiti u Custom HLSL node-ovima. Ali ovo je vec territory za iskusne graficke programere.
Async Compute u praksi
Kako async compute pomaze UE5 performansama?
Tipican rendering frame u UE5 izgleda otprilike ovako vremenski:
Bez async compute:
[Depth Prepass][G-Buffer][Lighting][Post-Process][UI]
GPU: ███████████████████████████████████████████████
(sve sekvencijalno)
Sa async compute:
Graphics: [Depth Prepass][G-Buffer][Lighting]........[Post-Process][UI]
Compute: .............[SSAO][Volumetric Fog][Lumen]..............
GPU: ███████████████████████████████████████████████
(compute popunjava "rupe" u graphics pipeline-u)
U idealnom slucaju, compute posao se izvrsava besplatno jer koristi SM-ove koji bi inace bili neiskorisceni tokom grafickog posla. U praksi, uvek postoji neko overhead jer compute i graphics dele memorijski bandwidth i L2 cache, ali korist je cesto znacajna.
Sumarni Pregled: Kljucni Termini
| Pojam | Znacenje |
|---|---|
| GPU | Graphics Processing Unit -- procesor specijalizovan za masovno paralelnu obradu, sadrzi hiljade jednostavnih jezgara |
| SIMD | Single Instruction Multiple Data -- ista instrukcija se primenjuje na vise podataka istovremeno |
| SIMT | Single Instruction Multiple Threads -- GPU varijanta SIMD-a gde svaka nit ima svoj kontekst ali se izvrsava u lockstep-u |
| Warp | Grupa od 32 niti (NVIDIA) koje se izvrsavaju istovremeno na istoj instrukciji |
| Wavefront | AMD-ov ekvivalent warp-a, tradicionalno 64 niti (32 na RDNA) |
| Branch Divergence | Situacija kada niti u istom warp-u idu razlicitim putevima grananja, primoravajuci GPU da izvrsava sve puteve sekvencijalno |
| Occupancy | Odnos aktivnih warp-ova prema maksimalno mogucem broju na jednom SM-u; veci occupancy = bolje skrivanje latencije |
| Latency Hiding | GPU-ova strategija prebacivanja izmedju warp-ova dok jedni cekaju na memoriju, kako bi se sakrila spora memorija |
| VRAM | Video RAM -- memorija na grafickoj kartici sa visokim bandwidth-om, gde se cuvaju teksture, buffer-i, framebuffer |
| Memory Bandwidth | Kolicina podataka koju GPU moze da procita/zapise iz/u VRAM po sekundi; cest bottleneck |
| SM (Streaming Multiprocessor) | NVIDIA-in naziv za osnovnu gradivnu jedinicu GPU-a koja sadrzi ALU-ove, registre, kes, texture unite |
| CU (Compute Unit) | AMD-ov ekvivalent SM-a |
| CUDA Core | Pojedinacna FP32 ALU jedinica na NVIDIA GPU-u; ne mesati sa CPU jezgrom |
| SFU | Special Function Unit -- specijalizovana jedinica za transcendentalne funkcije (sin, cos, sqrt, itd.) |
| TMU | Texture Mapping Unit -- specijalizovana jedinica za dohvatanje i filtriranje tekstura |
| ROP | Raster Operations Unit -- jedinica za finalno pisanje u framebuffer (depth test, blending) |
| Register File | Brza memorija na SM-u, podeljena izmedju svih aktivnih niti; vise registara po niti = manji occupancy |
| Shared Memory | Mali brzi memorijski prostor (48-128 KB) deljen izmedju niti na istom SM-u |
| Register Spilling | Kada shader koristi vise registara nego sto je dostupno, GPU preliva podatke u sporiju memoriju |
| Unified Shader Architecture | Dizajn gde isti fizicki hardver izvrsava sve tipove shader-a (vertex, pixel, compute, itd.) |
| Quad | Blok od 2x2 piksela -- minimalna jedinica rasterizacije; razlog zasto mali trouglovi imaju losiju efikasnost |
| Early-Z | Optimizacija gde GPU odbacuje piksele koji ce svakako biti prekriveni, pre pokretanja pixel shader-a |
| Async Compute | Mogucnost istovremenog izvrsavanja grafickih i compute operacija na istom GPU-u |
| Fill Rate | Brzina kojom GPU moze da obradi i zapise piksele u framebuffer |
| Overdraw | Visestruko renderovanje istog piksela zbog preklapanja objekata |
| Tensor Core | Specijalizovana jedinica za matricno mnozenje (AI/ML, DLSS) |
| RT Core | Specijalizovana jedinica za hardverski ray tracing (BVH traversal, ray-triangle intersection) |
Povezivanje sa ostalim poglavljima
Znanje iz ovog poglavlja je temelj za razumevanje svega sto sledi:
- Poglavlje 09 (Rendering Pipeline): Logicki pipeline koji smo ovde pomenuli -- detaljno objasnjen, sa UE5 specificnostima
- Poglavlje 10 (Overdraw i Fill Rate): Direktna primena znanja o fill rate-u, bandwidth-u i pixel shader bottleneck-ovima
- Poglavlje 11 (LOD sistem): Zasto LOD postoji -- direktno povezano sa problemom malih trouglova
- Poglavlje 12 (CPU Optimizacija): Druga strana medalje -- sta radi CPU dok GPU renderuje
- Poglavlje 13 (Teksture i Streaming): Kako teksture uticu na VRAM i bandwidth
- Poglavlje 14 (Materijali i Shader Optimizacija): Direktna primena znanja o registrima, occupancy-ju, divergenciji
- Poglavlje 17 (Upscaling Tehnologije): Kako Tensor Core-ovi i DLSS/FSR smanjuju pritisak na pixel shader-e
- Poglavlje 18 (Profajliranje i Analiza): Kako meriti sve ove metrike u praksi
Dalje citanje:
- "A trip through the Graphics Pipeline" -- Fabian Giesen (2011, ali i dalje relevantan, detaljno objasnjava svaki korak GPU pipeline-a)
- "GPU Gems" serija -- NVIDIA (besplatno dostupna online na developer.nvidia.com, prakticni primeri GPU programiranja)
- "Real-Time Rendering, 4th Edition" -- Akenine-Moller, Haines, Hoffman (sveobuhvatna knjiga o real-time rendering tehnikama)
- "Life of a Triangle" -- NVIDIA (blog post koji objasnjava put jednog trougla kroz GPU pipeline)
- "RDNA Architecture" -- AMD (white paper o AMD GPU arhitekturi)
- "GPU Architecture and Shader Programming" -- GDC prezentacije (dostupne na GDC Vault-u, godisnje azurirane)
- "Nsight Graphics Documentation" -- NVIDIA (prakticno znanje o GPU profilisanju)
- "Unreal Engine 5 Rendering Internals" -- Epic Games dokumentacija i Unreal Fest prezentacije