system_ml / 11 · Gradient accumulation lesson 11 / 26

Gradient accumulation & the effective batch

The optimizer cares about one number — the effective batch size — and the hardware imposes another — the micro-batch that fits in HBM. Gradient accumulation is the lever that decouples them: run several micro-batches, sum their gradients, step once. It is also where DDP's communication can be quietly cut by a factor of G.

Two batch sizes that want to be different

There are two completely separate batch sizes in a training run, and conflating them is the most common source of "why won't my loss go down" confusion.

Data parallelism (lesson 04) already multiplies the micro-batch by the world size: N ranks each run b samples and AllReduce the gradients, so one step sees b·N samples. Gradient accumulation multiplies it again, in time:

B_eff = b · N · G

where G is the number of micro-batches accumulated before stepping. The point: if the optimizer wants B_eff = 4{,}096 but each GPU only fits b = 8 and you have N = 64 ranks, you're at 512 — short by 8×. Set G = 8 and you hit the target without buying more GPUs and without OOMing.

The mechanic — why summing gradients just works

A loss averaged over a batch is the mean of per-sample losses, and the gradient is linear in the loss, so the gradient of a big batch is the average of the gradients of its parts:

∇L(B) = (1/G) · Σ_{g=1}^{G} ∇L(micro-batch g)

So you don't need all G·b samples in memory at once. Run micro-batch 1, backward, add its gradients into the .grad buffers (PyTorch's backward() accumulates by default — this is why you call zero_grad()). Run micro-batch 2, add. … After G of them, the buffer holds the summed gradient; scale by 1/G, take one optimizer step, zero the buffers. The activations of micro-batch g are freed before g{+}1 starts, so peak activation memory is set by b, not G·b.

One precision detail: accumulate the gradients in fp32. The reason is bf16's mantissa: it carries only ~7 mantissa bits, so once a running sum grows past a micro-batch gradient by more than ~2⁷ in magnitude, that small addend rounds away entirely — sum G bf16 partials into a bf16 buffer and you silently drop the low-order contributions of the later (or smaller) micro-batches. A running fp32 accumulator has ~23 mantissa bits, enough headroom to keep those small addends, so the standard practice is to keep the .grad accumulator in fp32. (This is the same fp32 master-gradient buffer that mixed-precision training maintains for the optimizer step — detailed in lesson 17 — but the argument here stands on its own: it is purely about not losing bits when you sum many small numbers.)

"Peak memory unchanged" is a DDP statement, not an FSDP one
The claim that peak memory is unaffected holds for plain DDP: you hold one micro-batch's activations and a single full-size gradient buffer regardless of G. Under FSDP it needs a caveat. FSDP normally reshards (frees) each layer's gathered gradients right after its backward, but skipping the inter-rank reduction across accumulation steps with no_sync (the comm win below) means those gradients cannot be resharded yet — the full, unsharded gradients stay resident for the whole accumulation window. That raises peak memory. So no_sync under FSDP trades memory for communication; on a tight budget you keep syncing (resharding) each step and forgo the saving. Peak activation memory is unchanged either way — it's the gradient residency that moves.
The one bug everyone writes
The loss must be divided by G somewhere — either scale each micro-batch's loss by 1/G before backward(), or divide the accumulated gradient before step(). Forget it and your effective learning rate is too large; the run diverges and looks like a bad LR.
The LM-specific version — divide by total tokens, not by G

For a token-averaged language-model loss the naïve "scale each micro-batch loss by 1/G" is wrong whenever the micro-batches hold unequal numbers of (non-padding) tokens. The true big-batch loss is the sum of all per-token losses divided by the total token count Σ_g t_g — so micro-batch g should contribute with weight t_g / Σ_g t_g, not a flat 1/G. Dividing each already-token-averaged micro-loss by G instead silently up-weights short sequences and down-weights long ones, biasing the gradient.

Concrete mini-example, G = 2: micro-batch 1 has t_1 = 100 tokens with mean loss 2.0; micro-batch 2 has t_2 = 900 tokens with mean loss 4.0. The correct token-weighted loss is (100·2.0 + 900·4.0)/1000 = 3.8. The flat-1/G recipe gives (2.0 + 4.0)/2 = 3.0 — it treats the 100-token batch as equal to the 900-token one, mis-weighting them by and skewing the gradient toward the short sequence. The fix: accumulate unnormalised token-summed losses (and the token count) across the G passes, then divide once by Σ_g t_g before step().

When is it exactly equivalent to a real big batch?
For any layer whose statistics are per-sample — LayerNorm, RMSNorm, attention — accumulation is numerically identical to one big batch (bf16 summation order aside). It is not identical for cross-sample ops like BatchNorm, whose statistics depend on the whole batch. Transformers use LayerNorm precisely, so for LLMs accumulation is exact. This is one quiet reason BatchNorm fell out of favour for large-scale training.

Interactive · compose the effective batch

Three multipliers, one product. Slide the micro-batch (bounded by what fits in HBM), the data-parallel world size, and the accumulation depth. The grid shows every sample that contributes to a single optimizer step, coloured by which rank and which accumulation pass produced it. The memory bar tracks only b — notice that cranking G grows the effective batch without moving the memory bar at all.

Effective batch composer · b × N × G
Each cell is one training sample feeding one optimizer step. Columns = the N data-parallel ranks (synced by AllReduce); row-blocks = the G accumulation passes (free, sequential in time). Within a block, height = micro-batch b.
effective batch
memory ∝ micro-batch
samples / GPU / step
step time vs G=1

The free win — skipping AllReduce with no_sync

Here is the systems subtlety. Naïve DDP fires an AllReduce on every backward() — it overlaps the reduction with the backward compute (lesson 04). But during accumulation, the gradients of micro-batches 1 … G{-}1 are intermediate: they're going to be summed locally anyway. AllReducing each one is pure waste — you'd average a partial sum across ranks, then keep adding to it, then average again. The mathematically identical and far cheaper plan is: accumulate locally and silently for the first G{-}1 passes, AllReduce only on the G-th.

PyTorch exposes this as the model.no_sync() context manager (and FSDP has an equivalent). The effect on communication is exactly : one AllReduce per optimizer step instead of one per micro-batch.

DDP accumulation · with and without no_sync
Each strip is one optimizer step made of G micro-batches. Grey = local forward+backward (no comm). Orange = an AllReduce on the inter-rank link. Toggle no_sync and watch G{-}1 of the orange bars vanish.
AllReduces / step
comm volume vs naïve
comm-bound step time
Why this is a real lever, not a micro-optimisation
At large G on an inter-node link (lesson 03, IB ~50 GB/s), the gradient AllReduce is a big fraction of step time. Cutting it can be the difference between communication-bound and compute-bound. This is also why accumulation pairs so naturally with the inter-node dimension of HSDP and 3D layouts: do the cheap local accumulation on the slow link's schedule, sync once.

How big should the effective batch be? The critical batch size

Bigger batch → less gradient noise → you can take a larger, more confident step → fewer optimizer steps to reach a target loss. So why not make B_eff enormous? Because the returns saturate. McCandlish et al. (2018) formalised this: there is a critical batch size B_crit set by the gradient's noise-to-signal ratio, and

steps(B) ≈ S_min · (1 + B_crit / B) examples(B) ≈ E_min · (1 + B / B_crit)

Read the two curves against each other. Below B_crit, gradient noise dominates: doubling the batch nearly halves the number of steps — you're "buying" wall-clock speedup almost for free (each step processes more data but the step count drops proportionally). Above B_crit, the gradient is already clean: doubling the batch barely reduces steps but doubles the data (and compute) you burn per step. B_crit is the knee — the most parallelism you can exploit before you're just wasting FLOPs. Crucially B_crit is not static: it tracks the gradient noise scale, which rises as training proceeds (the loss falls, the gradient signal shrinks relative to its per-sample noise, so a larger batch is needed to average that noise down) — which is why large runs ramp the batch size over training rather than fixing it, starting small while the gradient is informative and growing it as the knee moves right.

Critical batch size · the steps-vs-compute Pareto
Blue = optimizer steps to target (you want this low for wall-clock). Orange = total examples/compute to target (you want this low for efficiency). The green line is B_crit; the marker is your chosen B_eff. Past the knee, blue flattens while orange climbs.
steps vs minimum
compute vs minimum
regime

Linear LR scaling — the batch and the learning rate move together

You cannot change B_eff and leave the learning rate alone. The widely-used rule of thumb (Goyal et al., 2017, "1 hour ImageNet"): in the small-batch regime, scale the learning rate linearly with the batch size — double B_eff, double the LR — because a larger batch gives a lower-variance gradient that a bigger step can safely exploit. Two caveats that matter in practice:

Reproducibility footgun
Because accumulation, DP world size, and micro-batch all multiply into the same B_eff, a run that changes any one of them silently changes the effective batch — and therefore needs a re-scaled LR to match. Resuming a checkpoint on a different node count (lesson 26) with the same config but a different N is a classic way to accidentally retune your optimizer. Pin B_eff and back-solve G from the hardware, not the other way around.

Putting it together — the sizing recipe

  1. Pick B_eff from the optimization side — at or just below B_crit for the model/data. This is a convergence decision, not a hardware one.
  2. Find the largest micro-batch b that fits in HBM after FSDP + activation checkpointing (lessons 05, 20) — that's the memory budget talking.
  3. Read off your DP world size N from the cluster you have.
  4. Solve G = B_eff / (b·N), rounding to keep B_eff on target. Turn on no_sync so the G{-}1 intermediate AllReduces disappear.
  5. Set LR by linear scaling from a known-good small-batch baseline, with warmup.
Takeaway
The effective batch b·N·G is an optimization target; the micro-batch is a memory constraint; accumulation G is the slack between them — and it's nearly free (peak activation memory unchanged, and DDP comm cut with no_sync — though under FSDP that same no_sync keeps full gradients resident and does raise peak). The right B_eff sits near the critical batch size, beyond which extra batch buys steps you don't need at the price of compute you can't afford. Whatever you pick, the LR scales with it.