Aller au contenu

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 :

  1. 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.
  2. 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.

  3. 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 :

  1. Trier par type avant de traiter (voir Finance, §5.2) ;
  2. Regrouper les threads par branche avec __ballot_sync et une compaction ;
  3. Prédiquer si les branches sont courtes ;
  4. 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 :

  1. Bibliothèques spécialisées : cuGraph, Gunrock, GraphBLAS ont résolu ces problèmes mieux que vous ne le ferez ;
  2. 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 ;
  3. 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 ;
  4. 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 :

  1. Mémoire unifiée cohérente (Grace Hopper, MI300A, Jetson, Apple Silicon) : supprime le transfert PCIe ;
  2. 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 ;
  3. 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 :

  1. Réduire l'empreinte : précision plus basse, structures compactes, recalcul au lieu de stockage (gradient checkpointing) ;
  2. Découper le problème : traitement par morceaux, avec recouvrement des transferts ;
  3. Multi-GPU : 8 × 80 Go = 640 Go, avec le coût de la communication ;
  4. 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 ;
  5. 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 :

  1. votre problème est intrinsèquement séquentiel et vous n'avez pas trouvé de parallélisme externe ;
  2. votre budget de latence est inférieur à 50 µs et vous ne pouvez pas utiliser un noyau persistant ;
  3. 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

  1. 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.
  2. 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).
  3. Les unités à fonction fixe (RT cores, NVENC/NVDEC, texture, décompression) font une partie du travail et sont souvent ignorées.
  4. 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.
  5. 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 :

\[x_{i+1} = a_i x_i + b_i\]

En représentant l'étape \(i\) par le couple \((a_i, b_i)\), la composition de deux étapes s'écrit :

\[(a_2, b_2) \circ (a_1, b_1) = (a_2 a_1,\ a_2 b_1 + b_2)\]

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