feat(backward): explain the freed autograd graph in terms of invokes, and cover batched-invoke gradients - #699
Open
Hotragn wants to merge 1 commit into
Conversation
Calling `.backward()` in more than one invoke of a trace fails inside
`torch.autograd`:
RuntimeError: Trying to backward through the graph a second time (or
directly access saved tensors after they have already been freed). ...
Accurate, but it points away from the cause. Each invoke calls `.backward()`
exactly once, so "a second time" reads as describing someone else's code --
and the real reason is a fact about nnsight, not torch: invokes are not
separate runs. Every invoke's input is combined into one batch and the model
is called once, so the whole trace has a single autograd graph, and the first
`.backward()` frees it for every invoke after it.
`_explain_freed_graph` translates that one error. It matches autograd's
message (a bare `RuntimeError` with no type or code to key off, so string
matching is the only option, and it is narrow), states the shared-forward-pass
cause, and shows the fix -- `retain_graph=True` on all but the last backward --
keeping torch's original text appended. When the failing call already passed
`retain_graph=True` it says the earlier one did not, rather than suggesting a
flag that is set. Every other `RuntimeError` is re-raised untouched.
Behaviour is unchanged: `retain_graph=True` across invokes already worked, it
just wasn't discoverable.
Also adds the batched-invoke gradient coverage the suite was missing.
`test_backward.py` only exercised single-invoke gradients and
`test_batching.py` is entirely `@torch.no_grad()`, so nothing asserted that an
invoke's gradient is its own rows. `TestBackwardAcrossInvokes` pins that
against plain-autograd references for two and three invokes, with the
gradient taken from the first, middle and last so each batch offset is
exercised, plus a negative control and an edit-isolation case. Invokes are
given different row counts (2/3/1) so a slice read from the wrong offset shows
up as a shape mismatch even when values would be close. Using the existing MLP
+ `_BatchEnvoy` pattern keeps these exact -- `allclose(atol=1e-6)` -- and adds
no model download.
Docs: adds the cross-invoke case to docs/gotchas/backward.md. The existing
`retain_graph` section covers two backwards in one invoke, which does not read
as the same problem when your code has one per invoke.
Note: `test_serialization.py::TestScopeFiltering` has two failures on Python
3.14 on this branch. They are unrelated to this change and are fixed
separately.
Hotragn
force-pushed
the
feat/cross-invoke-backward-diagnostic
branch
from
August 27, 2026 03:06
5595127 to
1ccf8f1
Compare
Author
|
Rebased onto current No other changes; same single commit, just replayed onto |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Rebased onto
0.8as requested in #696 — this supersedes #694, and folds in the salvageable half of #693. Verified the problem still reproduces on0.8before porting.Summary
Calling
.backward()in more than one invoke of a trace fails insidetorch.autograd:Accurate, but it points away from the cause. Each invoke calls
.backward()exactly once, so "a second time" reads as describing someone else's code. And the real reason is a fact about nnsight rather than torch: invokes are not separate runs. Every invoke's input is combined into one batch and the model is called once, so the whole trace has a single autograd graph, and the first.backward()frees it for everything after it.docs/gotchas/backward.mddoes coverretain_graph, but as "if you call.backward()more than once on overlapping graphs". One backward per invoke does not read as that.Changes
_explain_freed_graphtranslates that one error — matching autograd's message (a bareRuntimeError, no type or code to key off, so string matching is the only option and it is kept narrow), stating the cause in nnsight's terms, and showing the fix while appending torch's original text:When the failing call already passed
retain_graph=True, it says the earlier one did not, instead of suggesting a flag that is already set. Every otherRuntimeErroris re-raised untouched.Batched-invoke gradient coverage, which the suite was missing entirely.
test_backward.pyonly exercised single-invoke gradients, andtest_batching.pyis entirely@torch.no_grad()— so nothing asserted that an invoke's gradient is its own rows.TestBackwardAcrossInvokespins that against plain-autograd references:a.grad = a.grad * 3in one invoke leaves the other untouchedInvokes get different row counts (2 / 3 / 1), so a slice read from the wrong offset shows up as a shape mismatch even where the values would be close. Built on the existing
MLP+_BatchEnvoypattern, which keeps the assertions exact —allclose(atol=1e-6)— and adds no model download; the whole file runs in ~16s.Behaviour is unchanged
retain_graph=Trueacross invokes already worked, and each invoke's gradient was already correct — verified before writing the diagnostic. This makes the failure legible and locks the correctness in.Not done here
nnsight could retain the graph implicitly for all but the last invoke and make this disappear. That trades peak memory for convenience on every batched trace and changes a default, so it seemed like your call rather than something to fold into an error message. Happy to do it if you'd prefer.
Verification
Without the diagnostic, 2 of the 15 tests in
test_backward.pyfail; with it, 15 pass. Full CPU suite on this branch: 857 passed, 7 skipped, 2 failed — the two failures aretest_serialization.py::TestScopeFilteringon Python 3.14, which predate this branch and are fixed in #698.0.8@ 1f974f0, Python 3.14.3,transformers5.15.1,torch2.9.1, CPU.