0.8 — the pipeline rewrite - #686
Conversation
Wholesale replacement of nnsight with the 0.8 architecture: greenlet-based interleaved execution (Interleaver/Mediator), the Envoy tree with eproperty hooks, source tracing, batching/invokers, and the Transformers, Diffusion, and vLLM runtimes. Remote execution on NDIF via the serialize-and-ship backend. Also brings the reworked docs/ task reference, the NNsight.md manual, the walkthrough notebook, and packaging (transformers + remote in core deps, populated metadata, __version__ via importlib.metadata). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
🙂 |
The `.source` AST rewrite replaced each call with a wrapper node but never copied the original location onto it. increment_lineno then stamped the locationless wrapper with the raw offset (`getattr(node, "lineno", 0) + n`), so an exception raised inside an instrumented forward reported a drifted line number. Copy the source location onto the wrapper (ast.copy_location) so tracebacks point at the real call site. Inspired by #658, which fixed the analogous crash in 0.7's (since-removed) ExceptionWrapper traceback machinery; 0.8 handles source rewriting and exceptions differently, so this is the 0.8-native equivalent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
interleave() now always records the passed (args, kwargs) as one more input set on the batcher and then assembles, instead of special-casing a caller-supplied batcher and merging trace-level kwargs on top. A direct (untraced) call has no batcher, so one is created; its raw input becomes a row and is tokenized/collated and device-placed exactly like a one-invoke trace. Batcher.add folds a zero-row set's kwargs (params-only, e.g. max_new_tokens, or an empty invoke()) into extra_kwargs, which assemble() lays over the combined call — so forward params flow in without a separate merge. The write-only Batcher.groups list is removed (grouping already travels via add()'s return -> mediator.batch_group). TransformersModel.generate loses its now-redundant raw-input fallback (and the unused apply import): interleave assembles and device-places before it runs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Restore tag-based versioning: setuptools-scm computes the version at build time from the git tag (tag v0.8.0 -> 0.8.0; between tags, the next dev version) instead of a hand-maintained static string. Add setuptools-scm to the build requires, mark the project version dynamic, and configure [tool.setuptools_scm] with a fallback for git-less builds. The publish workflow already checks out full history (fetch-depth: 0), so release builds get the tag. __version__ still reads importlib.metadata, so an installed build reports the scm version; no _version.py file is written or tracked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port the contributing guidance from #683 (merged to main) into the 0.8 CLAUDE.md — base PRs on `dev`, not `main` — placing it in the existing "For developers / contributors" section. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reading `.grad` inside `.backward()` from an activation captured in a batched invoke (two+ invokes) raised OutOfOrderError: each invoke reads a narrowed *view* of the full-batch activation, and that view is not in the loss graph (the model runs on the full batch), so the autograd hook registered on the view never fired. Mark batcher-created slice views, and in the backward grad hook redirect to the view's storage-owning base (which is in the graph): recover exactly this view's elements from the base gradient with as_strided, and splice edits back. Gated on the marker so user-made views keep the direct-hook behavior. 0.8-native equivalent of #671, which fixed the same bug in 0.7's (since-removed) tracing/backwards.py. Adds regression tests for the read and edit paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reintroduce the runtime_checkable IEnvoy protocol (from 0.7) describing what an eproperty host must provide — an `interleaver` and an optional `path` — and use it to type the `obj` parameters instead of `Any`. Documents the contract and gives type clarity; `path` stays optional (tracer hosts read it via getattr fallback). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- contributing/branches: base PRs on dev, not main (matches CLAUDE.md / #683) - backward-and-grad: document that gradients inside batched invokes now work - batching-internals: add/assemble fold params-only sets into extra_kwargs, and interleave is uniform (always add + assemble, no separate merge step) - extending-envoy: mention the IEnvoy host protocol (interleaver + optional path) - source-internals: wrapper nodes copy_location so instrumented-forward tracebacks report the real line Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
tracer.cache(..., non_blocking=True) exposes the cache's device-transfer mode. The default keeps today's fast async copy, which is safe under nnsight's single-stream execution (a same-stream copy is ordered against later compute and buffer reuse, and Python-side reads sync anyway). Set non_blocking=False to synchronize the copy — useful only if you move captured tensors across CUDA streams yourself. Motivated by #666, which added an unconditional GPU sync to fix a race that can't occur here; this gives the escape hatch without the always-on cost. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Store the NDIF API key from a notebook (`from nnsight import login; login()`) or a terminal (`nnsight login`). The helper prompts with getpass (never echoed) and persists via CONFIG.set_default_api_key; passing a key skips the prompt, empty input is a no-op. Registers a `nnsight` console script alongside `nnsight-serve`. 0.8 port of #668 (resolves #506). Adds tests and an api-key doc mention. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fold the login helper into ndif.py (dropping login.py) and add a whoami() helper that hits NDIF's new /whoami endpoint. login() now verifies the key best-effort: it saves regardless, greeting "logged in as <email>" on success and "unverified" when the service can't be reached or doesn't recognize the key. Both login() and whoami() are exposed (from nnsight import ...), plus a `nnsight whoami` subcommand; the console script is now nnsight.ndif:main. Follows the #506 discussion, where validation was blocked on a key-check endpoint that NDIF's /whoami now provides. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add two rename tests (test_language.py::TestRename) for patterns that worked but weren't explicitly asserted: calling a module through its alias runs the same forward, and reading `.input` through an alias matches reading it by name. Taken from #599; the rest of that PR's cases are already covered by test_envoy.py::TestRename and test_language.py::TestRename. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…g greenlets A torch op that raises a c10 error while running inside a worker greenlet crashed the whole process instead of raising a Python exception. nnsight runs each invoke's intervention code in a greenlet, which time-shares one OS stack by copying stack slices in and out; torch's c10::Error constructor eagerly captures a C++ backtrace via glibc backtrace(), which walks off the greenlet's stack slice into stale memory and SIGSEGVs libgcc's unwinder. It needs only a batched trace (>=2 invokes) and an error thrown after at least one read -- no barrier involved. Fix: neutralize glibc backtrace() at import (return 0, no walk) so the error propagates normally. Only torch's rarely-used C++ backtrace string is emptied; Python tracebacks and error messages are unaffected. Gated by CONFIG.APP.DISABLE_CPP_BACKTRACE (env NNSIGHT_DISABLE_CPP_BACKTRACE), default on, glibc/x86-64 Linux only, fully fail-safe. Adds nnsight/_c/backtrace.py, the config flag + env override, docs, and a subprocess regression test (guard on -> clean exception; guard off -> segfault). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The docstrings used Sphinx/reST roles (:class:`X`, :meth:`X`, :func:, :attr:, :mod:, ...), which the site's mkdocstrings renderer prints verbatim instead of linking. Convert all 516 references across the source to mkdocstrings autoref syntax ([`name`][full.path]): names are resolved against the real symbol tree, so unambiguous targets become real cross-reference links and ambiguous ones (a method name shared by several classes) or private/dunder members (which the API pages don't list) fall back to a plain code span. No behavior change; docstrings only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two related breaks, both found running the vLLM suite on a GPU (the PR's own validation covered only the CPU suite): VLLM._load assumed a repo-id string unconditionally, so the runner-side VLLM(self.model) — wrapping the module vLLM just loaded in the worker — fed an nn.Module into EngineArgs, and EVERY worker died at startup with "'GPT2LMHeadModel' object has no attribute 'lower'". Reproduced at the unmodified PR head (tests/vllm/test_tracing.py, TP=1). A ready module now wraps directly (the Loadable base contract this override lost): no engine to build, no meta tree; the runner sets the tokenizer itself. The mixins also dropped the Envoy interleaver= kwarg on the floor: Loadable and Meta thread rename/envoys to the Envoy but forwarded everything else into the load path, so the PP runner's interleaver injection leaked into EngineArgs. interleaver now threads exactly like rename/envoys. With both fixed (plus the C extension built for the env's python), the single-rank GPU tracing test passes from this branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ised it An exception raised by a barrier-released block propagated through the releasing block's switch, unwinding the releaser mid-block and getting recorded on whichever mediator the interleaver happened to be serving. The release loop now records the error on the mediator that raised it; under a deferring driver the remaining waiters are still released and the releasing block completes, so a shared engine fails only the raising request. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s across requests Two halves of one break, found running the interp-workload benchmark's batched cells against this branch (the documented multi-invoke idiom: a container saved above the invoke blocks, each invoke writing its own slot): .save() marks values by id in the sending process, so a worker serialized onto a vLLM request arrived with no record that its captured outer variables were saved — the runner's collection never shipped them, and the client's container stayed empty. Mediator.__getstate__ now ships the saved NAMES among the reduced block's captured variables; the runner unions them into its own collection. And each invoke rides its own request with its own COPY of the shared container, so the copies came back one per request, each carrying one write; the per-mediator result push then let the last request clobber the rest (the same clobber documented on the 0.7 branch). The sync collect now merges same-name saves shipped by more than one request element-wise (slot lists with None as the unwritten marker, dicts by key-union) and re-marks the merged containers so the save-gate keeps them. Also: chat-input detection no longer hard-imports transformers' Chat helpers, which don't exist before the chat-pipeline refactor — the import killed every text trace on transformers 4.57 (the version vLLM 0.19 environments carry). Benchmark evidence (steering, gpt2, HF + vLLM-sync): all cells SUPPORTED with top-1 agreement 1.00 and total-variation 0.000 vs the HF control, including the in-place batched cell the spec expected to ERROR from the 0.7 era. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A tensor with requires_grad=False is outside autograd: nothing a 'with tensor.backward():' block reads can ever receive a gradient. That is the case whenever the forward ran without gradient tracking, as it does under an inference engine (vLLM's forward runs under torch.inference_mode) or a trace wrapped in torch.no_grad(). The session now raises with the reason up front instead of failing deep inside hook registration; inside an engine worker the error is carried to the client as that request's deferred error. A requires-grad leaf still opens a session: its grad_fn is None too, but autograd fires its accumulation hook with the incoming gradient. Reaching the check inside a worker also requires source capture to work there: Mediator.__setstate__ now registers the deserialized block's source in linecache under a unique per-mediator filename (as the serve path does), so a nested with-block captures its body and worker tracebacks resolve to real lines. Previously the capture failed and the construct fell back to a bare torch backward. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RAwQb1rDcNojCPr3NgN429
The mixin chain made two classifications by scattered convention, and each produced a bug: every subclass's _load re-implemented the ready-module case by hand (VLLM's forgot it, killing GPU workers), and the Envoy-vs-load kwarg split was a list copied into each mixin's signature (interleaver was missing, so root-level injection was silently discarded). Both decisions now live in Loadable.__init__. A ready module dispatches to _wrap (base: build the tree over it as-is; TransformersModel overrides it to build the pipeline around the instance), load arguments to _load, so an override never receives the argument kind it didn't opt into — the module branches in VLLM._load and HuggingFaceModel._load are deleted rather than maintained. The kwarg split derives from Envoy.__init__'s signature (split_envoy_kwargs), so it follows Envoy's parameters instead of tracking them; Meta uses the same helper, keeping Envoy kwargs out of the stored load kwargs that dispatch() replays. tests/test_construction_routing.py pins the three behaviors against a stub model class: a ready module never enters _load, Envoy kwargs never reach the load path, and both hold across a lazy construction's dispatch replay. Full CPU suite: 632 passed, 6 skipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0.8 reviewI ran the 0.8 branch on real GPUs and against an interpretability-workload FixesWorker-side VLLM(module) construction. At the PR head, every vLLM GPU Barrier exception attribution. Two blocks meet at a Batched saves on vLLM. The documented batching idiom (a container saved Backward sessions on tensors outside autograd. Constructor argument routing. Hardening on top of the minimal fixes: the Validation: full CPU suite green on the branch (654 passed, 6 skipped), Design question 1: a per-trace scope on engine workersLocally, the invoke blocks of one trace share one namespace and one set of
0.7 answered this server-side: requests carried a trace id, the first The scheduler is the second half of the question. Continuous batching gives Design question 2: the gradient contract on inference enginesvLLM's forward runs under The backward refusal above covers one construct. The general decision: an |
| @@ -236,8 +236,21 @@ def __getstate__(self) -> dict: | |||
| return {"reduced": reduced, "copy": self.copy, "presaved": presaved} | |||
|
|
|||
| def __setstate__(self, state: dict) -> None: | |||
There was a problem hiding this comment.
Would be nice if we had some deduplication for this and Request.deserialize somewhere
A sweep of the offline suite across 3.10-3.14 found the tree green only on 3.12: 3.10 segfaulted mid-run, 3.13/3.14 leaked unsaved values into the caller's frame, and one control test failed on four of the five. - push() handed a SerializedFrame — the deserialized/server path's stand-in, not a PyFrameObject — to PyFrame_LocalsToFast, which dereferences it as a frame: SIGSEGV on 3.10, silent undefined behavior on 3.11/3.12. Guard on isinstance(frame, FrameType) and declare the C function's signature instead of letting ctypes guess. - Scope's shared store was frame.f_locals, which isolated a block's writes from the frame only by accident: pre-3.13 that was one stable snapshot dict whose contents never reached the fast locals. PEP 667 made it a write-through proxy, so from 3.13 every assignment inside a traced block landed in the user's frame and save() stopped deciding what escapes. Add shared_locals(), a per-frame store cleared with the saved set when the outermost trace exits, leaving push() as the only writer into a frame on every version. It holds the frame alongside the store — a helper that opens an invoke has already returned when the trace runs, so keying on id() alone lets the next helper reuse the address and merge two scopes. - code.co_positions() is 3.11+, so lambda serialization raised AttributeError on 3.10. Fall back to an empty position list; the tie-break degrades to the narrowest candidate, which still resolves nested and distinct-signature lambdas. Same-line, same-signature ones need the columns, so those three tests skip on 3.10. - test_guard_off_reproduces_the_segfault asserted that disabling the cpp backtrace guard brings the crash back, but whether the unguarded backtrace walks off the greenlet's stack depends on the interpreter build, not on nnsight — only 3.12 crashes here. Skip when it doesn't reproduce; test_guard_on_surfaces_python_exception stays strict. Verified on fresh envs for each version: 709-713 passed, 0 failed, skips limited to the two documented degradations. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vLLM lets a model opt one layer out of tensor parallelism with `disable_tp=True`, which sets that layer's own `tp_size` to 1 while the engine stays sharded; its forward guards every collective on `tp_size > 1` for exactly that reason. `_is_piece` asked only about `gather_output` / `input_is_parallel` / `reduce_results`, so on a sharded engine it recorded such a layer as a fragment. The value then came back all-gathered — `world_size`x too wide, built of identical copies of a tensor every rank already held whole — and a write went back through `fragment()`, which splits by the module's `tp_size` of 1 and hands that too-wide tensor straight into the next matmul. Silent in both directions. DeepSeek-V2/V3/R1 and openPangu hit this: `DeepseekV2MLAAttention.fused_qkv_a_proj` is a `MergedColumnParallelLinear(disable_tp=True)`, and the branch beside it uses a `ReplicatedLinear` for the same role, which is the intent stated outright. The `FusedMoE` arm already asked the module (`tp_size * ep_size > 1`); the two linear arms now do too. Found by an audit re-run against an installed vLLM, and verified against the 0.16.0 we run: `linear.py:297-298,470-471` set `tp_size = 1` under `disable_tp`, and `linear.py:606,1455` are the forward's own guards. No cached checkpoint uses `disable_tp`, so this is pinned the way the MoE policy already is — stubs, no GPU. Three of the four new cases fail without the guard. `_tp_world_size` now calls vLLM's own `get_tensor_model_parallel_world_size` rather than reaching for the group. 154 vLLM tests pass on 1-2 A100s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**Prompts.** `_prompt` sent every dict to `_tokenized_prompt`, which reads
`inputs["input_ids"]` — so `model.trace(TokensPrompt(prompt_token_ids=ids))` and
`model.trace({"prompt": "..."})` died on `KeyError: 'input_ids'`. vLLM's prompt
types are `TypedDict`s, plain dicts at runtime, and the engine understands them
better than we do; they pass through now.
Writing the test for that turned up the other half: a tokenizer returns a
`BatchEncoding`, which is a `UserDict` and therefore not a `dict`, so
`isinstance(prompt, dict)` was False and the tokenizer-output path was
unreachable from an actual tokenizer — `model.trace(tokenizer(prompt))` reached
vLLM as a `BatchEncoding` and raised "Prompt should be a string, list of tokens,
or dictionary". The test is `Mapping` now, which covers both.
`TestPromptForms` pins all five accepted forms and that they mix across invokes.
**aclear_edits.** `clear_edits()` on an async engine raised, and had no awaited
twin. Not a `clear_edits` that returns something awaitable on async engines: a
coroutine nobody awaits never runs at all, so the sync-looking call would leave
every edit installed and say nothing — the failure `Registration._rpc` exists to
refuse. `clear`/`aclear` already come in this pair, so `clear_edits`/`aclear_edits`
does too, and the refusal now names the awaited forms instead of claiming async
engines are unsupported.
162 vLLM tests pass on 1-2 A100s.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`tracer.result` is served from the worker's copy, which `collect_nnsight` gives the edit's values and nothing else; `attach` sets `saves`/`nnsight_saves` later, on the engine's copy, which a traced caller never receives. The note claimed the second object's behaviour for the first, so it promised an `nnsight_saves` that raises AttributeError and a collision rule that resolves the other way. Found by a red-team pass (F7). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…as a list
Two ways a trace's block runs more than once — `n=k` sampled sequences, and
several invokes — and both were broken, in the same place and in opposite
directions.
**`n>1` returned nothing and leaked.** vLLM fans the request into a child per
sequence, named `"{index}_{parent}"` (`ParentRequest`), while the engine only ever
asks about the parent. `engine_key` stripped vLLM's content hash but not the child
prefix, so `match()` found nothing: no values were collected and no mediator was
ever popped. Every save came back `UnboundLocalError` and two greenlets stayed
resident per `n=2` trace, holding whatever activations the block captured — for
the life of the engine, unbounded over a sweep. `harvest` matched ids the same
way, so an edit installed on a shared server leaked a per-request copy for every
`n>1` request any client sent, nnsight-aware or not.
`engine_key` now resolves a worker id to `(engine_id, sequence_index)`, taking the
child reading only when the parent it names is one the engine asked about — so an
id that merely starts with digits and an underscore is not mistaken for somebody's
second sequence. `harvest` keys by the worker's own id, which is never ambiguous,
and the collect resolves it.
**Several runs, several values.** Each sequence ran its own copy of the block
against its own rows, so there are `n` of everything it saved. They come back as a
list, one entry per run in submission order; a name saved by exactly one run stays
that value, so nothing changes for a trace that uses neither `n` nor several
invokes. Where the caller holds outputs rather than variables — async, serve,
`generate` with an edit installed — each sequence's values ride the completion they
belong to, `output.outputs[i].saves`, next to that sequence's text and token ids.
`output.saves` stays the primary sequence's.
The same rule fixes the invoke case, which failed the other way: `merge_shared_saves`
took "shipped by more than one request" to mean "one shared object", so two invokes
saving one name were merged to the *last* one's value. That is why the steering
recipe in `docs/patterns/steering.md` returned one activation for three prompts.
Merging is now keyed on `mediator.presaved` — the names actually bound and saved
*above* the invokes, which are one object locally and do merge element-wise — and
everything else is a list.
`tracer.result` stays a single object: a request has one `RequestOutput` however
many sequences sampled from it, and every sequence's block is served that same
object, so identical objects across runs collapse rather than listifying.
Tests: sequences (list shape, per-completion values, divergence lining up with
`outputs[i]`, no leak on any rank, async streaming) and invokes (list in order,
distinct names untouched, a container saved above the invokes still merging).
173 vLLM tests pass on 1-2 A100s; CPU suite unchanged. Examples in
`../sequences.py`, all five verified on hardware.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… trace's
Trace-level sampling settings fill in whatever an invoke did not name. "Did not
name" was inferred by comparing the invoke's `SamplingParams` against a fresh one
— which cannot tell a value the caller passed from the value it would have had
anyway. So an invoke asking for a setting that happens to be vLLM's own default
had it silently replaced by the trace-level one, while a neighbouring setting that
differed was honoured:
with model.trace(temperature=0.0, max_tokens=4) as tracer:
with tracer.invoke(prompt, temperature=1.0, max_tokens=16): # -> 0.0, 4
with tracer.invoke(other, temperature=0.99, max_tokens=7): # -> kept
The swallowed values are the obvious ones to type: `temperature=1.0`,
`max_tokens=16`, `n=1`, `top_p=1.0` are all defaults, and asking for sampling
against a greedy trace is exactly asking for `temperature=1.0`.
`_batch` now records the keys each invoke actually passed (`SamplingParams` is a
`msgspec.Struct(dict=True)`, so the note rides on the object it describes) and the
fill loop skips them. Nothing is compared to a default any more.
Three tests, two of which fail without the fix: a default-valued `max_tokens=16`
generating 16 tokens rather than the trace's 4, four draws at a default-valued
`temperature=1.0` diverging rather than all coming back greedy (they were
identically ' Paris, France.' before), and a control that trace-level settings
still fill in for an invoke that named nothing.
Found by a red-team pass (F2). 126 vLLM tests pass on one A100; the multi-GPU
batch is pending an idle second card.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_is_piece` and `fragment` imported `FusedMoE` at the top of the function. vLLM
0.27 rebuilt that layer around a factory and a modular kernel: the class is gone
from `vllm.model_executor.layers.fused_moe`, and a model's experts module is a
`MoERunner` built by `FusedMoEFactory`. The import is unconditional and
`_is_piece` runs for every module in the tree, so on 0.27 every worker died at
load with
ImportError: cannot import name 'FusedMoE' from
vllm.model_executor.layers.fused_moe
for *any* tensor-parallel engine, MoE or not. Single-GPU never noticed:
`instrument` returns early below two ranks, so the import was never reached.
`_moe_layer()` asks the module what the layer is called here and answers None
when it is called nothing — which is the right answer for a vLLM that has no such
layer, rather than a crash. `_moe_group_size()` reads the ranks from
`moe_config.moe_parallel_config` where 0.27 keeps them, and from the layer itself
where earlier versions do.
What is *not* ported is the deferred-reduce policy: 0.27 has neither
`reduce_results` nor `must_reduce_shared_expert_outputs`, having moved the combine
into the modular kernel, and whether the layer's output is still exposed as a
per-rank partial is a question about collectives that wants measuring, not
guessing. Until then `_is_piece` says no there — leaving a value alone reads back
whatever vLLM produced, while gathering one that was never split invents data.
So MoE activations on 0.27 are un-gathered rather than wrong-gathered, and MoE is
the one part of the port still outstanding.
Verified on hardware both ways: 19 tensor-parallel tests pass on vLLM 0.27.1
(torch 2.13.0+cu130), and the full multi-GPU suite still passes 50 on the pinned
0.16.0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
vLLM has two GPU model runners, and from 0.27 the worker picks the second for every non-MoE model — `use_v2_model_runner` resolves to `is_default_v2_architecture or not is_moe`, so the architecture allow-list only governs MoE and gpt2, Llama and Qwen2-dense all get the new one. nnsight's hooks arrive by subclassing the original and rebinding the name the worker resolves, so on the new runner the engine comes up with no instrumentation at all and the first trace dies on a missing `collect_nnsight` rather than on anything that explains itself. `_require_v1_model_runner` sets vLLM's own `VLLM_USE_V2_MODEL_RUNNER=0` before the engine is built — the worker processes inherit it — and refuses an explicit `1` rather than overriding it silently or leaving the engine quietly uninstrumented. Older vLLM has no such setting and ignores it. Instrumenting the V2 runner is the way out of the seam; this is what makes 0.27 work meanwhile. Also here, both found while testing against 0.27: - **transformers 5.** `batch_decode` on a flat list of *ints* means one sequence per id in transformers 4 and one sequence of all of them in 5, so a test asserting `[' New', ' York', ' City']` got `[' New York City']`. That call cannot state its intent across both; it decodes one id at a time now. The suite's other decodes pass tensors and are unambiguous. - **async streaming with `n>1`.** vLLM streams a request's sequences as they finish, so the final streamed output carries only the completions that ended in that step — often one of two. A test asserting the last output held both was flaky for that reason (it passed alone, failed in the batch). It now checks `nnsight_sequences`, which is the whole set the collect produced, and only requires that whichever completions an output *does* carry have their values attached. Documented, since it is a real thing an async caller must handle. Verified: 128 + 50 on the pinned 0.16.0, and 53 on 0.27.1 with no environment variable set by hand — nnsight now selects the runner itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Settled by reading vLLM's own guard and then measuring it. 0.27 moved the final all-reduce *inside* the fused-experts layer: `MoERunner` reduces its output unless the reduce is deferred (`skip_final_all_reduce`), the output was already reduced by the kernel (`_fused_output_is_reduced`), the layer is sequence-parallel, or there is only one rank. So by default the layer hands back the whole value and there is nothing to gather — confirmed on a two-rank Qwen1.5-MoE, where both ranks return the identical tensor. `_is_piece` now mirrors that guard, which is the successor to the `reduce_results` / `must_reduce_shared_expert_outputs` pair it replaced. A sequence-parallel layer is split by rows rather than left as a partial sum — a real fragment, but one wanting concatenation rather than a sum, so it is left alone rather than reduced as if it were arithmetic on the same tokens. The discriminator is the presence of `reduce_results` on the layer, not of a `moe_config`: both eras have the latter, and branching on it sent 0.16 down the 0.27 path, un-gathering every MoE read. Caught by the end-to-end suite — all 8 MoE tests failed — which is exactly what that oracle is for. `TestModularMoEPolicy` pins the new rule on either vLLM by pointing `_moe_layer` at a stub, rather than subclassing the real class: 0.27's `MoERunner` is abstract and exposes some of these names as read-only properties, and 0.26's class does not exist on 0.27 at all. The older stubs skip where their class is absent. Verified: 57 multi-GPU tests on the pinned 0.16.0 (MoE end-to-end included), and 19 tensor-parallel tests on 0.27.1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The question the last commit left open — whether a fused-experts output is still a per-rank partial on 0.27 — is answered, by reading the layer and then measuring it. `MoERunner` all-reduces its own output, under a guard that skips the reduce only when the output is sequence-parallel, when `skip_final_all_reduce` is set, when the kernel already reduced, or when there is one rank. On a two-rank Qwen1.5-MoE with none of those set, both ranks hand back the byte-identical tensor: the read is whole, and gathering it would have been wrong. So `_is_piece` implements that guard's negation rather than the blanket "no" it carried since the previous commit — a deferred reduce is still assembled, and everything else is left alone. A sequence-parallel MoE splits by rows rather than by summands and wants concatenation, not a sum; that is refused explicitly rather than mis-assembled, and noted. The branch is chosen by `reduce_results` on the layer, **not** by the presence of `moe_config` — both eras have a `moe_config`, and keying on it sent 0.16 down the 0.27 path and silently stopped gathering there. Caught by the suite: all eight MoE end-to-end tests failed on 0.16 before the discriminator was fixed. Tests: the modular policy is pinned by stubs that do not subclass the real layer — 0.27's is abstract and answers some of these names with read-only properties, and 0.26's class does not exist on 0.27 — so `_moe_layer` is patched to the stub and the policy is the only thing under test. That runs on either version. The four end-to-end tests read the two partials and sum them, which has no meaning where there is no partial; they skip there with a reason saying what a 0.27-shaped equivalent would assert instead. Verified: 57 pass on 0.16.0 (the eight end-to-end MoE tests among them), and 30 pass / 17 skip on 0.27.1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A block that raised took the whole engine core down with it, and every other tenant's request in flight — the exact opposite of what `defer_exceptions` and `_finish_erred` exist to guarantee. `Mediator.switch` documents that it returns the worker's next event, or None when the worker finishes. Greenlet does not honour that for a worker that has *already* finished: switching into a dead greenlet runs nothing and hands the arguments straight back. So `self.pending = self.switch(served)` stored the served activation where an event belongs, and the next `reindex` read `.provider` off a Tensor — inside `_update_states`, inside `execute_model`, where vLLM catches nothing. Engine core dead, every request `EngineDeadError`. Reachable because an erred worker is deliberately kept scheduled so `_finish_erred` can force its EOS each step, so it keeps being routed to. The preconditions are that the block raises *after* parking at least once (before that `_pending` is None and `reindex` never looks), and that its request survives two more forwards. `ignore_eos`, `min_tokens`, `n>1`, `tracer.stop()`, or simply a long enough `max_tokens` all supply the second — including vLLM's own default of 16. An installed `model.edit()` block that raises does it to every request the engine subsequently handles, which is precisely the traffic edits exist to instrument and which cannot anticipate it. `switch` now answers None for a dead worker, which is what the docstring always said it meant. The consuming loop already exits on None, and `reindex` already skips a None pending. The suite missed this on both preconditions: its erroring traces raise before the first read, and are short and EOS-stoppable. Three tests now cover it — a raise after the first read, a raise mid-`iter`, and an installed edit that raises — each asserting the engine still answers afterwards. All three fail with `EngineDeadError` without the guard. Found by a red-team pass (F0). Verified: 131 + 57 on vLLM 0.16.0, 119 on 0.27.1, 875 on the CPU suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`tracer.cache()` moved captured tensors with `non_blocking=True` and nothing
synchronised before the values were read, so a capture off a CUDA device could be
read while the copy was still in flight -- reading a CPU tensor does not
synchronise CUDA. The window scales with copy size: at batch 8 nothing is visible,
at batch 128 the last-enqueued layer is reliably wrong.
Measured on GPT-2, batch 128 x 64 tokens, 12 blocks, idle GPU, against a plain
`register_forward_hook`:
non_blocking=True 10/10 batches differ, up to 100% of elements wrong
non_blocking=False 0/10 differ, bit-identical
The values are not permanently wrong -- they arrive late; a `torch.cuda.synchronize()`
after the trace makes the same capture bit-exact. Correctness costs ~15-20%
throughput, and the failure it prevents is silent, so default to the safe copy and
leave `non_blocking=True` available for callers who synchronise themselves.
`docs/usage/cache.md` previously argued the async default was "safe under
nnsight's single-stream execution -- captured values are read after the run (and
any Python read syncs anyway)"; it is not, and that paragraph is replaced with the
measurement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`handle` stored every worker exception on the mediator and then re-raised it when
the interleaver was not deferring. Nothing reads that copy on the non-deferring
path -- re-raising propagates the exception normally -- but keeping it alive kept
its `__traceback__`, and with it the whole interrupted model call stack, whose
frames hold that run's activations and KV cache. One of those frames also binds
the mediator, closing a 32-hop cycle that refcounting cannot break:
Mediator -> EarlyStopException -> traceback -> frame switch -> frame handle
-> ...26 frames of the interrupted forward... -> frame execute -> Mediator
So `tracer.stop()` -- whose entire purpose is to stop early and save resources --
retained the run instead, until a cyclic GC pass happened to run.
Measured on GPT-2, batch 64 x 64, 15 stopped traces:
before after
held afterwards 1155 MiB 494.8 MiB (494.8 = the no-stop baseline)
peak 1719 MiB 891.1 MiB (no-stop peak is 1592.9 MiB)
cyclic objects 187 7 per stopped trace
`stop()` now gives a 44% peak reduction rather than a 16% penalty.
A deferring driver (vLLM) does read `exception` back after the run to end its
request, so that path is unchanged -- the store simply moves after the re-raise.
`Barrier.release` had the same shape and gets the same treatment. This also makes
the code match the comment on `Mediator.exception`, which already said the field
is set only under a deferring interleaver.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_supply_position_ids` skipped the mask-derived correction when the batch had one
row, on the reasoning that "a single or unpadded row needs no correction". That is
true of an *unpadded* row and false of a padded one, and the guard conflated them:
the same prompt answered differently depending on whether another row happened to
share its batch.
unpadded, batch 1 -> ' Paris'
left-padded, batch 1 -> ' the' (wrong)
left-padded, batch 2 -> ' Paris' (same prompt, second copy added)
An unpadded batch is already excluded by `mask.all()`, so row count does not need
testing at all. Dropping it makes a single padded row behave like every other
padded input.
Skip the correction under a fake-tensor mode: `scan` propagates shapes only, so
there are no real mask values to read and `bool(mask.all())` would raise
`GuardOnDataDependentSymNode`. position_ids do not affect shapes, so nothing a
scan can observe changes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A decorator's wrapper closes over the function it wraps, so `.source` refused it
with a bare "callable closes over free variables" -- which reads as a problem in
the user's own code and gives nothing to act on. When the decorator did not use
`functools.wraps` there is no `__wrapped__` to peel, but the wrapper's
`__qualname__` still names the decorator that built it and its closure still holds
the function it wraps. Both are worth saying.
On Mamba-1, whose mixer -- the one module holding the recurrent state -- is
wrapped by transformers' `force_accelerate_hooks`:
callable closes over free variables ('child_module_name', 'forward_func') --
it is a wrapper built by @force_accelerate_hooks
(transformers.integrations.accelerate), wrapping MambaMixer.forward. It could
not be peeled because the decorator does not use `functools.wraps`, so there is
no `__wrapped__` to follow. Instrument a real submodule's `.source` instead, or
have the decorator apply `@functools.wraps`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each of these was believed and acted on during an interpretability sweep, and each cost real time or produced wrong numbers. - `gotchas/iteration.md` offered bounding the loop as the fix for trailing code being dropped. A bounded `iter[:N]` drops it too when the model stops early: EOS, a stop string, any generation shorter than N. Since `max_new_tokens` is only ever an upper bound, no bound can guarantee the loop completes -- only the separate empty invoke does. - `usage/invoke-and-batching.md` said a batched invoke's output equals the same prompt run alone. It matches to ~2e-4 (fp32 GPT-2), not bit-for-bit. The drift is torch's, not nnsight's (logits are `torch.equal` to raw HuggingFace at every batch size), does not grow with batch size, and is driven by the shape the kernel sees. Harmless in fp32; comparable to the metric quantum in bf16, where it can reorder a head ranking. - `models/vllm.md` recommended ablating an expert by masking its router logit in `mlp.gate.output`. Correct on vLLM, a silent no-op on `TransformersModel`, where the block does `_, top_k_weights, top_k_index = self.gate(h)` and discards the logits -- masking all 64 to -inf gives `max|delta| = 0.0`. It was also the repo's only MoE intervention recipe, so it was being applied to the wrong backend. Scoped to vLLM, with the transformers-side alternative and two further traps (weight rescaling under `norm_topk_prob=False`, and the router's missing batch axis). - `usage/source.md` did not mention that the first `.source` access on a module raises `OutOfOrderError` when the trace body already read something. It is not an ordering mistake: instrumenting an operation rewrites the module's forward, which can only happen before that forward runs. It is per module, so a layer sweep hits it once per layer. Also documents making a parameter-based SAE observable in `patterns/sae-and-auxiliary-modules.md`: `hook=True` exposes `nn.Module` children, not bare parameters or plain methods, so no pretrained dictionary that ships as an array file works with the documented recipe. Route each value through a `WrapperModule` instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
peft matches adapter weights to modules by name and silently drops the ones it cannot place. Since `lora_B` initialises to zeros, an adapter whose weights did not land is exactly the identity: the model behaves like the base checkpoint, and a base-vs-adapter comparison quietly becomes base-vs-base with every number in it plausible. An interpretability run on fine-tuned model organisms hit this and lost ~25 minutes to it; loading all 496 `lora_B` matrices as zeros would otherwise have produced a confident, entirely wrong result. peft does warn -- `PeftModel.from_pretrained` reports missing adapter keys precisely because it cannot return its load result -- but a warning in the middle of a multi-gigabyte load is easy to miss, and by the time it matters the experiment is over. Escalate it: capture warnings around the load, re-emit them so nothing is swallowed, and raise if any reported missing adapter keys. Only a genuine mismatch warns. An adapter whose keys all place -- including a freshly initialised one whose `lora_B` is legitimately still zero, as `tests/test_language.py`'s fixture builds -- produces no warning and loads as before. That is why this checks the reported *names* rather than the loaded values, which cannot tell "failed to match" from "not trained yet". Both load paths are covered. `_remoteable_set_env` -- the adapter *swap*, used to sweep several adapters over one loaded base -- previously bypassed the check entirely, which is the worse omission: that workload is exactly where every organism silently collapsing to the base checkpoint looks like a result. The swap now clears `self.peft` after unloading, so a refused load leaves the envoy describing itself honestly as the base model rather than claiming an adapter it does not have. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- `lm_head.output` is not the model's logits on Gemma-2/3. `Gemma2ForCausalLM.forward` applies tanh softcapping *after* the head (`final_logit_softcapping=30.0`), outside the module, so a logit lens reads pre-cap values: the top-1 token usually survives but probabilities, entropies and perplexities do not. One run measured a 46x perplexity error from this alone, and `softcap` appeared nowhere in the docs. `patterns/logit-lens.md` now carries the transformers snippet, the check to run against `model.config`, and a lens recipe that applies the cap. - A weight edit inside a trace is permanent; an activation edit is scoped. The two read almost identically and nothing warns, so every later trace in the process silently runs against a modified checkpoint. Not an nnsight behaviour -- `.weight` is the module's real `nn.Parameter` -- but `with model.trace(...)` reads like a scope for everything inside it. `gotchas/modification.md` gains the repro, the save-and-restore fix, a pointer to `model.edit()` for reversible changes, and the related trap that pre-dispatch weights are silent meta tensors. - 40 instances of the pre-transformers-5 `.output[0]` block idiom across 11 pages, including `cache.md`'s post-intervention claim. On transformers 5 a decoder block returns a bare tensor, so `[0]` indexes the batch axis and everything downstream is wrong with nothing raised. The website quickstart was corrected earlier; these are the rest. Attention submodules keep their `[0]` (they really do return tuples), as do the diffusion pages (the UNet really does), and `models/vllm.md` is left alone -- that runtime shapes hidden states differently and could not be verified here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Envoy.__call__` avoided re-entering the interleaver by calling
`module.forward` instead of `module(...)`, which skipped PyTorch's hook
dispatch entirely. That was too blunt in both directions.
It skipped hooks that were not ours. A runtime is free to keep its
collectives there — transformers registers a tensor-parallel style's
`_prepare_input_fn`/`_prepare_output_fn` through `distribute_module` — so
calling `forward` directly returned one rank's slice of the answer.
And it only stopped the hooks on *this* module. A composite module called
ad hoc left its children instrumented, so the call fed their locations and
spent the occurrence the real forward was going to fill:
with model.trace(real):
junk = model(decoy) # ad-hoc on the root
h = model.l1.output.save()
OutOfOrderError: 'model.l1.output.i0' ... the model already ran past it
Switch this interleaver off for the duration and call the module normally
instead. The module's own hooks run; nnsight serves nothing and spends no
occurrence, for the module or anything beneath it. `hook=True` keeps its
meaning, now stated as what it always was: whether the trace watches.
Restores the flag's previous value rather than True, so nesting composes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Interleaving already shows a worker whole activations, but a module called *directly* — the logit lens, `model.lm_head(hidden)` — is outside that bracket: the caller holds whole tensors while the sharded module deals in slices, so the answer came back at 1/tp_size width, or failed to multiply. Both runtimes get the same bracket, off the `Fragments` rules already recorded for the envoy's two locations: re-split the input, run the module, reassemble the output. What differs is where the collectives live. vLLM's parallel layers run theirs inside `forward`, so only the input needs re-splitting; transformers keeps them in forward hooks, which now fire on their own because `Envoy.__call__` runs the module properly. That the style's *pre*-hook fires too is why re-splitting is conditional: `rowwise_split_input` splits for itself and must not be split first, while plain `rowwise` receives an input already split upstream and must be. Selected through the existing `envoys=` map, which needed no plumbing — `_ENVOY_KWARGS` is derived from `Envoy.__init__`'s signature, so it already routed through both constructors. vLLM keys on its parallel layer classes. transformers has to key on `Linear`/`Embedding`, since the style is stamped on the instance at load, so it is installed only when the construction will actually shard something. Parameters are left alone: `layer.weight` is this rank's slice. Weights are what tensor parallelism exists to split, and quietly gathering one would allocate the whole tensor on every rank exactly when memory was tight enough to reach for TP. Documented rather than papered over. Verified at tp=2 on Llama-3.2-1B against a HuggingFace forward: transformers `down_proj` (rowwise), `gate_proj` (colwise) and a full-vocab logit lens all match exactly; vLLM `down_proj` matches to bf16 noise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
vLLM edits and n>1, interleaver hot path, and fixes from an interpretability sweep
…P/DCP fixes Taps: VLLM(..., taps=[...]) records Interleaver.handle into vLLM's breakable CUDA graphs at the declared module locations and serves them on replay (VLLMInterleaver; runner captures with the interleaver open). Non-tap locations are never served on a tapped engine, including on prompts too long for a captured graph; enforce_eager follows from taps and a contradicting value is refused. Handoff: every module's forward is replaced by the controller and there are no torch hooks anywhere (TP included; TPFragments records shard/partial sides and gathers only when a worker waits). One occurrence counter per location on the interleaver; a worker keeps the counts it started at (counts_at_start). Mediator.done/paused removed; Interleaver.parked replaces waiting/park; observers are always location-keyed (a list-less cache lists the tree); mediators is a plain list and a driver that swaps it mid-run reindexes. Envoy: a module reachable by two paths gets one envoy and an alias (vLLM's MLA wrapper holds q_proj/kv_b_proj/o_proj under a second path; the first path was never served). vLLM runner: one Request record per in-flight request (mediator, registered copies, harvested values, deserialization error); ids parsed once on arrival; preempted requests continue with their counts adjusted; chunked prefill off by default and a chunked traced prompt refused per request; the collect RPC pickles its RequestOutputs (no VLLM_ALLOW_INSECURE_SERIALIZATION needed to trace); worker swaps the runner class in init_device and refuses any other; env is not mutated for the runner beyond what vLLM reads. Fragments: vLLM's DCP-group column layer gathers with replicas dropped; Fragments.begin/read and the .source-under-TP warning removed (caveat moved to docs); TPEnvoy gathers the post-hook view only for column styles. Tests: taps, preemption, chunked prefill, DeepSeek-V2-Lite at tp=2 and tp=4/dcp=2, request-id parsing, a no-insecure-flag subprocess trace, worker runner swap, shared-module aliasing, ad-hoc TP linears. Docs updated throughout (hooks -> controller, taps, chunked prefill, clone rules). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
vLLM: CUDA-graph taps, controller-only handoff, one request record, T…
The developer and concept pages for the per-module handoff still described PyTorch forward hooks and contrasted the design with earlier ones. They now describe the controller: hook-system.md is controller.md, interleaver-and-hooks.md is interleaver-and-controller.md, and interleaver-internals.md matches the current Interleaver (one visit counter per location, counts_at_start, parked and observer indexes, reindex, Pending, fragments). The fragments proposal, a design history, is removed; its rationale lives in intervention/fragments.py. Across the docs, notes of the form "renamed from", "the old X is gone", "now raises (it used to be a no-op)" and "unlike the old code" are rewritten to state the current behaviour; version-history.md remains the one place that maps the earlier API to this one. Two broken cross-references fixed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y count
`_function_referenced_names` filters a `def`'s payload down to the names its
body actually needs from outside it, so a name the function only ever binds
does not drag in the enclosing scope's same-named object. Its own docstring
gives the failure it exists to prevent:
a helper doing ``with open(path) as f:`` would drag in whatever ``f`` a
notebook cell left lying around, and a closed file can't be pickled at
all ("Cannot pickle closed files")
It located the function's symtable entry by counting children:
children = top.get_children()
if len(children) != 1:
return _referenced_names(source) # block rule -- captures locals
return globals_of(children[0])
A single module-level `def` does not produce a single child table on every
Python. Under PEP 649 (3.14+) it also gets an `__annotate__` table of type
`annotation`, whether or not the `def` is annotated:
>>> [(c.get_name(), c.get_type()) for c in symtable.symtable(
... "def f(x):\n return g(x)\n", "<s>", "exec").get_children()]
[('__annotate__', 'annotation'), ('f', 'function')]
So on 3.14 the guard is true for *every* `def`, the fallback runs every time,
and the filter is inert -- reinstating exactly the capture it was written to
stop. PEP 695 generics add a `type_parameters` table for the same reason.
`children[0]` was also the wrong table by then, so removing the guard alone
would not have been enough.
Select the one child whose `get_type()` is `"function"` instead, which covers
`def` and `lambda` and is stable across versions.
`pyproject.toml` lists 3.14 as supported, and the two tests that already
covered this (`test_function_locals_are_not_shipped`,
`test_function_globals_still_ship`) fail on it today -- CI runs 3.12, so
nothing surfaced it.
Adds `test_scope_filter_survives_extra_symtable_children`, parametrised over
plain / annotated / defaulted `def`s and a lambda, asserting the parameter
never ships; and `test_function_local_shadowing_an_unpicklable_global`, which
pins the original symptom end to end by putting a closed file handle in the
enclosing scope.
Without the fix, 6 of the 15 TestScopeFiltering tests fail on 3.14. With it,
the full CPU suite is green: 856 passed, 7 skipped.
fix(serialization): find the function's symtable child by type, not by count (Python 3.14)
A full CUDA graph captured over a gated-delta-net or Mamba trunk replays the wrong computation for the batch composition it was not captured for, so a tapped engine (breakable graphs, compiler off) silently miscomputes prefill on Qwen3.5 / Qwen3.6 / Mamba-style models — greedy generation diverges from eager. The compiled engine avoids this by splitting its graphs around the recurrent op; breakable graphs cannot, so on any model vLLM reports as hybrid or attention-free pin cudagraph_mode="FULL_DECODE_ONLY": prefill runs eagerly, decode keeps replay. A caller's own compilation_config wins. Verified on Qwen3.5-0.8B: tapped generation matches eager exactly; tests/vllm/test_taps.py passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Seven agents given only docs/ and a task each on hakone (patching, sweep, taps+steering, tp=2 lens, serving, stateful edits, hybrid+MoE) all had to rediscover the same facts and several were misled by statements that hold on the local runtimes but not on vLLM. This states them once and fixes the wrong ones: - models/vllm.md: new "What your block sees on vLLM" (flat [tokens, hidden], the (hidden, residual) layer-output pair and its sum, clone-what-you-keep on the eager engine too, where tensors live, logits/samples shapes, greedy); "Passing values between invokes" (no cross-invoke scope, no barrier, the two-trace recipe); container merge rule (slots merge, append does not); tracer.result must be the last read; prefill is step 0 and locals persist across steps; n>1 container layout; TP rules and the real shard table; the logit lens via logits_processor (lm_head(h) raises); gate.output is a (logits, bias) pair and top-k is not a module value; a hybrid-trunk section (FULL_DECODE_ONLY pin, layer kinds, VLM roots); the taps example steers every step instead of the prefill only; the real sweep cost (a model reference in the block ships the model per invoke); nnsight-serve flag forwarding, the boolean form, /health, no OpenAI routes, EngineCore child; named_modules() vs get()/taps path forms. - invoke-and-batching, cross-invoke, barrier, save, activation-patching, steering, logit-lens, api-quick-reference: one-line "on VLLM" corrections pointing at the new sections. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
model.edit(name="probe") tags an installed block. A request then says which installed edits run with edits=[...] — on trace(...), on an invoke (which wins over the trace's), on a with-less generate, on the async engine and on a served one alike. No edits= runs every edit (unchanged); edits=[...] runs the named ones listed plus every unnamed edit; edits=[] runs the unnamed only. A name is a tag, so two edits may share one. A name nothing is installed under is refused at the call on a local engine (ValueError) and comes back as the request's deferred error from a served one, where edits are installed over HTTP and the client cannot see them. The choice rides SamplingParams.extra_args["nnsight_edits"] beside the block, so the worker reads it the same way on every path; the register RPC and the serve route (?name=) carry the name. Request.deferred now reports the request's own error ahead of its block's, which is what lets a served request surface the unknown-name error. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TL;DR
0.8 is a ground-up rewrite of nnsight's execution engine. The mental model is unchanged — open
with model.trace(...), then read, edit, and.save()a model's internals as ordinary Python — but the core underneath is new: intervention code and the forward pass now interleave as greenlets instead of coordinating across OS threads. The result is a smaller, simpler, easier-to-reason-about codebase with the same (and in places broader) capabilities.The public API is largely familiar. A handful of things moved, were deprecated, or now raise where they used to no-op — the migration notes are at the bottom.
By the numbers (
main0.7 →0.8)main)What changed
Execution: greenlets, not threads
Each intervention block runs in its own worker greenlet (a
Mediator) that cooperatively hands control back to the model side whenever it parks on a location. Only one greenlet runs at a time, so there are no locks or queues. One sharedInterleaverinstalls persistent pass-through forward hooks on every module; the worker/model event protocol isVALUE/SWAP/SKIP/BARRIER. Reading a location the model already ran past now raises a preciseOutOfOrderErrorinstead of deadlocking.Model classes
NNsight(module)— base wrapper for anytorch.nn.Module.TransformersModel("repo/id", task=...)— the primary HuggingFace class, backed by atransformers.pipeline; supports any task (text, vision, multimodal, audio), pre-loaded module instances, andrename=/envoys=customization.DiffusionModel— anydiffuserspipeline, UNet- or transformer-based (SD/SDXL, Flux/SD3/DiT), withseed=, per-invoke batching (DiffusionBatcher), and denoising-step iteration.VLLM(..., mode="sync"|"async")— vLLM-backed runtime with interventions inside the engine worker (GPU; excluded from CPU CI).LanguageModel/VisionLanguageModel— now deprecated thin subclasses that warn on construction; useTransformersModel(task=...).generatevspipemodel.generate(...)generates through the model and returns token ids ontracer.result, greedy by default (0.7 read the ids offmodel.generator.output).model.pipe(...)runs the whole task pipeline and returns its records (decoded text, labels).model.trace(...)runs one forward;model.scan(...)runs one forward under fake tensors for shape inference (no dispatch, no weights).eproperty— the hookable-value descriptor.input/.inputs/.output,tracer.result, andVLLM.logits/.samplesare all built on the reintroduced@epropertydescriptor (preprocess/.postprocess/.transform/.provide, withdescription=surfacing a value in the Envoy repr tree). You can declare your own hookable values on a model subclass.saveis guardednnsight.save(x)/x.save()now raises if called outside a trace (it used to silently no-op). Idiom for collecting per-step values: save the container once, append raw values (xs = nnsight.save([]); xs.append(...)) — appendingx.save()drops values on remote.Iteration
Target occurrences across a repeated run with
for step in tracer.iter[...]:ortracer.all(). Use a boundediter[:N]so code after the loop still runs. Thewith tracer.iter[...]:form is deprecated;tracer.next()is gone.Source tracing, cache, scan, skip, stop
module.source.<op>hooks intermediate operations inside aforward(AST-rewritten, lazy, inert outside a trace, recursively drillable).tracer.cache(...)records many modules at once;module.skip(replacement)bypasses a module;tracer.stop()exits early;model.scan(...)infers shapes under fake tensors.Batching / invokers
tracer.invoke(prompt)batches multiple prompts into one forward with per-invoke row narrowing (left-padposition_ids, empty-invoke whole-batch, multi-invoke.skip()reassembly,tracer.barrier(n)for cross-invoke sharing).Remote (NDIF)
remote=True→RemoteBackend(blocking over one websocket, orblocking=Falsesubmit/poll).remote="local"→ in-process serialize/deserialize/execute dry run — catch serialization issues offline, no key.AsyncRemoteBackend—awaita job for its saves,async forstreamed status.model.session(remote=True)bundles multiple traces into one job. Model identity viato_model_key()/from_model_key().Config
CONFIG.API.HOST/APIKEY/COMPRESS,CONFIG.APP.DEBUG/REMOTE_LOGGING/PYMOUNT. Loaded from~/.config/nnsight/config.yamlover shipped defaults, then env (NDIF_API_KEY,NDIF_HOST,NNSIGHT_DEBUG);-v/--verboseflips DEBUG (full tracebacks).Packaging
pip install nnsightis a working local + remote install: transformers, huggingface-hub, and the remote deps are core (grouped inpyproject.toml), withdev/vllm/serveextras. Metadata is populated (keywords, classifiers, URLs →ndif-team/nnsight, license),nnsight.__version__resolves viaimportlib.metadata, and the C.save()mount extension builds as an optional step (soft-fails tonnsight.save(value)).Docs & tooling
NNsight.md— a human-readable design-and-implementation manual (why it exists, how it works).docs/— 96 recipe-style pages (usage / concepts / patterns / errors / gotchas / remote / models / developing), routed for both humans and agents byCLAUDE.md.NNsight_Walkthrough.ipynb— a runnable, Colab-ready guided tour (every cell verified on CPU)..github/workflowsinstall.[dev]and run the CPU suite (vLLM excluded); agent support via Context7 / the nnsight skill.Testing
CUDA_VISIBLE_DEVICES="" pytest tests/ --ignore=tests/vllm→ 692 passed. The vLLM suite (tests/vllm/) needs a CUDA GPU +nnsight[vllm].Performance
Two measurements, two stories.
1. Per-intervention overhead — the greenlet win
The rewrite swaps OS worker threads (a lock + GIL handoff on every park/resume) for greenlets (a cheap in-process switch). Isolated on a compute-free deep stack of
Linear(8, 8)layers — so wall-time ≈ the interleaving machinery, not the model — 0.8 cuts the cost of touching a module by 3.5–5×, and (unlike 0.7) keeps it flat as the intervention count grows:Per touched module: 0.7 ≈ 60–137 µs and rising with depth (thread contention); 0.8 ≈ 14–38 µs and flat. The more you inspect at once —
cache()-everything, per-head/source-op tracing, deep models, long generations with per-step edits, big batches — the further 0.8 pulls ahead.2. End-to-end on a real model — a wash, because compute dominates
On GPT-2 (CPU,
torch.set_num_threads(4), 3 passes × 80 runs, 40 for generate), a sparse trace is within a couple percent of 0.7: one forward (~37 ms) dwarfs a handful of interventions' worth of microseconds. Model load/dispatch is meaningfully faster.main)trace— read + save (1 layer)trace— edit + save (1 layer)generate— 10 tokensBottom line: 0.8 makes nnsight's own overhead ~3.5–5× cheaper and flat-scaling. For a couple of interventions on a large model you won't notice (the forward is the bottleneck); for intervention-heavy work the greenlet engine is a clear win — and per-trace runtime never regresses.
Repro: gpt2 + tiny
nn.Sequentialstacks,CUDA_VISIBLE_DEVICES="", 4 threads; identical logical workload per version (0.7 tuple.output[0]/m.generator.output, 0.8 bare.output/tracer.result).Known follow-ups (not in this PR)
Tracked gaps carried from the rewrite (vLLM, multi-invoke batching,
epropertyproviders, async remote, and diffusion seed/batching are in):tracer.local()— run a function back on the client) is not ported.__source__escape hatch); file-defined remote functions usingnonlocalfail at load.NotImplementedError(single multimodal generate works)..pyistubs,automodel=, arbitrary-method trace wrapping.Migration quick reference
The changes most likely to touch existing code:
LanguageModel(...)/VisionLanguageModel(...)TransformersModel(repo, task=...)— old names still work, but warnmodel.generator.output(generated ids)tracer.result(generated ids);model.pipe(...)for decoded pipeline recordsx.save()/nnsight.save(x)outside a trace (silent no-op)tracer.next()/with tracer.iter[...]:for step in tracer.iter[...]:/tracer.all()nnsight.apply/log/cond/iter/session(...),nnsight.list/dict/int/...wrappersmodel.session()tracer.barrier(n_participants)tracer.barrier(n)See
docs/reference/version-history.mdfor the full mapping.🤖 Generated with Claude Code