vllm: follow-ups from the v0.7.0 merges - #662
Merged
Merged
Conversation
khaiwang
added a commit
that referenced
this pull request
May 27, 2026
Models without a native vLLM definition (e.g. SmolLM3) run through vLLM's Transformers backend, which wraps the HuggingFace model and adds a leading singleton batch dim (inputs_embeds[None, ...]). Their decoder-layer activations are 3D [1, total_tokens, hidden], so the batched token axis is dim 1, not dim 0. Batcher._narrow/_swap hard-assumed the token axis was dim 0 and gated on shape[0] == total_batch_size. For the 3D case that gate is 1 == total_tokens (False), so reads returned the full batch (all prompts) and writes were silently discarded -- every intervention became a no-op once needs_batching (2+ prompts) was active. Native vLLM models (2D [total_tokens, hidden]) were unaffected, which is why Qwen3 worked but SmolLM3 did not. Generalize the base Batcher to narrow/swap along an axis reported by a new _batch_dim() hook (still dim 0 by default; preserves the existing in-place, concat-for-view/grad-leaf, and passthrough paths). VLLMBatcher overrides _batch_dim to recognize the Transformers-backend's [1, total_tokens, hidden] shape and select dim 1. Adds CPU-only regression tests (TestVLLMBatcherAxis): the 3D swap/narrow tests fail on the unpatched batcher and pass after; the 2D native test pins no regression. Related to #661/#662. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
khaiwang
added a commit
that referenced
this pull request
Jun 24, 2026
Models without a native vLLM definition (e.g. SmolLM3) run through vLLM's Transformers backend, which wraps the HuggingFace model and adds a leading singleton batch dim (inputs_embeds[None, ...]). Their decoder-layer activations are 3D [1, total_tokens, hidden], so the batched token axis is dim 1, not dim 0. Batcher._narrow/_swap hard-assumed the token axis was dim 0 and gated on shape[0] == total_batch_size. For the 3D case that gate is 1 == total_tokens (False), so reads returned the full batch (all prompts) and writes were silently discarded -- every intervention became a no-op once needs_batching (2+ prompts) was active. Native vLLM models (2D [total_tokens, hidden]) were unaffected, which is why Qwen3 worked but SmolLM3 did not. Generalize the base Batcher to narrow/swap along an axis reported by a new _batch_dim() hook (still dim 0 by default; preserves the existing in-place, concat-for-view/grad-leaf, and passthrough paths). VLLMBatcher overrides _batch_dim to recognize the Transformers-backend's [1, total_tokens, hidden] shape and select dim 1. Adds CPU-only regression tests (TestVLLMBatcherAxis): the 3D swap/narrow tests fail on the unpatched batcher and pass after; the 2D native test pins no regression. Related to #661/#662. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Member
|
@khaiwang please ping me when this is fixed so that i can go back to nnterp new version |
khaiwang
force-pushed
the
zikai/vllm-clone-on-save
branch
from
August 18, 2026 02:38
18f0b41 to
701e3d6
Compare
A model vLLM has no definition of its own is served through vLLM's Transformers backend, which runs the wrapped HuggingFace module with a leading singleton batch dim (`inputs_embeds[None, ...]`). Its decoder layers emit `[1, total_tokens, hidden]`, so tokens sit on dim 1, not dim 0. `VLLMBatcher` inherited the base `Batcher`'s dim-0-only row math, which gates on `shape[0] == total`. On a 3-D activation that reads `1 != total`, so the tensor is called unbatched and passes through whole: every read hands the block every in-flight request's tokens, and every write is discarded. Nothing surfaces — a passthrough is indistinguishable from a tensor that legitimately isn't batched — so an intervention just quietly does nothing as soon as a second request shares the step. Native models (2-D slabs) were never affected, which is why this went unnoticed. `VLLMBatcher` now overrides `_narrow_tensor`/`_widen_tensor`, the extension point the base documents for non-dim-0 layouts (as `DiffusionBatcher` already does), and locates the token axis with a new `_token_dim`. Nothing in `intervention/batching.py` changes: the layout is a vLLM concern, and core batching should not grow a hook for it. The override drops the base's `_nnsight_batch` view marker, which exists for backward to redirect a batch slice through its storage-owning base. Backward is not supported on the vLLM path, so the marker had no reader. Not covered: `Interleaver.replay` trims a padded tap tensor with `t[:total]`, which is still dim-0-only, so taps over a Transformers-backend model can be served padding. Deciding the token axis of a *padded* tensor needs a rule this one can't supply, and taps on such a model are untested besides; noted in the dev doc rather than guessed at here. Tests are CPU-only and need no engine. The three Transformers-backend cases fail on the old batcher (the in-place one writes all 8 tokens instead of its 5); the three native cases pass either way and pin no regression.
Replaces the clone-in-`tracing.save()` approach for #661. What a block reads on vLLM is a view into engine memory, and vLLM's fused kernels (`fused_add_rms_norm`, MLA's in-place rotation of the `q_proj` output) overwrite those buffers a few ops later, so a value kept past its read point comes back holding a later layer's data with nothing to indicate it. The documented fix is `.clone()` at each site, which is silent when forgotten. `VLLMBatcher.narrow` now clones what it serves when the env var is set. Doing it here rather than in `save()` covers every way a value is kept — `.save()`, `tracer.cache()` (which narrows through the same call), appends under `tracer.iter`, and taps, whose replay goes through the same handoff — where a clone at the save point only ever covered the first, since a saved *container* is marked once while its elements keep aliasing. It also keeps the change inside the vLLM module instead of changing what `save()` returns for every backend. Env var rather than a `CONFIG.APP` field because the batcher that narrows is built in the engine's worker process (`GPUModelRunner.load_model`); a field set client-side would never reach it. Read once per batcher, so it has to be set before `VLLM(...)`. Falsy spellings match the ones `NNSIGHT_DISABLE_CPP_BACKTRACE` already accepts. Off by default, because it costs in-place edits: with nothing aliasing engine memory, `layers[10].output[0][:] += v` writes to the copy and the model never sees it. That is as silent as the problem it fixes, so it is pinned by a test, called out in both docs, and paired with the form that works either way (edit the copy, then assign it back — a swap routes through `widen`). An `eproperty` with a registered `transform` write-back is unaffected; that path already splices through `widen`. The copy is of the request's own span, not the whole slab, but it is still one allocation per module read per step.
`TestAdHocCall` saved `module.output[0]` and compared an ad-hoc call's
result against it. A served value is the engine's live buffer, and for
`mlp.down_proj` that buffer *is* the decoder layer's returned
hidden_states: the next layer opens with `input_layernorm(hidden_states,
residual)` -> fused_add_rms_norm, which rewrites it in place. So by the
time the trace exits, the reference holds post-mutation state while the
ad-hoc result — computed by a forward that allocated its own output —
holds the real value, and `_min_row_cosine` comes back 0.126 against an
assert of > 0.99.
The test was blaming the ad-hoc call for the corruption of its own
reference. `test_row_parallel_call_takes_and_returns_the_whole[2]` has
been red on 0.8 for this reason, and it reads as a `Fragments.split`
/`whole` bug, which is what its docstring says it guards — nobody would
look at aliasing.
This is the anti-pattern docs/models/vllm.md:109 names outright ("a served
value is the model's live buffer, and the next layer's fused add+norm
rewrites it in place"), so the fix is the documented idiom: clone before
saving.
The column-parallel case gets the same clone. It passes un-cloned today
because act_fn allocates rather than writing back over gate_up's output,
but that is luck, not a property the test should rest on.
Found while verifying this branch on hardware: the failure disappears
under NNSIGHT_VLLM_CLONE_READS=1 with no test change (1 failed/1 passed ->
2 passed), which is what pointed at the buffer rather than the gather.
JadenFiotto-Kaufman
force-pushed
the
zikai/vllm-clone-on-save
branch
from
September 9, 2026 20:28
701e3d6 to
f1de399
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-ups from the vLLM-integration merges that landed into
devfor v0.7.0. This PR merges a mix of correctness fixes, one default change, packaging, and docs. Each is independent but small..save()(fixes #661)a40a5f2,fe7c072,cc565130bc57e35ef446618f0b41ee2022dnnsight-serveinstall machinerybf2949dc8bd79eintervention-gaps/intodocs/models/vllm.md; move probes totests/fc6c10b1. Clone inference-mode tensors on
.save()(fixes #661)vLLM runs forward passes inside
torch.inference_mode(), and several of its kernels (e.g.fused_add_rms_norm) mutate buffers in place. Without a clone, references returned byEnvoy.output/Envoy.inputsalias those buffers, so values surviving past the trace reflect post-mutation state, not what the user asked to save.intervention/tracing/globals.save()now clones when the saved object is an inference-mode tensor. The clone allocates a fresh, non-inference tensor so downstream fused ops mutate the original buffer rather than the user's saved reference. No-op for normal tensors — HF / vanilla PyTorch paths are unaffected.Object.save()returnssave(self)instead ofself, so the cloned tensor (not the original) is what the trace's local-frame filter retains viaGlobals.saves.Verification (SmolLM2-135M on vLLM 0.19.1, one A100): max reference-vs-clone diff across saved residual / attention / MLP tensors dropped from
4064.23→0.00.Regression coverage in
TestSaveCloningpins the invariants:module.output.save()insidetorch.inference_mode()returns a non-inference tensor.cc56513makes the end-to-end inference-mode test deterministic: the oldassert x.std() > 50depended on an unseeded 8-elementrandnsample and flaked ~2.4% of runs; it's replaced with an exact, RNG-free check (the saved clone holds the pre-mutation values; the in-place-mutated source is exactlysaved * 1000).Commits:
a40a5f2,fe7c072,cc56513.2. Narrow/swap on the token axis for Transformers-backend models
Models without a native vLLM definition (e.g. SmolLM3) run through vLLM's Transformers backend, which wraps the HuggingFace model and adds a leading singleton batch dim (
inputs_embeds[None, ...]). Their decoder-layer activations are 3D[1, total_tokens, hidden], so the batched token axis is dim 1, not dim 0.Batcher._narrow/_swaphard-assumed the token axis was dim 0 and gated onshape[0] == total_batch_size. For the 3D case that gate is1 == total_tokens(False), so reads returned the full batch (all prompts) and writes were silently discarded — every intervention became a no-op once batching was active (2+ prompts). Native vLLM models (2D[total_tokens, hidden]) were unaffected, which is why Qwen3 worked but SmolLM3 did not.The base
Batchernow narrows/swaps along an axis reported by a new_batch_dim()hook (still dim 0 by default; preserves the existing in-place, concat-for-view/grad-leaf, and passthrough paths).VLLMBatcheroverrides_batch_dimto recognize the Transformers-backend[1, total_tokens, hidden]shape and select dim 1. CPU-only regression tests (TestVLLMBatcherAxis): the 3D swap/narrow tests fail on the unpatched batcher and pass after; the 2D native test pins no regression.Commit:
0bc57e3.3. Surface deferred intervention errors on local sync/async paths
The vLLM interleaver always runs in defer mode (
GPUModelRunner.load_modelsetsdefer_exceptions=True) so a single bad intervention can't crash the engine that's serving other requests. The serve path re-raised captured errors viasurface_server_errors, but the localVLLM.trace()sync and async paths only collectedoutput.savesand never read the__nnsight_exceptions__envelope — so an intervention that errored on the worker (e.g. an in-place write on an inference-mode tensor) failed silently: no exception, and every.save()after the failing line was dropped.VLLM.__call__(sync) andAsyncVLLMBackend.__aiter__(async) now read the envelope and re-raise viasurface_server_errors, mirroring the serve path. The error surfaces at the trace boundary while the engine stays alive for the next trace (verified: a clean trace and a clone-based intervention both work in the same process after the surfaced error). Envelopes are merged per request so a multi-invoke trace doesn't clobber one request's error with another's. Addstest_inplace_inference_write_surfaces_errorcovering the sync path.Commit:
5ef4466.4. Submit every invoke in async traces, not just the first
AsyncVLLMBackend.__call__serialized every invoke's mediator but then submitted onlyprompts[0]/params[0]under a singlerequest_id, so a multi-invoke async trace ran only the first prompt: invokes past the first never reached the engine, their per-invoke saves came back empty, and trace-shared saves were never collected (the worker'sreceived_countnever reachedexpected_count). Every prior async test used a single prompt, so this code path had no coverage.The backend now submits one request per invoke (mirroring the fan-out in
serve/server.py) and merges the per-request generators into a single stream via an asyncio queue, collecting saves per finished request. The single-invoke streaming path is behavior-preserving, and the deferred-error surfacing from #3 is reused via a shared_attach_saveshelper.Adds
test_async_multi_invoke_runs_all_invokes— the async counterpart oftest_shared_list_across_invokes: a two-invoke async trace must produce two finished requests and collect the trace-shared list from both invokes.Verification (gpt2,
mode="async", vLLM 0.19.1, one A100): the new test fails on the old code (Expected 2 finished requests, got 1) and passes after; the fullTestAsyncEnginesuite is 6/6 green (the 5 pre-existing single-invoke async tests unaffected).Commit:
18f0b41.5. Disable vLLM prefix caching by default
vLLM's prefix caching reuses KV values from previously-seen sequences. When the next request shares a prefix, those tokens skip the forward pass — hooks don't fire and interventions on those tokens are silently skipped, with no error.
VLLM(...)now defaults toenable_prefix_caching=Falseso interventions consistently see every token. Users who explicitly opt in (e.g. for throughput on workloads that don't need to hook prefill tokens) can still passenable_prefix_caching=True. Matches whatdocs/models/vllm.mddocuments as the integration's default.Commit:
ee2022d.6. Restore
nnsight-serveinstall machineryPR #656 merged the
nnsight-servesources (cli.py,server.py,LocalServeBackend,ServeInterleavingTracer, …) ontodev, but thepyproject.tomlchanges were dropped during conflict resolution. Result:pip install "nnsight[serve]"returns "no matching distribution" and thennsight-serveCLI shim isn't on PATH for a fresh install.Restored:
serveoptional-dependency that pullsvllm+ FastAPI + uvicorn.[project.scripts]entry registeringnnsight-serve→nnsight.modeling.vllm.serve.cli:main.allextended to includeserve.After this, the documented
pip install "nnsight[serve]"/nnsight-serve …workflow works without thepython -m nnsight.modeling.vllm.serve.cliworkaround.Commit:
bf2949d.7. Sync
src/nnsight/modeling/vllm/README.mdto the v0.7.0 async pathTwo earlier refactors landed without README updates; the drift has been live through v0.7.0:
d124cc5(2026-03-12, "refactor vLLM input processing") eliminatedAsyncInterleavingTracerentirely.AsyncVLLMBackendnow calls_setup_interleaver()directly; the async path uses the defaultRemoteInterleavingTracer.bb61efa(2026-03-28, "refactor async backend") collapsed the dual-call__call__(tracer)/__call__()pattern into a single required__call__(self, tracer)that submits toAsyncLLM.generate()immediately on trace exit._stream()was removed; iteration moved to__aiter__/__await__.tracer.backend()(with parens) now raisesTypeError— the correct iteration form isasync for output in tracer.backend(no parens).This commit syncs the README accordingly:
async_tracer.pyfile listing and allAsyncInterleavingTracerreferences.AsyncVLLMBackenddescription (file responsibilities + Key Classes) to enumerate the current__call__/__aiter__/__await__surface.VLLM.trace()injects only the backend → default tracer applies →__call__(tracer)submits and parks the generator →__aiter__streams._stream()mentions with__aiter__().tracer.backend()parens-form mentions withtracer.backend.VLLM.trace()Routes the Async Path", showing currentsetdefault-based routing (RemoteableMixin.trace()never hard-codedtracer_cls— the previous prose was also wrong on that point).async def main()under anif __name__ == "__main__":guard (AsyncLLMusesmultiprocessingspawn); note thatoutput.savesis only set onoutput.finished.Verified the corrected usage example runs end-to-end on this branch (gpt2,
mode="async"): 8RequestOutputs streamed,finished=Trueon the last,output.saves == {'logits': Tensor[1, 50257]}.Commit:
c8bd79e.8. Fold
intervention-gaps/intodocs/models/vllm.md; move probes totests/Migrate the durable content of
intervention-gaps/{REPORT,VLLM_GUIDE}.mdinto the maintained user doc and delete the two stale docs (vLLM 0.15.1-era; their in-place-write recipes no longer work).docs/models/vllm.md:tracer.cache().(hidden, residual)output, int64 position-id.input, fused-RMSNorm/RowParallel tuples, mergedqkv_proj/gate_up_proj, flat[total_tokens, hidden]layout.enable_prefix_caching=Falsedefault, deferred errors keep the engine alive, no attention weights, vLLM ≠ transformers numerics.tracer.cache()is supported; version 0.15.1 → 0.19.1.tests/vllm_intervention_gaps/:git mvrun_all.py+test_*.pyhere (executable vLLM-vs-HF diagnostic suite) and add a README.Recipes verified on vLLM 0.19.1 (Qwen2.5-0.5B): in-place writes raise, replacement works, logit-lens matmul (
norm(hs) @ lm_head.weight.T) bitwise-matchesmodel.logitsat the last layer, and TP≥2 sub-module access works (the old "crashes at tp≥2" claim was stale).Commit:
fc6c10b.