TAI BUI
Back to curriculum
CapstonePython

Gradient Accumulation

Train at a batch size you can't fit, one micro-batch at a time. Scale the loss, hold the optimizer step, and let the gradients pile up.

TIME: ~90 minutesPREREQUISITES: Phase 19 lessons 42 to 45

Learning Objectives

  • Derive the effective batch identity: `effective_batch = micro_batch * accum_steps`.
  • Implement loss-per-micro-batch scaling so the accumulated gradient matches a single full-batch backward.
  • Skip optimizer synchronization until the last micro-batch (sync-on-last-step).
  • Read a throughput against effective batch curve and explain the diminishing return.

The Problem

Gradient accumulation means running several forward and backward passes before a single optimizer step, so the gradients sum inside the parameter buffers and the update behaves as if you had used one large batch. The effective batch is the micro-batch size times the number of accumulation steps.

Say you want an effective batch of 512 because the loss curve is smoother and the optimizer step makes more sense at that scale. The accelerator on the desk holds 32 examples before it runs out of memory. Doubling the batch is not an option. Halving the model is not an option. The trick the field reached for in 2017 and kept using is to run 16 backward passes, let the gradients accumulate inside the parameter buffers, and only step the optimizer when the count reaches the target.

The risk is that the loss is no longer the same number it was at the bigger batch. The cross entropy of 16 mini-batches summed naively is 16 times the loss of one full batch. Without scaling, the gradient direction is correct but the magnitude is wrong, and the optimizer step is 16 times too big. The fix is one division, and it is easy to forget.

Warm-up0/3
1. You call loss.backward() twice in a row on two different batches without calling zero_grad() in between. What does param.grad hold afterward?Warm-up
2. What usually stops you from using a very large batch on a single accelerator?Warm-up
3. A classifier uses cross entropy with the default mean reduction over a batch. If every example in a batch of 16 has a loss of 2.0, what is the batch loss?Warm-up
Check: The Problem0/3
1. What is gradient accumulation?Define
2. Why must each micro batch's loss be divided by accum_steps before backward?Explain
3. You forget the division by accum_steps with accum_steps=8 and plain SGD. What happens at the first optimizer step?Apply

The Concept

Mermaid diagram

The contract has four parts:

  • Divide each micro-batch's loss by accum_steps before backward(). PyTorch sums gradients into param.grad by default, and the division brings the running sum back to the right scale.
  • Fire the optimizer step once per effective batch, after the last micro-batch's backward. Stepping mid-accumulation skews every parameter the rest of the run depends on.
  • Advance the optimizer's state (momentum buffers, Adam moments) once per effective step, not once per micro-batch. Otherwise the exponential moving averages see the wrong frequency and burn through the schedule.
  • On a single device this is bookkeeping. On a multi-rank cluster the same pattern wraps the non-final micro-batches in a no_sync context that skips the gradient all-reduce, so the last micro-batch reduces the full accumulated gradient in one pass instead of paying the network cost N times.

The equivalence proof in code

python
loss = criterion(model(x_full), y_full)
loss.backward()
opt.step()

is equivalent to

python
for x, y in chunks(x_full, y_full, n):
    scaled = criterion(model(x), y) / n
    scaled.backward()
opt.step()

up to floating point summation order. The accumulated gradient buffer at the end of the loop is the same tensor a single full-batch backward would produce. equivalence_check returns the max absolute gradient and parameter differences, and main() asserts both stay under 1e-4.

Where the cost goes

Each micro-batch costs one forward and one backward, so accumulation trades memory for time. The throughput curve in outputs/accum-curve.json shows what happens as the effective batch grows at fixed micro-batch:

Mermaid diagram

There is no free lunch. Doubling accum_steps doubles the wall time per optimizer step. What changes is the variance of the gradient estimate: at the same wall budget you take fewer optimizer steps, but each one is averaged over more samples. The literature treats large batch and small batch as different optimization problems. The point here is mechanical, not statistical.

Check: The Concept0/15
1. What is a micro batch?Define
2. In a gradient accumulation run, what does accum steps count?Define
3. What does effective batch mean in a gradient accumulation run?Define
4. What is loss scaling in this lesson?Define
5. What does sync on last mean?Define
6. Why is the accumulated gradient still correct when the non-final micro batches run inside no_sync on a multi-GPU run?Explain
7. Why is the optimizer stepped only after the last micro batch's backward, not after each one?Explain
8. How often does optimizer state such as Adam's moment estimates advance during an accumulation window?Explain
9. The lesson says accumulated and full-batch gradients match 'up to floating point summation order'. What does that mean?Explain
10. You double accum_steps at a fixed micro batch. What does the lesson say changes?Explain
11. At a fixed micro batch, doubling accum_steps doubles the wall time per optimizer step and also doubles the samples each step sees. What does the throughput against effective batch curve do?Explain
12. On one process you use a micro batch of 8 and 6 accumulation steps. What is the effective batch?Apply
13. A teammate divides each loss by accum_steps correctly but calls optimizer.step() after every micro batch. What is wrong?Analyze
14. On a 4-GPU DDP run, raising accum_steps from 1 to 8 makes each effective step much slower than the extra compute explains, and the network is busy during every micro batch. Which ingredient is missing?Analyze
15. A sweep at fixed micro batch shows median_step_ms roughly doubling each time accum_steps doubles. What is the right conclusion?Analyze

Build It

code/main.py is the runnable artifact. It does three things.

Step 1: equivalence check

equivalence_check() builds two copies of the same network with the same seed. One sees a 16-sample batch in one forward pass. The other sees four 4-sample chunks with the loss divided by four. The function compares the gradient buffers before the optimizer step and the parameters after. It returns max_grad_diff, max_param_diff and params_init_match, and main() asserts both diffs are under 1e-4.

Step 2: sync-on-last-step pattern

train_one_optimizer_step walks the micro-batches. Every micro-batch except the last enters no_sync_context(model). On a single process the context is a no-op; on DDP it is where the gradient all-reduce gets skipped. The bookkeeping is the same either way. A sync_counter increments on each synced backward (the last micro-batch, or every micro-batch if no_sync_until_last is False), so with the default it grows by one per effective step, not N, and accumulates across num_steps.

Step 3: the throughput curve

sweep_effective_batches runs the same model with a fixed micro-batch and a list of accumulation steps. For each setting it logs:

  • samples_per_sec: total samples seen divided by wall time
  • median_step_ms: 50th percentile per effective step
  • sync_calls: collective points exercised
  • avg_loss: average across the sweep's optimizer steps

The output lands in outputs/accum-curve.json and is reusable from a notebook.

Run it:

bash
python3 code/main.py

The script prints the equivalence diff, then the sweep table, then the JSON path. Exit code zero.

Check: Build It0/9
1. What do equivalence_check and main() in main.py do together?Define
2. On a single process, what does no_sync_context actually skip in main.py?Explain
3. In the single-process demo with accum_steps=4, num_steps=25 and no_sync_until_last on, what does sync_calls report?Apply
4. The same demo runs with accum_steps=4 and num_steps=25, but no_sync_until_last is False. What does sync_calls report?Apply
5. In train_one_optimizer_step with accum_steps=4, the four raw micro batch losses are 2.0, 1.0, 3.0 and 2.0. What loss value does the function return?Apply
6. What happens when run_config is called with effective_batch=5 and accum_steps=2?Apply
7. The sweep runs with its defaults: micro-batch 4 and accum-grid 1,2,4,8,16. What is the last row's effective batch and micro batch?Apply
8. equivalence_check reports params_init_match: false and a large max_grad_diff. What should you suspect first?Analyze
9. You wrote a new accumulation loop and a throughput chart. What should you do before trusting the chart?Design

Use It

In production training, gradient accumulation lives behind one knob. PyTorch's pattern is accumulation_steps = effective_batch // (micro_batch * world_size). That is the general formula for a multi-rank run; the demo here is a single process, so world_size is 1. The frameworks you are not allowed to use here wrap the same loop with the same steps: scale the loss, skip sync on non-final micros, accumulate, step once.

Three patterns show up in practice:

  • Pick the micro-batch size to saturate device memory. Smaller wastes accelerator cycles, and larger crashes.
  • Pick the effective batch from a learning rate schedule. Large effective batches need scaled learning rates and warmup, which is the linear scaling rule people have talked about since 2017.
  • Treat the accumulation count as the bridge between the two. It is a knob you can tune at runtime without rewriting the data loader.
Check: Use It0/5
1. As the effective batch grows very large, what does the linear scaling rule recommend?Define
2. How should you pick the micro batch size in a production accumulation setup?Explain
3. Using the Use It formula, you want an effective batch of 512 with a micro batch of 32 on 4 ranks. What accumulation_steps do you set?Apply
4. You want to compare effective batches of 128, 256 and 512 on a device that holds 32 examples, without touching the model or data loader. Which design fits?Design
5. A run used 4 ranks, micro batch 32 and accum_steps 4. You must move to 8 ranks with the same micro batch and the same effective batch. What accum_steps do you set?Design

Ship It

outputs/skill-gradient-accumulation.md captures the recipe so a peer can drop it into a new repo: scale loss by accum_steps, skip optimizer sync on non-final micros, step the optimizer once per effective batch, and log throughput against effective batch as JSON so the trade is visible.

Exercises

  1. Re-run the sweep with --num-steps 100 and plot samples per second against effective batch. Where does the curve flatten?
  2. Add a wrong scaling variant (no division) and show the parameter diff at step 1 against the reference.
  3. Swap SGD for AdamW and confirm the optimizer state advances once per effective step, not once per micro-batch.
  4. Introduce a real DistributedDataParallel wrapper and route the no_sync_context to its method. Confirm the number of all-reduces per effective batch drops from N to 1, which is what sync_calls counts when no_sync_until_last is on.
  5. Modify the equivalence check to compare two different micro splits (2 by 8 vs 4 by 4) and explain any tolerance you need to relax.

Key Terms

TermWhat people sayWhat it actually means
Micro batchThe batch you forwardThe slice that fits in memory in a single forward pass
Accum stepsBackward passes per stepNumber of backwards summed before one optimizer step
Effective batchThe batchMicro batch times accum steps, times world size when training is data parallel (the demo is single process)
Loss scalingDivide by NPer-micro-batch division so summed gradients match full batch
Sync on lastSkip the restOnly run the gradient collective on the last backward in the window
Author's Note

My bet is that the missing division is the mistake worth guarding against first, because it fails quietly: the gradient direction is right and only the step size is wrong. I'd run the no-division variant from the exercises early, so you see the oversized parameter step for yourself instead of taking my word for it.

I'd also treat the equivalence check as the gate for everything after it. If the gradients from one 16-sample batch and four 4-sample chunks don't match under 1e-4, the throughput curve is measuring a different training run than the one you think you have.

One thing I'd keep in mind when you read the sweep: on a single process the no_sync context is a no-op, so nothing is actually skipped here. The sync_calls column counts the one synced backward per effective step, which is where the all-reduce would fire on a cluster. The saving only becomes real once you wire in DDP, which is what the DDP exercise asks for.

Further Reading

  • PyTorch docs on DistributedDataParallel.no_sync for the production version of the sync-on-last-step trick.
  • Goyal et al., 2017, on linear scaling for large batch training, the canonical reason to care about effective batch.
  • PyTorch issue tracker on gradient accumulation interactions with mixed precision unscaling.
  • Phase 19 lessons 42 to 45 cover the model, data loader, optimizer, and trainer scaffolding this lesson assumes.
  • Phase 19 lesson 47 covers checkpoint and resume so a long accumulation run survives a wallclock cap.

Code

Ships With