6 · Ce qui ne va pas sur GPU¶
Probablement le chapitre le plus utile de cette partie. Savoir dire non à un portage GPU évite des mois de travail pour un résultat décevant.
6.1 Les sept classes de problèmes hostiles¶
| # | Classe | Cause profonde |
|---|---|---|
| 1 | Travail insuffisant | le lancement coûte plus que le calcul |
| 2 | Dépendances séquentielles | rien à recouvrir |
| 3 | Contrôle irrégulier | divergence intra-warp |
| 4 | Accès mémoire irréguliers | non-coalescence |
| 5 | Latence critique | PCIe + lancement |
| 6 | Mémoire insuffisante | la VRAM est le mur |
| 7 | Allocation dynamique | pas de tas efficace |
Examinons chacune, avec les contournements possibles.
6.2 Travail insuffisant¶
Le symptôme : votre noyau prend 8 µs et le lancement en coûte 5.
Le calcul de rentabilité : un noyau doit durer au moins 50 à 100 µs pour que le surcoût de lancement soit négligeable. En dessous, il faut :
- regrouper (traiter 1 000 petits problèmes en un lancement) ;
- fusionner avec les noyaux voisins ;
- CUDA Graphs pour ramener le lancement à ~1,3 µs ;
- rester sur CPU.
Les ordres de grandeur :
| Taille du problème | Verdict |
|---|---|
| < 10 000 éléments | CPU, presque toujours |
| 10⁴ à 10⁶ | dépend de l'intensité arithmétique |
| > 10⁶ | GPU, presque toujours |
Le piège du benchmark
Beaucoup de comparaisons CPU/GPU publiées utilisent des problèmes trop petits, et concluent que le GPU est lent — ou trop gros pour la mémoire du CPU, et concluent qu'il est miraculeux. Vérifiez toujours la taille.
6.3 Dépendances séquentielles¶
L'exemple :
x = x0
for i in range(1_000_000):
x = f(x) # chaque étape dépend de la précédente
Il n'y a aucun parallélisme. Un GPU exécutera cette boucle plus lentement qu'un CPU, parce que sa fréquence est plus basse (1,4-1,8 GHz contre 4-5 GHz) et que sa latence d'instruction est plus élevée.
Les contournements :
- Chercher un parallélisme externe : si vous devez faire cette boucle pour un million de valeurs initiales différentes, le problème devient parallèle. C'est le cas de Monte-Carlo.
-
Reformuler : certaines récurrences se parallélisent. Une récurrence linéaire \(x_{i+1} = a_i x_i + b_i\) se calcule par scan avec un opérateur associatif sur les couples \((a, b)\) :
\[(a_2, b_2) \circ (a_1, b_1) = (a_2 a_1,\ a_2 b_1 + b_2)\]C'est exactement le mécanisme des modèles à espace d'états (Mamba, S4) et des variantes de RNN parallélisables.
-
Accepter que ce soit un travail CPU.
La question à se poser
« Cette dépendance est-elle essentielle ou accidentelle ? »
Beaucoup de boucles séquentielles le sont par habitude de programmation, pas par nécessité mathématique. Le scan a débloqué des familles entières d'algorithmes qu'on croyait séquentiels.
6.4 Contrôle irrégulier¶
L'exemple : un interpréteur, un analyseur syntaxique, une machine à états.
while (!fini) {
switch (instruction[pc]) { // 200 cas possibles
case ADD: ...; break;
case MUL: ...; break;
// ...
}
}
Chaque thread suit un chemin différent. Un warp exécute successivement tous les cas présents parmi ses 32 threads.
Les contournements :
- Trier par type avant de traiter (voir Finance, §5.2) ;
- Regrouper les threads par branche avec
__ballot_syncet une compaction ; - Prédiquer si les branches sont courtes ;
- Accepter si le problème est intrinsèquement irrégulier.
L'exception intéressante : les megakernels
Un megakernel contient précisément un switch sur un type d'instruction. La
différence est que la divergence y est organisée : tous les threads d'un
SM exécutent la même instruction, et l'hétérogénéité est entre SM, pas
entre threads.
C'est la leçon générale : la divergence n'est coûteuse qu'à l'intérieur du warp. La bonne conception place les décisions à un niveau supérieur.
6.5 Accès mémoire irréguliers¶
Les exemples : parcours de graphes, tables de hachage, listes chaînées, arbres.
Un parcours en largeur (BFS) illustre bien le problème :
// Pour chaque sommet de la frontière
for (int e = debut[v]; e < debut[v+1]; ++e) {
int voisin = aretes[e]; // accès dispersé
if (atomicCAS(&visite[voisin], 0, 1) == 0) { // contention
nouvelle_frontiere[atomicAdd(&taille, 1)] = voisin; // contention
}
}
Trois problèmes cumulés : accès dispersés, contention atomique, et déséquilibre de charge (les sommets ont des degrés très différents).
Les contournements :
- Bibliothèques spécialisées : cuGraph, Gunrock, GraphBLAS ont résolu ces problèmes mieux que vous ne le ferez ;
- Représentation matricielle : un BFS est un produit matrice creuse-vecteur sur le semi-anneau booléen. C'est l'approche GraphBLAS, et elle transforme un problème irrégulier en algèbre linéaire creuse ;
- Répartition par degré : traiter les sommets de faible degré par thread, ceux de degré moyen par warp, ceux de fort degré par bloc. C'est la technique standard, et elle est efficace ;
- Renumérotation : réordonner les sommets pour améliorer la localité (Reverse Cuthill-McKee, partitionnement par METIS).
Le verdict honnête : les accélérations GPU sur graphes vont de 1× à 10×, contre 20-50× sur les problèmes réguliers. Cela reste positif, mais l'effort d'ingénierie est bien supérieur.
6.6 Latence critique¶
Le budget incompressible d'un aller-retour GPU :
| Étape | Coût |
|---|---|
| Transfert hôte → périphérique | 2 à 10 µs |
| Lancement de noyau | 1,3 à 10 µs |
| Exécution | variable |
| Transfert périphérique → hôte | 2 à 10 µs |
| Total incompressible | ~5 à 30 µs |
Si votre budget de latence est inférieur, le GPU est disqualifié — quel que soit le calcul.
Les domaines concernés : trading haute fréquence, contrôle industriel temps réel, traitement audio à faible latence, boucles de contrôle robotique rapides.
Les contournements :
- Mémoire unifiée cohérente (Grace Hopper, MI300A, Jetson, Apple Silicon) : supprime le transfert PCIe ;
- Noyau persistant : le noyau tourne en permanence et scrute une file en mémoire. Supprime le coût de lancement, ramène la latence à celle de la communication mémoire ;
- FPGA si la latence est réellement le critère dominant.
Le noyau persistant comme réponse à la latence
C'est un point rarement souligné : la technique fondatrice des megakernels — le noyau persistant qui ne se termine jamais et consomme une file de travail — est aussi la réponse classique au problème de latence hors IA.
Concurrent Real-Time a documenté cette approche pour le temps réel dès 2020, et le papier fondateur de Gupta, Stuart et Owens (2012) cite explicitement la « synchronisation CPU-GPU » comme l'un des quatre cas d'usage des persistent threads.
6.7 Mémoire insuffisante¶
Le mur le plus concret.
| Carte | VRAM |
|---|---|
| RTX 4090 / 5090 | 24 / 32 Go |
| A100 | 40 ou 80 Go |
| H100 | 80 Go |
| H200 | 141 Go |
| B200 | 192 Go |
| MI355X | 288 Go |
| Serveur CPU | 1 à 8 To |
Les contournements, par ordre de préférence :
- Réduire l'empreinte : précision plus basse, structures compactes, recalcul au lieu de stockage (gradient checkpointing) ;
- Découper le problème : traitement par morceaux, avec recouvrement des transferts ;
- Multi-GPU : 8 × 80 Go = 640 Go, avec le coût de la communication ;
- Mémoire unifiée avec oversubscription (
cudaMallocManaged) : cela fonctionne, avec des défauts de page coûteux. Utile pour du prototypage ou des accès très localisés, catastrophique pour des accès dispersés ; - GPUDirect Storage : lecture NVMe → VRAM sans passer par le CPU.
6.8 Allocation dynamique¶
Le GPU dispose de malloc() et new dans les noyaux depuis Fermi. Ils sont
lents : le tas est partagé par des dizaines de milliers de threads, avec la
sérialisation correspondante.
La règle : allouer avant le noyau, jamais dedans.
Les motifs de remplacement :
| Besoin | Solution |
|---|---|
| Taille de sortie inconnue | scan + compaction, ou surallocation |
| Structure de taille variable | tableau plat + tableau d'offsets |
| File de travail dynamique | tampon circulaire préalloué + compteur atomique |
| Arbre, graphe | représentation en tableaux (CSR) |
| Chaînes de caractères | offsets + tampon de caractères (format Arrow) |
Le motif « tampon préalloué + compteur atomique » est celui des noyaux persistants et des megakernels : la file de tâches est un tableau de taille fixe, et l'ordonnanceur y écrit et y lit par indices atomiques.
6.9 La grille de décision¶
Faut-il porter ce problème sur GPU ?
1. Combien d'éléments indépendants ?
< 10 000 → NON
> 1 000 000 → continuer
2. Quelle intensité arithmétique ?
Calculer I = ops / octets. Comparer à P/B.
Trop faible → le gain sera celui du rapport de bandes passantes (~8×),
pas celui du rapport de puissances de calcul (~100×)
3. Les données tiennent-elles en VRAM ?
NON → évaluer le coût du découpage ou du multi-GPU
4. Le contrôle est-il régulier ?
NON → estimer la divergence, chercher un tri préalable
5. Les accès sont-ils coalescés ou coalescables ?
NON → estimer η, souvent 1/8
6. Le budget de latence est-il > 50 µs ?
NON → noyau persistant, ou renoncer
7. Existe-t-il une bibliothèque ?
OUI → l'utiliser, ne rien écrire
Les trois signaux d'alarme
Renoncez, ou au moins réfléchissez longuement, si :
- votre problème est intrinsèquement séquentiel et vous n'avez pas trouvé de parallélisme externe ;
- votre budget de latence est inférieur à 50 µs et vous ne pouvez pas utiliser un noyau persistant ;
- vos données ne tiennent pas en VRAM et le découpage détruit la localité.
Dans les autres cas, le GPU vaut au moins l'expérimentation — en commençant par une bibliothèque existante et un prototype mesuré.
Résumé de la partie 6¶
Les cinq idées à emporter
- Le GPU excelle sur les problèmes massivement parallèles, réguliers, et limités par la bande passante. Il est médiocre partout ailleurs.
- L'algorithme optimal sur CPU n'est pas l'algorithme optimal sur GPU : un algorithme qui converge moins bien mais se parallélise mieux peut gagner (préconditionneurs, CAGRA contre HNSW).
- Les unités à fonction fixe (RT cores, NVENC/NVDEC, texture, décompression) font une partie du travail et sont souvent ignorées.
- Le transfert de données est le goulot le plus fréquent, tous domaines confondus. Les données entrent une fois et sortent une fois.
- Savoir dire non à un portage est une compétence. Les sept classes de problèmes hostiles sont reconnaissables avant d'écrire une ligne.
Vérifiez que vous avez compris¶
Un algorithme séquentiel doit être appliqué à 10 millions d'éléments indépendants. GPU ou CPU ?
GPU, sans hésiter. Le parallélisme est externe : 10 millions d'instances indépendantes, une par thread.
La séquentialité interne de chaque instance n'est pas un problème — au contraire, elle donne du travail à chaque thread, ce qui amortit le lancement et crée de l'ILP si les étapes sont indépendantes en mémoire.
C'est exactement la structure de Monte-Carlo : chaque trajectoire est séquentielle, les trajectoires sont indépendantes.
Pourquoi une récurrence linéaire est-elle parallélisable alors qu'une récurrence générale ne l'est pas ?
Parce que la composition de deux transformations affines est une transformation affine :
En représentant l'étape \(i\) par le couple \((a_i, b_i)\), la composition de deux étapes s'écrit :
Cet opérateur est associatif, donc calculable par un scan en \(O(\log n)\) étapes.
Pour une récurrence générale \(x_{i+1} = f(x_i)\) avec \(f\) arbitraire, on ne peut pas composer \(f \circ f\) sous une forme fermée : il faut évaluer séquentiellement.
C'est exactement le mécanisme qui rend Mamba et les modèles à espace d'états parallélisables à l'entraînement tout en étant récurrents à l'inférence.
Vous devez traiter un graphe de 10 milliards d'arêtes. Le GPU en vaut-il la peine ?
Deux questions préalables.
1. Ça tient en VRAM ? 10 milliards d'arêtes en CSR avec des indices 32 bits font 40 Go, plus les offsets et les données de sommets. C'est jouable sur un H100 80 Go ou un MI355X 288 Go, mais juste. Sinon : multi-GPU (cuGraph le supporte) ou partitionnement.
2. Quelle opération ?
- PageRank, composantes connexes, centralité : ce sont des SpMV répétés, limités par la bande passante. Le GPU a un avantage réel (facteur 5 à 10), et cuGraph est mûr.
- BFS, plus courts chemins : irréguliers et dépendants, avec un parallélisme qui varie énormément selon la frontière. Facteur 2 à 5, avec un effort important.
- Isomorphisme de sous-graphes, motifs : très irrégulier, gains faibles.
Le verdict dépend donc de l'opération, pas du graphe. Et dans tous les cas : commencez par cuGraph, ne réimplémentez rien.
Partie suivante : GPU pour l'IA
Sources de ce chapitre¶
- CUDA C++ Best Practices Guide — Heterogeneous Computing
- Gupta, Stuart, Owens, A Study of Persistent Threads Style GPU Programming for GPGPU Workloads, InPar 2012 — eScholarship
- Improving Real-Time Performance With CUDA Persistent Threads, Concurrent Real-Time
- cuGraph · Gunrock · GraphBLAS
- Optimization Techniques for GPU Programming, ACM Computing Surveys 55(11)