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.
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.
| Component | Shape/Params | Meaning | Source |
|---|---|---|---|
Optimizer.zero_grad | set_to_none=True | Clears .grad; call once per optimizer step, before the first micro-batch. | optimizer.py 1066–1128 |
Optimizer.step | closure=None | Applies the update from the accumulated .grad; once per step. | optimizer.py 1130–1142 |
clip_grad_norm_ | max_norm, foreach | Global-norm clip of the summed gradient; returns the pre-clip norm. | clip_grad.py 185–232 |
CrossEntropyLoss | reduction, ignore_index | 'mean' divides by this call's non-ignored targets; use 'sum' and divide by the global count. | loss.py 1200–1409 |
_BatchNorm.forward | momentum=0.1 | Per-forward batch statistics and running-average update. | batchnorm.py 176–226 |
DistributedDataParallel | bucket_cap_mb (25 MiB default) | All-reduces (averages) gradients bucket by bucket during backward. | distributed.py 465–2776 |
DDP.no_sync | context manager | Skips the all-reduce; gradients accumulate locally until the first backward outside it. | distributed.py 1680–1707 |
DDP.forward | wraps the module | Calls _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-batch | Tokens | ÷ (nj·k) contribution | ÷ N contribution |
|---|---|---|---|
| 1 | 8 | (-2.116, -0.789) | (-2.822, -1.052) |
| 2 | 4 | (-2.536, -0.884) | (-1.691, -0.590) |
| 3 | 7 | (-1.753, -0.730) | (-2.045, -0.852) |
| 4 | 5 | (-2.736, -0.938) | (-2.280, -0.781) |
| Method | Gradient (∂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-batch | Own gradient norm | Clip coefficient |
|---|---|---|
| 1 | 12.046 | 0.830 |
| 2 | 7.162 | 1.000 |
| 3 | 8.861 | 1.000 |
| 4 | 9.641 | 1.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:
| Batch | Mean | Variance |
|---|---|---|
| All 8 | 3.00 | 3.50 |
| Micro-batch 1 | 2.00 | 1.00 |
| Micro-batch 2 | 4.00 | 4.00 |
| Micro-batch 3 | 2.00 | 4.00 |
| Micro-batch 4 | 4.00 | 1.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.
| Loop | All-reduces per step | Traffic per GPU per step |
|---|---|---|
| Sync every micro-batch | 8 | 72.8 GB |
no_sync for the first 7 | 1 | 9.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.
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
- The training loop intro
Read next
- Distributed training advanced
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_gradandstep,clip_grad_norm_,_BatchNorm.forward.