Polydesk-logotype
Polydesk.ai — Header

Gradient Accumulation

Le gradient accumulation (accumulation de gradients) est une technique d’entraînement qui simule un batch size plus grand en accumulant les gradients sur plusieurs micro-batches consécutifs avant de mettre à jour les poids du modèle.

Le problème est classique : vous voulez entraîner un LLM avec un batch global de 1024 échantillons pour une convergence optimale, mais votre GPU ne peut en traiter que 4 à la fois sans exploser sa mémoire. Le gradient accumulation résout cette impasse en traitant 256 micro-batches de 4 échantillons, en accumulant les gradients à chaque step, puis en ne faisant la mise à jour des poids qu’une seule fois toutes les 256 itérations. Le résultat mathématique est (quasi) identique à un entraînement avec un seul batch de 1024.

C’est l’une des techniques les plus simples et les plus universellement utilisées en deep learning. Tout framework moderne (PyTorch, Hugging Face, DeepSpeed, Lightning) la supporte nativement.

Gradient Accumulation en bref
Catégorie
Optimisation mémoire / Entraînement
Objectif
Simuler un batch size plus grand sans augmenter la mémoire GPU
Formule
Batch effectif = batch local × steps d’accumulation (× nombre de GPU)
Overhead
Entraînement plus lent (traitement séquentiel des micro-batches)
Compatible avec
DDP, FSDP, DeepSpeed, tout optimiseur
Paramètre Hugging Face
gradient_accumulation_steps

Comment fonctionne le gradient accumulation

Pour comprendre le gradient accumulation, il faut d’abord rappeler le cycle d’entraînement standard :

Entraînement classique (sans accumulation). Le modèle traite un batch complet en forward, calcule la loss, exécute le backward pour obtenir les gradients, puis l’optimiseur met à jour les poids. Chaque batch = une mise à jour.

Avec gradient accumulation (N steps). Le modèle traite un micro-batch en forward, calcule la loss (divisée par N), exécute le backward pour obtenir les gradients, mais au lieu d’appeler optimizer.step(), les gradients sont simplement accumulés (additionnés) dans le champ .grad de chaque paramètre. Ce cycle se répète N fois. À la N-ème itération, l’optimiseur met à jour les poids avec les gradients accumulés, puis les gradients sont remis à zéro.

L’astuce fondamentale est que la somme des gradients de N micro-batches équivaut au gradient d’un seul batch N fois plus grand, à condition de normaliser correctement la loss.

La normalisation de la loss : un piège classique

C’est le point le plus subtil du gradient accumulation, et la source d’erreurs la plus fréquente. Si vous utilisez une loss moyennée sur le batch (ce qui est le cas par défaut pour la plupart des fonctions de loss en PyTorch), vous devez diviser la loss par le nombre de steps d’accumulation avant le backward.

Pourquoi ? Parce que loss.backward() ajoute les gradients au champ .grad (il les accumule, justement). Si vous ne divisez pas, les gradients accumulés seront N fois trop grands, comme si vous aviez multiplié le learning rate par N.

# CORRECT : diviser la loss par le nombre de steps d'accumulation
loss = model(micro_batch) / accumulation_steps
loss.backward()

# INCORRECT : ne pas diviser
loss = model(micro_batch)
loss.backward()  # Gradients N fois trop grands !
Attention avec les loss par somme Si votre fonction de loss utilise reduction='sum' au lieu de reduction='mean', la normalisation est différente. Avec sum, les gradients sont proportionnels au nombre d’échantillons dans chaque micro-batch. Si les micro-batches ont des tailles variables (padding, dernier batch incomplet), les gradients accumulés ne seront pas correctement normalisés. Utilisez reduction='mean' + division par N pour un comportement prévisible.

Implémentation PyTorch complète

Voici le pattern complet de gradient accumulation en PyTorch pur :

import torch

accumulation_steps = 8
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4)

model.train()
optimizer.zero_grad()

for i, batch in enumerate(dataloader):
    inputs, labels = batch
    inputs, labels = inputs.to(device), labels.to(device)

    # Forward + loss normalisée
    outputs = model(inputs)
    loss = criterion(outputs, labels) / accumulation_steps

    # Backward (accumule les gradients)
    loss.backward()

    # Mise à jour uniquement tous les N steps
    if (i + 1) % accumulation_steps == 0:
        # Optionnel : gradient clipping
        torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

        optimizer.step()
        optimizer.zero_grad()

Dans cet exemple, le batch effectif est : batch_size_du_dataloader × 8. Si le DataLoader a un batch size de 4, le batch effectif est de 32.

Avec DDP (DistributedDataParallel)

En data parallelism distribué, DDP synchronise les gradients via all-reduce à chaque appel de loss.backward(). C’est un gaspillage quand on accumule : on ne veut synchroniser qu’au step final. PyTorch fournit le context manager model.no_sync() pour désactiver la synchronisation pendant l’accumulation :

import contextlib

for i, batch in enumerate(dataloader):
    # Désactiver la synchronisation DDP sauf au dernier step
    sync_context = model.no_sync() if (i + 1) % accumulation_steps != 0 
                   else contextlib.nullcontext()

    with sync_context:
        loss = model(batch) / accumulation_steps
        loss.backward()

    if (i + 1) % accumulation_steps == 0:
        optimizer.step()
        optimizer.zero_grad()

Sans no_sync(), DDP ferait un all-reduce à chaque micro-batch, ce qui gaspille de la bande passante réseau et ralentit significativement l’entraînement. L’all-reduce ne doit se produire qu’au moment de la mise à jour effective des poids.

Avec Hugging Face Trainer

Le Trainer de Hugging Face gère le gradient accumulation automatiquement :

from transformers import TrainingArguments

training_args = TrainingArguments(
    output_dir="./output",
    per_device_train_batch_size=4,
    gradient_accumulation_steps=8,  # Batch effectif = 4 × 8 = 32 (× num_gpus)
    learning_rate=3e-4,
    bf16=True,
)

Le Trainer gère automatiquement la normalisation de la loss, le no_sync() en mode distribué, et le gradient clipping. C’est la façon la plus simple d’utiliser le gradient accumulation pour le fine-tuning.

Avec DeepSpeed

DeepSpeed prend en charge le gradient accumulation via la configuration JSON :

{
    "train_batch_size": 256,
    "train_micro_batch_size_per_gpu": 4,
    "gradient_accumulation_steps": 8
}

Notez que DeepSpeed utilise une convention différente : train_batch_size = micro_batch × accumulation_steps × num_gpus. Vous pouvez spécifier train_batch_size et train_micro_batch_size_per_gpu, et DeepSpeed calculera automatiquement le nombre de steps d’accumulation, ou vice versa.

Calcul du batch size effectif

La formule complète du batch effectif dans un environnement distribué avec gradient accumulation :

batch_effectif = micro_batch_size × accumulation_steps × num_gpus

Exemple concret : vous avez 4 GPU, un micro-batch de 2 par GPU, et 16 steps d’accumulation. Le batch effectif est 2 × 16 × 4 = 128.

Pour les LLM, le batch effectif est souvent exprimé en tokens plutôt qu’en échantillons. Si chaque échantillon a une longueur de séquence de 2048 tokens, un batch effectif de 128 échantillons = 262 144 tokens par mise à jour.

Paramètre Scénario A (1 GPU) Scénario B (8 GPU) Scénario C (64 GPU)
Micro-batch size 4 4 4
Accumulation steps 32 4 1 (pas d’accumulation)
Nombre de GPU 1 8 64
Batch effectif 128 128 256
Temps relatif par step ~32x (séquentiel) ~4x ~1x

Le tableau illustre le compromis fondamental : le gradient accumulation simule un batch plus grand, mais les micro-batches sont traités séquentiellement. Plus vous accumulez, plus le step est lent. Ajouter des GPU est toujours préférable si possible, car le traitement est parallèle.

Quand utiliser le gradient accumulation

Mémoire GPU insuffisante

C’est le cas d’usage principal. Votre modèle + un batch de 32 ne tiennent pas en mémoire ? Utilisez un micro-batch de 4 avec 8 steps d’accumulation. Le résultat mathématique est identique, seul le temps de calcul est plus long (environ 8x par step, mais chaque step équivaut à 8 anciennes itérations).

Fine-tuning de LLM sur un seul GPU

C’est le scénario le plus courant. Quand vous fine-tunez un modèle 7B comme Llama 3 avec LoRA sur un GPU consommateur (24 Go), le micro-batch est souvent limité à 1 ou 2. Le gradient accumulation est indispensable pour atteindre un batch effectif de 8-32, nécessaire pour une convergence stable.

Remplacement du data parallelism

Si vous n’avez qu’un seul GPU mais avez besoin d’un grand batch, le gradient accumulation est la seule option. C’est du « data parallelism temporel » : au lieu de distribuer les données sur N GPU en parallèle, vous les traitez séquentiellement sur 1 GPU en N steps.

Quand NE PAS l’utiliser

Si votre batch souhaité tient en mémoire, n’utilisez pas le gradient accumulation. Le traitement séquentiel est toujours plus lent que le traitement en un seul batch. De même, si vous pouvez ajouter des GPU (via le data parallelism), c’est préférable car le calcul est parallèle.

Gradient Accumulation vs Gradient Checkpointing

Ces deux techniques sont souvent confondues car elles partagent le mot « gradient », mais elles résolvent des problèmes différents.

Critère Gradient Accumulation Gradient Checkpointing
Objectif Simuler un batch plus grand Réduire la mémoire des activations
Ce qui est économisé Mémoire des activations (batch plus petit) Mémoire des activations (stockage sélectif)
Coût Temps (traitement séquentiel) Calcul (~30% de forward en plus)
Mécanisme Accumule les gradients, retarde optimizer.step() Libère les activations, les recalcule au backward
Compatibilité Toujours compatible Nécessite de modifier le modèle ou la config
Combinable Oui (souvent combinées) Oui

En pratique, les deux techniques sont fréquemment combinées. Le gradient checkpointing réduit la mémoire des activations d’une couche (en recalculant au backward), tandis que le gradient accumulation réduit le nombre d’échantillons traités simultanément. Ensemble, elles permettent d’entraîner des modèles beaucoup plus grands sur du matériel limité.

Pièges et bonnes pratiques

Incompatibilité avec Batch Normalization

La Batch Normalization calcule la moyenne et la variance sur le micro-batch courant. Avec gradient accumulation, chaque micro-batch est petit, donc les statistiques de normalisation sont bruitées et instables. C’est l’une des raisons pour lesquelles les Transformers modernes utilisent Layer Normalization ou RMSNorm, qui sont indépendantes de la taille du batch.

Si votre modèle utilise du BatchNorm (courant en vision par ordinateur), le gradient accumulation peut dégrader les performances. Utilisez alors SyncBatchNorm en distribué, ou remplacez par GroupNorm.

Learning rate et warmup

Le gradient accumulation augmente le batch effectif. Si vous suivez la « linear scaling rule » (doubler le batch = doubler le learning rate), ajustez le learning rate en conséquence. Cependant, pour le fine-tuning de LLM avec des learning rates déjà faibles (1e-5 à 5e-5), cet ajustement est souvent négligé sans impact visible.

Learning rate scheduler

Attention au scheduler ! Si votre scheduler avance à chaque appel de optimizer.step() (ce qui est le comportement standard), il avancera au bon rythme. Mais si vous l’avez configuré pour avancer à chaque itération de la boucle de données, il sera N fois trop rapide. Avec Hugging Face Trainer, ce piège est automatiquement évité.

Logging et monitoring

La loss affichée pendant l’accumulation est celle du micro-batch courant (divisée par N). Ce n’est pas directement comparable à la loss d’un entraînement sans accumulation. Pour un logging cohérent, accumulez aussi la loss (en la sommant) et loggez la loss totale uniquement au step de mise à jour.

Dernier batch incomplet

Si le nombre de batches dans votre epoch n’est pas divisible par accumulation_steps, les derniers micro-batches ne déclencheront pas de mise à jour. Vous pouvez perdre des données d’entraînement silencieusement. Ajoutez un optimizer.step() après la boucle pour traiter les gradients restants, ou assurez-vous que le DataLoader a un nombre de batches divisible par N.

Techniques avancées

Accumulation dynamique

Certains frameworks permettent de modifier le nombre de steps d’accumulation pendant l’entraînement. Par exemple, commencer avec une accumulation de 16 (batch effectif élevé pour stabiliser le début) puis réduire à 4 (pour accélérer la convergence fine). PyTorch Lightning supporte cela via le paramètre accumulate_grad_batches qui peut accepter un dictionnaire mappant epoch vers nombre de steps.

Cette approche est utile dans les scénarios de curriculum learning où la complexité des données augmente progressivement. En début d’entraînement, un grand batch lisse les gradients bruités et aide à trouver une bonne direction générale. Plus tard, un batch plus petit permet des mises à jour plus fréquentes et une exploration plus fine de l’espace des paramètres.

Combinaison avec la mixed precision

L’entraînement en mixed precision (BF16/FP16) réduit la mémoire des activations et des gradients de moitié, ce qui permet d’augmenter le micro-batch avant de recourir au gradient accumulation. Commencez toujours par activer la mixed precision avant d’augmenter les steps d’accumulation, car la mixed precision est gratuite en performance (voire plus rapide grâce aux Tensor Cores) tandis que l’accumulation ajoute un coût temporel proportionnel.

L’ordre d’optimisation recommandé pour réduire l’utilisation mémoire est le suivant : 1) activer la mixed precision BF16, 2) activer le gradient checkpointing, 3) réduire le micro-batch size et ajouter du gradient accumulation, 4) passer à FSDP ou ZeRO si nécessaire. Chaque étape a un coût croissant en performance, donc on applique les solutions les moins coûteuses en premier.

Combinaison avec FSDP/ZeRO

Avec FSDP ou ZeRO de DeepSpeed, le gradient accumulation réduit aussi la fréquence des opérations de communication. Chaque micro-batch sans synchronisation évite un cycle all-gather + reduce-scatter. C’est un bonus de performance non négligeable en environnement distribué.


Questions fréquentes sur le gradient accumulation

Le gradient accumulation donne-t-il exactement le même résultat qu’un grand batch ?

Mathématiquement, oui, si la loss utilise reduction='mean' et que vous divisez correctement par le nombre de steps. Les gradients accumulés sont la somme des gradients de chaque micro-batch, ce qui est équivalent au gradient du batch complet. En pratique, de très légères différences numériques peuvent exister à cause de l’arithmétique en virgule flottante (les opérations ne sont pas parfaitement associatives), mais ces différences sont négligeables pour la convergence.

Combien de steps d’accumulation utiliser ?

Autant qu’il faut pour atteindre le batch effectif souhaité. Pour le fine-tuning de LLM, un batch effectif de 16 à 64 est un bon point de départ. Si votre micro-batch est de 2 sur un seul GPU, utilisez 8 à 32 steps. Au-delà de 64 steps, l’entraînement devient très lent (chaque mise à jour traite 64 micro-batches séquentiellement). Si vous avez besoin de plus, ajoutez des GPU plutôt que d’augmenter l’accumulation.

Faut-il ajuster le learning rate avec le gradient accumulation ?

Si vous partez d’un entraînement sans accumulation et ajoutez de l’accumulation tout en gardant le même micro-batch, le batch effectif augmente. La « linear scaling rule » recommande d’augmenter le learning rate proportionnellement. Mais en pratique, pour le fine-tuning de LLM, les learning rates sont souvent choisis empiriquement et restent inchangés. L’impact dépend du ratio d’augmentation du batch : doubler le batch n’a qu’un effet modéré, mais le multiplier par 16 nécessite probablement un ajustement.

Le gradient accumulation ralentit-il l’entraînement ?

Oui, proportionnellement au nombre de steps. Avec 8 steps d’accumulation, chaque mise à jour prend environ 8x plus de temps qu’un seul forward-backward. Cependant, chaque mise à jour est aussi « plus lourde » (batch 8x plus grand), donc le nombre total de mises à jour pour une epoch est 8x plus petit. Le temps total par epoch reste donc similaire. Le vrai coût apparaît quand on compare avec le data parallelism : 8 GPU en DDP traitent les 8 micro-batches en parallèle (1x le temps d’un seul), tandis que l’accumulation les traite séquentiellement (8x).

Le gradient accumulation fonctionne-t-il avec tous les optimiseurs ?

Oui, il est compatible avec SGD, Adam, AdamW, LAMB et tous les optimiseurs standard. L’accumulation se fait avant l’appel à optimizer.step(), donc l’optimiseur voit simplement des gradients « plus gros » (accumulés) et applique sa logique normalement. Les optimiseurs avec états internes (Adam) ne sont pas affectés car leurs moments sont mis à jour uniquement quand step() est appelé.

Polydesk.ai — Footer