Wat gebeurt er wanneer een GPU naar het geheugen schrijft
De warp was onderdeel van een kernel die een vector-optelling uitvoerde. Zodra de kernel de elementen van beide vectoren had geladen, werden deze bij elkaar opgeteld en werd het resultaat opgeslagen.
/*00c0*/ IMAD.WIDE R6, R6, R7, c[0x0][0x170] ; // &c[i]
/*00d0*/ FADD R9, R4, R3 ; // a[i] + b[i]
/*00e0*/ STG.E [R6.64], R9 ; // c[i] = ...
/*00f0*/ EXIT ;
STG.E is de instructie die verantwoordelijk is voor het terugschrijven van de berekende som naar het globale geheugen. In dit artikel volgen we STG.E door dezelfde tussenstations om te achterhalen wat er bij elke stap gebeurt. Aangezien deze informatie niet volledig openbaar is, zijn er nieuwe experimenten uitgevoerd om dit in kaart te brengen.
De situatie: De LDG.E is teruggekeerd naar de warp, de FADD heeft de inhoud van R4 en R3 opgeteld in R9, en nu moet de inhoud van dat register worden opgeslagen. De warp is binnen zijn subpartitie in aanmerking gekomen voor uitvoering en de lanes beginnen de STG.E uit te voeren.
Het verlaten van de warp
STG.E [R6.64], R9 is een globale store van de 32 bits in register R9 naar het 64-bit adres in R6 en R7. Waar we bij LDG.E twee rijen van het registerbestand lazen, moeten we bij STG.E drie rijen lezen: de twee die samen het adres vormen waar de data moet worden opgeslagen, en de data zelf.
De instructie wordt vervolgens doorgegeven aan de load/store unit (LSU). De LSU verzendt de opcode ('store naar deze adressen'), het 32-bit masker van actieve lanes en de 32 berekende adressen.
Hoe vaak kan de SM stores uitgeven?
Eén warp kan ongeveer elke 6,1 cycli (2,3 ns bij 2,6 GHz) een nieuwe STG.E-instructie door het register/LSU/coalescer/L1-traject duwen, ongeacht voor hoeveel lanes dit wordt gedaan.
De uitgang van de SM kan 32 bytes per cyclus aan opgeslagen (of geladen) data verwerken. Als alle warps instructies uitgeven, ontstaat hier een bottleneck [1].
Het volgende station is de coalescer. De taak hiervan is om 32 toegangen van vier bytes om te zetten in het kleinste aantal mogelijke sectoren van 32 bytes. Onze kernel schrijft 128 opeenvolgende bytes, wat neerkomt op vier sectoren, of één lijn [2].
Bij loads wordt altijd de volledige 32 bytes per sector opgehaald, waarna de resultaten in de LSU worden gefilterd zodat alleen wat de SASS daadwerkelijk vroeg in de registers wordt geschreven. Bij stores wordt elk sectorverzoek verzonden met een byte mask, dat aangeeft welke bytes van de sector door deze instructie worden beschreven.
Vier sectoren, vier maskers en 128 bytes aan data gaan vervolgens naar de L1.
Door L1 gaan
De vier sectoren bereiken de L1-cache. De L1-cache is per SM 4-way set-associatief, virtueel geïndexeerd en virtueel getagd. Ongeacht of de lijn aanwezig is of niet, de sectoren, hun maskers en de data gaan direct door naar de L2: de L1 is write-through [3].
Als onze store ruimte nodig heeft in zijn set, maken oude slots plaats in strikte LRU-volgorde (Least Recently Used). Onder de L1 wordt het virtuele adres van de store vertaald, waarna de vier sectoren via de crossbar naar de L2-slice gaan die de lijn beheert.
Aankomst bij L2
Het verzoek wordt via de crossbar verzonden naar een van de 36 L2-slices. De slice wordt gekozen op basis van dezelfde functie van het fysieke adres die eerder is gereverse-engineered. Het verzoek bevat het adres van de lijn, maximaal vier sectoren aan data en de maskers van die sectoren. Stores verzenden één verzoek per lijn [4].
Binnen een slice is elke cache 16-way set-associatief, met 1024 sets, gehasht op basis van het fysieke adres.
- Bij een hit: Als een lijn al aanwezig is bij het opzoeken, worden de bytes die door elk sectormasker zijn geselecteerd in het slot geschreven en worden de sectoren als dirty gemarkeerd.
- Bij een miss: Als de lijn niet aanwezig is, moet de slice een slot vinden. Dit kan betekenen dat bytes van andere lijnen naar het DRAM moeten worden verplaatst (eviction). Zodra er een plek is gevonden, worden de bytes in het slot geschreven en legt het masker vast welke bytes van de sector geldig zijn.
Zodra de bytes in het slot staan, stuurt de slice een bevestiging (acknowledgement) via de crossbar terug naar de SM. Na deze bevestiging bevinden de vier sectoren zich in hun slot, gemarkeerd als dirty onder hun maskers.
De schrijfactie voltooien
De bevestiging komt via de crossbar terug bij de SM die de store heeft verzonden. De warp die de store initieerde is inmiddels allang verdergegaan; in dit specifieke geval is de gehele kernel zelfs al beëindigd. De bevestiging bereikt de LSU en wordt daar verwerkt.
Voor deze specifieke kernel bereikt de data in feite nooit het DRAM. De kernel uit het oorspronkelijke bericht leest deze geschreven resultaten terug met een cudaMemcpyDeviceToHost, waardoor ze direct van de L2 via de PCIe-bus naar het DRAM van de host gaan.
De hardware houdt onze data nu dirty in de L2. We hebben de STG.E-instructie voltooid. Om dit betekenis te geven, moet elke pointer naar onze data vanuit een volgende kernel de data daar kunnen vinden. Maar omdat de data in een cache staat, heeft het nog niet het DRAM bereikt. Hoe en wanneer gebeurt dit?
Het 'hiernamaals' van een store-instructie
De L2 dient als het serialisatiepunt voor al het verkeer op de chip, maar op een gegeven moment raakt de ruimte op en moet er iets worden verwijderd (evicted). Onze data bevindt zich in de L2. Een volgende kernel kan data lezen of schrijven; deze kan onze lijnen nodig hebben of andere lijnen. Terwijl die kernel draait, moet onze data zijn weg naar het DRAM vinden.
Hoe data bij het DRAM terechtkomt
Elke lijn in onze data wordt opgeslagen met een 2-bit 're-reference prediction value' (RRPV) [5], ingesteld op 000, 111 of 222. Lagere getallen betekenen dat de cache denkt dat een lijn snel weer gebruikt zal worden. Onze cachelijnen staan op 111, omdat ze net zijn geplaatst.
Elke lijn heeft een 'dirty mask', dat aangeeft of de L2 de enige plek is waar deze versie van de lijn bestaat — onze lijnen zijn volledig dirty. Daarnaast heeft elke lijn tellers voor het laatste gebruik en de laatste store.
Wanneer er een nieuwe load of store binnenkomt voor een lijn, gebeurt het volgende:
- Bij een resident hit: Wanneer een load een van onze lijnen zoekt, wordt deze gevonden en wordt de RRPV van de lijn ingesteld op 000. Als een store de lijn raakt, worden de sectoren bijgewerkt en wordt het dirty mask aangepast.
- Bij een miss: De L2 moet de ontbrekende waarde in de cache halen. Hiervoor moet een 'slachtoffer' (victim) worden gevonden.
Het vervangingsbeleid (replacement policy) werkt als volgt:
- Er wordt gescand door alle 16 ways in de set naar een lijn met RRPV=2 (die de cache denkt niet meer nodig te hebben). Als er geen worden gevonden, worden alle RRPV-waarden verhoogd en wordt er opnieuw gescand. Van alle RRPV=2-waarden wordt de least recently used gekozen.
- Er wordt bepaald wat er met het slachtoffer gebeurt. Als het slachtoffer dirty is, kan het niet direct worden verwijderd zonder met het DRAM te communiceren. In plaats daarvan wordt het schoonmaakproces gestart door de dirty sectoren over te dragen aan de geheugencontroller voor schrijfacties naar het DRAM. De lijn gaat naar de FIFO write-back buffer en blijft leesbaar. Vervolgens wordt er opnieuw gescand naar een clean slachtoffer. Zodra er een is gevonden, wordt deze definitief verwijderd om plaats te maken. De nieuwe lijn wordt in dat slot geplaatst met een RRPV van 1.
Sets schoonhouden
Het bovenstaande beleid regelt evicties om ruimte te maken voor nieuwe data. Maar als we constant dirty resident lijnen raken, is er geen manier om ze terug te schrijven.
Bij een store naar een set die $\geq 8$ dirty lijnen bevat (van de 16), wordt vóór het toepassen van het bovenstaande beleid eerst de least-recently-stored dirty lijn in de set gezocht en schoongemaakt: de dirty sectoren worden naar de geheugencontroller gestuurd en de lijn wordt als clean gemarkeerd.
Zonder deze extra regel zou een set vol dirty lijnen bij een miss kunnen leiden tot een situatie waarin alle 16 lijnen tegelijkertijd naar de write-back buffer gaan. Dit zou resulteren in een enorme burst van DRAM-verkeer, waardoor de set tijdelijk geen capaciteit heeft voor nieuwe misses en de geheugencontroller overbelast raakt. De '8-dirty regel' werkt als een conciërge die proactief schoonmaakt, zodat er altijd een clean slachtoffer te vinden is en de verkeersstroom naar het DRAM soepeler verloopt.
Terugschrijven (Write-back)
Deze schrijfacties kunnen asynchroon verlopen ten opzichte van inkomende stores naar de L2, totdat de backlog van de geheugencontroller vol is, waarna nieuwe stores moeten wachten [6].
Wanneer een schrijfactie wordt uitgevoerd, gaan de sectoren naar de geheugencontroller en van daaruit naar de DRAM-chip. De controller activeert de rij en voert per sector een schrijfactie uit van 32 bytes via dezelfde 16 pinnen, maar in de tegenovergestelde richting. Met behulp van het byte mask worden alleen de geschreven bytes daadwerkelijk opgeslagen [7].
Conclusie
Een LDG.E duurde 255 ns, waarbij de warp de hele tijd moest wachten. Een STG.E duurt slechts 6 cycli; net genoeg om de schrijfactie in gang te zetten.
Stores hebben een lang 'hiernamaals' terwijl ze door de eenheden bewegen, waarbij de warp onwetend is tenzij deze de data direct terug wil lezen. Ze passeren de L1, gaan naar de L2 (waar ze een bevestiging terugsturen naar de SM) en blijven daar dirty staan. Terwijl nieuwe stores en loads binnenkomen, worden ze langzaam richting de uitgang geduwd. Uiteindelijk worden ze als slachtoffer gekozen bij een miss en via de geheugencontroller naar het DRAM gestuurd, lang nadat de warp die ze schreef is verdwenen.
---
Bijlage: Zichtbaarheid (Visibility)
Wanneer wordt de schrijfactie zichtbaar?
Onze STG.E was fire-and-forget: de warp voerde de instructie uit en het programma beëindigde direct. Dit is nuttig omdat een kernel die stores uitvoert, kan vooruitlopen en ander werk kan doen terwijl de store wordt voltooid. Echter, omdat data niet onmiddellijk duurzaam wordt geschreven, zijn fences nodig om te weten wanneer een schrijfactie is voltooid.
Een fence is een instructie die de warp vasthoudt totdat elke uitgevoerde store zichtbaar is. Er zijn drie scopes:
membar.cta: wacht tot de stores zichtbaar zijn voor het eigen blok van de warp.membar.gl: wacht tot de stores zichtbaar zijn voor elke SM op de chip.membar.sys: wacht tot de stores zichtbaar zijn voor de host en andere apparaten [8].
Naar het eigen blok: rendezvous bij L1
Een membar.cta wacht tot de stores zichtbaar zijn in de gedeelde L1. Omdat de L1 het coherentiepunt is voor al het verkeer in en uit de SM, is de store zodra deze succesvol is overgedragen aan de L1 per definitie zichtbaar. Dit voegt ongeveer 1 ns of 3 cycli toe aan een STG.E.
Naar alle SM's: rendezvous bij L2
membar.gl wacht tot de store zichtbaar is voor elke SM op de chip. Omdat al het verkeer via de L2 stroomt, is de write globaal zichtbaar zodra deze in de L2 is geland en de bevestiging (ack) is ontvangen. membar.gl voltooid in ongeveer 140 ns, dezelfde tijd als de L2.
Dit is in principe globaal zichtbaar; om de data op te halen, moet de lezer deze uit de L2 halen (de eigen L1 kan verouderde lijnen bevatten). Dit moet bewust gebeuren via instructies zoals LDG.E.STRONG.GPU (die de L1 omzeilt) of ld.acquire.gpu (die de L1 van de SM ongeldig maakt via CCTL.IVALL).
Naar de hele wereld: membar.sys
membar.sys ordent de store ten opzichte van de rest van de wereld: peers over NVLink, de host over PCIe of NVLink-C2C, etc. Dit is complexer om te beantwoorden en duurt langer; een membar.sys neemt ongeveer 1 $\mu$s in beslag.
Waar wachtte het lezen van onze store op?
De PTX van onze kernel bevatte geen fence: hij schreef de data en beëindigde. De benodigde semantiek is echter gelijk aan die van membar.sys: de cudaMemcpyDeviceToHost moet wachten tot de data zichtbaar is voor het hele systeem. In de praktijk plant de driver een analoge system-scope membar aan het einde van de kernel [9]. Zodra die membar is opgelost, wordt de data zichtbaar voor de host en kan deze worden teruggestuurd naar het scherm.
---
Voetnoten
[1] Timingen zijn gebaseerd op %globaltimer rond een loop van zestien (unrolled) stores naar L2-resident lijnen. De plateauwaarde is 84 GB/s, of 32 bytes per cyclus.
[2] ncu rapporteert het aantal sectoren per store-verzoek via l1textsectorspipelsumemglobalopst.sum tegenover l1textrequestspipelsumemglobalopst.sum.
[3] Er zijn drie opties voor stores bij een miss in L1: write-around, write-through of write-back. Experimenten tonen aan dat de GPU write-through gebruikt met allocate & update.
[4] Bij een sweep van 512 MB telt ncu 4.194.304 write-verzoeken die bij de L2 aankomen vanuit de SM's voor 16.777.216 sectoren (vier per verzoek).
[5] De RRPV state machine is bepaald door sets te vullen met bekende lijnen en te meten hoeveel interfererende fills er nodig zijn voordat de oorspronkelijke lijn een miss veroorzaakt.
[6] De stall wordt gezien bij de LSU input queue, maar waarschijnlijk wordt dit getriggerd door een backup bij de geheugencontroller die verzadigd raakt op de 55 GB/s die het DRAM van elke controller kan ondersteunen.
[7] De GDDR6X van de 4090 past het byte mask direct toe. HBM met ECC kan dit niet direct omdat het in codewords werkt; dit gebeurt via read-merge-write back.
[8] Fence-kosten zijn gemeten door N stores in een loop te plaatsen, omringd door %globaltimer, en dit te vergelijken met N stores gescheiden door fences.
[9] CWDMEMBARTYPE = L1_SYSMEMBAR kan worden uitgelezen uit de pushbuffer. De wachttijd is ongeveer 0,74 $\mu$s overhead aan het einde van een grid, mits er system-scope verkeer is geweest.
Groetjes,