DistributedDataParallel raises an internal reducer assertion when a backward pass arrives unexpectedly
DDP's gradient reducer arms itself for exactly one backward pass per forward and disarms once that pass is finalised. A second backward, or a backward whose forward was run under a different synchronisation context, arrives when the reducer is not armed and trips an internal assertion whose text invites you to file a PyTorch bug.
Count your backward calls: DDP expects exactly one per forward. Disable static_graph to confirm, use non-reentrant gradient checkpointing, and keep each forward and its backward inside the same no_sync context.
What this failure is
An internal assertion in DDP's gradient reducer raised when autograd hooks fire while the reducer is not armed for a backward pass, caused by backward counting or synchronisation-context mismatches rather than by a defect in PyTorch.
Why it happens (the mechanism)
DDP overlaps gradient reduction with backward computation by attaching hooks to parameters and reducing buckets as they fill. That only works if it knows when a backward begins and ends, so it arms itself before one and disarms after. Anything that produces an extra backward, or that moves a backward relative to the forward it belongs to, breaks the bookkeeping rather than the mathematics, and the assertion is the bookkeeping noticing.
What you'll observe
- The error asks you to report a bug to PyTorch, which is almost never the right response
- The same model trains correctly on a single GPU and only fails once wrapped in DDP
- It appears after enabling gradient checkpointing or a static graph, neither of which mentions backward counting
- The traceback points into C++ reducer internals with no application frame to act on
Common symptoms and what they mean
| Symptom | Why it happens |
|---|---|
| RuntimeError: expect_autograd_hooks_ INTERNAL ASSERT FAILED at ../torch/csrc/distributed/c10d/reducer.cpp | The reducer keeps a flag saying a backward pass is expected. DDP sets it when it prepares for backward and clears it when that pass is finalised, and the assertion fires when hooks arrive while the flag is clear. The condition is therefore about counting and ordering, not about the values in any tensor. |
| please report a bug to PyTorch appended to the assertion text | Static graph tightens the contract: it promises the same graph and exactly one backward per iteration so the reducer can precompute its bucket order. A second backward, or a backward on a second returned tensor, then reaches a reducer that has already finalised and is no longer expecting anything. |
| The failure appearing only when the model is wrapped in DistributedDataParallel | Gradient checkpointing conflicts by re-running the forward during backward, which can fire the same hooks twice. Its reentrant implementation is the one that does this; the non-reentrant implementation exists partly to be compatible with hook-based systems like DDP. |
| Onset coinciding with enabling static_graph or gradient checkpointing rather than with a model change | Using no_sync incorrectly desynchronises the same flag. If a forward runs inside the context and its backward outside it, or the reverse, the reducer's expectation and the actual sequence diverge and the assertion is how that divergence surfaces. |
Which systems are affected
- DDP with static_graph enabled, which assumes one unchanging backward per iteration
- Gradient checkpointing under DDP, where the forward is re-run during backward and can re-fire hooks
- Gradient accumulation using no_sync, where a forward and its backward can end up on opposite sides of the context
- Models returning several tensors that are each backwarded separately
How to confirm this is the problem
Apply the following checklist to a small reproduction: each box below is a positive signal that you are looking at this exact failure rather than a sibling in the same taxonomy.
- ✓Count backward calls per forward in the training step. More than one against the DDP-wrapped module is the condition the assertion describes.
- ✓Disable static_graph and re-run. Success identifies the strict contract as the trigger even when the underlying pattern is what needs changing.
- ✓Disable gradient checkpointing and re-run. Working with one of the two enabled but not both is the signature of the reentrant checkpointing conflict.
Example training logs (fingerprint)
[rank0]: RuntimeError: expect_autograd_hooks_ INTERNAL ASSERT FAILED at "../torch/csrc/distributed/c10d/reducer.cpp":1603, please report a bug to PyTorch.
[rank0]: No no_sync() context - works without errorTimestamps and exact values vary across runs, but the pattern. An info-level start, an early WARN, an ERROR carrying the symptom. Is the actual fingerprint you should alert on. The Denpex platform flags this combination automatically.
Root cause, fix & prevention, signed in
Sign up free to see why this failure really happens, the exact remediation steps, and the production-grade prevention pattern. You also get 3 free full diagnoses for your own training logs.
Sign up free. Unlock the full analysisNo credit card · 3 free diagnoses · Instant access
Why the recommended fix works
Collapsing several losses into one backward restores the one-per-forward assumption the reducer is built on. Non-reentrant checkpointing avoids re-running the forward in a way that re-fires hooks. Aligning no_sync boundaries makes the reducer's expectation and the actual sequence agree again. All three fix the sequence rather than suppressing the check.
Code examples
// Typical pattern:
import torch.optim as optim
optimizer = optim.AdamW(model.parameters(), lr=3e-4)
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=num_training_steps)
for step in range(num_training_steps):
optimizer.zero_grad()
loss = model(batch)
loss.backward()
optimizer.step()
scheduler.step()Adapt the snippet to your framework. The same pattern holds for PyTorch Lightning, Hugging Face Trainer, DeepSpeed, Megatron-LM, and vLLM training wrappers. Where the wrapper exposes a config flag (for examplelr_scheduler_type in Trainer), prefer the flag over the imperative API to keep the schedule declarative and reproducible.
Best practices by model family
| Model / Stack | Recommendation | Notes |
|---|---|---|
| Several losses from one forward | Sum to one scalar, call backward once | Restores the one-backward-per-forward contract. |
| static_graph enabled | Disable it while diagnosing | It is the strictest contract and the most common trigger. |
| Gradient checkpointing with DDP | Use the non-reentrant implementation | The reentrant one re-runs the forward and can re-fire hooks. |
| Gradient accumulation | Keep forward and backward on one side of no_sync | A boundary that drifts by one step desynchronises the reducer. |
With the fix vs without the fix
| Dimension | With the fix | Without the fix |
|---|---|---|
| What the assertion measures | Backward count and ordering | Assumed to indicate corrupted gradients |
| Report a bug to PyTorch | Boilerplate on every internal assert | Read as a diagnosis |
| Why single-GPU works | No reducer, so no bookkeeping to break | Taken as evidence the model is correct under DDP |
Real engineering notes
“The phrase please report a bug to PyTorch does real harm here. It is attached to every internal assertion regardless of cause, and it points people at an upstream issue tracker when the trigger is in their own training loop. Treat it as boilerplate. The genuinely useful signal is what changed immediately before: enabling static_graph, enabling checkpointing, or altering an accumulation schedule are the three that produce almost all occurrences.”
Visual fingerprint
forward -> reducer ARMED (expect_autograd_hooks_ = true) backward -> buckets reduce -> finalize -> DISARMED second backward on the same forward: backward -> hooks fire while DISARMED -> INTERNAL ASSERT FAILED
Related failures to investigate next
Root cause, fix & prevention
Frequently asked questions
Twelve targeted questions that engineers and on-call staff most commonly ask about this failure.
Should I report this to PyTorch?
It works without DDP. Is my model wrong?
Why did enabling static_graph cause this?
Can I keep gradient checkpointing?
Don't just read the fix, diagnose your run
The encyclopedia tells you what went wrong. Denpex tells you what went wrong in YOUR training run. With your logs, your config, and your stack.