ml-models / Training systems / Gradient accumulation core

Gradient accumulation

The batch you want doesn't fit in memory, so you run several smaller micro-batches, add their gradients into the same .grad buffers, and step the optimizer once. Done naively, that gives a gradient that is close to the big-batch one but not equal to it. This page works through the loss normalization that makes it exact on variable-length sequences, and shows why clipping, batch normalization and DistributedDataParallel each need care.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. The gradient is a sum
  5. What the loop must do once per step
  6. Normalization layers see micro-batches
  7. DDP and no_sync
  8. Worked example
  9. Three ways to scale the micro-batch losses
  10. Clip the sum, not the pieces
  11. Batch normalization under accumulation
  12. Communication with and without no_sync
  13. Implementation
  14. Tradeoffs
  15. Connections
  16. Read first
  17. Read next
  18. References

Problem

A recipe says "batch size 256 sequences of 2,048 tokens" (524,288 tokens per optimizer step). The learning rate, warmup and weight decay in that recipe were tuned for gradients averaged over that many tokens. Your GPU holds activations for 4 sequences at a time. Shrinking the batch to 4 changes the noise in every gradient and, with it, the learning rate that works (see learning-rate schedules), so the recipe no longer applies.

Activation memory scales with the micro-batch. The gradient is just a sum over examples, and backpropagation in PyTorch adds into param.grad rather than overwriting it. So you can run the batch in pieces, let the gradients pile up, and step once. The trade is time for memory: the same arithmetic, done sequentially, with one step at the end. The questions are how to scale each piece so that the sum is the gradient you meant, and which other parts of the training loop quietly assume one forward pass per step.

Intuition

Think of the gradient as a weighted vote. The full-batch mean-loss gradient gives every token (or example) the same weight, 1/N where N is the total count. If you split the batch into micro-batches and average each one separately, every token in a micro-batch of 4 tokens gets weight 1/4 of that micro-batch's share, while every token in a micro-batch of 8 gets 1/8. Short micro-batches are over-represented. With fixed-size images that never happens: every micro-batch has the same count, and averaging the averages is the average. With language data, padding and ignore_index make the counts differ, and the error is silent.

Four micro-batches add into one gradient buffer before a single optimizer step A timeline left to right. Four forward-and-backward boxes, for micro-batches with 8, 4, 7, 5 tokens, each scale their summed loss by one over 24, the total token count, and add into the same grad buffer drawn below them. Only after the fourth do clip, optimizer step and zero grad run, once. The buffer ends at the full-batch gradient. fwd + bwd 1 8 tokens + sum ÷ 24 fwd + bwd 2 4 tokens + sum ÷ 24 fwd + bwd 3 7 tokens + sum ÷ 24 fwd + bwd 4 5 tokens + sum ÷ 24 param.grad accumulates: (-8.837, -3.275) clip_grad_norm_ optimizer.step() zero_grad() once per step memory: activations of one micro-batch · result: the gradient of all 24 tokens

The other trap is anything that runs per forward pass or per backward pass rather than per step. Batch normalization computes statistics per forward, so it sees micro-batches, not the batch. Clipping a micro-batch's gradient clips the wrong vector. DDP all-reduces after every backward, so with accumulation it would communicate k times per step unless you tell it not to.

Mechanics

The gradient is a sum

Let the batch have micro-batches B1…Bk with nj counted tokens each and N = Σnj. The mean loss over the batch is

L = (1/N) Σj Σt ∈ Bj ℓt  ⇒  ∇L = Σj (1/N) ∇ Σt ∈ Bj ℓt

So each micro-batch should backpropagate its summed loss divided by the global N. The common recipe, loss = criterion(out, y) / k, backpropagates (1/k)(1/nj)Σℓt. That equals the line above only when every nj = N/k. CrossEntropyLoss with the default reduction='mean' divides by the number of non-ignored targets in its own call (ignore_index, reduction), which is exactly nj. Use reduction='sum' and divide by N yourself.

N has to be known before the first backward. On one GPU, count the targets that are not ignore_index across all micro-batches up front (the data is already loaded, or the collate step can report it). Under DDP, ranks see different N; all-reduce the count first and scale by world size, because DDP averages gradients across ranks (the note on summed versus averaged losses in its docstring is the same issue across GPUs).

What the loop must do once per step

zero_grad (which sets .grad to None by default) runs once at the start of the step, not per micro-batch, or it erases what you accumulated. step, the learning-rate scheduler and any EMA of weights run once at the end. Clipping (clip_grad_norm_) runs once, after the last backward and before the step, on the accumulated gradient. With mixed precision, GradScaler scales each backward but unscales and checks for infinities once, before clipping. Step counters that drive warmup count optimizer steps, not micro-batches.

Normalization layers see micro-batches

_BatchNorm.forward normalizes with the current batch's mean and variance in training mode and updates the running estimates with momentum 0.1 on every forward call. Under accumulation both change: each micro-batch is normalized with its own, noisier statistics, and the running averages are updated k times per step, so the effective momentum per optimizer step is 1 − (1 − 0.1)k. Neither is fixed by scaling the loss. The options are to accept it (micro-batches of 16+ are usually fine), to use SyncBatchNorm to pool statistics across GPUs (not across time), or to use a normalization that doesn't depend on the batch: LayerNorm and GroupNorm are identical under accumulation, which is one reason transformers are easy to accumulate.

DDP and no_sync

DistributedDataParallel hooks each parameter's gradient and, as buckets of 25 MiB fill during backward, launches an all-reduce that averages them across ranks. With accumulation that averaging would happen after every micro-batch, moving the full gradient over the network k times to produce the same sum. The no_sync context manager sets require_backward_grad_sync to False; the flag is read in _pre_forward, so the forward, not just the backward, must run inside the context. Gradients accumulate locally, and the last micro-batch runs outside it and syncs the sum once.

ComponentShape/ParamsMeaningSource
Optimizer.zero_gradset_to_none=TrueClears .grad; call once per optimizer step, before the first micro-batch.optimizer.py 1066–1128
Optimizer.stepclosure=NoneApplies the update from the accumulated .grad; once per step.optimizer.py 1130–1142
clip_grad_norm_max_norm, foreachGlobal-norm clip of the summed gradient; returns the pre-clip norm.clip_grad.py 185–232
CrossEntropyLossreduction, ignore_index'mean' divides by this call's non-ignored targets; use 'sum' and divide by the global count.loss.py 1200–1409
_BatchNorm.forwardmomentum=0.1Per-forward batch statistics and running-average update.batchnorm.py 176–226
DistributedDataParallelbucket_cap_mb (25 MiB default)All-reduces (averages) gradients bucket by bucket during backward.distributed.py 465–2776
DDP.no_synccontext managerSkips the all-reduce; gradients accumulate locally until the first backward outside it.distributed.py 1680–1707
DDP.forwardwraps the moduleCalls _pre_forward, which reads the sync flag, so forward must be inside no_sync.distributed.py 1906–1915

Worked example

A one-weight model ŷ = w·x + b at w = 0.5, b = 0, with per-token loss ½(ŷ − y)², stands in for a language model. The batch is 8 sequences of lengths 2, 6, 1, 3, 5, 2, 4, 1 (24 tokens), split into micro-batches of 2 sequences. Every number below comes from tested helpers in ml_models_docs.py.

Three ways to scale the micro-batch losses

Micro-batchTokens÷ (nj·k) contribution÷ N contribution
18(-2.116, -0.789)(-2.822, -1.052)
24(-2.536, -0.884)(-1.691, -0.590)
37(-1.753, -0.730)(-2.045, -0.852)
45(-2.736, -0.938)(-2.280, -0.781)
MethodGradient (∂w, ∂b)Loss
Full batch, mean over 24 tokens(-8.838, -3.275)6.684
Accumulate, each ÷ N (sum reduction)(-8.837, -3.275)6.684
Accumulate, mean ÷ k (common recipe)(-9.141, -3.341)6.898
Accumulate, mean, no ÷ k(-36.565, -13.365)27.592

Dividing by the global token count reproduces the full batch exactly. The common recipe is off by 3.4% in ∂w here because micro-batch 2 has only 4 tokens but gets the same quarter of the vote as micro-batch 1 with 8. Forgetting the ÷ k altogether makes the gradient 4× too large, which acts like a 4× learning rate.

Clip the sum, not the pieces

With max_norm = 10, the accumulated gradient has norm 9.425, so the correct loop doesn't clip at all. Clipping each micro-batch's own gradient (its contribution × k, the gradient that micro-batch alone would produce) clips a different vector:

Micro-batchOwn gradient normClip coefficient
112.0460.830
27.1621.000
38.8611.000
49.6411.000

Micro-batch 1 is scaled by 0.830 and the result is (-8.358, -3.096) instead of (-8.837, -3.275). A noisy micro-batch is clipped even though the batch's gradient is well inside the limit, so the update depends on how the batch happened to be split.

Batch normalization under accumulation

One feature over 8 examples, 1.0, 3.0 | 2.0, 6.0 | 4.0, 0.0 | 5.0, 3.0, split into micro-batches of 2:

BatchMeanVariance
All 83.003.50
Micro-batch 12.001.00
Micro-batch 24.004.00
Micro-batch 32.004.00
Micro-batch 44.001.00

Every micro-batch is normalized with a mean of 2 or 4 instead of 3, and a variance from 1 to 4 instead of 3.5. Starting from running mean 0 and variance 1, one update with the full batch gives (0.30, 1.30); four micro-batch updates give (1.05, 2.37), because momentum 0.1 applied 4 times is an effective momentum of 0.3439 per step. Loss scaling cannot fix either.

Communication with and without no_sync

A 1.3B-parameter model in fp32 gradients (5.2 GB) on 8 GPUs, accumulating 8 micro-batches of 4 sequences: global batch 256 sequences. A ring all-reduce sends and receives 2(p − 1)/p of the payload per GPU, 9.1 GB.

LoopAll-reduces per stepTraffic per GPU per step
Sync every micro-batch872.8 GB
no_sync for the first 719.1 GB

Both produce the same averaged gradient (averaging is linear), but one moves 8× the data. At an effective 100 GB/s per GPU that is about 0.73 s versus 0.09 s of communication each step, much of which the bucketed overlap with backward can't hide.

All-reduce after every micro-batch versus once with no_sync Two timelines of eight micro-batch backward passes. In the top one, each backward is followed by an all-reduce bar, eight in total, 72.8 GB per GPU. In the bottom one, the first seven run inside no_sync with no communication, drawn dashed, and only the eighth is followed by one all-reduce, 9.1 GB per GPU. sync every micro-batch · 8 all-reduces · 72.8 GB bwd 1 AR bwd 2 AR bwd 3 AR bwd 4 AR bwd 5 AR bwd 6 AR bwd 7 AR bwd 8 AR no_sync for 1–7 · 1 all-reduce · 9.1 GB bwd 1 bwd 2 bwd 3 bwd 4 bwd 5 bwd 6 bwd 7 bwd 8 AR dashed: gradients only added to local .grad · the sum is averaged across ranks once

Implementation

A DDP training step with token-count normalization, no_sync on all but the last micro-batch, and clipping, stepping and zeroing once:

import contextlib
import torch
import torch.distributed as dist
import torch.nn.functional as F

def train_step(model, optimizer, scheduler, micro_batches, max_norm=1.0):
    # micro_batches: list of (input_ids, labels), labels == -100 where padded
    local = sum((y != -100).sum() for _, y in micro_batches)
    total = local.clone()
    dist.all_reduce(total)                       # tokens across all ranks
    scale = dist.get_world_size() / total        # DDP averages over ranks

    optimizer.zero_grad(set_to_none=True)
    for i, (x, y) in enumerate(micro_batches):
        last = i == len(micro_batches) - 1
        ctx = contextlib.nullcontext() if last else model.no_sync()
        with ctx:                                # forward must be inside too
            logits = model(x)
            loss = F.cross_entropy(logits.flatten(0, 1), y.flatten(),
                                   ignore_index=-100, reduction="sum")
            (loss * scale).backward()
    norm = torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm)
    optimizer.step()
    scheduler.step()                             # once per optimizer step
    return norm

With GradScaler, wrap each backward as scaler.scale(loss * scale).backward() and call scaler.unscale_(optimizer) once before clipping, then scaler.step and scaler.update. Under FSDP the same no_sync exists but keeps unsharded gradients on every rank, so it trades memory back for communication (named here; the distributed-training page will cover it).

Tradeoffs

  • Time, not memory, is what you pay. Accumulating k micro-batches takes about as long as running the big batch would have, minus the GPU's loss of efficiency on smaller kernels (see GPU execution). If a larger micro-batch fits, prefer it.
  • Activation checkpointing is the alternative. Recomputing activations in backward cuts memory per example instead of examples per pass; the two combine, and checkpointing is better when even one sequence is too large.
  • Exactness needs the global count first. Token-count normalization requires knowing N (and, under DDP, an extra small all-reduce) before the first backward. Some codebases accept the per-micro-batch mean instead; with sequence packing to a fixed length the counts are nearly equal and the error is small.
  • Batch statistics don't accumulate. BatchNorm and anything else computed per forward (in-batch negatives for contrastive losses, MoE load-balancing statistics) sees the micro-batch. For in-batch negatives that is a real loss of signal; gradient caching exists for that case.
  • Dropout and data order are the same. Random masks per example and the sequence of examples don't change, so runs with and without accumulation differ only by floating-point summation order.
  • Large effective batches still need retuning. Accumulation lets you reach a batch size; it doesn't make that batch size a good one. Past the critical batch size, more tokens per step buy little.
Why does loss / k give the exact gradient for image classification but not for padded text?

Every image micro-batch has the same number of examples, so each mean has the same denominator and averaging the means is the overall mean. Padded text has a different number of counted tokens per micro-batch.

What breaks if model(x) runs outside no_sync and only backward() runs inside?

DDP reads the sync flag in _pre_forward and prepares the reducer there, so the backward still all-reduces. You pay the communication you meant to skip.

The example's four micro-batch BN updates give an effective momentum of 0.3439. What momentum per forward would give 0.1 per step?

1 − 0.91/4 ≈ 0.0260.

Connections

Read first

Read next

References

  • Goyal et al. Accurate, Large Minibatch SGD: Training ImageNet in 1 Hour. 2017.
  • McCandlish, Kaplan, Amodei and the OpenAI Dota Team. An Empirical Model of Large-Batch Training. 2018.
  • Li et al. PyTorch Distributed: Experiences on Accelerating Data Parallel Training. VLDB, 2020.
  • Ioffe and Szegedy. Batch Normalization: Accelerating Deep Network Training by Reducing Internal Covariate Shift. ICML, 2015.
  • Gao, Zhang, Han and Callan. Scaling Deep Contrastive Learning Batch Size under Memory Limited Setup (gradient cache). RepL4NLP, 2021.
  • PyTorch v2.15.0: DistributedDataParallel.no_sync, Optimizer.zero_grad and step, clip_grad_norm_, _BatchNorm.forward.