3 · La mémoire partagée¶
Le chapitre qui transforme un programmeur GPU débutant en programmeur GPU. Tout ce qui suit dans ce cours — GEMM, FlashAttention, megakernels — est une variation sur le motif présenté ici.
3.1 Le problème que ça résout¶
Reprenons la multiplication matricielle naïve \(\mathbf{C} = \mathbf{A}\mathbf{B}\) avec des matrices \(N \times N\) :
__global__ void matmul_naif(const float* A, const float* B, float* C, int N) {
int lig = blockIdx.y * blockDim.y + threadIdx.y;
int col = blockIdx.x * blockDim.x + threadIdx.x;
if (lig >= N || col >= N) return;
float acc = 0.0f;
for (int k = 0; k < N; ++k) {
acc += A[lig * N + k] * B[k * N + col];
}
C[lig * N + col] = acc;
}
Comptons.
Opérations : chaque thread fait \(N\) multiplications et \(N\) additions, soit \(2N\) opérations. Pour \(N^2\) threads : \(2N^3\) opérations au total.
Octets lus depuis la mémoire globale : chaque thread lit \(2N\) float, soit
\(8N\) octets. Total : \(8N^3\) octets.
Intensité arithmétique :
Sur un H100, dont le ratio machine en FP32 est \(67 \times 10^{12} / 3{,}35 \times 10^{12} = 20\) opérations par octet, on est 80 fois en dessous du seuil. Le noyau est massivement limité par la mémoire, et il plafonnera à :
soit 1,2 % du pic FP32. Et ce calcul est optimiste : il suppose une
coalescence parfaite, alors que B[k * N + col] est lue avec un pas de \(N\).
Le vrai gaspillage
L'élément A[lig][k] est lu par les \(N\) threads de la ligne lig. L'élément
B[k][col] est lu par les \(N\) threads de la colonne col. Chaque élément
est donc lu \(N\) fois depuis la HBM. Toute la question est : comment ne le
lire qu'une fois ?
3.2 Le motif fondamental¶
┌─────────────────────────────────────────────┐
│ 1. Chaque thread charge un élément de la │
│ tuile depuis la mémoire globale │ ← accès coalescés
│ vers la mémoire partagée │
├─────────────────────────────────────────────┤
│ 2. __syncthreads() │ ← tout le monde a fini
├─────────────────────────────────────────────┤
│ 3. Chaque thread lit plusieurs fois dans │
│ la tuile partagée et accumule │ ← accès rapides
├─────────────────────────────────────────────┤
│ 4. __syncthreads() │ ← avant d'écraser
├─────────────────────────────────────────────┤
│ 5. Tuile suivante │
└─────────────────────────────────────────────┘
Les deux __syncthreads() sont tous les deux obligatoires et pour des
raisons différentes :
- le premier garantit que la tuile est complètement écrite avant qu'on la lise ;
- le second garantit que tout le monde a fini de lire avant qu'on écrase la tuile à l'itération suivante.
Oublier le second est un bug classique qui ne se manifeste que sous charge.
3.3 La GEMM par tuiles¶
#define T 32 // taille de tuile
__global__ void matmul_tuiles(const float* A, const float* B, float* C, int N) {
__shared__ float As[T][T];
__shared__ float Bs[T][T];
int tx = threadIdx.x, ty = threadIdx.y;
int lig = blockIdx.y * T + ty;
int col = blockIdx.x * T + tx;
float acc = 0.0f;
for (int t = 0; t < N / T; ++t) {
// 1. Chargement coopératif
As[ty][tx] = A[lig * N + (t * T + tx)];
Bs[ty][tx] = B[(t * T + ty) * N + col];
__syncthreads(); // 2.
// 3. Calcul sur la tuile
for (int k = 0; k < T; ++k) {
acc += As[ty][k] * Bs[k][tx];
}
__syncthreads(); // 4.
}
C[lig * N + col] = acc;
}
Recomptons l'intensité arithmétique.
Par bloc : on charge \(2 T^2\) float par itération, soit \(8T^2\) octets, et on
effectue \(2T^3\) opérations. Avec \(N/T\) itérations :
- octets : \(8T^2 \times (N/T) = 8TN\) par bloc, et il y a \((N/T)^2\) blocs, soit \(8N^3/T\) octets au total ;
- opérations : \(2N^3\) inchangé.
Pour \(T = 32\) : \(I = 8\) opérations par octet. On a gagné un facteur 32.
La leçon générale
L'intensité arithmétique d'un calcul par tuiles est proportionnelle à la taille de la tuile. C'est pour cela que toute l'ingénierie GPU moderne consiste à faire tenir la plus grande tuile possible en mémoire rapide — et pourquoi Blackwell a ajouté 256 Ko de TMEM.
Avec \(I = 8\) et un seuil machine de 20, on reste limité par la mémoire, mais
plus que d'un facteur 2,5 au lieu de 80. Pour franchir le seuil, il faut soit
des tuiles plus grandes (impossible : \(32\times32\) float × 2 = 8 Ko, on
pourrait aller à \(T=96\) mais 1 024 threads maximum par bloc l'empêche), soit
plusieurs éléments par thread.
3.4 Le registre comme troisième niveau de tuile¶
C'est l'astuce qui fait passer de 20 % à 90 % de cuBLAS. Chaque thread calcule non pas 1 élément de \(\mathbf{C}\), mais un petit bloc \(R \times R\), gardé en registres.
#define BM 128 // tuile en lignes de C, par bloc
#define BN 128 // tuile en colonnes de C, par bloc
#define BK 8 // profondeur de la tuile
#define TM 8 // lignes de C par thread
#define TN 8 // colonnes de C par thread
// 128*128 / (8*8) = 256 threads par bloc
__global__ void matmul_registres(const float* A, const float* B,
float* C, int N) {
__shared__ float As[BK][BM]; // stockée transposée
__shared__ float Bs[BK][BN];
float acc[TM][TN] = {0.0f}; // ← 64 registres par thread
float regA[TM], regB[TN];
for (int t = 0; t < N; t += BK) {
// chargement coopératif de As et Bs (détails omis)
charger_tuiles(A, B, As, Bs, t, N);
__syncthreads();
for (int k = 0; k < BK; ++k) {
// charger une colonne de As et une ligne de Bs en registres
for (int i = 0; i < TM; ++i) regA[i] = As[k][/* ... */ + i];
for (int j = 0; j < TN; ++j) regB[j] = Bs[k][/* ... */ + j];
// produit extérieur : TM*TN FMA pour TM+TN lectures
for (int i = 0; i < TM; ++i)
for (int j = 0; j < TN; ++j)
acc[i][j] += regA[i] * regB[j];
}
__syncthreads();
}
// écriture de acc dans C (détails omis)
}
Le cœur du gain est dans le produit extérieur : pour \(TM + TN = 16\) lectures en mémoire partagée, on effectue \(TM \times TN = 64\) FMA. Le ratio calcul/mémoire-partagée passe de 1:2 à 4:1.
L'intensité arithmétique vis-à-vis de la mémoire globale devient \(I \approx BM \cdot BN / (2(BM + BN)) \cdot 1/4 \approx 8\) pour cette configuration, mais le vrai gain est que la mémoire partagée n'est plus le goulot.
Le coût en registres
acc[8][8] = 64 registres, plus regA, regB et les indices : on approche
les 100 registres par thread, donc ~20 warps résidents par SM (31 %
d'occupancy). C'est voulu. C'est exactement le régime « faible occupancy,
fort parallélisme d'instructions » décrit par
Volkov.
Le déroulement complet de cette optimisation, du noyau naïf à 95 % de cuBLAS, est détaillé dans IA · GEMM et dans le worklog de Simon Boehm, qui est la référence pédagogique du domaine.
3.5 Allocation statique et dynamique¶
Statique — la taille est connue à la compilation :
__shared__ float tuile[32][32];
Dynamique — la taille est passée au lancement :
extern __shared__ char partagee[]; // un seul tableau, typé char
__global__ void noyau(int n) {
float* a = reinterpret_cast<float*>(partagee);
float* b = a + n; // découpage manuel
...
}
// lancement
size_t octets = 2 * n * sizeof(float);
noyau<<<grille, bloc, octets>>>(n);
Au-delà de 48 Ko, il faut le demander
Par défaut, un bloc ne peut allouer que 48 Ko de mémoire partagée, quelle que soit la carte. Pour aller jusqu'aux 227 Ko de Hopper/Blackwell :
cudaFuncSetAttribute(noyau,
cudaFuncAttributeMaxDynamicSharedMemorySize, 227 * 1024);
Sans cet appel, le lancement échoue avec cudaErrorInvalidValue — souvent
ignoré si on ne vérifie pas les erreurs.
3.6 Les conflits de banc, en pratique¶
Rappel du chapitre 4 des fondations : 32 bancs de 4 octets, entrelacés.
Le cas typique dans une transposition :
__shared__ float tuile[32][32];
// Écriture : tuile[ty][tx], tx varie sur le warp → bancs 0..31 → OK
tuile[threadIdx.y][threadIdx.x] = entree[...];
__syncthreads();
// Lecture transposée : tuile[tx][ty], ty constant sur le warp
// adresse = 32*tx + ty → banc = (32*tx + ty) mod 32 = ty
// → les 32 threads visent LE MÊME banc → conflit à 32 voies
sortie[...] = tuile[threadIdx.x][threadIdx.y];
Le remède, une seule colonne de plus :
__shared__ float tuile[32][33]; // ← +33 au lieu de 32
// adresse = 33*tx + ty → banc = (33*tx + ty) mod 32 = (tx + ty) mod 32
// → tous distincts quand tx varie. Conflit résolu.
Coût : \(32 \times 4 = 128\) octets de mémoire partagée gaspillés. Gain : facteur 32 sur cet accès.
Le swizzling, l'alternative moderne
Le padding gaspille de la mémoire et casse l'alignement nécessaire à TMA
et wgmma. Les bibliothèques modernes (CUTLASS, ThunderKittens) utilisent
plutôt le swizzling : une permutation XOR des adresses qui élimine les
conflits sans ajouter d'octets.
C'est plus complexe, et c'est exactement le genre de chose qu'on délègue à une bibliothèque. Voir Écosystème · CUTLASS.
3.7 La mémoire partagée comme ressource globale¶
Un point souvent négligé, et qui devient central dans les megakernels.
La mémoire partagée est une ressource par SM, pas par bloc. Si votre noyau demande 100 Ko, un SM Hopper (227 Ko) ne peut héberger que 2 blocs. Cela plafonne l'occupancy quelle que soit la consommation de registres.
Dans un megakernel, où un seul bloc persistant occupe un SM pendant toute la durée de l'exécution, la mémoire partagée devient un espace à gérer dynamiquement. C'est exactement ce que fait l'allocateur par pages de Hazy Research : les 213 Ko de mémoire partagée d'un H100 sont découpés en treize pages de 16 Ko, que les « instructions » demandent et libèrent. MPK utilise des pages de 32 Ko.
Retenez cette idée : au-delà d'un certain niveau de sophistication, la mémoire partagée cesse d'être une variable et devient un tas.
Résumé du chapitre¶
À retenir
- Le motif universel : charger coopérativement →
__syncthreads()→ calculer →__syncthreads()→ tuile suivante. Les deux barrières sont nécessaires. - L'intensité arithmétique d'un calcul par tuiles est proportionnelle à la
taille de tuile : \(I = T/4\) pour une GEMM en
float. - Le vrai gain vient du troisième niveau de tuile, en registres : un produit extérieur \(TM \times TN\) donne \(TM\cdot TN\) FMA pour \(TM+TN\) lectures partagées.
- Au-delà de 48 Ko par bloc,
cudaFuncSetAttributeest obligatoire. - Les conflits de banc se règlent par padding (simple, gaspilleur) ou par swizzling (complexe, gratuit, requis par TMA).
- La mémoire partagée limite le nombre de blocs résidents. Dans un megakernel, elle devient un tas à gérer.
Vérifiez que vous avez compris¶
Que se passe-t-il si on supprime le second __syncthreads() de la GEMM par tuiles ?
Un thread rapide peut entamer l'itération \(t+1\) et écraser As[ty][tx]
pendant qu'un thread lent lit encore cette case pour l'itération \(t\).
Résultat : corruption silencieuse, non déterministe, qui n'apparaît
souvent qu'avec de grandes matrices ou sur une autre carte.
C'est le type de bug qui coûte deux jours. compute-sanitizer --tool racecheck
le détecte.
Pourquoi stocker As transposée (As[BK][BM] plutôt que As[BM][BK]) dans la version à registres ?
Parce que la boucle interne lit une colonne de As pour un k donné :
As[k][i] pour i = 0..TM-1. Avec le stockage As[BK][BM], ces éléments
sont contigus en mémoire partagée, donc dans des bancs différents : pas de
conflit, et on peut même les lire en float4.
Avec As[BM][BK], les mêmes éléments seraient espacés de BK : conflits de
banc garantis.
Un noyau utilise 100 Ko de mémoire partagée par bloc sur H100 et vous mesurez une occupancy de 12,5 %. D'où vient ce chiffre ?
227 Ko / 100 Ko = 2 blocs résidents par SM. Si chaque bloc fait 128 threads, cela donne 256 threads sur les 2 048 possibles, soit exactement 12,5 %.
Est-ce grave ? Pas forcément. Si chaque thread a beaucoup de parallélisme d'instructions et que les tuiles sont grandes, ce régime peut être optimal — c'est celui des noyaux CUTLASS. Ce qui compte est le débit obtenu, pas l'occupancy. Voir Performance · Occupancy.
Chapitre suivant : 4 · Les primitives de warp
Sources de ce chapitre¶
- CUDA C++ Programming Guide — Shared Memory
- How to Optimize a CUDA Matmul Kernel for cuBLAS-like Performance, Simon Boehm — la référence pédagogique sur l'optimisation de GEMM par étapes.
- Using Shared Memory in CUDA C/C++, NVIDIA Technical Blog
- Hazy Research, Look Ma, No Bubbles! pour l'allocateur par pages de mémoire partagée.