Skip to content

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.

Quick answer

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.

Distributed Training#ddp#distributeddataparallel#reducer#static_graph#gradient-checkpointing#no_sync

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

SymptomWhy it happens
RuntimeError: expect_autograd_hooks_ INTERNAL ASSERT FAILED at ../torch/csrc/distributed/c10d/reducer.cppThe 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 textStatic 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 DistributedDataParallelGradient 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 changeUsing 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)

training.log (synthetic 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 error

Timestamps 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 analysis

No 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 reference pattern
// 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 / StackRecommendationNotes
Several losses from one forwardSum to one scalar, call backward onceRestores the one-backward-per-forward contract.
static_graph enabledDisable it while diagnosingIt is the strictest contract and the most common trigger.
Gradient checkpointing with DDPUse the non-reentrant implementationThe reentrant one re-runs the forward and can re-fire hooks.
Gradient accumulationKeep forward and backward on one side of no_syncA boundary that drifts by one step desynchronises the reducer.

With the fix vs without the fix

DimensionWith the fixWithout the fix
What the assertion measuresBackward count and orderingAssumed to indicate corrupted gradients
Report a bug to PyTorchBoilerplate on every internal assertRead as a diagnosis
Why single-GPU worksNo reducer, so no bookkeeping to breakTaken 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

The reducer arms once and disarms once
  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
DDP arms the reducer when it prepares for a backward pass and disarms it once that pass is finalised. A second backward fires hooks while the reducer is disarmed, which is the condition the assertion reports.

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?
Almost certainly not. That text is attached to every internal assertion. The trigger is usually multiple backward passes, reentrant gradient checkpointing, or a misplaced no_sync boundary.
It works without DDP. Is my model wrong?
The model is fine. Without DDP there is no reducer, so there is no backward-pass bookkeeping to violate.
Why did enabling static_graph cause this?
It promises the graph is unchanging and there is exactly one backward per iteration, which lets the reducer precompute bucket order. A second backward then reaches an already-finalised reducer.
Can I keep gradient checkpointing?
Yes, with the non-reentrant implementation. The reentrant one re-runs the forward during backward and can fire the same hooks twice.

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.