7 · Atomiques et synchronisation¶
Le chapitre qui ferme la partie 2 et ouvre directement la partie 8. Comment des threads qui ne se connaissent pas peuvent coopérer — et à quel prix.
7.1 Les opérations atomiques¶
Une opération atomique lit, modifie et écrit une adresse sans que personne puisse s'intercaler.
int ancien = atomicAdd(&compteur, 1); // renvoie l'ancienne valeur
float f = atomicAdd(&somme, 3.14f); // flottant : Pascal+
int m = atomicMax(&maximum, valeur);
int e = atomicExch(&cellule, nouvelle);
int c = atomicCAS(&verrou, attendu, nouveau); // compare-and-swap
atomicCAS est le primitif universel : tous les autres peuvent s'en déduire.
// atomicMax pour float, qui n'existe pas nativement
__device__ float atomicMaxFloat(float* adr, float val) {
int* adr_i = (int*)adr;
int ancien = *adr_i, suppose;
do {
suppose = ancien;
ancien = atomicCAS(adr_i, suppose,
__float_as_int(fmaxf(val, __int_as_float(suppose))));
} while (suppose != ancien);
return __int_as_float(ancien);
}
Ce truc ne marche que pour les positifs
La comparaison d'entiers ne correspond à celle des flottants que pour les valeurs positives (la représentation IEEE 754 des flottants positifs est monotone quand on la lit comme un entier). Pour des valeurs signées, il faut un traitement du bit de signe. C'est un piège très classique.
Où vivent les atomiques¶
| Portée | Où | Latence |
|---|---|---|
| Mémoire partagée | dans le SM | ~30 cycles |
| Mémoire globale | au niveau du L2 | ~200-400 cycles |
Système (atomicAdd_system) |
cohérent avec le CPU et les autres GPU | très lent |
Point important : un atomique en mémoire globale s'exécute dans le L2, pas dans le SM. C'est ce qui le rend visible par tous les blocs, et c'est aussi ce qui le rend lent.
7.2 Le coût réel : la contention¶
Le débit d'un atomique dépend entièrement du nombre de threads qui visent la même adresse.
32 threads → 32 adresses différentes : ~1 transaction, rapide
32 threads → 1 adresse identique : sérialisé, 32× plus lent
Trois stratégies pour réduire la contention, par ordre d'efficacité.
1. Agréger dans le warp (vu au chapitre 4) :
// Au lieu de 32 atomicAdd sur la même adresse
float somme_warp = reduce_add_warp(ma_valeur);
if ((threadIdx.x & 31) == 0) atomicAdd(&total, somme_warp);
2. Privatiser en mémoire partagée. Le motif de l'histogramme :
__global__ void histogramme(const unsigned char* donnees, int n,
unsigned int* hist_global) {
__shared__ unsigned int hist_local[256];
// 1. Initialiser la copie privée
for (int i = threadIdx.x; i < 256; i += blockDim.x) hist_local[i] = 0;
__syncthreads();
// 2. Accumuler dans la copie privée (atomiques en mémoire partagée)
for (int i = blockIdx.x * blockDim.x + threadIdx.x;
i < n; i += blockDim.x * gridDim.x) {
atomicAdd(&hist_local[donnees[i]], 1);
}
__syncthreads();
// 3. Fusionner : 256 atomiques globaux par bloc, pas n
for (int i = threadIdx.x; i < 256; i += blockDim.x) {
if (hist_local[i]) atomicAdd(&hist_global[i], hist_local[i]);
}
}
Gain typique : facteur 10 à 50 selon la distribution des données.
3. Répliquer les compteurs. Si même la mémoire partagée sature (données très
concentrées sur peu de valeurs), on maintient \(k\) copies de l'histogramme et
chaque warp écrit dans la copie warp_id % k.
7.3 Le modèle mémoire de CUDA¶
Voici la partie que presque personne ne lit et qui décide de la correction des megakernels.
Le problème¶
// Bloc producteur
donnees[0] = 42;
pret = 1;
// Bloc consommateur
while (pret == 0) { }
int x = donnees[0]; // ← garanti d'être 42 ?
Non, pas sans annotation. Trois choses peuvent casser :
- le compilateur peut réordonner les deux écritures, ou garder
pretdans un registre et boucler à l'infini ; - le matériel peut rendre
pret = 1visible avantdonnees[0] = 42; - les caches ne sont pas cohérents entre L1 de SM différents.
La solution : le modèle mémoire C++ appliqué à CUDA¶
Depuis CUDA 9, CUDA implémente un modèle mémoire formel inspiré de celui de
C++11, exposé par cuda::atomic :
#include <cuda/atomic>
// Producteur
donnees[0] = 42;
pret.store(1, cuda::memory_order_release);
// Consommateur
while (pret.load(cuda::memory_order_acquire) == 0) { }
int x = donnees[0]; // ← maintenant garanti
La paire release/acquire garantit que tout ce qui a été écrit avant le
release est visible après le acquire correspondant.
Il faut aussi préciser la portée :
cuda::atomic<int, cuda::thread_scope_block> compteur_bloc;
cuda::atomic<int, cuda::thread_scope_device> compteur_gpu; // ← inter-blocs
cuda::atomic<int, cuda::thread_scope_system> compteur_systeme;
Plus la portée est large, plus l'opération est coûteuse. Utiliser
thread_scope_device là où thread_scope_block suffirait est un gaspillage
courant.
La version bas niveau¶
En CUDA C++ classique, on utilise volatile + __threadfence() :
volatile int* pret_v = &pret;
donnees[0] = 42;
__threadfence(); // toutes les écritures précédentes sont visibles
*pret_v = 1;
volatile empêche le compilateur de mettre la variable en cache dans un
registre. __threadfence() est la barrière mémoire au niveau du périphérique.
volatile seul ne suffit jamais
volatile traite le problème du compilateur, pas celui du matériel ni celui
des caches. Beaucoup de code trouvé en ligne utilise volatile sans
__threadfence() : il fonctionne souvent par chance, et casse à la première
optimisation matérielle.
L'écriture correcte en 2026 utilise cuda::atomic avec un ordre mémoire
explicite.
7.4 La barrière inter-blocs, écrite à la main¶
Ce code est le lien direct avec les megakernels. Il est correct uniquement si tous les blocs sont simultanément résidents.
#include <cuda/atomic>
__device__ cuda::atomic<unsigned, cuda::thread_scope_device> arrives{0};
__device__ cuda::atomic<unsigned, cuda::thread_scope_device> generation{0};
__device__ void barriere_grille(unsigned nb_blocs) {
__syncthreads(); // d'abord synchroniser le bloc
if (threadIdx.x == 0) {
unsigned gen = generation.load(cuda::memory_order_relaxed);
if (arrives.fetch_add(1, cuda::memory_order_acq_rel) == nb_blocs - 1) {
// dernier arrivé : réinitialiser et libérer tout le monde
arrives.store(0, cuda::memory_order_relaxed);
generation.fetch_add(1, cuda::memory_order_release);
} else {
// attendre le changement de génération
while (generation.load(cuda::memory_order_acquire) == gen) { }
}
}
__syncthreads(); // rediffuser au bloc
}
La condition de résidence n'est pas négociable
Si vous lancez plus de blocs que le GPU ne peut en héberger simultanément, ce code interbloque : les blocs résidents attendent des blocs qui ne seront jamais lancés, parce que l'ordonnanceur attend qu'un SM se libère.
C'est pourquoi cooperative_groups::grid_group::sync() exige
cudaLaunchCooperativeKernel et une grille dimensionnée par
cudaOccupancyMaxActiveBlocksPerMultiprocessor. C'est aussi pourquoi tout
megakernel est un noyau persistant : exactement une vague de blocs, qui
ne se termine jamais avant la fin du travail.
Le compteur de génération évite le bug classique où un bloc rapide franchit
la barrière, revient et incrémente arrives avant que les lents aient vu 0.
7.5 Du verrou global aux dépendances fines¶
La barrière de la section précédente coûte cher. Rappel des mesures de Kog sur MI300X :
| Approche | Latence de synchronisation |
|---|---|
| Barrière atomique naïve | 7,59 – 7,88 µs |
| Sentinelle NaN | 0,80 – 0,93 µs |
La technique de la sentinelle est élégante : au lieu d'un compteur séparé, on initialise le tampon de sortie avec une valeur impossible (ici NaN). Le consommateur lit la donnée elle-même et attend qu'elle ne soit plus NaN. Une seule lecture au lieu de deux, et aucune opération atomique.
// Producteur : écrit simplement la donnée
sortie[i] = valeur; // remplace le NaN
// Consommateur : attend que la donnée soit valide
float v;
do { v = sortie[i]; } while (isnan(v));
Les limites de la sentinelle
- Il faut une valeur impossible dans le domaine de sortie. NaN convient pour des activations, pas pour des données arbitraires.
- Il faut réinitialiser le tampon à chaque itération, ce qui coûte de la bande passante.
- La visibilité mémoire reste à garantir : la lecture doit contourner le L1
(
ld.global.cvouvolatile), sinon le consommateur peut lire indéfiniment une valeur mise en cache.
Mais l'idée générale est le vrai enseignement : on ne synchronise pas tout le monde, on attend exactement la donnée dont on a besoin.
C'est le principe du megakernel. Là où une frontière de noyau dit « attends que tout soit fini », un megakernel dit « attends que le compteur 47 atteigne 3 ».
Hazy Research le formule ainsi : des compteurs en mémoire globale, initialisés à zéro avant le lancement, incrémentés à la fin de chaque instruction ; les instructions dépendantes attendent que leurs compteurs atteignent leur valeur cible. Et leur exemple canonique : plutôt que d'attendre tout l'état caché du MLP avant la projection descendante, on le produit et le consomme en quatre morceaux avec quatre compteurs.
Mirage MPK généralise cela avec une notion d'événement : chaque tâche a au plus un événement déclencheur et un événement de sortie, et le compilateur fusionne les événements redondants.
7.6 Récapitulatif des mécanismes de synchronisation¶
| Mécanisme | Portée | Coût | Quand |
|---|---|---|---|
__syncwarp() |
warp | ~1 cycle | après un accès partagé intra-warp |
__syncthreads() |
bloc | ~10-50 cycles | motif charger/calculer |
__syncthreads_count/and/or |
bloc | idem + réduction | vote sur le bloc |
cuda::barrier / mbarrier |
bloc, asynchrone | variable | pipeline avec TMA |
cluster.sync() |
cluster (Hopper+) | ~100 cycles | coopération multi-SM |
grid.sync() |
grille | µs | rare, exige la résidence |
atomic + attente |
arbitraire | dépend | dépendances fines, megakernels |
| Fin de noyau | grille | 1,3 à 10 µs | le cas par défaut |
La règle d'or
Synchronisez à la portée la plus étroite qui suffit. Un
__syncthreads() là où un __syncwarp() suffit coûte 10 à 50× plus cher.
Un grid.sync() là où un compteur dédié suffirait coûte 1 000× plus cher.
Résumé du chapitre¶
À retenir
- Les atomiques globaux s'exécutent dans le L2. Leur coût dépend de la contention, pas de leur nombre.
- Trois remèdes : agréger dans le warp, privatiser en mémoire partagée, répliquer les compteurs.
- Sans annotation mémoire (
cuda::atomicavec release/acquire, ouvolatile+__threadfence()), la communication inter-blocs est incorrecte, même quand elle semble marcher. - Une barrière de grille écrite à la main n'est correcte que si tous les blocs sont résidents. D'où : tout megakernel est un noyau persistant.
- Le passage de « barrière globale » à « attendre exactement la donnée dont j'ai besoin » est l'idée centrale de la partie 8.
Vérifiez que vous avez compris¶
Pourquoi un histogramme à 256 cases est-il beaucoup plus rapide qu'un histogramme à 2 cases, à volume de données égal ?
Parce que la contention est inversement proportionnelle au nombre de cases. Avec 2 cases, tous les threads visent l'une des deux adresses : les atomiques sont massivement sérialisés. Avec 256 cases et des données uniformes, la contention est divisée par 128.
Pour le cas à 2 cases (ou pour des données très concentrées), la bonne
approche n'est pas l'atomique du tout : c'est une réduction. On compte
localement dans chaque warp avec __ballot_sync + __popc, puis on agrège.
Ce code fonctionne sur votre machine et échoue sur une autre. Pourquoi ?
__device__ int pret = 0;
// bloc 0
resultat[0] = calculer();
pret = 1;
// bloc 1
while (pret == 0) { }
utiliser(resultat[0]);
pretn'est pasvolatile: le compilateur peut le charger une fois en registre et boucler à l'infini. Le comportement dépend du niveau d'optimisation.- Pas de
__threadfence(): rien ne garantit queresultat[0]soit visible avantpret. - Interblocage possible : si le bloc 1 est ordonnancé avant le bloc 0 et que le GPU est trop petit pour les héberger tous les deux, le bloc 0 ne démarre jamais.
La version correcte utilise cuda::atomic<int, thread_scope_device> avec
release/acquire et garantit la résidence.
Dans un megakernel, pourquoi remplacer une barrière de grille par des compteurs fait-il gagner autant ?
Deux raisons cumulatives.
Coût unitaire : une barrière de grille fait communiquer tous les blocs entre eux (mesuré à ~7,6 µs sur MI300X en version naïve). Un compteur dédié ne fait communiquer que les blocs concernés.
Parallélisme conservé : la barrière impose que toute l'étape \(n\) soit finie avant toute l'étape \(n+1\). Avec des compteurs fins, le bloc qui produit le premier quart de l'état caché débloque immédiatement son consommateur, pendant que les autres quarts sont encore en cours. La profondeur du chemin critique diminue, pas seulement le coût par synchronisation.
Partie suivante : Performance
Sources de ce chapitre¶
- CUDA C++ Programming Guide — Memory Consistency Model
- libcu++ : cuda::atomic
- CUDA Pro Tip: Optimized Filtering with Warp-Aggregated Atomics
- Kog, Single-kernel LLM inference on MI300X — mesures de synchronisation de grille et technique de sentinelle.
- Hazy Research, Look Ma, No Bubbles! — compteurs en mémoire globale et découpage en quatre morceaux.