Was intern passiert, wenn man einen CUDA-Kernel ausführt
(fergusfinn.com)- Selbst ein einfaches CUDA-Programm zur Vektoraddition durchläuft bis zum Ergebnis
2.000000eine Compiler-Pipeline, Treiberaufrufe, eine GPU-Befehlswarteschlange, Warp-Scheduling, Speicherhierarchien und ein Completion-Semaphore nvcctrennt Host-Code und Device-Code, erzeugt mitciccPTX, mitptxasSASS, bündelt cubin und PTX in einem fatbin und legt dieses in die Linux-Executable- Die Launch-Syntax
vadd<<<4096, 256>>>wird in einen Host-Launch-Stub umgewandelt; die Argumenteda,db,dc,nwerden über die CUDA-Runtime undlibcuda.so.1an den Treiber übergeben - Die GPU-Ausführung beginnt mit QMD, pushbuffer, GPFIFO,
GP_PUTund einem doorbell-MMIO-Schreibzugriff; die 128 SMs der RTX 4090 führen die Konfiguration aus 4096 Blöcken und 256 Threads pro Block auf Warp-Ebene aus - Wegen der niedrigen arithmetischen Intensität dieses Kernels, der pro float-Addition 12 Byte übertragen muss, ist er in Nsight Compute mit 10,78 μs, 79,65 % des DRAM-Peaks und 5,17 % Warp-Issue von der Speicherbandbreite abhängig
Beispiel-Kernel und Beobachtungsumfang
- Das Beispielprogramm addiert mit dem CUDA-Kernel
vaddzwei float-Arrays und speichert das Ergebnis in einem dritten Array- Mit
n = 1 << 20werden 1.048.576 floats verarbeitet - Die Launch-Konfiguration lautet
vadd<<<4096, 256>>>(da, db, dc, n)und verwendet4096 * 256 = nThreads
- Mit
- Wenn man für die RTX 4090 mit
nvcc -arch=sm_89kompiliert und ausführt, wirdc[0]=2.000000 c[n-1]=2.000000ausgegeben - Selbst an dieser einen Ergebniszeile sind zig Millionen CPU-Instruktionen, device files, etwa 900
ioctls und speichergemappte doorbell-Register beteiligt
Wie nvcc die ausführbare Datei erzeugt
- Mit
nvcc --keepkann man die Artefakte der Compiler-Pipeline direkt ansehenvadd.ptx: PTX des vonciccerzeugten Device-Codesvadd.sm_89.cubin: SASS des vonptxaserzeugten Device-Codesvadd.fatbin: ein fatbin, das cubin und PTX bündeltvadd.cudafe1.stub.c: Host-Launch-Stub und Kernel-Registrierungscodevadd.o: finales Host-Objekt inklusive fatbin
- Der Host-Code wird vom Host-Compiler verarbeitet, während der Device-Kernel
vadddie Schritteciccundptxasdurchläuft - PTX ist eine virtuelle ISA, nutzt typisierte, unendlich viele virtuelle Register und spiegelt die tatsächliche Anzahl der Hardware-Register nicht direkt wider
- Das Beispiel-PTX enthält die Berechnung
blockIdx.x * blockDim.x + threadIdx.x, Grenzprüfung, global load, float add und global store - CUDA-Pointer sind standardmäßig generic pointer; daher werden sie mit
cvta.to.globalin global addresses umgewandelt, bevorld.globalverwendet wird mul.wide.s32wandelt den Index in einen Offset in Einheiten von 4 Byte, alsosizeof(float), um und erweitert von 32 auf 64 Bit
- Das Beispiel-PTX enthält die Berechnung
- SASS sind architekturspezifische echte Instruktionen und erscheinen in der Ausgabe für die RTX 4090 kompakter als PTX
S2Rkopiert Spezialregister wieSR_CTAID.XundSR_TID.Xin allgemeine Register- Die Kombination aus
mul.wideundaddin PTX wird in SASS zuIMAD.WIDEzusammengeführt - Die
cvta-Umwandlung geht im Adressierungsprozess auf
- Der Operand
c[0x0][...]verweist auf die vom Treiber verwaltete constant bank 0- Die Pointer
a,b,cliegen bei0x160,0x168,0x170 nliegt bei0x178- Auch Launch-Geometrie und ABI-Werte wie
blockDim.xbefinden sich in derselben bank
- Die Pointer
- cubin ist eine ELF-Datei, also dasselbe Containerformat wie eine Linux-Executable
- Die fatbinary bündelt cubin und PTX gemeinsam
- Auf dieser RTX 4090 wird tatsächlich SASS ausgeführt, PTX ist aber als Fallback enthalten, den der Treiber auf anderen Architekturen per JIT kompilieren kann
- Da PTX ausführlicher Klartext ist, komprimiert
nvcces standardmäßig
Wie Host-Code den Launch vorbereitet
- Das Compiler-Frontend
cudafe++fügt einen versteckten Constructor ein, der vormainausgeführt wird- Dieser Constructor registriert die eingebettete fatbinary bei der CUDA-Runtime
- Er verknüpft den Host-seitigen Funktionspointer
vaddmit dem gemangelten Device-Kernel-Namen im fatbin
- Die Syntax
vadd<<<4096, 256>>>(da, db, dc, n)wird in den generierten Host-Launch-Stub umgewandeltda,db,dc,nwerden jeweils an den Offsets0,8,16,24ausgerichtet in einem Argumentpuffer im Host-Speicher abgelegt- Diese Offsets entsprechen den Positionen
0x160,0x168,0x170,0x178, die SASS aus constant bank 0 liest
- Der Stub ruft
__cudaLaunchauf und übergibt dabei die Adresse der Host-seitigen Dummy-Funktionvadd- Diese Adresse ist keine auf der CPU auszuführende Funktionsadresse, sondern dient als Schlüssel für die Lookup-Tabelle der Runtime
- Die Runtime findet den entsprechenden Device-Symbolnamen und übergibt dann an den closed-source User-Mode-Treiber
libcuda.so.1
- Beim ersten GPU-Aufruf öffnet die CUDA-Runtime dynamisch
libcuda.so.1und erzeugt einen Context- In
straceist zu sehen, dass/lib/x86_64-linux-gnu/libcuda.so.1geöffnet wird - Der Context enthält einen channel, über den die CPU mit der GPU kommuniziert
- In
- Seit CUDA 12.2 ist module loading standardmäßig lazy
- Das Hochladen des SASS-cubin wird bis zum ersten Launch eines bestimmten Kernels aufgeschoben
- Es lässt sich über
CUDA_MODULE_LOADINGsteuern
Die Befehlswarteschlange, die Arbeit an die GPU übergibt
- Eine GPU nimmt keinen Funktionsaufruf wie eine CPU entgegen und springt nicht zu einem Entry Point
- Sie liest über den PCIe-Bus hinweg einen Treiber-Command-Stream im Host-Speicher
cuLaunchKernellegt den fertigen Launch-Befehl in diesen Stream und benachrichtigt die GPU
- Bei der ersten Ausführung kopiert der Treiber das Kernel-SASS in den GPU-Speicher
- Er allokiert einen Code-Buffer und kopiert SASS hinein
- Im channel gibt es zwei zentrale Strukturen im Host-RAM
- pushbuffer: Speicherbereich, in den der Treiber methods schreibt, also GPU-Befehle
- GPFIFO: ein Pointer-Ringbuffer, der auf pushbuffer-Spans zeigt
- Ein GPFIFO-Eintrag besteht aus zwei 32-Bit-Wörtern, die
(base, length)eines pushbuffer-Spans angeben - GPU und Treiber verfolgen mit zwei Cursors, wo Arbeit konsumiert und produziert wurde
GP_GET: zeigt an, bis wohin die GPU konsumiert hatGP_PUT: zeigt an, bis wohin der Treiber produziert hat- Beide liegen in einer per-channel-Struktur namens USERD
- Beim Kernel-Launch schreibt der Treiber methods in einen pushbuffer-Span, lässt einen GPFIFO-Eintrag darauf zeigen und schiebt
GP_PUTvor - Auf modernen GPUs überwacht die host engine den Cursor nicht kontinuierlich, daher ist eine doorbell nötig
- Die GPU mappt ein kleines Registerfenster in den Prozess
- Der Treiber schreibt das Work-Submit-Token des channels in das doorbell-Register
- Nach dem Empfang der doorbell liest die host engine
GP_PUTund holt GPFIFO-Eintrag und pushbuffer-Span per DMA
Welche Ausführungsinformationen QMD enthält
- Der Launch beginnt mit einem method burst aus
SET_INLINE_QMD_ADDRESS_A/BundLOAD_INLINE_QMD_DATA - QMD (Queue Meta Data) ist der Launch-Descriptor des Compute-Grids
- Enthält die Grid- und Blockgrößen
4096,256 - Enthält die Registerzahl pro Thread und den Bedarf an shared memory
- Enthält die Programmstartadresse und die Adresse der constant bank mit den Kernel-Argumenten
- Enthält auch die Position, an die Completion gemeldet wird
- Enthält die Grid- und Blockgrößen
- Die vom Host-Stub gepackten Argumente kopiert der Treiber in die constant bank, und die Adresse dieser bank wird im QMD eingetragen
- QMD teilt der GPU mit, wo SASS liegt, wie das parallele Programm aufgebaut ist und wo das Completion-Signal abgelegt werden soll
cuLaunchKernelkehrt in dem Moment zurück, in dem die doorbell ausgelöst wird- Der Aufruf ist asynchron, die CPU kann also weiterlaufen, während die GPU-Arbeit stattfindet
SM, Warp und Occupancy
- Die host engine übergibt QMD an den compute work distributor
- Diese Komponente existiert einmal pro GPU
- Sie verteilt den linearen SASS-Instruktionsstrom auf die SMs und lässt ihn als paralleles Programm ausführen
- Die Ziel-GPU GeForce RTX 4090 verwendet 128 SMs
- Der Launch besteht aus 4096 Blöcken und 256 Threads pro Block
- Jeder SM hat einen lokalen Instruction Cache, und aktive Warps halten einen Program Counter
- Seit Volta gibt es ein Independent-Thread-Scheduling-Modell mit Program Counter und Call Stack pro Thread
- Das Issue erfolgt weiterhin auf Warp-Ebene
- Im Beispiel-Kernel bestimmt das Resource Limit die Block Residency
256 threads = 8 warpspro Blockptxasreserviert 16 Register pro Thread- Nach Registern wären 16 Blöcke pro SM möglich
- Die Thread-Kapazität beträgt 1.536 aktive Threads pro SM, also sind nur
1536 / 256 = 6Blöcke möglich - Daher können maximal 6 Blöcke pro SM, also 48 Warps, resident sein
- Ein SM ist in 4 processing blocks, also sub-partitions, unterteilt
- Die 48 resident Warps werden gleichmäßig auf 4 sub-partitions verteilt
- Jeder Warp-Scheduler verwaltet im vollen Zustand 12 aktive Warps
- In jedem Cycle wählt er einen eligible Warp aus und dispatched die nächste Instruktion an 32 Lanes
Bedingungen, unter denen ein Warp eligible wird
- Eine GPU extrahiert dynamische Abhängigkeiten aus einem einzelnen Thread nicht in großem Umfang wie bei der Out-of-Order-Ausführung einer CPU
- Sie hält viele resident Warps bereit und wechselt bei Stalls zu einem anderen Warp, um Latenz zu verbergen
- Der Compiler plant vorhersehbares Timing, und die Hardware-Scoreboard behandelt schwer vorhersehbare Teile
- Eine 128-Bit-SASS-Instruktion enthält eine von
ptxasgeschriebene control-code payload- Fixed-Latency-Instruktionen enthalten eine statische Stall-Zahl
- Ein Yield-Hint teilt mit, ob Scheduler-Priorität abgegeben werden soll
- Operationen mit variabler Latenz verwenden sechs per-warp physical scoreboard barriers
- Im Beispiel-SASS-Abschnitt setzen die beiden
LDG.Edieselbe Scoreboard-BarrierB2FADDhatB2als wait-on- Bis beide Loads zurückkommen und die Barrier gelöscht ist, ist der betreffende Warp ineligible
- Der Scheduler wählt in der Zwischenzeit andere Warps derselben sub-partition aus
- Der Übergang von
FADDzuSTG.Ewird als Fixed Latency behandeltFADDhatstall=5und parkt den Warp einige Cycles, bis das Ergebnis inR9bereitsteht- Eine separate Barrier ist nicht nötig
- Diese control payload ist in der Standardausgabe von
nvdisasmverborgen- In der rohen 128-Bit-Codierung von
cuobjdump -sasssteckt sie im zweiten 64-Bit-Wort - Das Layout ist nicht dokumentiert, sondern wurde durch Microbenchmarking rekonstruiert
- In der rohen 128-Bit-Codierung von
Speicherzugriffe und Performance-Messung
- Wenn ein Warp
LDG.Eausführt, berechnen alle 32 Threads jeweils ihre Adresse- Im Beispiel handelt es sich um Zugriffe auf aufeinanderfolgende float-Arrays, sodass der gesamte Warp einen zusammenhängenden Block von
32 * 4 = 128 bytesanfordert
- Im Beispiel handelt es sich um Zugriffe auf aufeinanderfolgende float-Arrays, sodass der gesamte Warp einen zusammenhängenden Block von
- Die Load/Store-Unit des SM führt request coalescing aus
- Sie fasst die 32 Anforderungen à 4 Byte zu vier 32-Byte-Sector-Requests zusammen
- Bei nicht aufeinanderfolgenden Zugriffen könnte mehr Datenvolumen gelesen werden als nötig
- Eine coalesced request prüft zuerst den lokalen L1 Data Cache des SM
- Bei einem Miss geht sie über das Crossbar-Interconnect zu einem Slice des 72 MB großen L2 Cache
- Bei einem weiteren Miss geht sie über Memory Controller und Memory Bus in den GDDR6X-VRAM
- Auch ein
STG.E-Store folgt im Prinzip demselben Pfad in umgekehrter Richtung - Die Messwerte aus Nsight Compute zeigen, dass dieser Kernel memory-bound ist
launch__grid_size: 4.096launch__block_size: 256launch__registers_per_thread: 16launch__waves_per_multiprocessor: 5,33sm__warps_active.avg.pct_of_peak: 82,77 %smsp__issue_active.avg.pct_of_peak: 5,17 %dram__throughput.avg.pct_of_peak: 79,65 %gpu__time_duration.sum: 10,78 μs
- Der Kernel hat eine sehr niedrige arithmetische Intensität
- Er führt eine float-Addition pro zwei 4-Byte-Loads und einem 4-Byte-Store aus, also pro insgesamt 12 Byte Übertragung
- Auf DRAM-Read-Seite liest er 8,4 MB in 10,78 μs, etwa 780 GB/s und damit rund 4/5 des Peaks
- Die 4-MB-Ausgabe
cpasst in den 72-MB-L2 und wird daher erst dann in den DRAM geflusht, wenn der Device-to-Host-Copy sie liest
Wie das Ergebnis zur CPU zurückkommt
- Da der Kernel-Launch in dem Moment zur CPU zurückkehrt, in dem die doorbell ausgelöst wurde, muss die GPU den Abschluss separat melden
- Wenn alle 4096 Blöcke retired sind, postet die GPU das im QMD enthaltene Completion-Semaphore
- Das fence field des QMD liegt in den Wörtern 23–24
- Im default stream liegt
cudaMemcpy(c, dc, ...)hinter dem Kernel- Die GPU-copy engine bleibt gated, bis das Semaphore gesetzt ist
- Da
cim 72-MB-L2 noch dirty ist, wird der Lesezugriff der copy engine aus dem L2 bedient, ohne Roundtrip zum DRAM - Die Daten werden über PCIe in den Host-Speicher übertragen
- Nach Abschluss des Kopiervorgangs postet die copy engine ihr eigenes Semaphore
- Das Warten von
cudaMemcpyauf dem Host endet cist wieder normaler Host-Speicherprintfliestc[0]undc[n-1]aus dem RAM und gibt sie auf stdout aus
- Das Warten von
Wie man in den Launch hineinblickt
- Allein das Lesen der open kernel modules reicht nicht aus, um manche Abläufe direkt zu prüfen, weil
libcudaclosed-source ist - Method writes laufen ohne Syscall ab und schreiben direkt in einen bereits gemappten write-combined Buffer; um den pushbuffer zu sehen, muss man also Speicher auslesen
- Mit einem
LD_PRELOAD-Shim kann manmmapumhüllen und Bereiche aufzeichnen, die von/dev/nvidia*gemappt wurden- Wenn ein Testprogramm direkt nach dem Launch die Dump-Funktion des Shims aufruft, kann es den gemappten pushbuffer ausgeben
- Der Dump sucht den method burst, der
SET_INLINE_QMD_ADDRESS_Aentspricht
- Ein pushbuffer-method-header enthält Opcode, Payload Count, Subchannel Index und Register Offset als Bitfelder
0x0318istSET_INLINE_QMD_ADDRESS_A0x0320 + i * 4istLOAD_INLINE_QMD_DATA(i)- Im Dump ist ein increasing-method burst mit Count 66 zu sehen; er enthält zwei Address-Wörter und 64 QMD-Wörter, also insgesamt ein 256-Byte-QMD inline
- Wort 12 im QMD ist
0x1000, Wort 18 ist0x100; sie entsprechen den Launch-Werten 4096 und 256
- Das Driver Setup läuft über
ioctl- Bei einem One-Kernel-Programm protokolliert
strace948ioctls - Die meisten davon sind einmaliges Setup
- Die wichtigsten File Descriptors sind
/dev/nvidiactlund/dev/nvidia-uvm - Das ioctl-Magic-Byte des NVIDIA Resource Managers ist
0x46, also'F' - Command Number
0x2Awird alsNV_ESC_RM_CONTROL,0x2BalsNV_ESC_RM_ALLOCinterpretiert
- Bei einem One-Kernel-Programm protokolliert
- In der von
nvcc --keeperzeugten Dateivadd.cudafe1.stub.csieht man auch den Startup-Registrierungscode- Eine Funktion mit
__attribute__((__constructor__))wird vormainausgeführt - Über
__cudaRegisterBinaryund__cudaRegisterEntrywerden der Host-Funktionspointervaddund der Device-Entry-Point_Z4vaddPKfS0_Pfiverknüpft
- Eine Funktion mit
1 Kommentare
Hacker-News-Kommentare
Ein interessanter Artikel, und auch die Erklärung des Semaphors des Default-Streams war spannend
Es ist gut, dass CUDA die Befehlssynchronisation implizit übernimmt und parallele Befehle optional über Streams nutzbar macht
Das steht im Kontrast zu Vulkan, das die gesamte Komplexität der Synchronisation von Anfang an dem Nutzer überlässt
Auf der Hardware-Seite gibt es einige öffentlich zugängliche Dokumente
Man muss nicht zwingend den Kernel-Source lesen, um Methodendokumente oder das QMD-Format zu finden
Siehe https://github.com/NVIDIA/open-gpu-doc/blob/master/classes/c...
Sehr nützlich
Besonders der Teil zu Doorbell und QMD war am hilfreichsten, weil er zeigt, wie die CUDA-Ausführungssyntax mit dem zusammenhängt, was tatsächlich an die GPU übermittelt wird
Die meisten Erklärungen enden bei Kernel, Block und Warp, aber dieser Artikel macht den Pfad CPU→Treiber→GPU viel leichter nachvollziehbar
Der Steuerungscode ist etwas komplexer, als im Artikel beschrieben
In der Praxis ist es eher ein Tabellen-Lookup als Bits in einem Steuerwort
Es gibt inzwischen Firmen, deren Hauptgeschäft darin besteht, Kernel zu optimieren, damit sie schneller laufen
Ich frage mich, ob solche Firmen irgendwann von einer Open-Source-Bibliothek verdrängt werden, die das extrem gut kann
Nvidia könnte so etwas jederzeit selbst herausbringen
Oder es läuft für diese Firmen sogar noch besser, wenn große Anbieter sie übernehmen, um daraus einen
moatfür schnellere Inferenz zu machenWenn man sich aber ansieht, wie sich Modelle bei einschlägigen Benchmarks wie kernelbench verbessern, glaube ich, dass am Ende auch allgemeinere Lösungen kommen werden
Das Problem ist, dass mit jeder neuen Hardware-Generation oft Einschränkungen oder Features auftauchen, die bestehende Modelle noch nie gesehen haben
Zum Beispiel war Blackwells tcgen05 zeitweise ein Out-of-Distribution-Fall
Wenn Modelle besser zu generalisieren beginnen, muss das keine fatale Hürde sein, aber zumindest im Moment ist es noch ein Stolperstein
[1] https://kernelbench.com/
Ich kenne nicht viele Leute, die darauf hoffen, noch stärker von Nvidia-Bibliotheken abhängig zu werden
Denn die Details des Workloads – also genaue Parameter, die Repräsentation der Daten im Speicher und die Wertebereiche – führen zu sehr unterschiedlichen Optimierungsstrategien
Ich habe gerade meinen HPC-Master abgeschlossen und Kurse zu CUDA, MPI+CUDA und OpenCL besucht; wenn ich vor den Kursen so einen Artikel gelesen hätte, hätte mir das viel geholfen
Besonders der Kontext rund um den Teil, der behandelt, was es bedeutet, dass ein Warp ausführbar ist, war gut
Zunächst einmal: ein guter Artikel, der viele Ecken und Winkel gründlich ausleuchtet
Allerdings verschwindet vieles von dem voodoo-artigen Kram im User-Space, wenn man nicht über die
runtime APIvon CUDA gehtWenn man die Treiber-API verwendet und den Kernel-Source als String an NVIDIAs Runtime-Compiler übergibt, kann man viel besser sehen, was passiert
Nicht alles, aber doch ziemlich viel wird transparent
Eine „rohere“ Version gibt es hier:
https://github.com/NVIDIA/cuda-samples/tree/master/cpp/0_Int...
Wenn man dasselbe in Form einer viel besser lesbaren und dennoch völlig transparenten modernen C++-API sehen will, dann hier:
https://github.com/eyalroz/cuda-api-wrappers/blob/master/exa...
Das ist ein Beispielprogramm aus meiner Header-only-Bibliothek für CUDA-API-Wrapper
Es macht Spaß, während der Laufzeit Code zu ändern und so zu entwickeln
Auf Bare Metal?