🧵Das CUDA-Programmiermodell

Ein CUDA-Programm besteht aus Host-Code (CPU) und Kernels (GPU). Ein Kernel wird einmal geschrieben und von tausenden Threads gleichzeitig ausgeführt – jeder Thread findet über seine Indizes heraus, welches Datenelement ihm gehört.

🖥️Host und Device

   Host (CPU + RAM)                Device (GPU + VRAM)
  ┌──────────────────┐   PCIe    ┌──────────────────────────┐
  │ main()           │ ════════▶ │ Grid                     │
  │  cudaMalloc      │           │ ┌───────┐┌───────┐       │
  │  cudaMemcpy      │           │ │Block 0││Block 1│ …     │
  │  kernel<<<…>>>   │           │ │▓▓▓▓▓▓▓││▓▓▓▓▓▓▓│       │
  │  cudaMemcpy      │ ◀════════ │ └───────┘└───────┘       │
  └──────────────────┘           │ ▓ = Thread               │
                                 └──────────────────────────┘
💡 Drei Ebenen
Grid = alle Blöcke eines Kernel-Starts · Block = bis zu 1024 Threads, laufen gemeinsam auf einem SM, teilen Shared Memory und können sich mit __syncthreads() abstimmen · Thread = eine Ausführung des Kernels mit eigenen Registern.
Kernel deklarieren und starten
1// Qualifizierer: wo läuft eine Funktion, von wo wird sie aufgerufen?
2__global__ void kernel(float* x, int n); // läuft auf der GPU, Aufruf vom Host
3__device__ float helper(float v); // läuft auf der GPU, Aufruf nur von GPU-Code
4__host__ __device__ float both(float v); // für beide Seiten übersetzt
5
6// Start-Konfiguration: <<<Blöcke, Threads pro Block, dyn. Shared Memory, Stream>>>
7dim3 block(256); // bis 1024 Threads pro Block
8dim3 grid((n + block.x - 1) / block.x); // genug Blöcke für alle n Elemente
9kernel<<<grid, block, 0, stream>>>(d_x, n);
10
11cudaError_t err = cudaGetLastError(); // Startfehler (z. B. zu viele Threads)
12cudaDeviceSynchronize(); // auf das Ende warten (Laufzeitfehler)

🔢Grid-, Block- und Thread-Visualisierer

Stelle Datenmenge und Blockgröße ein und klicke auf einen Thread: Die Indexformel wird live ausgerechnet und das bearbeitete Element markiert.
blockDim.x (Threads pro Block)
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < n) out[i] = f(in[i]);

// Start: kernel<<<3, 16>>>
gridDim.x
3
Threads gesamt
48
untätige Threads
8
unbearbeitet
0
i = blockIdx.x · blockDim.x + threadIdx.x = 1 · 16 + 3 = 19(Warp 0, Lane 3)
bearbeitet: out[19]
Grid → Blöcke → Threads (klicken)
blockIdx.x = 0
blockIdx.x = 1
blockIdx.x = 2
Daten-Array out[0 … 39] – Farbe = Block des bearbeitenden Threads
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

Graue Threads haben kein Element (i ≥ n). Rot = Element ohne Thread (Grid zu klein, keine Grid-Stride-Schleife). Blassere Zellen = zweiter oder späterer Schleifendurchlauf. Abwechselnd helle/dunkle Threads = unterschiedliche Warps (je 32).

👯Warps: 32 Threads im Gleichschritt

Aufteilung

Der SM zerlegt jeden Block in Warps zu je 32 Threads (warpSize): Threads 0–31 = Warp 0, 32–63 = Warp 1 usw. Blockgrößen sollten daher Vielfache von 32 sein – ein Block mit 48 Threads belegt 2 Warps, der zweite ist nur halb voll.

Ausführung

Der Warp-Scheduler gibt einen Befehl für den ganzen Warp aus (SIMT). Lanes, die gerade nicht mitmachen sollen, werden maskiert. Warpwechsel kosten nichts, weil alle Register fest zugeteilt sind.

Warp-Funktionen

Threads eines Warps können direkt Register austauschen: __shfl_down_sync, __ballot_sync, __any_sync, __syncwarp – ohne Shared Memory (siehe Reduktion im Debugger).

🔀Warp-Divergenz

Wählen die Lanes eines Warps bei if/else unterschiedliche Wege, arbeitet der Warp beide Pfade nacheinander ab – mit teilweise maskierten Lanes.
Bedingung
🧪 Kostenmodell: 1 Ausgabeschritt je BefehlAnimation: Bedingung → if → else → vereint
if (threadIdx.x % 2 == 0) {
// if-Zweig: 4 Befehle
} else {
// else-Zweig: 4 Befehle
}
// wieder alle 32 Lanes gemeinsam
32 Lanes von Warp 0
0if
1else
2if
3else
4if
5else
6if
7else
8if
9else
10if
11else
12if
13else
14if
15else
16if
17else
18if
19else
20if
21else
22if
23else
24if
25else
26if
27else
28if
29else
30if
31else
grün = if-Zweig aktiv · violett = else-Zweig aktiv · grau = maskiert (wartet) · blau = wieder vereint
divergent?
ja
ausgegebene Befehle
8
ohne Divergenz
4
Lane-Auslastung
50 %

Divergenz gibt es nur innerhalb eines Warps. Ist die Bedingung für alle 32 Lanes gleich (z. B. abhängig von threadIdx.x / 32), läuft jeder Warp nur einen Pfad. Seit Volta hat jeder Thread einen eigenen Programmzähler (Independent Thread Scheduling) – die beiden Pfade werden dennoch nicht gleichzeitig ausgeführt.

📊Occupancy-Rechner

Wie viele Warps können gleichzeitig auf einem SM wohnen? Das begrenzen vier Ressourcen: Warp-Plätze, Block-Plätze, Register und Shared Memory.
Grenzwerte CC 8.6
max. 48 Warps (1536 Threads) und 16 Blöcke je SM
65.536 Register je SM, max. 255 je Thread
Shared Memory: 100 KB je SM, max. 99 KB je Block
Quelle: CUDA C++ Programming Guide, „Technical Specifications per Compute Capability“; Vergaberegeln wie cuda_occupancy.h.
Occupancy
100 %
aktive Warps / SM
48 / 48
Blöcke / SM
6
begrenzt durch
Warps pro SM, Register
Warps pro SM
6 Blöcke
Blöcke pro SM
16 Blöcke
Register
6 Blöcke
Shared Memory
11 Blöcke
Warps je Block: 8
Register je Warp: 40 · 32 → aufgerundet auf 256er-Einheit = 1280
Shared je Block inkl. Reserve/Granularität: 9.00 KB
Occupancy über Register pro Thread (sonst gleiche Einstellungen)
100 %0 %84072104136168200232

Hohe Occupancy hilft, Speicherlatenz zu verbergen – ist aber kein Selbstzweck: Manche schnelle Kernels (z. B. getiltes GEMM) laufen bewusst mit wenigen Warps und vielen Registern. Die Register-Zahl zeigt nvcc --resource-usage; begrenzen lässt sie sich mit __launch_bounds__ oder -maxrregcount.

Vereinfachung: angenommen wird die größte Shared-Memory-Aufteilung (Carveout) des SM; der Treiber wählt sie in echt je nach Kernel.