Skip to content

envoy: len, iteration and indexing answer for the same entries - #725

Open
JadenFiotto-Kaufman wants to merge 1 commit into
0.8from
fix/envoy-container-dunders
Open

JadenFiotto-Kaufman wants to merge 1 commit into
0.8from
fix/envoy-container-dunders

Conversation

@JadenFiotto-Kaufman

Copy link
Copy Markdown
Member

The bug

A container may keep its modules one level down and reach them through its own __getitem__ — a set of transcoders or SAEs holding them in a child ModuleList:

class TranscoderSet(nn.Module):
    def __init__(self, transcoders):
        super().__init__()
        self.transcoders = nn.ModuleList(transcoders)   # the tree's one child

    def __len__(self):        return len(self.transcoders)
    def __getitem__(self, i): return self.transcoders[i]

Envoy.__len__ delegates to the module, while __iter__ and __getitem__ go through the envoy children. For this shape those disagree three ways:

before
len(envoy) 18 the module's __len__
len(list(envoy)) 1 envoy children
envoy[0] AttributeError child names

So the envoy reported a length whose every index raised.

The change

Indexing asks the container when no child answers to the name, and names the envoy of the module it returns. Iteration walks it the same way. __len__ already delegated — it was the other two that disagreed with it.

len(model.transcoders)      # 18
model.transcoders[2]        # model.transcoders.transcoders.2 — an Envoy
list(model.transcoders)     # the 18 transcoder envoys

Torch's own containers are untouched. ModuleList, Sequential and ModuleDict hold their modules as their own entries, so a name always resolves and the delegation never runs. That is what keeps a shared entry at its own name (#721) and what keeps layers[2] an envoy rather than a bare module.

The delegation only ever yields an Envoy. A __getitem__ returning a tensor element, or a module built on the fly that this tree does not wrap, raises rather than handing back something no trace can address. A container with no __iter__ of its own is walked over the length it reports, since Python would otherwise synthesize iteration from __getitem__ and stop only on IndexError.

Behaviour

len iter [k]
ModuleList / Sequential / ModuleDict unchanged unchanged unchanged, by child name
shared entry, negative index, slice unchanged unchanged unchanged
container indexing past its entries 4 4 envoys tset.items[2]
container with no __iter__ 3 3 envoys delegates
__getitem__ → tensor element children AttributeError
__getitem__ → untracked module children AttributeError

How this surfaced

circuit-tracer's TranscoderSet is exactly this shape. Before #721, model.transcoders[0] indexed _children positionally and returned the ModuleList — the container's sole entry — so for t in model.transcoders[0] iterated the transcoders by accident. #721 moved indexing to by-name, correctly, and the accident became an AttributeError.

Note this PR does not restore that expression: under these semantics transcoders[0] correctly means the first transcoder, not the list. Downstream code relying on the old positional accident still needs updating — that is the right outcome, and this PR is about the len/iter/[] disagreement rather than about restoring it.

Tests

Nine tests in a new TestContainerDunders class covering the torch containers, a delegating container, one with no __iter__, negative indexing, both raising cases, and tracing through a delegated index. tests/test_envoy.py: 110 passed.

Full suite (excluding tests/vllm, not installed here): 1040 passed. The 16 failures and 29 errors are all tests/tp/* and test_tensor_parallel_rules.py, which need torchrun on a multi-GPU box — byte-identical sets with and without this change (comm on the sorted lists is empty in both directions).

🤖 Generated with Claude Code

A container may keep its modules one level down and reach them through its own
`__getitem__` -- a set of transcoders holding them in a child `ModuleList`. Its
`__len__` counts the modules while the envoy tree's children are the container's
own entries, so `len(envoy)` reported eighteen while iteration yielded one and
every index in that range raised.

Indexing asks the container when no child answers to the name, and names the
envoy of the module it returns; iteration walks it the same way. Torch's own
containers are untouched: their entries are the children, so a name always
resolves and the delegation never runs -- which is what keeps a shared entry at
its own name, and what keeps `layers[2]` an envoy rather than a bare module.

The delegation only yields an `Envoy`. A `__getitem__` returning a tensor
element, or a module built on the fly that this tree does not wrap, raises
rather than handing back something no trace can address. A container with no
`__iter__` of its own is walked over the length it reports, since Python would
otherwise synthesize iteration from `__getitem__` and stop only on `IndexError`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant