What is gradient accumulation and why is it useful?
Gradient accumulation averages gradients from N micro-batches and updates model weights once, producing an effective batch size equal to micro-batch size times accumulation steps times data-parallel device count. It makes a larger training batch possible when activation memory is limited, but it can reduce throughput and is not exactly equivalent when batch-dependent layers or incorrect loss normalization are involved.
How to think about it
Gradient accumulation lets you train with a large effective batch even when the GPU cannot hold that many examples at once. The model processes several small micro-batches, adds their gradients while keeping the weights fixed, and calls the optimizer only after N micro-batches.
Why it works
A micro-batch is the small group of examples processed in one forward pass. A forward pass produces predictions. The loss is a number measuring the prediction error. A backward pass uses automatic differentiation to calculate how each model weight contributed to that error. Those calculated values are the gradients.
Normally, a training loop does this:
- Clear old gradients.
- Run one batch through the model.
- Run backward propagation.
- Update the weights with
optimizer.step().
With accumulation, step four waits. The gradients from each backward pass are added to gradient buffers. After N micro-batches, the optimizer sees one combined gradient and updates the weights once.
If each micro-batch contains b examples, and there are N accumulation steps, the effective batch size is:
effective batch size = b × N
With ordinary data-parallel training across D devices, it is:
global effective batch size = b × N × D
The important memory trick is that the model does not need to keep all N micro-batches in memory. After backward propagation finishes, the temporary intermediate tensors used by that micro-batch can be released. The gradient buffers remain, but they have roughly the size of the model parameters, not N times that size.
The usual implementation divides each micro-batch loss by N before calling backward. For equal-sized micro-batches, that produces the same average gradient as processing one large batch:
combined gradient = (gradient 1 + gradient 2 + ... + gradient N) / N
That division is not cosmetic. Without it, the accumulated gradient is N times larger. You have silently increased the update size, which is similar to multiplying the learning rate by N.
Common mistake: accumulating the loss values and calling backward only once at the end is not the same thing. That keeps every computation graph alive until the final backward pass, which can use as much memory as the large batch you were trying to avoid. Accumulate gradients, not graphs.
A concrete example
Suppose a language model fits only 8 sequences per GPU, but experiments work best with a batch of 64 sequences. Set:
- Micro-batch size: 8
- Accumulation steps: 8
- Effective batch size: 8 times 8, or 64
- Optimizer updates: 1 update for every 64 sequences
Imagine a toy one-dimensional example. The per-example gradient values in two micro-batches are:
| Micro-batch | Per-example gradients | Mean gradient |
|---|---|---|
| 1 | 2, 4 | 3 |
| 2 | 6, 8 | 7 |
The gradient for one batch containing all four examples is 5, because (2 + 4 + 6 + 8) / 4 = 5.
Accumulation gets the same result when each micro-batch loss is divided by 2: (3 + 7) / 2 = 5. If you simply add the two mean gradients, you get 10, which is twice the intended update.
A basic PyTorch pattern looks like this:
import torch.nn.functional as F
accum_steps = 8
num_micro_batches = len(loader)
optimizer.zero_grad(set_to_none=True)
for micro_step, batch in enumerate(loader):
logits = model(batch["inputs"])
loss = F.cross_entropy(
logits,
batch["labels"],
reduction="mean",
)
remaining = num_micro_batches - micro_step
group_size = min(accum_steps, remaining)
(loss / group_size).backward()
is_full_group = (micro_step + 1) % accum_steps == 0
is_last_group = micro_step == num_micro_batches - 1
if is_full_group or is_last_group:
torch.nn.utils.clip_grad_norm_(
model.parameters(),
max_norm=1.0,
)
optimizer.step()
optimizer.zero_grad(set_to_none=True)
The code clips the combined gradient immediately before the update. Gradient clipping means capping the gradient norm so one unusually large update cannot destabilize training. Clipping each micro-batch separately is a different algorithm; the combined gradient should normally be clipped after accumulation.
The example handles a final partial group by dividing by its actual number of micro-batches. Another valid choice is to configure the data loader to discard incomplete groups when a fixed effective batch is required. For variable-sized examples, weighting by the number of examples rather than merely counting micro-batches is more precise.
With mixed-precision training, the same principle applies: divide the loss before scaling it for backward propagation, and unscale the accumulated gradients before clipping. The optimizer and any loss scaler should advance only when optimizer.step() actually runs.
The senior-level nuance
Gradient accumulation is exact only under useful but limited assumptions. The model parameters must remain unchanged during all micro-batches, the loss must be normalized correctly, and the computation must not depend on seeing the whole effective batch at once.
Batch normalization is the classic exception. A batch-normalization layer computes statistics from the examples in its current batch. A real batch of 64 therefore uses statistics from 64 examples. Eight accumulated micro-batches of 8 use eight separate sets of statistics from 8 examples. The weights receive an accumulated gradient, but the forward passes were not identical. Layer normalization or group normalization avoids this particular mismatch.
Loss normalization also matters in language models. Suppose one micro-batch contains 100 valid tokens with mean loss 0.2, while another contains 10 valid tokens with mean loss 1.0. Averaging those two batch means gives 0.6. The token-weighted mean is (100 × 0.2 + 10 × 1.0) / 110, which is about 0.273. If padding or sequence lengths vary, averaging micro-batch means gives short batches too much influence. Sum the valid-token losses and divide by the total valid-token count instead.
In distributed training, the effective global batch includes the number of data-parallel devices. For example, 4 GPUs, 8 sequences per GPU, and 4 accumulation steps produce a global effective batch of 4 × 8 × 4 = 128 sequences. Communication can be reduced by synchronizing gradients only on the final micro-batch, but that requires the distributed framework to preserve the correct gradient scaling.
Accumulation also changes optimizer timing. Adam, for example, maintains moving averages of gradients and squared gradients. Those state variables update once per effective batch, not once per micro-batch. A learning-rate scheduler should likewise advance once per optimizer update. If the intended configuration is batch size 64 and you reproduce it with micro-batches of 8 and eight accumulation steps, use the schedule in terms of those optimizer updates, not eight scheduler steps per update.
Trade-offs and when not to use it
Accumulation solves an activation-memory problem. It does not make the model’s parameters, gradients, or optimizer states smaller. If the process runs out of memory while creating Adam’s optimizer state, accumulation will not rescue it. You need parameter sharding, a smaller model, lower-precision states, or another memory strategy.
It also does not make training free. Processing 64 examples as one batch and processing them as eight batches requires roughly the same forward and backward arithmetic, but small micro-batches often use the GPU less efficiently. There are more launches and synchronization boundaries, so training can take longer per token.
A larger effective batch reduces gradient noise because averaging more examples makes the update direction less variable. That can make training steadier, but it can also change convergence and generalization. Learning-rate warmup and batch-size scaling rules are starting points, not laws. If the model is sensitive to frequent updates, accumulation may hurt even though it fits in memory.
Failure modes you should recognize
-
The gradient is cleared inside the micro-batch loop. The symptom is that training behaves like batch size 8 instead of 64: gradient norms are noisier, and the effective batch-size experiment shows no benefit. Call
zero_gradonly after an optimizer update, or before starting a new accumulation group. -
The loss is not divided by the accumulation count. With eight micro-batches, gradient norms can be about eight times larger than intended. The loss may spike, weights may become
NaN, or training may diverge immediately. If deliberately summing gradients, reduce the learning rate accordingly. -
The scheduler advances on every micro-batch. Warmup ends eight times too early, or the learning rate reaches its final value long before the planned number of updates. Count optimizer steps, not data-loader iterations.
-
A partial final group is scaled as if it were full. The final update is too small when only three micro-batches remain but every loss was divided by eight. Either drop the remainder or normalize it using the actual group size.
What they’ll ask next
Is gradient accumulation exactly equivalent to using a physically larger batch?
Not always. For a mean-reduced additive loss and batch-independent layers, it can produce the same gradient up to floating-point and randomness differences. Batch normalization, variable token counts, per-batch augmentation, and other batch-dependent operations make it only an approximation.
Should I multiply the learning rate by the accumulation count?
Not when accumulation is replacing a target large batch. If the intended batch is 64 with a learning rate of 0.001, use 0.001 with eight micro-batches of 8. If you are changing an existing batch-8 training run into an effective batch of 64, retuning may help. Linear learning-rate scaling is a heuristic, mainly associated with SGD-style training, not a universal Adam rule.
Does accumulation save training time?
It saves memory, primarily activation memory. It usually does not save wall-clock time and can be slower than a true large batch because small batches use hardware less efficiently. Its value is that the desired batch fits at all.
Say this in the interview
“Gradient accumulation averages gradients from several memory-sized micro-batches and performs one optimizer update, giving me a larger effective batch without storing all of it at once; I must scale the loss correctly and step the optimizer and scheduler only after the accumulation window.”