Repository navigation
feat(plugin): DeepSeek-V4.1-Flash on the vLLM plugin backend - #2494
Conversation
🏷️ CI GuideRuns automatically on every eligible PR before approval:
Heavy model tests:
|
73a5d17 to
72ca470
Compare
Capture is a third phase, and the guard for the memory profile does not
cover it. Probed at the bind during a PIECEWISE capture:
has_attr=True flag=False marker_attr=True
num_gpu_blocks=64 kv_numel=47349760
callers=[... 'capture', 'decorate_context', 'capture', 'forward_fn' ...]
The profiling flag is False and correctly so -- this is not the profile. The
pool is the capture's: 64 blocks, where the serving one has 246218. Binding
there carved the STATE tail out of a pool 3800x too small and raised "proxy
pool is too small", which is true of what it was handed and says nothing
about what will serve.
Returning False is the caller's existing signal to run on a private scratch
cache, which is what a captured dummy batch wants regardless.
The detection reuses what the V4 bridge already reads --
`vllm.compilation.monitor.cudagraph_capturing_enabled` -- rather than a
second guess at where that flag lives; the first draft of this helper
imported two module paths that do not have it.
On the two commits before this one: both fix real gaps (V4.1 did lack the
profiling marker attribute, and the patch that flips it was installed only
from V4's own registration), and neither fixes this. They are not the
remedy for this failure and are not claimed as such. This one only matters
with graphs on, so it changes nothing for #2494 as it currently ships, where
the gate forces eager.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Capture is a third phase, and the guard for the memory profile does not
cover it. Probed at the bind during a PIECEWISE capture:
has_attr=True flag=False marker_attr=True
num_gpu_blocks=64 kv_numel=47349760
callers=[... 'capture', 'decorate_context', 'capture', 'forward_fn' ...]
The profiling flag is False and correctly so -- this is not the profile. The
pool is the capture's: 64 blocks, where the serving one has 246218. Binding
there carved the STATE tail out of a pool 3800x too small and raised "proxy
pool is too small", which is true of what it was handed and says nothing
about what will serve.
Returning False is the caller's existing signal to run on a private scratch
cache, which is what a captured dummy batch wants regardless.
The detection reuses what the V4 bridge already reads --
`vllm.compilation.monitor.cudagraph_capturing_enabled` -- rather than a
second guess at where that flag lives; the first draft of this helper
imported two module paths that do not have it.
On the two commits before this one: both fix real gaps (V4.1 did lack the
profiling marker attribute, and the patch that flips it was installed only
from V4's own registration), and neither fixes this. They are not the
remedy for this failure and are not claimed as such. This one only matters
with graphs on, so it changes nothing for #2494 as it currently ships, where
the gate forces eager.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This reverts the guard added in e9b8258, which read `vllm.compilation.monitor.cudagraph_capturing_enabled` as "a capture is in progress". It is not that. The module declares it cudagraph_capturing_enabled: bool = True and `set_cudagraph_capturing_enabled(False)` runs only when a capture phase ends. It is a permission, and it is granted by default. In an eager run no capture phase ever runs, so it stays True for the life of the process, the bind stood down on every forward, and the model served from the private scratch cache the whole time -- a healthy server emitting noise. Pinned on measurement, not on reading. Eight archived runs of the same eight greedy prompts, same harness, same arm: eager started 12:41:51 -> "391." "Paris." eager started 12:53:23 -> " [ [ | | |)" " Paris @ sweepersrsrs" e9b8258 landed at 12:53:08, fifteen seconds before the first bad run and eleven minutes after the last good one. The claim in its own message -- "this one only matters with graphs on ... changes nothing for #2494 as it currently ships, where the gate forces eager" -- has it exactly backwards: eager is the only configuration it could break, and it broke it. Keying the guard the other way would be wrong too, which is why none is put back: capture against the *serving* pool is precisely when the bind must happen, or the captured graphs replay a scratch cache. The phase that must be excluded is the profiling pool, and `_atom_v4_profiling_kv_cache` names it -- once the patch that sets it is installed on the runner class in use, which is the commit before this one. The test that went with the guard asserted that a helper I had just written mirrored the flag it had just read. It never touched the bind. Replaced with one that asserts the bind does not consult that flag at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
93f7798 to
786539f
Compare
…aking test state Two leftovers from closing the prefix-caching gate. The stores kept running. With `images_can_serve()` false nothing will ever restore them, so every step was paying a copy for bytes with no reader. Both directions now skip, counted (`image_skipped_gate_shut`) so "no images stored" does not read the same whether the work was skipped deliberately or failed quietly. They start again with the gate rather than needing a switch of their own. The image module's globals are process-wide -- a wired runtime, a cached layer class, a learned group index -- and tests that set them left every later test looking at a model with state-image layers registered. Measured: two tests in other files failed in the full suite and passed alone. An autouse fixture snapshots and restores them, and is a no-op in runs that never import the module. Also shrinks this branch's diff on the two files #2494 owns. The prefix-caching gate had a copy of the same predicate in each, with its docstring; what remains is the one condition this branch actually needs -- that a state tier is configured. The hook that would have opened the gate on state images is gone with it: it could only ever return False, and a dead branch in somebody else's file is a rebase to re-resolve for nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review summaryI recommend addressing the confirmed correctness issues before merging. The shared PAGE/STATE allocation and centralized proxy geometry are sensible directions; the main problems are at the boundaries between native execution, vLLM batch semantics, and upstream interfaces. Revision scope: the original review covered Confirmed issuesP1: The shared attention path overrides the native bounded-replay row viewLocation: attention.py:426.
For an ordinary finishing, unpadded 1024-token prefill with a 128-token window, decoder attention receives 128 rows, but selects a Step describing 1024 rows. A concrete incompatible consumer is the KV reshape using This affects the native path with decoder bounded replay enabled by default; it does not require FULL graphs. The existing row-context unit test does not exercise this real attention/cache interaction. Suggested change: preserve the caller's Step on native execution and select plugin live metadata at the adapter boundary. Updating every Step consumer within the scoped context is another possibility, but creates more shared mutable-state coupling. Add a native long-prefill test that reaches the real tail attention path. P1: The globally installed sampled-output wrapper breaks the upstream calling conventionLocation: v41_speculative.py:218–227. Upstream The exact signatures and upstream keyword payload reproduce this failure in a CPU-only binding probe: The patch is installed globally. Python argument binding fails before the V4.1-specific pending-state check executes, so other plugin models using the V2 runner with pipeline parallelism are affected too, even without speculation. Suggested change: preserve upstream parameter names, defaults, and forwarding semantics. Add tests using the actual pipeline-parallel keyword payload and a call omitting the optional argument, with no V4.1 speculative step pending. P1: A legal chunked-prefill tail is mistaken for speculative verificationLocation: deepseek_v41_bridge.py:1538–1569.
A 4099-token prompt can reach this state after its first 4096-token chunk, then share the next step with a new request. The short tail is classified as a draft block, and the new request triggers Suggested change: carry P2: Resolved Hugging Face configuration overrides are discardedLocations: model_wrapper.py:457–469, configuration conversion, and bridge configuration loading. The V4.1 wrapper keeps ATOM's freshly loaded checkpoint configuration instead of vLLM's resolved configuration, but the conversion forwards neither the resolved HF config nor its overrides. The bridge independently reloads and caches the checkpoint config by model path. For example, an override applied by vLLM is lost before ATOM constructs its RMSNorm: --hf-overrides '{"text_config":{"rms_norm_eps":1e-5}}'Suggested change: normalize a supported serialization/copy of vLLM's resolved configuration before ATOM's validation runs, then use that same normalized result for model construction and proxy geometry. Simply assigning the unnormalized root object back, or fixing only the model while leaving geometry on checkpoint defaults, is insufficient. Cache by resolved configuration identity rather than only model path. This finding concerns HF fields consumed from the normalized config, not every CLI option; maximum length, dtype, and tensor parallelism have separate conversion paths. P2: A waiting backlog unnecessarily disables draft scheduling on decode-only stepsLocation: v41_speculative.py:135–170. The wrapper treats a nonempty waiting queue as proof that the current step will mix prefill with drafts, then clears all running requests' speculative slots before calling the scheduler. Counterexample: every running request is decoding, the active-request cap is reached, waiting is nonempty, and KV capacity is sufficient with no preemption. Upstream cannot admit another request in that step, so there is no prefill to avoid. Suggested change: base the restriction on the actual scheduled batch or actual prefill eligibility, including the active cap, token budget, and blocked/deferred state. Queue nonemptiness is not a substitute. The confirmed effect is unnecessary clearing of speculative slots. Some slots can be asynchronous placeholders, not already-generated draft tokens. I did not measure a throughput or latency penalty. P2: Reading a V1 block-table attribute from V2 InputBatch discards valid request IDs and forces a synchronous table copyLocation: deepseek_v41_bridge.py:901–930. The code successfully reads For a nonempty real V2 batch with a device-resident table, each affected snapshot fallback therefore copies the entire supplied table, with size Suggested change: separate request-ID acquisition from host-table acquisition. Preserve valid IDs independently and provide the V2 adapter with its separately supplied block tables or an appropriately ordered host staging view. An invalid V1 attribute lookup should not be the normal V2 path. P2: Post-STATE-reservation capacity validation omits the permanent null blockLocation: deepseek_v41_state_reserve_patch.py:94–130. The new lower bound compares the remaining total block count with
The existing boundary test incorrectly accepts this case. The original upstream capacity check happens before the wrapper removes the STATE reserve, so it does not catch the resulting shortage. For an actual reserve of Suggested change: revalidate capacity after reservation using final block counts, the permanent null block, and actual per-group allocation requirements. Target and draft groups share the pool, so a complete fix needs group-aware accounting rather than assuming a fixed additional draft allocation. Correct the boundary test and add draft-group coverage. Conditional graph concerns and design recommendationsThe following are unverified hypotheses, not confirmed GPU failures or established root causes. They arose in the original review. The intervening changes do not provide an evident fix, but I have not re-proven them against the complete updated graph stack. Historical non-speculative FULL-graph accuracy results were good, and their relationship to these candidate edge cases remains unresolved.
Recommended structure:
Documentation, compatibility, and validation scopePlease reconcile the capability descriptions. The PR body still says DSpark is refused and graphs are forced off, while the recipe and implementation support DSpark and opt-in graph execution. The platform docstring still describes the experimental graph mode as "wrong on purpose", and the graph test module's opening description says PIECEWISE-only even though its tests preserve FULL-bearing modes. State defaults, opt-ins, supported combinations, and the revision associated with each measurement consistently. Older vLLM compatibility is not a blocker in this review. The new Validation performed:
The ordinary native unit-test script excludes No source files or containers were modified during this review. The reported performance-related behaviors are not measured end-to-end performance claims. 🤖 Generated with Claude Code |
…ure DSpark Resolving the connector's group by eliminating every other one needs a new clause for each group anybody adds. DSpark's draft KV is its own group, so the engine refused to start with `expected exactly one non-recurrent KV cache group, found [0, 1]`. The codec registers the proxy's planes, so the proxy's group is the one it owns: it is claimed by layer name instead, and a V4.1 run with no proxy group says so rather than picking something. `_is_deepseek_v41` is also assigned before that resolution rather than after. It is a class attribute defaulting to False, so the branch that reads it was dead and nothing said so -- found by the branch having no effect, not by an error. With those, DSpark alongside this connector runs for the first time, and the refusal's prediction holds. Measured: the engine boots, the three-arm accuracy harness scores blind 0/16, cold 16/16, warm 16/16 with zero stale-cursor refusals and `cursor_mismatch` 0 -- and the tier stores 32 objects and retrieves none. External prefix cache hit rate 0.0%, against 44.1% for the same configuration without drafts, with the lookups happening and missing: a draft moves the frontier a store is keyed on, so nothing later asks for the key that was written. So the combination is safe and useless rather than unmeasured, and the refusal now says that instead of listing what it feared. Fixing it means making the boundary keys speculation-aware. The draft also needs its own `kv_cache_dtype` inside `--speculative-config` (#2494 now refuses the configuration without it). Without that key the draft runs vLLM's V4.1 attention against the TARGET's `cache_config` and writes the resolved `fp8_ds_mla` back into that same object, so the target's dtype changes under ATOM's gate between startup and the check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
23ef886 to
2a89e29
Compare
valarLip
left a comment
There was a problem hiding this comment.
Reviewed at fc0b853 (after the latest push). Several points from an earlier pass are already fixed in this push: the compress-plan shape under FULL capture (_v41_prepare_shape), the fork join under FULL capture (capturing), the state slots (now V2's idx_mapping_np), _staging_advances, the core-copy digest test moving out of tests/plugin, and the staging test no longer patching the core class. They are not repeated here.
What is left, most severe first:
- Every plugin model fails at startup on current vLLM (
spec_decode_patch.py:519). The wrapper takes one argument; vLLM's_pool_bytes_per_blockis(vllm_config, kv_cache_groups)and every call site passes two. The patch is installed for all plugin models, so this is not V4.1-only. - Requests that should be refused at startup are accepted. PP / DCP / PCP > 1 (the plugin Config never carries those sizes, so the remaining checks cannot fire), and KV connectors other than
lmcache_mp(LMCacheMPConnectorpasses through the alias; other vLLM connector names fail with "unknown KV connector"). - The engine can die mid-serving. A preempted request recomputing its output is not seen as prefill by the mixed-step guard;
--max-model-len -1always fails after vLLM's auto-fit; with DSpark the minimum-capacity check ignores the draft group. - Latent / masking issues: the proxy bind ignores the slice's
storage_offset; the staged path skips the width check; the blocking state probe hides an unexplained cursor off-by-one. - Cleanups: eager deployments still swap in the copied
_forward; patch-install results that report failure as success; docs and env vars out of sync with the code.
Details inline. Everything here is from reading the code at this commit against vLLM main; nothing was run.
valarLip
left a comment
There was a problem hiding this comment.
Thanks for the substantial work here. I went through HEAD 6746431fb and verified each point below against the pinned vLLM; 15 inline comments — the first 8 are correctness, the rest are about duplicated implementations of the same functionality.
Blocking: DSpark currently never verifies a draft — the prefill predicate in v41_speculative.py:177 is off by one token at schedule entry, so spec_token_ids is cleared every step (introduced in 061208745; the reported acceptance predates it, and the CI DSpark case gates on ≥ 0.35). The tests/plugin tests that should catch this and several other issues here are skipped by run_unit_tests.sh --ignore=tests/plugin.
Structural theme: a lot of the plugin is copies of core / V4 code (pool geometry, _prepare, Attention.forward, Engram staging, forward_hidden, proxy-layer helpers, capture probes, V4.1 identity checks, CI acceptance gate), held together by digest/AST/parity tests that can only alarm on drift. Small seams in core plus a few shared helpers would remove most of them.
Smaller items (not inline):
- Derived state stored twice: module-global
_V41_PENDING_SPECULATIVE_STEPvscache.pending;cache.current_stepis added to corecache.py:361but only the plugin reads it (and the insertion separates an existing comment from the code it describes). - Three hand-built
SimpleNamespacebatches guarded by an AST test (test_vllm_deepseek_v41_batch_fields.py); seven test files each re-implement an "extract function via AST and exec" helper. - Docs vs code:
state_slot_allocator.pysays V4.1 uses it (V4.1 uses V2'sidx_mapping_np);model_wrapper.py:89still says DSpark is refused; two docstrings inreq_id_passthrough_patchdisagree on whether V4.1 pays the D2H;_staging_advances()in the description is no longer in HEAD;--kv-cache-dtype nvfp4+ DSpark is rejected natively ("requires a BF16 KV cache"), contradicting the platform hint text and the recipe. - Size:
deepseek_v41_bridge.pyis 1893 lines;_v41_stage_outside_forwardandbind_deepseek_v41_proxy_cacheare ~155 lines each.
|
Follow-up: the review above was written against
|
ec0bc1a to
aa97410
Compare
Consolidate the finalized model-adaptation changes before rebasing onto current main. The original development history is retained in backup/pr2494-before-rebase-20261013-1414. Co-Authored-By: Claude Code <noreply@anthropic.com>
Use persistent V2 request indices for STATE ownership and fixed FULL graph plan capacities. Publish metadata and cache-owned pinned sources through explicit lifetime owners, join Engram prefetch once per stage, and retain scratch only for effective graph configurations. Restore the native cursor branch, add real ROCm replay and H2D lifetime regressions, move dependency-free contracts into CPU CI collection, isolate installer fixtures, and clarify the plugin recipe's support and validation boundaries. Co-Authored-By: Claude Code <noreply@anthropic.com>
Stage profiling FULL descriptors on private scratch without binding the placeholder KV pool. Preserve decode and DSpark verify shapes, publish metadata before capture, and leave the serving cache binding unchanged. Cover the real metadata-factory and forward-context entry chain, profiling-to-serving rebinding, and all bound/profiling/capture control-flow combinations in the CPU CI contract suite. Validated V2 TP4 DSpark5 FULL_AND_PIECEWISE: profiling FULL 2/2 and serving FULL 41/41; GSM8K 5-shot 1224/1319. Targeted suite: 289 passed. Local CPU CI entry: 8369 passed, 903 skipped, 3 xfailed. Black and Ruff passed. Co-Authored-By: Claude Code <noreply@anthropic.com>
Account for independent request tail pages when allocating retained capture scratch. Reject page, STATE-slot or token requests beyond its fixed capacity instead of replacing storage referenced by captured graphs. Keep eager scratch allocation independent and exact-sized. Cover capacity and address stability with real CPU cache and AST contract tests. Exercise profiling-to-serving GPU graph recreation with real Engram cursor/candidate rebinding and stable staging payloads in non-spec/DSpark5. Keep optional vLLM test gating local and clean up UVA after setup failures. Validated 327 targeted tests and the local CPU CI entry (8376 passed). All 36 PR Python files pass Black and Ruff. V2 TP4 GSM8K 5-shot: DSpark5 1221/1319; non-spec flexible extraction 1225/1319. Both arms completed 1319 HTTP-200 requests; existing worker teardown issues remain. Co-Authored-By: Claude Code <noreply@anthropic.com>
Apply the Black and Ruff findings from the actual PR CI run: normalize imports and blank lines, combine nested context managers, use dictionary items/values directly, and document trusted repository-AST exec in tests. Do not change formatter rules, model computation or cache behavior. Co-Authored-By: Claude Code <noreply@anthropic.com>
Correctness: - Count a preempted request as prefill. The mixed-step guard compared against num_prompt_tokens, so a request recomputing its generated tokens read as a decode and reached a step still carrying drafts, which _v41_prepare_kwargs refuses outright. Use vLLM's own is_prefill_chunk test. - Check the width invariant before the staged path returns, not after, so it covers the plugin as well as native execution. - Build the proxy bind from the slice's storage offset. Reading from zero made the alignment check pass by construction and sized the pool from bytes belonging to other layers. - Size the state reserve against every KV cache group in the pool. With DSpark the draft layers are a second group, so a max-length request needs their blocks too and startup could pass for a request that can never be admitted. - Forward _pool_bytes_per_block verbatim, so the wrapper's arity stops deciding whether vLLM's call works. Reporting and cost: - Compare the model-state patch outcome against the results that install it. Its failure names are as truthy as its success name, so a failed install was reported as an install, in the log. - Count runner classes that are covered rather than classes a call installed on, so the second of two deliberate install sites stops reporting zero. - Install the broken-out attention forward on the deployment's effective graph setting, not on a flag vLLM sets for V4.1 on ROCm by itself, so eager runs stop paying for its extra output buffer. - Name both environment variables the capture gate requires. - Drop _state_probe_blocking_default, which nothing read. - Correct two docs that described refusals the code no longer makes. Startup refusals for PP/DCP/PCP and for KV connectors, the reserve's interaction with max-model-len auto-fit, and the blocking state probe's default are left alone here; they are new behaviour rather than local corrections, and the last needs a test that reproduces the stale read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The vLLM TP reuse path builds its own CustomAllreduce and QuickAllReduce and
stores the adapter only in aiter's _TP global, whose group registry holds a
weakref. Nothing ever closed them, so the first thing to release the IPC
buffers was interpreter finalization -- by which point torch.library has
deregistered the operator namespace aiter frees them through, and every rank
ended a run with
AttributeError: '_OpNamespace' 'aiter' object has no attribute
'free_meta_buffer'
raised inside __del__ where nothing can handle it. aiter expects the explicit
call: its own close() frees the buffers "deterministically instead of leaving
them for GC".
Close them from vLLM's Worker.shutdown, which runs inside the worker process,
is reached from worker_main's finally on a SIGTERM exit, and runs before the
executor destroys the groups this adapter borrows. The original is called
first because model_runner.shutdown() inside it drops the captured graphs, and
the teardown goes in a finally so a failing shutdown cannot skip it. vLLM
releases its own allocator pools in the same method for the same reason.
Only what this module built is closed. The PyNccl communicator, the process
groups and the PP/DP/EP coordinators belong to vLLM, which destroys them a few
lines later. aiter's own destroy() is not usable here: AiterTPAdapter skips
GroupCoordinator.__init__, so the _owns_* fields destroy() reads do not exist
on it. No barrier: each rank frees only mappings it opened, nothing issues a
collective afterwards, and a barrier on the groups being torn down would hang
the path that is already failing to exit.
Installed from the reuse path alone, so TP=1, a failed reuse and the DP+EP
fallback keep aiter's own ownership and get no hook at all.
Measured on DeepSeek-V4.1-Flash TP4 with DSpark-5 and FULL_AND_PIECEWISE: the
destructor tracebacks are gone (0 against 4 CustomAllreduce + 8 IPCBuffer
before), and the teardown's log line appears once per rank, which is the half
that says the hook ran rather than that it was absent.
It does not fix the worker SIGTERM/SIGKILL escalation, which is a separate
defect: stacks taken during that window show all four workers inside vLLM's
own shutdown, first at gpu/model_runner.py cudagraph_manager = None and then
at multiproc_executor.py destroy_model_parallel().
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ater Three settings reached a running server that cannot serve them, because the check that would have refused each one could not see it. Pipeline and context parallel: the model refuses both, but the ATOM Config the plugin builds never carries those sizes, so they read 1 whatever vLLM was told and the check passes for want of being asked. `vllm serve -pp 2` was accepted and failed later somewhere that did not name the cause. Asked of vLLM's config directly now, and every offending axis is named rather than the first one. KV connectors: the model admits `lmcache_mp` because ATOM's transport checkpoints STATE through its PAGE-backed images. That reasoning does not carry to this path -- vLLM resolves the connector through its own registry, so `LMCacheMPConnector` there is vLLM's, and ATOM registers that name as an alias of `lmcache_mp`, which is the whole reason it was admitted. ATOM's own offload connector is refused for a different reason: it is selected by module path rather than by name, but the version here carries recurrent mamba state and has no V4.1 path at all. Either way a hit restores PAGEs whose STATE nobody wrote. Every other name was reaching `connector_name`, which raises "unknown KV connector" for vLLM's `NixlConnector` / `OffloadingConnector` -- an error about ATOM's registry in answer to a question about V4.1. The docstring records what has to be true to admit one again. Auto-fit: `--max-model-len -1` is fitted by binary search against the pool before the STATE tail comes out of it, so the fitted length filled the pool and startup then failed over a length vLLM chose itself. vLLM already subtracts its null block from the memory auto-fit plans against while leaving allocation alone; the tail is subtracted the same way. Also: name a remedy that works. "Raise --gpu-memory-utilization" does nothing under `--num-gpu-blocks-override`, which pins the block count, so a reader who followed it saw the identical failure. Covered by tests in both places they can run: the parallel, connector and auto-fit arithmetic in CPU CI, and the reserve itself with the plugin suite, since it needs the bridge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The mixed-step guard stopped DSpark verifying anything. It runs at Scheduler.schedule entry, and 0612087 gave it vLLM's own is_prefill_chunk expression -- which vLLM evaluates only after advancing num_computed_tokens later in the same call. On entry every decoding request is exactly one token short of its length, so the predicate was true for every request on every step and the drafts were cleared each time. Ask instead how much is left to compute: one token is a decode row whoever it belongs to, including a prompt's last chunk, and more than one is the prefill shape the refusal is about. That keeps the preempted-request case 0612087 was fixing. Measured on TP4 with DSpark-5: 891 drafts, 4455 draft tokens, 1400 accepted (0.314 on 64 short prompts, which is a different workload from the 5-shot GSM8K the 0.512 in the description came from) and a normal per-position decay. The point is that it is not zero. The covering test built requests without num_tokens, so three cases raised AttributeError rather than failing -- and run_unit_tests.sh excludes tests/plugin, so nothing ran them. Its fake now states what a request looks like on entry, which is the thing the predicate was read against wrongly. Also: - One rule for "is this V4.1". The platform read architectures only while the STATE reserve also accepted model_type, so a config carrying just the type had its tail reserved out of the pool and none of the refusals applied. - Enforce the constraints before asking whether graphs are enabled. The eager break was installed against a cudagraph_mode that enforce might still set to NONE, which put the extra attention buffer and copy on eager runs. - Delete two capture probes nobody called, one of them named so that it reads like the status it was not. - Fold the patch installs the two registration hooks each pasted into one function; the coverage line was logged twice in a normal run. - model_wrapper no longer says DSpark is refused. _check_row_alignment is left inert and now says why. It names the V1 runner's field while real serving goes through V2, so it has never compared anything -- but reading V2's field is not the fix: snapshot_v41_batch takes num_computed from the attention metadata precisely because the persistent batch is ordered independently, and a test pins that with a poisoned value the step must ignore. Comparing them would turn documented independence into a crash. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… collapse The gate was written twice -- a python heredoc in the PR script, jq+awk in the nightly workflow -- and the copies had diverged where it matters: the PR one checked the overall rate and not the per-position rates. A draft that still lands its first token and nothing after it keeps a respectable overall number, so that collapse passed PR CI and only the nightly could catch it. Extracted to .github/scripts/check_mtp_acceptance.sh, called by both. The thresholds were already in oot_models_accuracy.json; the workflow matrix now carries mtp_per_pos_threshold through to the PR path, which never read it. The script reads the JSON with jq or python3 because the two callers have different ones available: the nightly host runner has no python, the PR path runs in a container with no jq. MTP_GATE_READER pins which, so the two can be tested against each other -- a gate whose answer depends on what the runner happens to have installed is not a gate. tests/test_mtp_acceptance_gate.py runs every case through both readers, including the one the PR copy could not see: first position intact, the rest on the floor, overall still above its threshold. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two paths stage a V4.1 step: the builder one, which real serving takes, and the in-forward fallback, reached during warmup before the proxy cache is bound. They answered "is this vLLM's own batch" differently. The builder asks in_dummy_run(), the capture state and the request ids; the fallback asked the request ids alone. That is the inference apply_vllm_dummy_run_patch was written to replace -- its docstring records four attempts at reading a synthetic batch off the batch itself, each wrong under load. V2 numbers its dummy requests uniquely, so the id test reads one as real traffic and hands it the STATE slots real requests hold. The fallback now uses the same three sources. The other divergences in that function (it registers no pending speculative step, does not clear cache.pending, does not close the previous Engram fork) are left alone: they are speculative-state ordering rather than a predicate, and merging the two staging implementations is a change that needs a served run to stand behind, which this does not have. tests/test_v41_fallback_staging_predicate.py pins all four combinations by AST-executing the real function; nothing covered this path before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The translation that fills ATOM's `kv_transfer_config` is not V4.1's: it runs
for every plugin model, and several gates read the field as a plain truth
value. `qwen4_exp_attn` refuses outright when it is non-empty, `EngineCore`
sets `kv_transfer_enabled` from it, `simulated_tp` rejects on it.
Its docstring promises an empty dict when vLLM configured no transport. It did
not keep that promise. The pinned vLLM's `KVTransferConfig` is a pydantic
dataclass with no `model_dump`, so the field-by-field branch is the one that
runs, and that object's defaults alone -- a load-failure policy and an empty
extra-config -- came back as a truthy dict for a configuration naming no
connector at all. vLLM builds the object before deciding what goes in it.
No connector is no transport, whatever else the object carries.
Verified against the real class rather than a stand-in: an unset
`KVTransferConfig()` now translates to `{}` and a configured one still carries
its name through for a model's own gate to judge.
Also stop swallowing a zero coverage count. `_install_shared_vllm_patches`
logged only when the dummy-run marking covered something, which traded the
misleading `covers 0` for an ambiguous silence -- "did not install" and
"installed, this is the second call" then read identically. Covering nothing
is the failure the count exists to expose, so it is now a warning. Prompted by
a sibling session hitting the same shape: an installer keyed on
`_model_forward` is silently inert on vLLM's V2 runner, which has no such
attribute (checked here: V2 has `update_requests`, not `_update_states` or
`_model_forward`).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… literal
The CSA2 pool is declared twice and a drift between the two is pool
corruption rather than an exception, so this check is the only thing standing
in front of it. It sat in tests/plugin behind an importorskip("vllm") it never
needed -- it compares source text and imports nothing -- which meant it ran
only in the approval-gated GPU job. Moved out; CPU CI collects it now.
It also could not see the field it was pointed at. Both sides spell the index
plane as a name, and the normalization makes those equal, so what the parity
assertions compare is the construction and not the value handed to it. The
plugin's call site writes that value as a literal False.
That literal is correct only while V4.1's index_cache_dtype resolves to fp8,
which atom/config.py does for this model type and which the plugin's config
translation never overrides. Both halves are asserted together, so wiring an
index-cache dtype through to plugin mode -- the way kv_transfer_config was
wired through -- turns the literal red instead of sizing a pool for the wrong
plane.
Checked the guards bite: the tests fail if the call site stops passing False,
if that config branch resolves to fp4, or if core drops a geometry keyword.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A tentative step runs the draft's whole block and leaves its cursor aside; only the accepted prefix advances it, and nobody knows that prefix until the sampler has rejected what it rejects. So every tentative step has to be settled: a real one handed to the sampler, a synthetic one dropped, because nothing will ever sample a capture. That settlement lived inline in the builder staging path. The in-forward fallback reaches `_prepare` through the same `_v41_prepare_kwargs`, so it can produce a tentative step just as easily, and it did neither half. Leaving one unsettled is not a silent loss. The cache refuses the next step while a prefix is outstanding, so the failure lands a step later and in another function -- "Commit the accepted prefix before reusing or checkpointing state", with nothing pointing back at the step that caused it. Shared rather than copied: a third copy of this would drift the same way. Reachability is narrow today -- the fallback runs when the builder did not stage, which is warmup before the proxy cache is bound -- so this is a latent defect being closed, not an observed failure being fixed. tests/test_v41_tentative_step_settlement.py pins all four combinations and reads back off the source that both paths call it; I checked it fails for the original bug, for either half dropped, and for a non-tentative step settled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
85ad5fe to
6099de0
Compare
…them The rebase onto #2502 had to reconcile two edits to the same V4 bridge: main improved `_V4StateSlotAllocator` in place (evict_lru, release, lookup, last_fresh_rows) and added `_V4SharedSlotLookup`, while this branch had moved the class out to `state_slot_allocator`. Keeping both meant importing main's two names from the moved module, which I wrote as one parenthesised import. Ruff's isort rules want aliased imports from the same module as separate statements, and the version CI resolves (`pip3 install ruff`, unpinned) flags it as I001 where the container's older 0.12.12 does not. No behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`#2494` landed the V4.1 plugin backend, so this branch no longer has to carry it. Rebasing onto it with `-X ours` takes main's side on every file the two PRs both touch -- which is right for the model adaptation and silently wrong wherever this branch's LMCache wiring lived in one of those files. Each of those is restored here, and the audit that found them is `git diff backup/pre-rebase-main-10160102 HEAD` per file: nine of the ten LMCache-core files came through byte-identical, and every drop was in a file the two PRs shared. Restored, with what each one costs when missing: * `deepseek_v41_bridge.py`: `ensure_v41_proxy_bound`, `v41_page_planes`, `_v41_install_state_checkpoint_runtime`, `_v41_install_restore_hook`, `_v41_state_checkpoints_around_forward` and their two call sites. The first two are imported by `kv_transfer/connector.py`, so the tree did not import at all. * `state_slot_allocator.py`: the reservation half (`reserve`, `slot_for`, `release_unclaimed`, `_acquire_unused` and the `_reserved` set `assign` has to treat as live). A tier load writes a slot before its request is ever scheduled; without this the next `assign` reports that slot as fresh and zeroes the bytes the restore just wrote. main's batch `release` is kept and taught to drop reservations. * `register.py`: `apply_vllm_v41_prefix_install` was defined and never called, which leaves the adapter unregistered in EngineCore and the whole checkpoint mechanism off. * `platform.py`: prefix caching and `AtomLMCacheOffloadConnector` are admitted again once something owns V4.1's STATE. main refuses both outright -- `_refuse_v41_kv_transfer`'s own docstring asks for the allow-list this adds. Found by a peer session reading the tree. * `config.py`: the gate that refuses prefix caching on the plugin with no connector and no checkpoints behind it. * `spec_decode_patch.py`: the checkpoint groups are carved back out as k single-layer groups (lumped in with the draft, vLLM refuses the config), `_groups_have_v41_proxy` stops counting them as a second pool, and the pool divisor goes back to agreeing with the other two derivations. main's `*args/**kwargs` wrapper is kept. * `deepseek_v41_state_reserve_patch.py`: the STATE tail is withheld once again, by whichever of the two mechanisms is on. * `tp_group_reuse.py`: the rebase produced a `try` block with no `try`; taken from main whole, since nothing here is this branch's. Also fixed, and introduced by this branch rather than by the rebase: `MooncakeStoreOffloadScheduler._build_load_request` never grew the `recurrent` argument its base gained, so every mooncake load raised TypeError. It refuses a non-None snapshot rather than dropping one. Scope reduction, now that main carries the model adaptation: * The seam in `atom/models/deepseek_v41/attention.py` is gone -- main installs the same break from the plugin (`_atom_v41_attend_block`), and its own test asserts core stays untouched. Same for the Engram staging seam and `model.py`'s aux hidden states, both of which main does from the plugin's copies. * `_build_v41_pool_geometry` calls core's `build_v41_pool_geometry` instead of repeating the arithmetic. That duplication was the one whose halves had to agree numerically rather than loudly, so it had a test comparing them; a seam retires both. * Dropped: the gsm8k and kv-offload measurement harness under `tools/` with its tests, the tests that pin main's model adaptation rather than this change, and two modules of this branch's that nothing imports (`v41_hit_probe.py`, `v41_shutdown.py`). The recipe keeps the acceptance criteria and loses the tool invocations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings DeepSeek-V4.1-Flash up on the vLLM plugin path. Text backbone only, no
KV offload — this is the half of #2357 that is about the model running under
vLLM at all, split out so it can land on its own. #2357 then becomes the
LMCache byte-offload work on top of it.
Based on
maindirectly: it carries none of #2443's MP LMCache commits, andnone of the offload connector, STATE tier or prefix-caching work that depends
on them.
What it takes to run V4.1 under vLLM
V4.1 buys its cache in two currencies out of one contiguous allocation. PAGE
bytes scale with cached history; STATE bytes scale with in-flight requests —
each one owns a fixed-size entry holding its sliding-window ring, the
compressor's rings and its Engram cursor. vLLM can only size the first: it buys
num_blocksuniform blocks and hands every one to itsBlockPool.So the bridge declares a proxy attention layer whose
page_size_bytesisexactly one V4.1 PAGE, and then withholds the STATE tail's worth of block ids
from the scheduler while leaving the allocation's byte size alone
(
deepseek_v41_state_reserve_patch). The profiled memory budget is unchanged;what shrinks is how many of those blocks may hold history. At cache-bind time
the bridge adopts that slab as
PagedAttentionCache's whole pool rather thanallocating a second one beside vLLM's accounting.
v41_proxy_geometryis the single authority for the shape: the proxy layer'shead_size, the reserve, and the bound cache all come from it, so the bytesvLLM allocated and the bytes the kernels address cannot disagree.
Two defects in V4.1 itself
Neither is in the bridge.
V4.1 never received V4's non-immediate block reuse patch. The markers are
matched as substrings, and
".atom_deepseek_v4_proxy"is not one of"...atom_deepseek_v41_proxy", so the model went without a patch that existsfor precisely its property: PAGE and STATE share one address space, a slot's
ring being addressed as an offset past the end of the paged region, so a block
id vLLM recycles immediately can expose live arena contents.
The deferred stale-slot probe reports false positives.
prepare_stateships the cursor rows asynchronously and judges them on the next step. Under
concurrency on this path it pairs a position with a cursor one step out of
register and, since the verdict is a hard refusal, kills the engine on state
that was never stale — always
needs state at N, found N+1, off by exactly theone token a decode step advances. The blocking path is now the default on the
plugin path only; the native engine keeps its ~58 µs/step saving, since that
is where the fault was never seen.
ATOM_V41_BLOCKING_STATE_PROBEoverrideseither way.
What changed in ATOM's shared code, and why
Five files outside
atom/plugin/, 135 lines. The rule applied throughout: afact about one frontend does not live in shared code, even at the cost of a
copy. Where this path needs different behaviour it carries its own version in
atom/plugin/vllm/, and core keeps what its own engine ran.model_ops/attentions/deepseek_v41/backend.py_staging_advances(), andcandidate_blockspassed to the scorer workspacestaged is None) assumed the two always travel together -- where they did not, a step advanced nothing, no error, the request one behind its own position for the rest of its life (needs state at 828, found 818). The second is belowmodel_ops/deepseek_v41/score_workspace.pycandidate_blocksadded to the plane sizingcandidate_blocks × index_block_rows, which the checkpoint fixes andmax_model_lendoes not bound -- so which of the two widths is larger flips with context length. Sizing from context alone fits every long-context configuration and overruns the short ones, and the error names the scorer rather than the budget it was sized frommodel_ops/attentions/deepseek_v41/cache.py_allocate_backing(size, device)seam, defaulttorch.zeros;current_stepheld on the cachecurrent_stepis a read point, not a policy: who reads it, and whether to prefer it over an argument, is the frontend's call -- getting that wrong in core is the regression described belowmodels/deepseek_v41/runtime.pystaged_outside_forward, and the removal of@eager_break_during_capturewith itsimport vllmif not metadata.step.requests: return. The decorator's removal is a reduction in coupling: it only ever covered one frontend's PIECEWISE mode, and the work it guarded has moved out of the forwardmodels/deepseek_v41/config.py("plugin mode", config.plugin_config is not None)grep -r "from atom.plugin\|import vllm"under the V4.1 and Engram treesreturns 0.
What moved out, and what that cost
Four things started as seams in core and are now copies in the plugin:
Engram's staging boundaries, the pool geometry,
Attention.forward, and_prepare's dummy-batch cache.atom/models/deepseek_v41/attention.py,model.py,engram/device/staging.pyandengram/device/runtime.pyarebyte-identical to main.
Two of those copies are procedural code main actively changes -- it added
queriesto the forward andis_final_chunk/replayingto the batchprotocol in the same week -- and their drift is arithmetic rather than a
crash. Each is pinned by a digest over core's version
(
tests/plugin/test_vllm_deepseek_v41_attention_copy.py), and the failuresays to port the change, because refreshing the digest is the one way to
defeat it. The geometry copy gets something stronger: a parity test that
compares every argument of both
V41PoolGeometry(...)constructions andnames the field that diverged, because that pair must agree numerically --
two shapes means vLLM allocates N bytes while the kernels read M.
The one place a seam was got wrong
_attend_blockreadcache.current_stepinstead of itsstepargument --a workaround for the capture freezing arguments, written into shared code.
Bounded replay re-enters attention with a tail Step while the cache still
holds the whole forward's, so a 1024-token prefill with a 128-token window
handed attention 128 rows under a Step describing 1024.
enable_decoder_swa_bounded_replaydefaults to True, so that was theordinary native path. Caught in review; the substitution now lives in the
plugin's copy, and tests assert both directions.
What the plugin does, and why each piece exists
deepseek_v41_bridge.pydeepseek_v41_state_reserve_patch.pyV41PoolGeometry.windowis an offset past the absolute end of the paged regionv41_core_overrides.pyv41_speculative.pystate_slot_allocator.pyvllm_runtime.pyreq_id_passthrough_patch.pyreq_ids, theInputBatchand the cudagraph mode_build_attention_metadataat all — the patch had been silently inert for every deployment selecting it, and inert reads exactly like applied in a logplatform.py--hf-overrides, non-DSpark speculationATOMPlatformWhy prefix caching is refused rather than fixed
vLLM's prefix cache is bookkeeping: registering a cached block is a claim that
the backend left recoverable state at that block's boundary. A V4.1 forward
leaves state only at the end of the query, never at an interior boundary.
Zeroing
num_prefill_checkpoint_blocksdoes not opt out — align mode stillcaches the last complete block's state. So this is a property of the contract,
not a flag.
Why
--hf-overridesis refusedMeasured through vLLM's own
ModelConfig, which is what a worker gets: theresolved config carries
text_config=Noneand novision_config, and ATOM'snormalize_hf_configis built from exactly those two. Normalizing vLLM'sobject was attempted and reverted — it works through
AutoConfig.from_pretrainedand dies in a worker. Refusing by name beats aserver that starts and answers as though the flag had not been passed.
What is supported, and what is refused
mtp.*stages, so there is no separate draft model. The bridge drives the tentative Engram lifecycle and commits the accepted prefixThe connector allow-list (
_VLLM_PLUGIN_KV_CONNECTORS) is empty rather thanoptimistic, and is where a connector that owns both legs gets named once one
exists. The plumbing that makes the gate reachable stays: ATOM's
Configcarries vLLM's
kv_transfer_configacross, without which the dict is alwaysempty there and every connector is admitted because the question was never
asked.
CUDA graphs: opt-in, and what makes capture work
The stated reason V4.1 ran eager was that ATOM's plugin models are not fx-split,
so PIECEWISE had nothing to split on.
VLLM_USE_BREAKABLE_CUDAGRAPHends thecapture at runtime instead, so that reason does not survive it. Three further
things are each load-bearing:
stages it before the model is called, so nothing that must run per step sits
inside a recorded region.
The decorator requires in-place output — a fresh tensor hands back a new
address every replay while the captured segments after it still point at the
first.
_attn_out_bufferallocates it in what stays inside the graph. Thisis what the earlier "the remedy does not apply as a decoration" was missing.
open across a segment boundary is refused (
hipErrorStreamCaptureUnjoined),and so is a captured stream waiting on an event recorded off-capture. Under
FULL the decorator returns the function unwrapped, so there is no break and
the fork is joined immediately instead.
Still default-off, and not out of doubt: vLLM auto-enables
VLLM_USE_BREAKABLE_CUDAGRAPHfor some architectures, so withoutATOM_V41_EXPERIMENTAL_CUDAGRAPH=1on top of it, an upgrade that adds V4.1 tothat list would change how this path runs with nothing in the log. A test holds
that shut.
Accuracy, measured on this tree
Full GSM8K, 1,319 questions, TP4, gfx950, BF16 pool,
--max-model-len 8192,5-shot:
0.92800.9280FULL /0.9325PIECEWISE0.9303 ± 0.0070Native TP4 reference on the same set is
0.9204; the number this work wasfirst written against (on #2357's base, a different tree) was
0.9242.The speculative arm is the one the CI cell runs. Its graphs and drafts were
both confirmed active rather than silently skipped: 47 FULL and 110 PIECEWISE
graphs captured, mean acceptance length 3.47–3.57. Server identity and
max_model_lenwere checked against the served model before the eval, and thesource-tree fingerprint was re-checked afterwards.
Acceptance length is gated separately from accuracy in CI
(
mtp_accept_threshold), because rejection sampling preserves the targetdistribution: a draft that produces noise leaves accuracy intact and only
shows up as acceptance collapsing to ~1.
Testing
recipes/atom_vllm/DeepSeek-V4.1-Flash.mdcarries the operating instructions,the pool-sizing arithmetic, the refusal table and the cudagraph record.
~130 new unit tests, no GPU. Failure attribution, since "all green" is not
available in a container whose aiter and xgrammar predate
main:mainrun_unit_tests.shshape)No failure appears on this branch that is not on
main. The two thatmainhad and this run did not are in
tests/model_ops/test_blockscale_packed.py,which this branch does not touch — run-to-run variation, not a fix claimed
here. The 2 failures left in the V4.1 set come from an aiter too old for
main(make_fp4_mqa_plan), the 4 errors from an xgrammar/tvm-ffi mismatchthat breaks
import vllm.v1.request; both reproduce onmainunchanged.Lint is at parity with
mainon every file touched.One thing that is new here and not on #2357's measured tree
mainmoved under this work:PagedAttentionCachegained aworkspaceargument and the pool geometry gained
index_fp4. Both are threaded through(the bridge now hands the builder's own
score_workspaceto the cache itbinds, as the native path does), and the bind asserts that the proxy geometry
and the runtime builder's geometry are equal before carving — a divergence is a
byte layout the kernels and the allocation disagree about, which nothing later
catches. That assertion is the one piece of logic here that #2357's hardware
runs did not cover; it is a startup-time check that names both geometries if it
ever fires.
🤖 Generated with Claude Code