Gradient Accumulation Gone Wrong: Debugging Silent Training Divergence
Gradient accumulation is one of those features that feels benign until it isn't. You configure it to simulate a larger effective batch size across multiple forward passes, the training loop runs without error, and the loss curve slopes downward in a way that looks plausible. Then, six hours or six days in, you notice the validation metrics aren't tracking. The model is learning something, just not what you intended.
Photo by panumas nikhomkhai on Pexels.
This post is about the failure modes that live inside that silence.
What Accumulation Is Supposed to Do
When GPU memory won't fit your target batch size, gradient accumulation offers a trade: run N forward-backward passes without a parameter update, sum the gradients, then step the optimizer once. With N=8 and a per-device batch of 16, you get an effective batch of 128 without needing 8x the VRAM.
The trade has a hidden term. Every library implements the "sum" or "mean" side of that contract slightly differently, and mixing libraries, or mixing library versions, breaks the contract in ways that don't raise exceptions.
Three Ways It Breaks Silently
Reduction mismatch. PyTorch's CrossEntropyLoss defaults to reduction='mean' over the batch. With accumulation, you're averaging over a sub-batch, then averaging again when you step. The effective gradient is scaled by 1/(N*batch_size) instead of 1/total_batch_size. Training converges, but the learning rate is wrong by a factor of N. You'll often compensate by hand-tuning the LR and never realize why your sweep produced "unexpected" optima.
The fix: set reduction='sum' in your loss function and divide by total tokens or total samples yourself, once, before the optimizer step.
Mixed-precision gradient scaling with accumulation. torch.cuda.amp.GradScaler multiplies gradients by a dynamic loss scale to prevent underflow in fp16. If you call scaler.step() after accumulating without calling scaler.update() correctly, the scale can decay to a point where all gradients flush to zero. No error. No NaN. Just a model that stops learning around step 200 while the loss holds suspiciously flat.
The pattern to use:
for i, batch in enumerate(loader):
with torch.autocast(device_type='cuda'):
loss = model(batch) / accumulation_steps
scaler.scale(loss).backward()
if (i + 1) % accumulation_steps == 0:
scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
scaler.step(optimizer)
scaler.update()
optimizer.zero_grad()
Clipping after unscale_ is not optional. Clipping scaled gradients clips the wrong quantity.
Data pipeline desync in distributed runs. With DDP across 8 GPUs, each rank draws from its own DataLoader shard. If your accumulation loop doesn't coordinate the no_sync() context correctly, DDP triggers an all_reduce on every backward pass instead of only on the final step. You pay the communication cost N times and, depending on bucket boundaries, can get partial gradient reductions that corrupt the accumulated result.
The correct usage:
for i, batch in enumerate(loader):
ctx = model.no_sync() if (i + 1) % accumulation_steps != 0 else contextlib.nullcontext()
with ctx:
loss = model(batch) / accumulation_steps
loss.backward()
Missing no_sync() won't crash. On small models it barely affects results. On large models with long accumulation windows, the gradient staleness accumulates alongside the gradients.
Where to Look When Loss Curves Lie
graph TD
A[Loss curve looks normal] --> B{Validate metrics diverging?}
B -- Yes --> C{Check reduction mode in loss fn}
C --> D{Check GradScaler update order}
D --> E{Check DDP no_sync usage}
E --> F[Add per-step gradient norm logging]
B -- No --> G[Continue: monitor grad norms anyway]
Log gradient norms at every optimizer step, not every forward pass. A healthy run produces norms that vary but don't trend monotonically downward toward zero or spike without recovery. A SummaryWriter tag like train/grad_norm_global costs almost nothing and gives you something to look at when someone asks why the run from last Tuesday never converged.
Also checkpoint your effective batch size as a training hyperparameter alongside the learning rate. When someone tunes accumulation steps to fit a new GPU SKU without adjusting the LR schedule, you want the discrepancy visible in your experiment tracker, not buried in a config file that may or may not have been committed.
The Honest Assessment
Gradient accumulation is well-supported in frameworks like Hugging Face Accelerate (which wraps no_sync() for you automatically when you call accelerator.accumulate(model)). If you're writing accumulation logic by hand, you're carrying the reduction contract yourself. That's a reasonable thing to do. Carry it carefully.
The machine rewards precision. Sloppy bookkeeping at the gradient level surfaces weeks later, in a model that mostly works but never quite reaches the benchmark you were promised.
Get Omnissiah Systems in your inbox
New posts delivered directly. No spam.
No spam. Unsubscribe anytime.