3 · Le modèle SIMT¶
Trente-deux threads, une seule instruction. C'est le mécanisme d'exécution du GPU, la source de ses performances, et la source de la moitié de ses pièges.
3.1 SIMD, SIMT, et la différence¶
SIMD (Single Instruction, Multiple Data) est le modèle des extensions
vectorielles CPU : une instruction vaddps additionne 16 float d'un coup. Le
programmeur voit un vecteur.
SIMT (Single Instruction, Multiple Thread) est le modèle NVIDIA : une
instruction est exécutée par 32 threads simultanément. Le programmeur voit
32 threads indépendants qui ont chacun leur threadIdx, leurs variables
locales, et peuvent prendre des branches différentes.
C'est la même mécanique matérielle, exposée différemment.
| SIMD (AVX) | SIMT (CUDA) | |
|---|---|---|
| Ce que le programmeur écrit | _mm512_add_ps(a, b) |
c[i] = a[i] + b[i]; |
| Divergence | à gérer à la main par masques | gérée par le matériel |
| Accès mémoire dispersés | gather/scatter explicites |
naturels (mais lents) |
| Largeur visible | explicite (16 float) |
invisible (mais toujours 32) |
Intuition
SIMT, c'est du SIMD auquel on a ajouté un mécanisme automatique de masques d'activation. Cela rend le code beaucoup plus lisible et cache un coût que vous devrez apprendre à voir.
3.2 Le warp¶
Un warp est un groupe de 32 threads consécutifs, formé automatiquement à
partir du threadIdx :
où tid est l'indice linéaire du thread dans son bloc.
Propriétés fondamentales :
- le warp est l'unité d'ordonnancement. Le matériel ne planifie jamais un thread seul ;
- tous les threads d'un warp progressent ensemble (avec les nuances de la section 3.5) ;
- le warp est l'unité d'accès mémoire. Les 32 requêtes d'un warp sont fusionnées en un minimum de transactions ;
- la taille de 32 est matérielle et immuable sur toutes les architectures NVIDIA depuis 2006. Chez AMD, un wavefront CDNA fait 64.
Erreur fréquente
Utiliser la variable warpSize en supposant qu'elle vaut 32 à la
compilation. Sur NVIDIA c'est vrai, mais warpSize n'est pas une constante
de compilation en CUDA — il faut écrire 32 explicitement si on veut
qu'elle soit utilisable dans un template ou un #pragma unroll. Sur HIP,
c'est warpSize = 64 sur CDNA et 32 sur RDNA, et ce piège est réel.
3.3 Le bloc et la grille¶
Au-dessus du warp, deux niveaux logiciels :
grille (grid) ← tout le lancement de noyau
└─ bloc (block) ← affecté à un SM, ne le quitte jamais
└─ warp ← 32 threads, unité d'ordonnancement
└─ thread
Un bloc :
- est affecté entièrement à un seul SM, pour toute sa durée de vie ;
- peut se synchroniser en interne avec
__syncthreads(); - partage une allocation de mémoire partagée ;
- a une taille choisie par le programmeur, maximum 1 024 threads.
Une grille est l'ensemble des blocs. Il n'y a aucune garantie d'ordre entre les blocs, et aucun moyen standard de les synchroniser. Deux blocs peuvent s'exécuter simultanément, ou l'un après l'autre, ou dans n'importe quel ordre.
La limite fondamentale du modèle CUDA classique
L'absence de synchronisation inter-blocs n'est pas un oubli : c'est ce qui permet à un noyau de tourner sur un GPU de 20 SM comme sur un GPU de 148 SM. Le matériel lance les blocs au fur et à mesure que des SM se libèrent.
Le prix : la seule barrière globale disponible est la fin du noyau. C'est exactement le coût que les megakernels cherchent à contourner.
Hopper a ajouté un niveau intermédiaire, le cluster : un groupe de blocs garantis co-résidents sur des SM voisins, qui peuvent se synchroniser entre eux. Voir CUDA moderne · Clusters.
3.4 La divergence¶
Voici le concept qui fait échouer les premiers noyaux de tout le monde.
if (threadIdx.x % 2 == 0) {
resultat = calcul_A(); // 16 threads du warp
} else {
resultat = calcul_B(); // les 16 autres
}
Il n'y a qu'un seul compteur ordinal par warp (nuance en 3.5). Le matériel ne
peut donc pas exécuter calcul_A et calcul_B en même temps. Il fait ceci :
1. exécute calcul_A avec un masque activant les threads pairs
→ les 16 threads impairs sont présents mais leurs résultats sont jetés
2. exécute calcul_B avec le masque complémentaire
→ les 16 threads pairs sont inactifs
3. réunit les deux chemins
Coût : \(t_A + t_B\) au lieu de \(\max(t_A, t_B)\). Avec un switch à 32
branches toutes prises par un thread différent, on paie 32 fois le temps d'une
branche, soit une efficacité de 1/32.
Ce qui n'est PAS de la divergence¶
C'est aussi important que le reste :
if (blockIdx.x % 2 == 0) { ... } else { ... }
Ici, tous les threads d'un warp prennent la même branche (ils ont le même
blockIdx). Il n'y a aucune divergence, aucun coût. Le branchement est
uniforme au niveau du warp.
De même :
if (i < n) { ... } // garde de bord classique
ne diverge que dans le dernier warp du dernier bloc. Négligeable.
La règle opérationnelle
La divergence coûte quand des threads du même warp prennent des chemins différents. Rangez vos données pour que les threads voisins fassent le travail voisin. Le tri par catégorie avant traitement est un motif classique et rentable.
Un cas où la divergence est déguisée¶
while (donnees[i] != FIN) { ... } // longueur variable par thread
Le warp entier tourne aussi longtemps que le thread le plus lent. Si un thread traite 1 000 éléments et les 31 autres en traitent 10, le warp coûte 1 000 itérations. C'est le problème du déséquilibre de charge intra-warp, très courant en traitement de graphes et en parcours de rayons.
3.5 Volta a changé les règles : l'exécution indépendante des threads¶
Jusqu'à Pascal (2016), un warp avait littéralement un compteur ordinal unique et une pile de reconvergence. Depuis Volta (2017), chaque thread a son propre compteur ordinal et sa propre pile d'appels.
Le matériel peut donc entrelacer les branches divergentes au lieu de les sérialiser strictement, et surtout : des threads divergents peuvent communiquer entre eux, ce qui était impossible avant.
La contrepartie est brutale : il n'y a plus de reconvergence implicite garantie. Ce code, valide avant Volta, est un bug depuis :
// BUG depuis Volta
if (lane < 16) {
partage[lane] += partage[lane + 16];
}
// Rien ne garantit que l'écriture ci-dessus est visible ici
valeur = partage[0];
Il faut désormais synchroniser explicitement :
if (lane < 16) {
partage[lane] += partage[lane + 16];
}
__syncwarp(); // ← obligatoire
valeur = partage[0];
Et toutes les primitives de warp prennent un masque explicite indiquant quels threads participent :
// Ancienne API, supprimée
int v = __shfl_down(x, 16);
// API actuelle : masque explicite
int v = __shfl_down_sync(0xffffffff, x, 16);
Limite importante
Beaucoup de code CUDA trouvé en ligne date d'avant 2017 et omet ces
synchronisations. Il semble fonctionner — souvent pendant des mois — puis
casse au changement de compilateur ou de carte. Si vous lisez un tutoriel de
réduction sans __syncwarp() ni _sync, il est obsolète.
3.6 Les primitives de warp¶
Puisque les 32 threads d'un warp sont physiquement dans la même partition, ils peuvent s'échanger des registres sans passer par la mémoire. C'est extrêmement rapide (quelques cycles) et c'est la base de toutes les réductions performantes.
| Primitive | Ce qu'elle fait |
|---|---|
__shfl_sync(m, v, src) |
chaque thread reçoit v du thread src |
__shfl_up_sync(m, v, d) |
reçoit v du thread lane − d |
__shfl_down_sync(m, v, d) |
reçoit v du thread lane + d |
__shfl_xor_sync(m, v, k) |
reçoit v du thread lane XOR k |
__ballot_sync(m, pred) |
renvoie un masque 32 bits des threads où pred est vrai |
__all_sync / __any_sync |
réduction booléenne sur le warp |
__activemask() |
quels threads sont actuellement actifs |
__match_any_sync(m, v) |
quels threads ont la même valeur v |
__reduce_add_sync(m, v) |
réduction matérielle (Ampere et plus) |
Une somme sur tout un warp, en 5 instructions au lieu de 32 :
__device__ float somme_warp(float x) {
// Réduction en arbre : 16, 8, 4, 2, 1
for (int offset = 16; offset > 0; offset /= 2) {
x += __shfl_down_sync(0xffffffff, x, offset);
}
return x; // le résultat est dans la lane 0
}
Déroulement pour 4 threads (au lieu de 32, pour tenir sur la page) :
départ : [a] [b] [c] [d]
offset = 2 : [a+c] [b+d] [c] [d]
offset = 1 : [a+c+b+d] …
\(\log_2(32) = 5\) étapes. Chaque étape est un échange de registre, sans aucun accès mémoire.
Pourquoi 0xffffffff ?
C'est le masque « les 32 threads participent ». Il est correct si et
seulement si le warp est plein et non divergent à cet endroit. Dans un
noyau avec des gardes de bord, il faut utiliser __activemask() ou, mieux,
structurer le code pour que le warp soit toujours complet.
3.7 Le prédicat, ou comment le matériel évite les branchements¶
Pour de très courtes branches, le compilateur ne génère pas de saut du tout : il utilise la prédication. L'instruction est exécutée par tous les threads, mais son écriture est annulée pour ceux dont le prédicat est faux.
x = (cond) ? a : b;
devient en SASS quelque chose comme :
ISETP.GT P0, R2, R3 // P0 = cond
@P0 MOV R4, R5 // exécuté si P0
@!P0 MOV R4, R6 // exécuté si !P0
Aucun branchement, aucune divergence au sens strict — mais les deux chemins
sont toujours payés. C'est bien pour deux instructions, catastrophique pour
deux cents. Le compilateur choisit le seuil ; on peut l'influencer avec
#pragma unroll ou en restructurant.
Résumé du chapitre¶
À retenir
- Un warp = 32 threads (64 en wavefront AMD CDNA), unité d'ordonnancement et unité d'accès mémoire.
- Un bloc vit sur un seul SM ; il n'existe aucune synchronisation inter-blocs standard. C'est le point de départ des megakernels.
- La divergence coûte la somme des branches, mais seulement entre threads du même warp.
- Depuis Volta, chaque thread a son compteur ordinal : plus de reconvergence
implicite, et toutes les primitives de warp exigent un masque
_sync. - Les échanges de registres intra-warp (
__shfl_*) sont la base de toute réduction rapide.
Vérifiez que vous avez compris¶
Un noyau contient if (donnees[i] > 0) traiter(); où donnees est aléatoire, avec 50 % de positifs. Quelle est l'efficacité d'exécution attendue ?
Pour un warp donné, la probabilité que les 32 threads soient tous du même côté est \(2 \times 0{,}5^{32} \approx 5 \times 10^{-10}\) : essentiellement jamais. Presque tous les warps divergent, et paient les deux chemins.
Si traiter() coûte \(T\) et le chemin vide coûte 0, on paie \(T\) par warp,
mais seulement la moitié des threads en profitent : efficacité ≈ 50 %.
Le remède classique : trier ou compacter les indices positifs dans un tableau
séparé (stream compaction), puis lancer un noyau dense dessus. On paie un
passage supplémentaire pour gagner un facteur 2 sur le traitement — rentable
dès que traiter() est cher.
Pourquoi __syncthreads() dans une branche divergente est-il un comportement indéfini ?
__syncthreads() est une barrière au niveau du bloc : elle attend que
tous les threads du bloc l'atteignent. Si seuls certains threads y
arrivent (parce que les autres ont pris la branche else), la barrière
n'est jamais satisfaite. Sur les architectures pré-Volta, cela produisait un
blocage. Depuis Volta le comportement est mieux défini mais reste
dangereux : la règle est toujours placer __syncthreads() dans du code
atteint uniformément par tout le bloc.
Réécrivez somme_warp pour un wavefront AMD de 64 threads.
Il faut une étape de plus, et le masque n'a pas le même sens :
__device__ float somme_wave(float x) {
for (int offset = 32; offset > 0; offset /= 2) {
x += __shfl_down(x, offset, 64);
}
return x;
}
\(\log_2(64) = 6\) étapes. C'est le genre de détail qui rend un portage CUDA → HIP non trivial malgré la compatibilité syntaxique.
Chapitre suivant : 4 · La hiérarchie mémoire
Sources de ce chapitre¶
- CUDA C++ Programming Guide — SIMT Architecture
- Using CUDA Warp-Level Primitives, NVIDIA Technical Blog
— la référence sur le passage aux primitives
_syncaprès Volta. - Inside Volta: The World's Most Advanced Data Center GPU — l'annonce du compteur ordinal par thread.
- Modal GPU Glossary — Warp divergence