💾Speicherzugriffe – wo die Leistung entschieden wird
Die meisten Kernels warten mehr auf Daten, als sie rechnen. Wer Zugriffe bündelt, Bank-Konflikte vermeidet und Kopien überlappt, holt oft ein Vielfaches an Geschwindigkeit heraus.
🏔️Speicherhierarchie
Register
- Sichtbar für
- je Thread
- Größe
- 64 K × 32 Bit = 256 KB je SM
- Latenz
- ≈ 1 Takt Größenordnung
- Hinweis
- schnellster Speicher; max. 255 Register pro Thread
Latenzen sind grobe Größenordnungen und hängen stark von Generation, Takt und Zugriffsmuster ab (vgl. Mikrobenchmark-Studien wie Jia et al., „Dissecting the NVIDIA Volta GPU Architecture via Microbenchmarking“, 2018). Größen: NVIDIA-Whitepaper bzw. CUDA Programming Guide.
🚚Coalescing: Zugriffe eines Warps bündeln
int i = blockIdx.x * blockDim.x + threadIdx.x; x = a[i];🧪 Modell: 32-Byte-Sektoren, 128-Byte-Lines
0
4
8
12
16
20
24
28
32
36
40
44
48
52
56
60
64
68
72
76
80
84
88
92
96
100
104
108
112
116
120
124
Liegen die Adressen eines Warps dicht beieinander, reichen wenige Transaktionen (coalesced). Bei großem Stride holt die Hardware ganze 32-Byte-Sektoren, obwohl nur 4 Byte davon gebraucht werden – die Bandbreite geht verloren. Abhilfe: Datenlayout „Structure of Arrays“ statt „Array of Structures“, oder Daten gemeinsam über Shared Memory umsortieren.
🏦Shared Memory und Bank-Konflikte
__shared__ float tile[32][32]; float x = tile[threadIdx.x][0]; // Spalte lesen🧪 Modell: 32 Bänke à 4 Byte, 32-Bit-Zugriffe
Zahl = Lane · „×n“ = n Lanes lesen dasselbe Wort (Broadcast, kostenlos) · rot umrandet = zusätzlicher Durchgang wegen Konflikt.
Bank = (Wortadresse) mod 32. Liest ein Warp die Spalte eines float tile[32][32], liegen alle 32 Elemente in derselben Bank (Abstand 32 Wörter) → 32 Durchgänge. Mit einer Füllspalte (tile[32][33]) verschiebt sich jede Zeile um eine Bank → konfliktfrei. Allgemein gilt für Stride s: Konfliktgrad = ggT(s, 32).
🚧__syncthreads(): Barriere im Block
Schnelle Warps warten an der roten Linie, bis der letzte Warp des Blocks angekommen ist.
Wichtig: __syncthreads() muss von allen Threads des Blocks erreicht werden – in einem if-Zweig, den nur manche Threads nehmen, führt es zu Hängern oder undefiniertem Verhalten. Es synchronisiert nur innerhalb eines Blocks, nicht blockübergreifend.
📦Speicher anlegen und kopieren
1float *h = (float*)malloc(bytes), *d;2cudaMalloc(&d, bytes); // VRAM reservieren3cudaMemcpy(d, h, bytes, cudaMemcpyHostToDevice); // explizit kopieren4kernel<<<grid, block>>>(d, n);5cudaMemcpy(h, d, bytes, cudaMemcpyDeviceToHost); // zurück (wartet auf Kernel)6cudaFree(d); free(h);
- volle Kontrolle, vorhersagbar
- Kopien sichtbar im Profiler
- zwei Zeiger je Puffer (h/d)
- Kopien vergisst man leicht – oder kopiert zu oft
🛤️Streams und asynchrone Kopien
Innerhalb eines Streams läuft alles der Reihe nach, verschiedene Streams dürfen sich überlappen. So kopiert die GPU Teil 1, während sie Teil 0 schon rechnet. Mit nur einer Kopier-Engine blockiert die Rückkopie von Teil 0 die Hinkopie von Teil 1, wenn Teil für Teil abgesetzt wird – dann hilft die Reihenfolge „erst alle Kopien hin“.
1// Host-Speicher muss „pinned“ sein, sonst ist cudaMemcpyAsync nicht asynchron2cudaMallocHost(&h_buf, bytes);3cudaStream_t s[NSTREAMS];4for (int k = 0; k < NSTREAMS; ++k) cudaStreamCreate(&s[k]);56for (int c = 0; c < CHUNKS; ++c) {7 cudaStream_t st = s[c % NSTREAMS];8 size_t off = c * chunkElems;9 cudaMemcpyAsync(d_buf + off, h_buf + off, chunkBytes, cudaMemcpyHostToDevice, st);10 process<<<grid, block, 0, st>>>(d_buf + off, chunkElems);11 cudaMemcpyAsync(h_buf + off, d_buf + off, chunkBytes, cudaMemcpyDeviceToHost, st);12}13cudaDeviceSynchronize(); // auf alle Streams warten
--default-stream per-thread ändert das.