LangueFR
Français
English
Retour au sommaire du cours

Passer à l'échelle : GPU, entraînement distribué, performance

Le module 2 traite la concurrence côté processeur. Ici, il s'agit du matériel qui fait réellement tourner l'apprentissage profond. Le message central tient en une phrase : sur un GPU moderne, la puissance de calcul n'est presque jamais le facteur limitant — c'est la mémoire, sa bande passante et les transferts. Comprendre cela change complètement la façon d'optimiser un entraînement.

1. Anatomie d'un GPU

Un processeur est conçu pour exécuter vite une séquence d'instructions compliquées : quelques cœurs puissants, de gros caches, de la prédiction de branchement. Un GPU est conçu pour l'inverse : exécuter la même instruction sur des milliers de données simultanément. Des milliers de cœurs simples, organisés en multiprocesseurs, exécutent les threads par groupes de 32 appelés warps, en mode SIMT — tous les threads d'un warp exécutent la même instruction au même instant.

la conséquence immédiate
if (condition dépendant du thread) { A } else { B }

Dans un warp où la moitié des threads prend A et l'autre B, le GPU exécute
A pour tous (en masquant la moitié), PUIS B pour tous (en masquant l'autre) :
le temps d'exécution est la SOMME des deux branches, pas le maximum.
→ toute divergence de branche à l'intérieur d'un warp sérialise l'exécution.

Les tensor cores, unités dédiées au produit matriciel en précision réduite, fournissent l'essentiel de la puissance annoncée sur les fiches techniques — mais uniquement pour des opérations qui prennent cette forme.

La hiérarchie mémoire est ce qui gouverne les performances réelles :

de la plus rapide à la plus lente
registres              ~ 0 cycle       quelques Ko par multiprocesseur
mémoire partagée       ~ 20 cycles     ~100 Ko par multiprocesseur, gérée à la main
cache L2               ~ 200 cycles    quelques dizaines de Mo
mémoire globale (HBM)  ~ 500 cycles    des dizaines de Go, ~2-3 To/s de bande passante
mémoire hôte (RAM)     ~ 10 000 cycles via PCIe : ~30 Go/s → 100× plus lent que la HBM

2. Intensité arithmétique et modèle roofline

L'intensité arithmétique — le nombre d'opérations effectuées par octet lu en mémoire — décide du régime dans lequel on se trouve :

\[ I = \frac{\text{FLOP}}{\text{octets transférés}}, \qquad \text{performance} \leq \min\big(P_{\max},\; I \times BP\big) \]
le modèle roofline, et où tombent les opérations réelles
  perf │                    ┌──────────────── P_max (limite de CALCUL)
 (FLOP/s)│                 ╱ │
        │              ╱    │      matmul de grande taille (I ≈ 100)
        │           ╱       │      → limité par le CALCUL
        │        ╱          │
        │     ╱  ← pente = bande passante mémoire
        │  ╱ │
        │╱   │ addition, ReLU, normalisation (I ≈ 0,5-2)
        └────┴──────────────────────► intensité arithmétique I
             └── limité par la MÉMOIRE : le GPU attend, ses unités de calcul
                 tournent à vide l'essentiel du temps

Un produit matriciel de grande taille lit \(2n^2\) valeurs et fait \(2n^3\) opérations : son intensité croît avec \(n\), il sature donc le calcul. Une addition élément par élément lit deux valeurs, en écrit une et fait une opération : son intensité est de l'ordre de 0,1 — elle est limitée par la bande passante, et le GPU passe son temps à attendre la mémoire.

La conséquence pratique : fusionner les noyaux

Enchaîner trois opérations élémentaires — multiplier, ajouter un biais, appliquer une activation — demande trois allers-retours complets en mémoire globale : lire, écrire, relire, réécrire, relire, réécrire. Les fusionner en un seul noyau qui fait les trois opérations pendant que la donnée est dans les registres divise le trafic par trois, et le temps d'autant. C'est précisément ce que fait torch.compile automatiquement, et ce que fait FlashAttention à la main pour l'attention (module 16) : au lieu de matérialiser la matrice d'attention \(n \times n\) en mémoire globale, elle la calcule par blocs dans la mémoire partagée. Le gain ne vient pas d'un calcul plus malin — le nombre d'opérations est identique — mais de la mémoire économisée.

3. Précision numérique et calcul mixte

formats et usages
          signe+exposant+mantisse   plage        précision   usage
FP32      1 + 8 + 23                ±10³⁸        ~7 chiffres  référence historique
TF32      1 + 8 + 10                ±10³⁸        ~3 chiffres  matmul FP32 sur tensor cores
FP16      1 + 5 + 10                ±65 504      ~3 chiffres  rapide, mais DÉBORDE facilement
BF16      1 + 8 + 7                 ±10³⁸        ~2 chiffres  même plage que FP32 : stable
FP8       1 + 4 + 3                 restreinte               très grands modèles
INT8      entier                                             inférence quantifiée (module 22)

La ligne à retenir : BF16 sacrifie de la précision, FP16 sacrifie de la plage. Or en apprentissage, les gradients sont souvent minuscules (\(10^{-7}\)) : en FP16 ils tombent à zéro, alors qu'en BF16, dont l'exposant est identique à celui du FP32, ils restent représentables. C'est pourquoi BF16 est devenu le défaut dès que le matériel le supporte.

Le calcul en précision mixte stocke une copie maîtresse des poids en FP32 mais effectue les passes avant et arrière en 16 bits : mémoire divisée par deux, débit multiplié par deux à trois, sans perte mesurable. Avec FP16, le loss scaling est indispensable — on multiplie la perte par un grand facteur pour remonter les petits gradients dans la plage représentable, puis on divise avant la mise à jour.

Python — PyTorch
from torch.amp import autocast, GradScaler

scaler = GradScaler()                      # inutile en bf16, indispensable en fp16
for x, y in loader:
    opt.zero_grad(set_to_none=True)        # set_to_none : moins de trafic mémoire
    with autocast("cuda", dtype=torch.bfloat16):
        perte = critere(modele(x), y)
    scaler.scale(perte).backward()
    scaler.step(opt); scaler.update()

4. Le budget mémoire d'un entraînement

La question « pourquoi ce modèle ne tient-il pas sur ma carte ? » se répond par une addition. Pour \(N\) paramètres entraînés avec Adam en précision mixte :

décomposition de la mémoire
Poids (bf16)                  2N octets
Copie maîtresse (fp32)        4N
Gradients                     4N
États Adam (m et v, fp32)     8N     ← Adam stocke DEUX moments par paramètre
──────────────────────────────────────
Total "statique"             ~18N octets  → un modèle de 1 Md de paramètres = ~18 Go
                                            AVANT la moindre activation

Activations                  ∝ BATCH × profondeur × longueur de séquence
                             et c'est presque toujours ce qui fait déborder la carte

Les leviers, par ordre de coût croissant :

  • Réduire la taille de lot et compenser par accumulation de gradient : on fait quatre passes avant/arrière sans mettre à jour, puis une seule mise à jour. Le lot effectif est identique, la mémoire d'activations divisée par quatre.
  • Gradient checkpointing — ne conserver que certaines activations et recalculer les autres pendant la passe arrière. Environ 30 % de temps en plus pour une réduction mémoire d'un facteur cinq à dix : l'échange est souvent excellent.
  • Optimiseur moins gourmand — SGD avec momentum coûte 4N au lieu de 8N, Adafactor bien moins encore.
  • Distribuer les états d'optimiseur entre plusieurs cartes (ZeRO, section 7).
# accumulation de gradient : lot effectif = 4 × taille du mini-lot
for i, (x, y) in enumerate(loader):
    with autocast("cuda", dtype=torch.bfloat16):
        perte = critere(modele(x), y) / 4          # on moyenne sur les 4 micro-lots
    perte.backward()                                # les gradients S'ACCUMULENT (module 3)
    if (i + 1) % 4 == 0:
        opt.step(); opt.zero_grad(set_to_none=True)

modele.gradient_checkpointing_enable()              # recalcul plutôt que stockage

Par ailleurs, la fragmentation de l'allocateur explique beaucoup d'erreurs de mémoire insuffisante alors qu'il « reste » de la place : la variable d'environnement PYTORCH_CUDA_ALLOC_CONF, réglée sur expandable_segments:True, aide souvent.

5. Profiler avant d'optimiser

L'intuition se trompe presque toujours. Avant d'acheter une carte plus puissante ou de réécrire un noyau, la seule question utile est : le GPU travaille-t-il ? Un nvidia-smi qui affiche 30 % d'utilisation signale un problème en amont — chargement des données, transferts, synchronisations — que le matériel ne résoudra pas. Un entraînement limité par un décodage d'images sur processeur ne va pas plus vite sur une carte deux fois plus chère.
Python — profiler PyTorch
from torch.profiler import profile, ProfilerActivity, schedule

with profile(
    activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
    schedule=schedule(wait=1, warmup=1, active=3),   # ignorer les premières itérations
    record_shapes=True, profile_memory=True,
    on_trace_ready=torch.profiler.tensorboard_trace_handler("./logs"),
) as prof:
    for i, (x, y) in enumerate(loader):
        etape_entrainement(x, y)
        prof.step()
        if i > 6: break

print(prof.key_averages().table(sort_by="cuda_time_total", row_limit=15))
les signaux à chercher dans la trace
Longs trous entre noyaux CUDA     le GPU ATTEND les données → section 6
Beaucoup de temps dans            transferts hôte↔GPU excessifs → réduire les
  aten::copy_                     allers-retours, utiliser pin_memory
Multitude de petits noyaux        candidats à la FUSION → torch.compile
  élément par élément
Appels bloquants                  .item(), print(tenseur), .cpu() DANS la boucle :
                                  chacun force une SYNCHRONISATION et casse le
                                  pipeline asynchrone. Accumuler et n'afficher
                                  qu'en fin d'époque.

6. Le pipeline d'entrée, goulot n° 1

  • Plusieurs workers — num_workers à 4-8 par GPU, avec persistent_workers=True et prefetch_factor pour que le chargement recouvre le calcul. C'est du parallélisme de processus (module 2), avec les mêmes contraintes de sérialisation.
  • Mémoire épinglée — pin_memory=True plus .to(device, non_blocking=True) permettent un transfert asynchrone par DMA pendant que le GPU calcule. Sans mémoire épinglée, le système doit d'abord recopier dans une zone verrouillée, ce qui sérialise.
  • Format de stockage — des millions de petits fichiers écroulent n'importe quel système de fichiers (chaque ouverture est un aller-retour). Regrouper en archives séquentielles (WebDataset, TFRecord) ou en Parquet transforme des accès aléatoires en lecture séquentielle, souvent un facteur dix.
  • Décodage — le décodage JPEG et les augmentations sur processeur saturent vite : les déporter sur GPU (NVIDIA DALI, torchvision.io avec décodage matériel) libère le goulot.
  • Prétraitement figé — tout ce qui est déterministe (redimensionnement, normalisation, tokenisation) se calcule une fois et se stocke, plutôt qu'à chaque époque.

7. Entraînement distribué

les stratégies de parallélisme
DONNÉES (DDP)     chaque GPU a une COPIE COMPLÈTE du modèle et traite un sous-lot
                  différent ; les gradients sont moyennés par all-reduce.
                  Simple, efficace, LE DÉFAUT — tant que le modèle tient sur une carte.

ZeRO / FSDP       on PARTITIONNE états d'optimiseur, puis gradients, puis paramètres
                  entre les GPU, rassemblés à la demande couche par couche.
                  Permet d'entraîner des modèles bien plus gros que la mémoire d'une
                  carte, au prix de communications supplémentaires.

TENSEUR           une même couche est découpée entre plusieurs GPU (les matrices sont
                  partitionnées). Communication intense à chaque couche : réservé à
                  l'intérieur d'un nœud, où les liens sont rapides (NVLink).

PIPELINE          les couches sont réparties par étages ; les micro-lots circulent.
                  Attention à la "bulle" : les premiers étages attendent pendant que
                  les derniers finissent.

CONTEXTE          découpe la dimension de séquence, pour les très longs contextes.
Python — DDP, l'essentiel
# lancement : torchrun --nproc_per_node=4 entrainement.py
import os, torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP
from torch.utils.data.distributed import DistributedSampler

dist.init_process_group("nccl")                  # NCCL : la bibliothèque de
rang_local = int(os.environ["LOCAL_RANK"])       # communication GPU de NVIDIA
torch.cuda.set_device(rang_local)

modele = DDP(modele.to(rang_local), device_ids=[rang_local])
echantillonneur = DistributedSampler(dataset)    # partition SANS recouvrement

for epoque in range(n):
    echantillonneur.set_epoch(epoque)            # sinon le même ordre à chaque époque
    for xb, yb in loader:
        ...                                      # DDP moyenne les gradients
                                                 # automatiquement au backward()
    if rang_local == 0:                          # journaliser et sauvegarder
        torch.save(modele.module.state_dict(), "ckpt.pt")   # .module : le vrai modèle

Trois points de vigilance. L'efficacité de passage à l'échelle n'est jamais de 100 % : la communication (all-reduce) croît avec le nombre de cartes, et huit GPU donnent typiquement un facteur six à sept, pas huit — il faut la mesurer, pas la supposer. Le lot effectif est multiplié par le nombre de GPU, ce qui impose d'ajuster le taux d'apprentissage et le warmup (module 8) ; sinon la convergence se dégrade sans qu'on comprenne pourquoi. Enfin, la reproductibilité demande de gérer les graines par rang et l'ordre de l'échantillonneur.

8. Optimiser l'inférence

En production, le coût cumulé de l'inférence dépasse largement celui de l'entraînement : un modèle s'entraîne une fois et répond des millions de fois. C'est donc là que l'optimisation rapporte le plus. Les leviers, par ordre de rentabilité :

  • Regroupement dynamique — traiter plusieurs requêtes ensemble améliore radicalement le débit, car l'inférence d'un petit lot est limitée par la bande passante et non par le calcul (section 2) : charger les poids coûte pareil pour une requête ou pour trente-deux. C'est ce que font vLLM et Triton (module 22).
  • Quantification — INT8 ou 4 bits : mémoire divisée, latence réduite, dégradation faible avec les schémas modernes.
  • Compilation — torch.compile, ONNX Runtime, TensorRT : fusion de noyaux et spécialisation aux formes d'entrée, souvent un facteur deux sans rien changer au modèle.
  • Cache KV — en génération, conserver les clés et valeurs déjà calculées évite de tout recalculer à chaque jeton ; sa gestion mémoire est le principal enjeu du service de LLM.
  • Distillation — entraîner un petit modèle à imiter le grand : le gain le plus important quand la charge est massive et stable.
  • Ne pas utiliser de GPU — pour un gradient boosting ou un petit réseau, un processeur bien utilisé (ONNX Runtime, threads correctement bornés — module 2) est moins cher et plus simple à exploiter.

L'arbitrage de fond reste latence contre débit : un gros lot maximise le débit et dégrade la latence individuelle, puisque la première requête attend que le lot se remplisse. La bonne cible se déduit du contrat de service, pas d'un réflexe d'optimisation.

9. Coût, énergie, louer ou posséder

Un calcul de coin de table vaut mieux qu'une intuition : coût \(\approx\) prix horaire de l'instance \(\times\) nombre de GPU \(\times\) durée. À quelques euros de l'heure par carte, un entraînement d'une semaine sur huit GPU se chiffre en milliers d'euros — ce qui justifie largement de passer deux jours à profiler avant de lancer. Les instances spot ou préemptibles divisent la facture par deux ou trois, à condition que l'entraînement sache reprendre depuis un point de contrôle, ce qui est de toute façon une bonne pratique.

Sur l'énergie : la consommation se calcule à partir de la puissance des cartes, du PUE du centre de données et de l'intensité carbone du réseau électrique — codecarbon l'automatise. Le choix du fournisseur et de la région pèse davantage que la plupart des optimisations logicielles, et c'est une information qui a sa place dans une model card (module 26).

Enfin, l'arbitrage louer/posséder : au-delà d'une utilisation soutenue, une machine locale devient rentable — c'est ce qui m'a conduit à installer un GPU dans mon NAS pour l'inférence de modèles locaux. Il faut simplement compter le coût complet : matériel, électricité, refroidissement, temps d'administration, et l'impossibilité de changer d'échelle rapidement.

Avant de passer à l'échelle

La bonne question n'est pas « comment répartir ce calcul sur huit GPU ? » mais « ce calcul est-il nécessaire ? ». Réduire la résolution des images, échantillonner les données, utiliser un modèle pré-entraîné plus petit (module 13), élaguer l'espace d'hyperparamètres par successive halving (module 14) ou par recherche bayésienne (module 19) : ces décisions font gagner des ordres de grandeur, là où la distribution en fait gagner un facteur huit au mieux. Passer à l'échelle est la dernière étape, pas la première.

Ce qu'il faut retenir du module

  • La plupart des opérations d'un réseau sont limitées par la bande passante mémoire, pas par le calcul : fusionner les noyaux est le levier principal.
  • BF16 en précision mixte est le défaut actuel ; FP16 impose le loss scaling car sa plage est étroite.
  • Un entraînement Adam coûte environ 18 octets par paramètre avant les activations ; accumulation et checkpointing sont les premiers leviers mémoire.
  • Profiler avant d'optimiser : le goulot est souvent le chargement des données ou une synchronisation accidentelle.
  • DDP par défaut ; FSDP/ZeRO quand le modèle ne tient plus ; ajuster le taux d'apprentissage au lot effectif.
  • En production, l'inférence coûte plus que l'entraînement : regroupement, quantification, compilation, distillation.
  • Réduire le travail à faire bat toujours la parallélisation de ce travail.

GPUCUDAMixed precisionDDPFSDPProfiling