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.
📋 Au programme
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.
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 :
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 :
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 tempsUn 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
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.
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 :
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 carteLes 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 stockagePar 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
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.
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))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, avecpersistent_workers=Trueetprefetch_factorpour 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=Trueplus.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.ioavec 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é
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.# 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èleTrois 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.