Aller au contenu

5 · GPU et hétérogène

Complément de PGPU35. Ce chapitre traite ce que l'UE n'aura pas le temps de couvrir : les collectives entre accélérateurs, l'entraînement distribué, la portabilité au-delà de CUDA, et la méthodologie de portage d'un gros code.

Difficulté : ★★★ · ⏱ 30 h

Ressource principale, interne

Cette bibliothèque contient un cours complet et progressif sur le sujet : Développement GPU : de zéro à expert. Ce chapitre-ci ne le répète pas : il le complète sur les aspects « plusieurs accélérateurs » et « portabilité », et sur la méthodologie.

Les trois chiffres à connaître

Avant toute optimisation GPU, trois grandeurs de la carte utilisée :

  1. La bande passante mémoire, mesurée (par BabelStream, pas la fiche technique). C'est la borne de la majorité des noyaux.
  2. Le pic de calcul, en FP64, FP32 et en précision réduite avec les unités matricielles. Les trois diffèrent souvent d'un facteur important, et le rapport FP64/FP32 varie fortement selon que la carte est destinée au calcul scientifique ou au graphisme et à l'IA.
  3. La bande passante du bus hôte-accélérateur, qui est d'un à deux ordres de grandeur inférieure à la bande passante mémoire de la carte.

Le troisième chiffre est celui qui détermine la faisabilité d'un portage. Si le calcul ne fait pas assez d'opérations par octet transféré depuis l'hôte, le portage est perdant quel que soit le talent du programmeur.

Le critère de faisabilité d'un portage

Notons \(F\) le nombre d'opérations du noyau, \(D\) le volume de données à transférer depuis l'hôte, \(P_{\text{gpu}}\) le pic de calcul de l'accélérateur et \(B_{\text{bus}}\) la bande passante du bus.

Le portage a un intérêt si :

\[ \frac{F}{P_{\text{gpu}}} \gg \frac{D}{B_{\text{bus}}} \]

c'est-à-dire si le temps de calcul sur l'accélérateur dépasse largement le temps de transfert. Cela définit une intensité arithmétique minimale par rapport à l'hôte :

\[ \frac{F}{D} \gg \frac{P_{\text{gpu}}}{B_{\text{bus}}} \]

Le rapport de droite est grand — typiquement plusieurs centaines à plusieurs milliers d'opérations par octet. Conséquence : un noyau isolé à faible intensité ne doit pas être porté seul. Il faut porter toute la chaîne de calcul et garder les données sur l'accélérateur entre les étapes.

C'est la règle la plus importante du portage, et la plus souvent violée.

Le multi-accélérateur sur un nœud

Un nœud de calcul moderne porte quatre à huit accélérateurs, reliés entre eux par un réseau interne dont la bande passante est très supérieure à celle du bus vers l'hôte. Exploiter cela change tout.

Trois voies :

  1. Un processus, plusieurs accélérateurs (cudaSetDevice et ses équivalents). Le programme gère explicitement les appareils et les transferts entre eux. Contrôle maximal, code plus complexe.
  2. Un rang MPI par accélérateur. Le plus courant en HPC : chaque rang voit un seul appareil, et les échanges passent par MPI. Simple, et cela passe naturellement à plusieurs nœuds.
  3. NCCL ou RCCL pour les collectives entre accélérateurs. C'est ce qu'utilisent les bibliothèques d'apprentissage. Optimisé pour la topologie interne du nœud.

Le GPU-aware MPI permet de passer directement un pointeur de mémoire d'accélérateur à MPI_Send ou MPI_Allreduce. L'implémentation choisit le chemin le plus court : entre deux accélérateurs du même nœud, par le lien interne direct sans passer par l'hôte ; entre deux nœuds, par RDMA direct depuis la mémoire de l'accélérateur vers la carte réseau si le matériel le permet.

Le gain par rapport à une recopie manuelle vers l'hôte puis MPI puis recopie est substantiel, et c'est une ligne de code de différence. Vérifiez que votre MPI a été compilé avec le support de l'accélérateur : sinon le pointeur est invalide et le programme plante ou renvoie des ordures.

L'entraînement distribué de réseaux de neurones

Sujet absent de la maquette, et pourtant MLPerf figure au programme de la compétition SC26. C'est aussi la charge dominante des grands centres.

Le parallélisme de données

Le modèle le plus simple et le plus répandu. Chaque accélérateur détient une copie complète du modèle et traite une part différente du lot de données. À chaque pas :

  1. passe avant sur son sous-lot ;
  2. passe arrière, produisant des gradients locaux ;
  3. Allreduce des gradients sur tous les accélérateurs ;
  4. mise à jour des paramètres, identique partout.

L'étape 3 est un Allreduce sur un tampon de la taille du modèle, à chaque pas. Pour un modèle de cent millions de paramètres en 32 bits, cela fait 400 Mo réduits à chaque itération. C'est du HPC pur.

Les optimisations classiques :

  • Recouvrir la réduction et la passe arrière. Les gradients des dernières couches sont disponibles avant ceux des premières : on peut lancer leur réduction pendant que la passe arrière continue. C'est ce que fait DistributedDataParallel de PyTorch par défaut, en regroupant les gradients par paquets (buckets).
  • Réduire en précision inférieure. Transférer les gradients en 16 bits divise le volume par deux.
  • Choisir la bonne collective. NCCL implémente l'Allreduce en anneau ou en arbre selon la taille et la topologie.

Le parallélisme de modèle et de pipeline

Quand le modèle ne tient pas dans la mémoire d'un accélérateur. Deux découpages :

  • Parallélisme tensoriel : une même couche est répartie sur plusieurs accélérateurs, chacun calculant une part de la multiplication matricielle. Coûte des communications à chaque couche, donc réservé aux accélérateurs reliés par le lien interne rapide.
  • Parallélisme de pipeline : les couches sont réparties entre accélérateurs, et les micro-lots circulent en pipeline. Coûte moins de communications mais introduit des bulles d'inactivité.

Les systèmes de production (DeepSpeed, Megatron-LM, FSDP) combinent les trois formes de parallélisme. Les articles correspondants sont lisibles et instructifs.

La précision mixte

Calculer en 16 bits (FP16 ou BF16) tout en accumulant en 32 bits. Gain : un facteur deux à huit selon les unités matricielles disponibles, et la moitié de la mémoire.

Les deux formats 16 bits ne sont pas équivalents : FP16 a plus de mantisse et moins d'exposant, BF16 l'inverse. BF16 a la même plage d'exposant que FP32, ce qui évite les débordements et rend souvent l'entraînement plus stable sans mise à l'échelle de la perte (loss scaling).

Le lien avec le HPC : c'est exactement le principe du benchmark HPL-MxP, qui résout un système linéaire en précision réduite puis raffine itérativement en précision supérieure pour retrouver la précision FP64. La technique s'appelle raffinement itératif et elle est ancienne ; les unités matricielles l'ont rendue spectaculairement rentable.

La portabilité

Le problème stratégique du domaine : les grandes machines ne sont pas toutes du même constructeur, et un code écrit en CUDA n'est pas portable.

Approche Nature Portabilité Coût de portage depuis CUDA
CUDA Langage propriétaire NVIDIA seul —
HIP Langage, quasi-copie de CUDA AMD, NVIDIA Très faible (hipify automatise)
SYCL / oneAPI DPC++ C++ standard, standard Khronos NVIDIA, AMD, Intel, CPU Moyen
Kokkos, RAJA Bibliothèque C++ de templates Toutes, via des backends Moyen à élevé
OpenMP target Directives Toutes Faible (directives)
OpenACC Directives Principalement NVIDIA Faible
nvc++ -stdpar Algorithmes parallèles C++17 NVIDIA, et CPU par TBB Très faible si le code est déjà en STL

Recommandation pratique : pour un code neuf destiné à durer, Kokkos ou SYCL. Pour un gros code Fortran existant, OpenMP target, qui est le seul chemin réaliste. Pour un code CUDA existant à porter sur AMD, HIP, qui est conçu pour cela.

La question honnête à documenter : combien de performance perd-on par rapport au CUDA natif ? La réponse dépend du noyau et de la maturité du backend, et elle se mesure. Un projet qui mesure cela honnêtement sur un noyau réel produit un résultat rare et utile. C'est le sens du projet de PGPU35.

La méthodologie de portage d'un gros code

En sept étapes, dans cet ordre. C'est le protocole utilisé par les équipes qui portent des codes de production.

  1. Profiler le code CPU et identifier les noyaux qui portent 80 % du temps. En général trois à dix.
  2. Vérifier le critère de faisabilité ci-dessus pour l'ensemble de la chaîne, pas noyau par noyau.
  3. Définir la frontière de données. Décider quelles structures vivent en permanence sur l'accélérateur. C'est la décision de conception la plus importante : elle détermine le volume de transferts, donc la performance finale.
  4. Porter un noyau, valider numériquement, mesurer. Un seul, en entier, jusqu'à la validation.
  5. Porter les suivants et faire disparaître progressivement les transferts intermédiaires. La performance peut baisser pendant cette phase, parce que les allers-retours entre noyaux CPU et noyaux accélérés sont coûteux. C'est normal et il faut le savoir pour ne pas conclure trop tôt à l'échec.
  6. Optimiser les noyaux une fois la chaîne entière portée : coalescence, mémoire partagée, recouvrement, dimensionnement.
  7. Passer à plusieurs accélérateurs, puis à plusieurs nœuds.

L'erreur de séquençage la plus coûteuse

Optimiser un noyau avant d'avoir porté la chaîne entière. On passe une semaine à gagner 30 % sur un noyau qui représente 5 % du temps total, alors que les transferts hôte-accélérateur en consomment 60 %. La loi d'Amdahl s'applique au portage comme à tout le reste.

Exercices

E1 · ★★ ⏱ 3 h — Les trois chiffres. Mesurer, sur un accélérateur accessible : la bande passante mémoire par BabelStream, le pic de calcul en FP64, FP32 et précision réduite par des micro-noyaux ou par cuBLAS, et la bande passante du bus hôte-accélérateur en mémoire épinglée et non épinglée. Constituer la fiche de la carte. C'est le préalable à toute optimisation.

E2 · ★★ ⏱ 3 h — Le seuil de rentabilité. Écrire un noyau dont on peut faire varier l'intensité arithmétique (par exemple, appliquer \(k\) fois la même opération sur chaque élément). Mesurer le temps total, transferts inclus, en fonction de \(k\), sur CPU et sur accélérateur. Localiser le \(k\) à partir duquel l'accélérateur devient gagnant. Comparer à la prédiction du critère de faisabilité.

E3 · ★★★ ⏱ 4 h — Multi-accélérateur, trois voies. Distribuer un même calcul sur tous les accélérateurs d'un nœud, en API multi-appareil, avec un rang MPI par accélérateur, et avec NCCL pour les échanges. Mesurer les trois. Comparer aussi la version avec GPU-aware MPI et la version avec recopie manuelle par l'hôte.

E4 · ★★★ ⏱ 5 h — L'entraînement distribué, décomposé. Entraîner un modèle sur 1, 2, 4 puis 8 accélérateurs avec DistributedDataParallel. Mesurer séparément, à l'aide de Nsight Systems ou du profileur intégré : le temps de la passe avant, de la passe arrière, de la synchronisation des gradients, et du chargement de données. Tracer la décomposition en fonction du nombre d'accélérateurs. Vous identifierez le goulot, qui est souvent le chargement de données et non la communication.

E5 · ★★★ ⏱ 4 h — Précision mixte et raffinement itératif. Résoudre un système linéaire dense : en FP64 avec la bibliothèque de référence, puis en FP32 ou en précision réduite avec les unités matricielles suivi d'un raffinement itératif en FP64. Comparer le temps et l'erreur relative finale. Vous devriez retrouver la précision FP64 en une fraction du temps. C'est HPL-MxP en miniature.

E6 · ★★★★ ⏱ 6 h — Le même noyau, cinq fois. Implémenter un noyau non trivial — un stencil 3D, ou un SpMV — en CUDA, en HIP, en SYCL, en Kokkos et en OpenMP target. Mesurer les cinq sur le même matériel, et consigner aussi le temps de développement de chacun. Produire un tableau performance/effort. C'est un travail que peu de gens font et dont le résultat intéresse beaucoup de monde.

Ressources

Priorité 1 :

  • Le cours GPU de cette bibliothèque.
  • Hwu, Kirk, El Hajj, Programming Massively Parallel Processors, 4ᵉ éd., 2022.
  • Le CUDA C++ Programming Guide et le CUDA C++ Best Practices Guide.
  • Le blog technique de NVIDIA : séries sur les transferts de données, le recouvrement, les streams, le multi-accélérateur.

Priorité 2 :

  • La documentation de NCCL, et les articles de DeepSpeed et de Megatron-LM pour le parallélisme de modèle.
  • Micikevicius et al., « Mixed Precision Training », ICLR 2018.
  • Les tutoriels PyTorch sur DistributedDataParallel et FSDP.
  • MLPerf (mlcommons.org) : règlement et soumissions publiques. Les soumissions contiennent les configurations, ce qui en fait un manuel implicite.
  • BabelStream (github.com/UoB-HPC/BabelStream) : mesure de bande passante portable sur CPU, CUDA, HIP, SYCL, OpenMP. Outil idéal pour comparer des approches.

Priorité 3 :

  • Les Kokkos Lectures, enregistrées et publiées : le meilleur cours sur la portabilité de performance.
  • La documentation HIP d'AMD et l'outil hipify.
  • Les spécifications SYCL de Khronos, et la documentation oneAPI DPC++ ou AdaptiveCpp.
  • Les proxy apps du projet Exascale américain (LULESH, miniFE, XSBench, SW4lite) : des applications réduites conçues pour l'expérimentation de portage.
  • Nsight Systems et Nsight Compute : lire les guides d'utilisation, pas seulement cliquer.

À retenir

Les cinq règles du calcul sur accélérateur

  1. Le critère de faisabilité d'abord : le rapport opérations/octets-transférés-depuis-l'hôte doit dépasser le rapport pic-de-calcul/bande-passante-du-bus. Sinon, ne portez pas.
  2. Porter la chaîne, pas le noyau. Les données doivent rester sur l'accélérateur entre les étapes. C'est la décision de conception centrale.
  3. Le GPU-aware MPI est gratuit et rapporte beaucoup — à condition de vérifier que l'implémentation le supporte.
  4. Un entraînement distribué est une suite de GEMM séparés par des Allreduce : c'est du HPC classique, et le goulot est souvent le chargement de données, pas la communication.
  5. Pour un code neuf, visez la portabilité (Kokkos, SYCL) ; pour un gros code Fortran, OpenMP target ; pour porter du CUDA vers AMD, HIP.

Chapitre suivant : Entrées-sorties et stockage.