envoy: mirror every module entry, not every distinct module - #721
Merged
Merged
Conversation
`Envoy.__init__` built the tree from `named_children()`, which deduplicates by module identity, and `_wrap_envoy` returned a shared module's envoy before recording the name at all. So a `ModuleList` holding one module twice lost an entry, and a container rebuilt from blocks the tree already wraps — truncating `transformer.h` to its first four blocks — got no children whatsoever: `h[3]` raised IndexError, iteration yielded nothing, the repr was empty. The tree is now built from `module._modules`, and every entry is recorded in a name-keyed `_child_map` alongside `_children`, which keeps holding one envoy per module for the recursive walks. Indexing, iteration, `modules()` and the repr go through the map, so an entry sharing its module with another keeps its own index while still naming the one envoy at the module's first path. Three more bugs in the same bookkeeping: replacing a child appended the new envoy instead of putting it at the replaced one's index, shifting every later index; the replaced module kept its `interleaver.envoys` registration, so re-attaching it served the replacement's values; and `base_model`, a property returning the module itself, fell through to build a duplicate envoy of a module inside itself — a RecursionError with `rename`, a phantom `cache()` key without it. Docs: mutate the tree through the envoy, not the wrapped module; the eproperty descriptor may go on a submodule's class, which `envoys=` has allowed since it landed; and the auxiliary-module attach uses the replacement form, since writing the attachment's output into its own input makes backward through it impossible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Sep 8, 2026
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.
Items 12 (all five parts), 19, and 23.4 from the stress-sweep next-steps, plus the
in-place auxiliary-module idiom in
docs/concepts/envoy.md.What was broken
Envoy.__init__built the child tree frommodule.named_children(), whichdeduplicates by module identity, and
_wrap_envoyreturned early for a module thetree already wrapped without recording the name at all. Four symptoms, three silent:
ModuleListholding one module twice got fewer children than entries, solayers[2]raisedIndexErrorand iteration skipped it. A container rebuilt fromblocks the tree already wraps —
model.transformer.h = nn.ModuleList(list(h)[:4]),an ordinary truncation — got zero children:
h[3]raised, iteration yieldednothing, the repr was empty, and the blocks vanished from
modules()(so fromcache()targets too)._children.remove(existing)then.append(...), shiftingevery later index: after
setattr(m, "1", nn.Identity())on a 4-entrySequential,m[1]was the third module andm[3]was theIdentity.interleaver.envoysregistration, so re-attaching itunder a new name bound it as an alias of the stale envoy and served the
replacement's values.
.base_model— atransformersproperty returninggetattr(self, prefix, self),i.e. the module itself on a bare
GPT2Model/LlamaModel— fell through theshared is not selfclause and built a duplicate envoy of a module inside itself:RecursionErrorat construction withrename, and without it a re-pointed registryand a phantom
tracer.cache()key. Plain gpt2, no adapter needed.What changed (
src/nnsight/intervention/envoy.py)module._modules.items()(skippingNoneentries), so noentry is deduplicated away.
_child_map: dict[str, Envoy]— every entry of the wrapped module's_modules,by name, in module order.
_childrenkeeps its meaning (one envoy per module, plusstandalone children appended from outside, e.g.
TransformersModel.generator), somodeling/transformers.pyand the_children.appendpattern documented indocs/usage/extending.mdare untouched._named_children()joins the two: mapentries first, then any
_childrenextras.__getitem__resolves an int/str key by name (getattr(self, str(key)),negative ints normalised against
len(self)); slices index the ordered children.__iter__,_update,modules()and the repr walk_named_children().modules()gained an identity
seenset so an envoy two paths reach is still listed once, theway
named_modules()lists a shared module once — that is also what stops aself-referential spelling recursing.
interleaver.envoysentry.shared is not selfdropped, so a property returning the module itself resolves tothis envoy like any other shared module.
One behaviour change worth naming:
envoy[i]out of range now raisesAttributeError(naming the key) rather than
IndexError, since the key is resolved by name.Docs (
docs/concepts/envoy.md)module — direct assignment on
_moduleis not tracked and leaves the envoyaddressing the module it replaced (
OutOfOrderErroron read).Envoy, sothe descriptor goes on the model subclass ... not an arbitrary submodule") predates
envoys=, documented 30 lines above it; rewritten to say the descriptor goes on theclass the envoy is built as.
layer.output[:] = aux(acts, hook=True),which writes the attachment's output into its own input and makes backward through
it impossible; now the replacement form, matching
docs/patterns/sae-and-auxiliary-modules.md:101. The same idiom appeared twice inEnvoy.__call__'s docstring and is fixed there too.Tests
tests/test_envoy.pygains four classes adapted from the repro scripts:TestSharedEntries(aModuleListholding one module twice),TestRebuiltContainer(truncation, including the real gpt2
transformer.h[:4]case with a trace),TestReplacement(index kept, old module deregistered),TestSelfNamingAttribute(a property returning the module itself, including the
renameRecursionError).13 of the 14 fail on
origin/0.8and all pass here.Ran with
PYTHONPATHat this worktree'ssrc:tests/test_envoy.py— 101 passedtests/vllmandtests/performance(so includingtests/tp)— 1044 passed, 72 skipped, 1 xfailed
tests/vllmwas not run: vLLM is not installed in this environment. The change isgeneric to
Envoy, so the vLLM tree is affected in principle; the MLA shared-modulecase it exercises is covered by
TestSharedEntrieson a plain module.🤖 Generated with Claude Code