Skip to content

0.8 — the pipeline rewrite - #686

Open
JadenFiotto-Kaufman wants to merge 91 commits into
devfrom
0.8
Open

0.8 — the pipeline rewrite#686
JadenFiotto-Kaufman wants to merge 91 commits into
devfrom
0.8

Conversation

@JadenFiotto-Kaufman

@JadenFiotto-Kaufman JadenFiotto-Kaufman commented Jul 23, 2026

Copy link
Copy Markdown
Member

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 (main 0.7 → 0.8)

0.7 (main) 0.8
Core source ~18.2k LOC ~11.1k LOC (~39% smaller), 59 modules
Execution model thread workers + locks/queues greenlets (cooperative, single-threaded)
Diff 388 files, +31.8k / −61.1k
Tests 692 passing on CPU (vLLM suite needs a GPU)

Base branch: this targets dev (it was cut from dev, so the diff is exactly the rewrite). Heads-up: main currently has 6 commits not in dev/0.8 that should be reconciled separately before a dev → main release.


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 shared Interleaver installs persistent pass-through forward hooks on every module; the worker/model event protocol is VALUE / SWAP / SKIP / BARRIER. Reading a location the model already ran past now raises a precise OutOfOrderError instead of deadlocking.

Model classes

  • NNsight(module) — base wrapper for any torch.nn.Module.
  • TransformersModel("repo/id", task=...) — the primary HuggingFace class, backed by a transformers.pipeline; supports any task (text, vision, multimodal, audio), pre-loaded module instances, and rename= / envoys= customization.
  • DiffusionModel — any diffusers pipeline, UNet- or transformer-based (SD/SDXL, Flux/SD3/DiT), with seed=, 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; use TransformersModel(task=...).

generate vs pipe

model.generate(...) generates through the model and returns token ids on tracer.result, greedy by default (0.7 read the ids off model.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, and VLLM.logits / .samples are all built on the reintroduced @eproperty descriptor (preprocess / .postprocess / .transform / .provide, with description= surfacing a value in the Envoy repr tree). You can declare your own hookable values on a model subclass.

save is guarded

nnsight.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(...)) — appending x.save() drops values on remote.

Iteration

Target occurrences across a repeated run with for step in tracer.iter[...]: or tracer.all(). Use a bounded iter[:N] so code after the loop still runs. The with tracer.iter[...]: form is deprecated; tracer.next() is gone.

Source tracing, cache, scan, skip, stop

module.source.<op> hooks intermediate operations inside a forward (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-pad position_ids, empty-invoke whole-batch, multi-invoke .skip() reassembly, tracer.barrier(n) for cross-invoke sharing).

Remote (NDIF)

  • remote=TrueRemoteBackend (blocking over one websocket, or blocking=False submit/poll).
  • remote="local" → in-process serialize/deserialize/execute dry run — catch serialization issues offline, no key.
  • AsyncRemoteBackendawait a job for its saves, async for streamed status.
  • model.session(remote=True) bundles multiple traces into one job. Model identity via to_model_key() / from_model_key().

Config

CONFIG.API.HOST / APIKEY / COMPRESS, CONFIG.APP.DEBUG / REMOTE_LOGGING / PYMOUNT. Loaded from ~/.config/nnsight/config.yaml over shipped defaults, then env (NDIF_API_KEY, NDIF_HOST, NNSIGHT_DEBUG); -v/--verbose flips DEBUG (full tracebacks).

Packaging

pip install nnsight is a working local + remote install: transformers, huggingface-hub, and the remote deps are core (grouped in pyproject.toml), with dev / vllm / serve extras. Metadata is populated (keywords, classifiers, URLs → ndif-team/nnsight, license), nnsight.__version__ resolves via importlib.metadata, and the C .save() mount extension builds as an optional step (soft-fails to nnsight.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 by CLAUDE.md.
  • NNsight_Walkthrough.ipynb — a runnable, Colab-ready guided tour (every cell verified on CPU).
  • CI.github/workflows install .[dev] and run the CPU suite (vLLM excluded); agent support via Context7 / the nnsight skill.

Testing

CUDA_VISIBLE_DEVICES="" pytest tests/ --ignore=tests/vllm692 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:

Modules touched Op 0.7 overhead 0.8 overhead Speedup
50 read + save 3.0 ms 0.7 ms 4.3×
200 read + save 13.7 ms 2.9 ms 4.7×
500 read + save 43.8 ms 8.2 ms 5.3×
500 edit 68.5 ms 18.8 ms 3.6×

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.

Workload 0.7 (main) 0.8 Δ
Raw forward (no nnsight) 37.2 ms 37.3 ms +0.4%
trace — read + save (1 layer) 39.6 ms 40.7 ms +2.8%
trace — edit + save (1 layer) 39.7 ms 40.4 ms +1.6%
generate — 10 tokens 302 ms 310 ms +2.4%
Model load + dispatch 3.45 s 2.66 s −23%

Bottom 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.Sequential stacks, 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, eproperty providers, async remote, and diffusion seed/batching are in):

  • Streaming/hybrid remote (tracer.local() — run a function back on the client) is not ported.
  • Serialization edges: frame objects don't serialize; sourceless functions fall back to cloudpickle silently (no __source__ escape hatch); file-defined remote functions using nonlocal fail at load.
  • Multimodal generate batching raises NotImplementedError (single multimodal generate works).
  • Dropped extensibility/ergonomics: .pyi stubs, automodel=, arbitrary-method trace wrapping.
  • Security (greenfield, absent in 0.7 too): no deserialization allow/deny layer — a deliberate hardening decision for remote servers.

Migration quick reference

The changes most likely to touch existing code:

0.7 0.8
LanguageModel(...) / VisionLanguageModel(...) TransformersModel(repo, task=...) — old names still work, but warn
model.generator.output (generated ids) tracer.result (generated ids); model.pipe(...) for decoded pipeline records
x.save() / nnsight.save(x) outside a trace (silent no-op) raises — call it inside the trace
tracer.next() / with tracer.iter[...]: for step in tracer.iter[...]: / tracer.all()
nnsight.apply/log/cond/iter/session(...), nnsight.list/dict/int/... wrappers plain Python + model.session()
tracer.barrier(n_participants) tracer.barrier(n)

See docs/reference/version-history.md for the full mapping.

🤖 Generated with Claude Code

JadenFiotto-Kaufman and others added 2 commits July 22, 2026 23:17
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>
@AdamBelfki3

Copy link
Copy Markdown
Member

🙂

JadenFiotto-Kaufman and others added 10 commits July 24, 2026 20:27
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>
JadenFiotto-Kaufman and others added 8 commits July 25, 2026 13:23
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>
@khaiwang

Copy link
Copy Markdown
Contributor

0.8 review

I ran the 0.8 branch on real GPUs and against an interpretability-workload
benchmark (56 cells, HF and vLLM-sync backends, semantics compared against an
HF control). Everything below was reproduced on this machine: py3.11, torch
2.11, transformers 5.12.1 for local runs; vllm 0.19.1 with transformers 4.57
for engine runs; 2×A100 for the GPU suites. Repro commands are in each item.

Fixes

Worker-side VLLM(module) construction. At the PR head, every vLLM GPU
worker dies at startup: the runner wraps the already-loaded module with
VLLM(self.model), _load assumes a repo-id string, and the module lands in
EngineArgs ('GPT2LMHeadModel' object has no attribute 'lower'; repro:
tests/vllm/test_tracing.py, TP=1). The PR's CI covers the CPU suite only,
so the vLLM path was never executed on a GPU. Fix: a ready module wraps
directly, and the interleaver= kwarg threads to the Envoy the way
rename/envoys already do instead of leaking into the load path.

Barrier exception attribution. Two blocks meet at a tracer.barrier(2);
the block that resumes after release raises. The exception surfaces inside
the releasing block's greenlet, unwinds it mid-body, and gets recorded on the
releasing mediator: with defer_exceptions (the engine driver mode) the
wrong invoke's request fails while the raising invoke returns truncated work
as success. Both blocks belong to one trace, so this is a diagnosability and
partial-result bug for batched traces; other traces are unaffected. Fix: the
release loop records the exception on the mediator that raised it, keeps
releasing the remaining waiters under a deferring interleaver, and re-raises
otherwise with the raiser's traceback already stashed.

Batched saves on vLLM. The documented batching idiom (a container saved
above the invoke blocks, each invoke writing its slot) returns the container
empty. Two causes. .save() marks object ids, which do not cross the
process boundary, so a serialized mediator arrives in the worker with no
record that its captured variables were saved. And each invoke rides its own
request with its own pickled copy of the container, so the copies come back
one per request and the last one clobbers the rest. Fix: __getstate__
ships the saved names with the mediator, and the sync collect merges
same-name copies element-wise before results push. The merge half is
interim: a per-trace worker scope (design question below) removes the copies
entirely, at which point collect.py deletes whole. The names half is
needed under any design, since saved-ness must always travel by name. Same
commit: the chat-input probe hard-imported transformers.pipelines.base.Chat,
which exists only from the transformers 5 chat-pipeline refactor, so every
text trace on a 4.x environment (what vllm 0.19 pins) died on ImportError;
the probe now feature-detects and treats input as not-chat when the helpers
are absent.

Backward sessions on tensors outside autograd. with t.backward(): on a
tensor from an untracked forward fails today with
RuntimeError: cannot register a hook on a tensor that doesn't require gradient, raised from the block's first .grad read. Inside an engine
worker it is worse: the shipped block's source is registered nowhere, the
nested capture fails, and the construct silently degrades to a bare
t.backward() followed by TypeError: 'NoneType' object does not support the context manager protocol, with the bare backward having already run
outside the trace. Fix: execute refuses up front when the tensor does not
require grad (a requires-grad leaf still opens a session; autograd fires its
accumulation hook), and Mediator.__setstate__ registers the deserialized
block's source in linecache under a unique filename, which 0.7's serializer
also did and which the rewrite dropped. Known limitation of the fix as
committed: the linecache entries are keyed by id(self) and never removed,
so a long-lived worker accumulates one entry per shipped block and an id
reuse can point an old traceback at the wrong source. A weakref.finalize
plus a monotonic counter closes both.

Constructor argument routing. Hardening on top of the minimal fixes: the
two decisions every model constructor makes (ready module vs load arguments,
Envoy kwargs vs load kwargs) each move to one place. Loadable.__init__
dispatches a ready module to _wrap and load arguments to _load, so no
subclass _load can receive a module again; the kwarg split derives from
inspect.signature(Envoy.__init__), so the next Envoy parameter routes with
zero mixin edits. tests/test_construction_routing.py pins both invariants
across lazy construction and dispatch replay. Take it or leave it
independently of the fixes beneath it.

Validation: full CPU suite green on the branch (654 passed, 6 skipped),
single-rank GPU vLLM tracing green, and the benchmark's batched steering
cells match the HF control (top-1 agreement 1.00, total variation 0.000).

Design question 1: a per-trace scope on engine workers

Locally, the invoke blocks of one trace share one namespace and one set of
objects through their frame. On the engine, each invoke's mediator is
pickled separately into its request's extra_args, so every shared object
arrives as a private copy per request. One missing link, four symptoms:

  • shared containers come back empty or clobbered (patched by the interim
    merge above);
  • a barrier never releases: each request holds its own copy, each copy waits
    for arrivals that go to the other copies, and every request retires with
    ValueError: A barrier was never reached by every block it waits for;
  • a variable bound in one invoke and read in another resolves locally
    through the shared frame and is simply absent in the sibling's worker
    scope;
  • any identity-based cross-invoke pattern silently operates on copies.

0.7 answered this server-side: requests carried a trace id, the first
arrival's globals became canonical, later arrivals were grafted onto them,
and the canonical values shipped home once when the trace completed. The
0.8 equivalent would be a per-trace scope keyed by trace id, established at
deserialization, with mediator scopes pointing at it.

The scheduler is the second half of the question. Continuous batching gives
no co-residency guarantee: with capacity for one request, invoke A runs and
retires (its parked barrier killed by the dangling check) before invoke B is
ever admitted, so shared state alone cannot make a rendezvous work. Either
a trace's requests are gang-scheduled (admitted together, none retired while
a sibling owes it a rendezvous), or the engine contract restricts invokes to
the schedule-independent subset: disjoint writes during the trace, merged
reads after it. Naming that contract is the decision; the interim merge
implements the restricted subset today.

Design question 2: the gradient contract on inference engines

vLLM's forward runs under torch.inference_mode in both eras. On 0.7,
intervention code ran on its own OS threads outside the thread-local mode,
so torch itself raised on every gradient-surface touch: the 0.7 gaps report
records requires_grad_() failing with RuntimeError. Greenlets run
intervention code on the model's thread, inside the mode. That is what
makes in-place steering work on 0.8, and it also means requires_grad_()
now silently no-ops and gradient misuse fails deep inside the machinery
instead of at the door.

The backward refusal above covers one construct. The general decision: an
engine backend could declare centrally that it has no gradient surface, with
every entry point (backward sessions, requires_grad_, .grad access)
raising the same error, or it could grow a real gradient path by recomputing
the relevant forward segment outside inference mode. Until one of those is
chosen, a forward-only probe that flips requires_grad and expects
gradients later is silently wrong on 0.8 where 0.7 raised.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm

@@ -236,8 +236,21 @@ def __getstate__(self) -> dict:
return {"reduced": reduced, "copy": self.copy, "presaved": presaved}

def __setstate__(self, state: dict) -> None:

@JadenFiotto-Kaufman JadenFiotto-Kaufman Aug 3, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
JadenFiotto-Kaufman and others added 30 commits August 18, 2026 01:12
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>
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.

4 participants