feat(SAMPLE-CORE): the checkpoint's sampling defaults, and the Gumbel draw that scanned 248,320 tokens on one thread (#1984, #1985) - #1997
Conversation
bb7191b to
c281148
Compare
072adcd to
e5b9b00
Compare
PRE-CHECK RESULT — the premise holds, and it was understated by 12xThis PR asked for a pre-check before anything else: on a tree without the fix, speculation off, does the server default temperature cost 12-20 ms/token more than Run on dgx:gpu0 (GB10) idle under an
Difference: ~236 ms/token. Predicted 12-20. Falsifier 3. The premise is confirmed by roughly an order of magnitude more than forecast, and the run-to-run spread is under 0.4 ms in both arms. In throughput terms the default path costs 3.25x — 2.93 tok/s against 9.53. What the 236 ms is
The tree already recorded this shape as catastrophic eleven lines above the kernel: Upstream is fully parallel — Scope of the impact, stated preciselyThis does not affect the #1574 DFlash2 ladder. As this PR established, It affects every non-speculative request at the default temperature, which is the common serving configuration. It also compounds the correctness divergence in #2002: at temperature 1.0 under speculation we emit the target argmax where vLLM draws from the residual distribution. What is still owed before mergeThe A-vs-D measurement on the integrated tree, at the same recipe and predicted by this PR at 10-20 ms/token faster than A, recovering >=80% of the gap above, with its falsifier: do not merge on that evidence if D is less than 8 ms/token faster, or if D is slower than A on any arm by more than 1%. Given the gap is 236 ms rather than the 12-20 assumed, the "recover >=80%" criterion is the one to hold this to, not the absolute figure.
|
Success criterion, agreed and recorded BEFORE the runI offered "recover >=80% of the measured gap". The author asked to be held to a tighter bar and gave a checkable reason, so the criterion is:
Floor 104.99 ms/token (measured, The reason 95% is defensible rather than optimistic: the temperature-0 arm already runs this exact reduction. Three arms, not two — the earlier framing was wrongI had planned an A-vs-D comparison. That would have misattributed a cost, because D is not "A with a faster sampler": it also switches on the checkpoint's
D exceeding C by more than 10 ms/token does not block this row — D is the parity-correct configuration and matching upstream's distribution is what it is for — but it would open a follow-up on the sort-free pivot search at n=1. Also being collected on the same runsThe residual becomes measurable for the first time. At 341 ms/token the serial scan was ~69% of the step, so the probs Token identity, which is the correctness half and is free to collect. A and C at the same seed must emit identical token ids — that is the bit-identical claim. A and D must emit different ids, because D samples from 20 candidates where A sampled from 248,320; identical ids there would mean the checkpoint defaults are not reaching the sampler at all. Why the original estimate missed by 12x, recorded because the reasoning generalisesThe 12-20 ms/token forecast scaled this file's own 7.5 ms/151k-vocab anchor, which is a load-bound scan, and assumed the RNG and |
… draw that scans 248,320 tokens on one thread (#1984, #1985) On Qwen3.8-27B vLLM draws its next token from 20 candidates and we draw ours from 248,320, because `hf_config.cpp::ReadGenerationConfigEosIds` reads the checkpoint's `generation_config.json` for `eos_token_id` and discards every sampling key in it. `vllm bench serve` no longer sends `--temperature`, so both engines resolve 1.0 and both take the random-sampling path, which makes this a correctness divergence on the workload the parity gate runs rather than a cosmetic default. The same fact explains the second defect. `RandomSampleKernel` is launched `<<<n, 1>>>` and walks the whole vocabulary on one lane, computing two SplitMix64 rounds and an f64 `log` per element. Eleven lines above it, this file already records that a single-block single-thread scan of a ~151k vocab cost ~7.5 ms/token, and that is why greedy argmax was rewritten into the two-pass grid-strided reduction sitting there now. The Gumbel draw never got the same treatment, and its answer is the same lowest-index argmax that reduction already computes. The spec commits the accepting evidence before it is measured: four same-binary arms separating the config read from the kernel, end to end on the leased box, with predicted values and with the two outcomes that would falsify the design rather than the tuning. A per-kernel figure is not evidence here; #1929 landed one and cost 16.9 tok/s end to end. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: claude-code:claude-opus-5-1m [Claude Code]
…enAI request that omits them (#1985) `Qwen/Qwen3.8-27B` ships `top_k: 20` and `top_p: 0.95` in its generation_config.json. We read that file for `eos_token_id` and threw the rest away, and `to_sampling_params` said so in its own comment: "no server-provided default_sampling_params in T0". Every omitted knob resolved to the neutral OpenAI default, and `kDefaultTopK = 0` / `kDefaultTopP = 1.0` both mean disabled. Since `vllm bench serve` stopped sending `--temperature`, both engines resolve 1.0 and both sample, so vLLM has been drawing from 20 candidates while we drew from all 248,320 -- different sampling on two sides of the parity benchmark. The same sibling read now also yields the six keys ModelConfig.get_diff_sampling_param narrows, `GetDiffSamplingParam` mirrors that narrowing and vLLM's `--generation-config auto|vllm|<dir>` selector, and both serving handlers resolve a request against it with upstream's exact precedence: an explicitly sent field wins, an omitted field takes the checkpoint's value, and only a knob neither declares reaches the neutral default. `nullptr` and an empty DefaultSamplingParams both reproduce the old resolution exactly, which is what `--generation-config vllm` gives a user who wants it back. Red first, on the stubbed resolution: 3 cases and 9 assertions of test_openai_protocol failed for the intended reason before the resolution was written. One expectation had to be corrected rather than the code -- temperature 0 clears top_p/top_k/min_p in `__post_init__` upstream and here, so a checkpoint top_k cannot survive a greedy request, and that interaction is now pinned by its own case. Reachability is gated at two hops and honest about the third. The handler hop is entered through `create_completion` / `create_chat_completion` over the synthetic engine, with the defaults produced by the real `LoadHfConfig` and `GetDiffSamplingParam` off a real generation_config.json, so reverting either call site to `to_sampling_params()` turns it red. The CLI hop re-execs the real `VllmServerMain`. The two-line join in server_main sits after model load, and no committed generative checkpoint fixture can reach it; that is stated here rather than papered over. `--override-generation-config`, the `max_new_tokens` server-wide output cap, and an offline `get_default_sampling_params` for the C ABI are recorded under `## Owed` in the row's spec. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: claude-code:claude-opus-5-1m [Claude Code]
… of walking 248,320 tokens on one thread (#1984) `RandomSampleKernel` was launched `<<<n, 1>>>`, returned from every thread but lane 0, and walked the whole vocabulary serially, computing two SplitMix64 rounds and an f64 `log` per element. Eleven lines above it the same file records why that shape was already unacceptable: a single-block single-thread scan of a ~151k vocab cost ~7.5 ms/token, which is why greedy argmax became the two-pass grid-strided `ArgmaxPartialKernel`/`ArgmaxFinalKernel` pair. The Gumbel draw never got the same treatment, and upstream's is whole-tensor (`probs.div_(q).argmax(-1)`, topk_topp_sampler.py::sample_with_exponential_noise), so this is a mirror obligation rather than a tuning choice. The answer is the same lowest-index argmax the greedy path already computes, so the partial kernel is now parameterised on the score and `random_sample` instantiates it. That keeps ONE order-independent reduction in the file instead of a second hand-written copy, and greedy's instantiation compiles the expression it always did. The output is bit-identical rather than merely equivalent. Every element's score is the same `GumbelScore(prob, seed, row, j)` evaluated by the same device libm on both paths; only the order in which those identical floats are combined changes, and `ArgReduce` compares the true global index rather than thread or block order. The serial kernel is retained behind `VT_FAST_RANDOM_SAMPLE=0`, mirroring `VT_FAST_ARGMAX`, so the equality gate is a same-binary A/B rather than a claim. `SplitMix64`, `ExpNoise`, `ArgReduce` and the launch partition moved to include/vt/sample_common.h, which cpu_sample.cpp and cuda_sample.cu both include. They had been written out three times; the CPU copy is the reference the device copies are gated against, so a drift between any two of them was a token difference nothing looked for. ROCm's copy is deliberately untouched and recorded under `## Owed`: `rocm_sample.hip::RandomSampleK` carries the same `<<<n, 1>>>` defect, on hardware this change has no way to gate. Mutation-proven, each red then restored byte for byte: dropping the `bi < ai` tie-break from `ArgReduce` fails 35 assertions of test_ops_sample; turning the score's divide into a multiply fails the pre-existing distribution case, which is the right attribution, because the equivalence cases compare two orderings of one shared score expression and cannot see a change to the expression itself. NOT gated on this host and stated rather than implied: there is no CUDA toolkit here, so the `.cu` change is not compiled locally and its first compile is CI's `cuda-fat-build`. The host cases gate the property the kernel rests on -- the production `ArgReduce`, `GumbelScore` and `ArgBlocksPerRow` under the kernel's exact two-pass partition, against `vt::RandomSample`, over ten vocabulary widths including 248,320, six row shapes chosen for ties and masked zeros, and four seeds. The kernel itself is gated by the `VT_FAST_RANDOM_SAMPLE` A/B, which skips without a device, and by the end-to-end measurement the row's spec commits to in advance. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: claude-code:claude-opus-5-1m [Claude Code]
…server's own usage line (#1985) A user cannot reach the escape hatch they are told to reach for unless the flag is written down where they look. The server reference gains the row, and `--help` gains the line, alongside the resolved-defaults message the server already prints at startup. The spec records what landed, the six mutations that were applied and restored, and the one reachability hop no CPU test can enter -- the two-line join in server_main, which sits after model load and has no committed generative checkpoint fixture to drive it. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: claude-code:claude-opus-5-1m [Claude Code]
…hor the spec on the renamed kernel (#1984) Folding both scratch allocations onto one message would have made a `cudaMalloc` refusal say which pair failed but not which of the two buffers, so the value and index allocations keep their own names as they did before the struct. The spec cited `RandomSampleKernel`, which this row renamed to `RandomSampleKernelSlow` when it kept it as the A/B arm, so `scripts/check-symbol-anchors.py` went red on a citation naming a symbol the file no longer contains. Re-anchored on `RandomSampleCuda` -- the launcher, which is the stable name -- with the rename recorded beside it. `using vt::sample::ExpNoise` went with the local copy it replaced: nothing in cuda_sample.cu calls it any more, because the score expression it fed now comes from `GumbelScore`. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: claude-code:claude-opus-5-1m [Claude Code]
…e 0 (#1984) Every harness in tools/bench/ passes it -- `run_qwen35_4b_ab.sh` sends `--temperature 0` and `profile_vllm_online_gate.py` sets `temperature=0.0` -- and under that flag `PostInit` clears top_p, top_k and min_p, the batch is all-greedy, and `RandomSample` is never called. All four arms would then be the same run, and four identical numbers read exactly like "no effect". Copying an existing harness line is the likeliest way to get a null result that measured nothing, so the spec now names the trap instead of relying on the flag being left off by luck. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: claude-code:claude-opus-5-1m [Claude Code]
…ckpoint defaults the server does (#1985) `vllm_chat` takes the same OpenAI request JSON the HTTP server takes and hands it to the same `OpenAIServingChat::create_chat_completion`, so an omitted `top_k` there is the identical "the client did not say" state. Wiring only the server would have left two entry points of one library sampling from different distributions on one checkpoint, which is the kind of split `include/vllm.h` exists to prevent. There is no C-ABI counterpart of `--generation-config` yet, so `EnsureChatServing` takes upstream's own default for the selector, `"auto"`. Adding the knob is recorded under `## Owed`, together with the note that the struct-shaped entry points (`vllm_complete` and friends) receive an explicit `vllm_sampling_params` and have no omitted state to fill -- that is where an offline `LLM.get_default_sampling_params` equivalent would go. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: claude-code:claude-opus-5-1m [Claude Code]
…AX (#1984) `scripts/check-env-doc.py` refuses a production environment variable that is neither documented nor allowlisted, and it went red on the new A/B selector. It belongs on the allowlist rather than in docs/ENVIRONMENT.md for the same reason its twin does: `VT_FAST_ARGMAX` selects between a kernel and the reference scan it replaced, and so does this one. Neither is a user-facing knob; both exist so a performance claim can be a same-binary A/B. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: claude-code:claude-opus-5-1m [Claude Code]
…ve-config, because drafts bypass the sampler entirely (#2002) `GPUModelRunner::sample_tokens` branches on `exec_state_.step.num_draft_tokens > 0` alone and returns the greedy-only `RejectionSampler`'s output, so with drafts present `Sampler::forward` -- and with it `vt::RandomSample` -- is never called, whatever the temperature. The standing baseline recipe carries `--speculative-config '{"method":"dflash",...}'`, so a before/after taken on it would have returned two identical numbers for the kernel this row rewrites, and two identical numbers read exactly like "the change did nothing". Filed as #2002 rather than fixed here, because the same routing is a correctness divergence with its own scope: upstream's `rejection_sample` carries both the greedy and the stochastic accept, ours implements only `is_greedy`, and `include/vllm/v1/spec_decode/rejection_sampler.h` already states the contract nothing enforces -- "a temperature > 0 request must NOT be routed here yet". Refusing such a request by name is small; porting `_resample_kernel` is a row. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: claude-code:claude-opus-5-1m [Claude Code]
…rm, and record why (#2002) The arms table said nothing about speculative decoding, and the operator's standing baseline recipe carries `--speculative-config '{"method":"dflash",...}'`. With drafts present `GPUModelRunner::sample_tokens` returns the greedy-only `RejectionSampler`'s output and never reaches `Sampler::forward`, so the kernel this row rewrites would not have been launched once during the measurement. Four identical numbers would then have read as "no effect". The same routing is a correctness divergence in its own right and is now under `## Owed`: a temperature-1.0 request under spec decode takes the target argmax where vLLM draws from the residual distribution, and the rejection sampler's own header already says such a request must not be routed there. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: claude-code:claude-opus-5-1m [Claude Code]
e5b9b00 to
d0b3f7c
Compare
…ults, so landing this re-baselines our arm (#1985, #1984) The premise was verified against the public `Qwen/Qwen3.8-27B` file; the operator confirmed it on the checkpoint the project actually benchmarks. `qwen3.8-27b-nvfp4-mtp-sm121-r0b0tlab` ships `temperature: 1.0`, `top_k: 20`, `top_p: 0.95`, so every benchmark run on it has sampled the full 248,320-token vocabulary while vLLM sampled from twenty candidates. That is a per-token cost we inflicted on ourselves and an asymmetry inside the comparison itself. The consequence is recorded because it is easy to misread later: a number taken before this merges is not comparable with one taken after, and not because anything regressed -- the two are different sampling configurations. The arm re-baselines at the merge commit, and `--generation-config vllm` reproduces the old resolution exactly if an old figure has to be re-derived. Also recorded: #1994 voids TTFT and TPOT measured on older trees, and nothing in this row quotes either. The only measured figures it carries are `cuda_sample.cu`'s own ~7.5 ms/token greedy-scan anchor, which is a per-kernel attribution rather than a harness TPOT, and #1929's 16.9 tok/s, which is throughput. #1994's own spec lists `/v1/completions` among the paths it does not touch, so the acceptance arms are unaffected; on the chat backend the residual bias would overstate the greedy-versus-default gap by about 1.6%, which moves the acceptance bar by under a millisecond. And the CUDA half now has a compile verdict instead of a hope: `cuda-fat-build` returned success at `e5b9b0045` across all ten shipped architectures, with both sanitizers green. It is a compile verdict only -- CI has no device, so no CUDA test ran -- and it belongs to that tree, so the merged tree owes its own. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: claude-code:claude-opus-5-1m [Claude Code]
…URRENCY Two commits landed while this row waited. #2000 is the first commit on main ever to touch the two files this row rewrites, so the runner is now a real interaction surface and is checked as one rather than accepted on git's word. #1997 arrived during the resolution; the merge onto #2000 alone was never pushed, so it was discarded and this is one merge onto the final head rather than two stacked ones. #1997 DOES NOT TOUCH THE RUNNER. Its 28 files are the sampling defaults, the Gumbel kernels, config resolution and the OpenAI protocol surface; neither `runner.h` nor `runner.cpp` is among them. The runner overlap is entirely #2000's, and it is checked rather than assumed because a prediction about which file would conflict was already wrong once on this branch. BOTH RUNNER FILES THREE-WAY MERGED WITH NO CONFLICT, and the reason is that the changes are disjoint by a wide margin rather than that git was lucky. Against the common base b758127, #2000 edits `runner.h` at 357-474 and `runner.cpp` at 334-823; this row edits `runner.h` at 836-870 and `runner.cpp` at 2752-3148. The merge is verified in both directions: this row's delta for each file is byte-identical to `git diff b758127 2ea92d2`, and main's delta for each file is byte-identical to `git diff b758127 origin/main`, at shifted offsets only. Brace balance is checked arithmetically for the take-both failure this branch already recorded, even though no conflict region existed to reconstruct: `runner.h` 53 base / 54 ours / 54 theirs, expected 55, merged 55; `runner.cpp` 335 / 337 / 335, expected 337, merged 337; both counts for `{` and for `}`. The issue index was union-appended and checked by row-ID set difference against the CURRENT base rather than by reusing either earlier round's numbers: 745 base + 2 ours (#2008, #2009) + 5 theirs (#1963, #1966 from #2000; #1984, #1985, #2002 from #1997) = 752 expected, 752 actual, 0 lost, 0 invented, 0 duplicated, every merged row byte-identical to a row in one of the three sources and the preamble unchanged on all three sides. `tests/CMakeLists.txt` was rebuilt from the new origin/main (blob d4d6cff, sha256 d4011b98eb1dedae75f2563400a408561cae89996d90d045279a0d38420a6dd5) with this row's seven lines re-applied after the `test_dflash2_ctx_capacity` source line, an anchor asserted to occur exactly once. The rebuild is byte-identical to the three-way result, `git diff origin/main` is 7 added and 0 deleted, and both #2000's `target_include_directories` line and #1997's `test_generation_config` registration survive in it. This row's reviewed change is unchanged across all three re-merges. Measured against the original reviewed delta `git diff 2a42cb3 3d895a2`, the added and removed line sets are identical: 786 added and 68 removed excluding file headers, 792 and 74 including them. The spec and `test_dflash2_concurrency.cpp` are blob-identical to the reviewed head. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: Claude:claude-opus-5 [Claude Code]
origin/main gained #1994 (b758127), #2000 (61ba99f, the KV group layer-count fix) and #1997 (2c27f0d, the sampling defaults plus the Gumbel kernel) while this row was in flight. Merged rather than rebased. The .agents/issue-index.md union is verified by row-ID SET DIFFERENCE rather than by a clean automerge, because GitHub ignores this repository`s union driver and two relocations can automerge into a duplicate. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [Claude Code]
…d batch row cannot take it from its owner (#2008) (#2010) DFlash2 served exactly one sequence. At concurrency 2 the engine refused on its own position invariant one request in, `EngineCore` stopped, and every later request on that server came back `[request submitted to a stopped AsyncLLM]`. This is the blocking defect for the #1574 three-engine comparison: vLLM and SGLang were both measured at c=1..32 with the same DFlash2 draft at k=8, and we could produce c=1 and nothing above it. Closes #2008. ## Why the accumulation desynchronises The runner held the draft context in four arrays indexed by **batch row**. A row index is not stable for a request's lifetime here: `InputBatch::condense` slides a live request down into the hole a finished neighbour left (`input_batch.cpp:686-706`), and `swap_states` exchanges two live rows (`:762-847`). Both permute every per-slot array they own — `req_ids`, `num_computed_tokens_cpu`, `num_accepted_tokens`, `last_sampled_tokens`, `prefill_len`, the block-table rows, the index-keyed sampling maps — and neither knows these four exist, because they live on `GPUModelRunner` rather than in `InputBatch`. So when the first of two concurrent requests finished, the survivor was moved onto the departed request's bookkeeping. The reused-slot test at `runner.cpp:2895-2906` read a changed occupant, allocated a fresh empty store and set `ctx_len = 0` — under a request still using it — and the invariant at `:2939-2945` then refused rather than drafting from a foreign context. `ok=1` at c=2 is that mechanism's signature, not an incidental count. At c=1 the only row is row 0: a request finishes, the next is admitted into the same row, and the reset is *correct*, because the new occupant is a fresh prefill whose first position is 0. The condense move that breaks the state exists only when a second live request has to be slid over a departed one. The operator's isolation had already settled the layer: with `--speculative-config` omitted and everything else identical, the same two concurrent requests both complete. This tree already names the bug class, in a comment above the log written to solve it for a different array (`input_batch.h:240-252`): *"Upstream needs no equivalent because it never condenses ... the slot index is stable for the request's lifetime. This log is the price of our condensed dense batch."* The DFlash2 arrays never got that treatment. ## Upstream Read beyond our parity pin at `b389ac2946` (the clone is shallow and checked out at the pin, so anchors were read with `git show`). **Upstream has no analogue of the host-side counter at all.** DFlash and DFlash2 address the draft KV by absolute position (`dflash/speculator.py:562-590`), re-read their one anchor from the target's own positions every step (`:553`), and index every cross-step tensor by the persistent request slot through `req_state_idx = idx_mapping[req_idx]` (`:536`, `dflash2/speculator.py:95`) — in a V2 runner that has no `condense` at all (`gpu/states.py:29,100,132`). Its legacy V1 runner does condense, and there the draft's block-table row moves with the request (`gpu_input_batch.py:786` → `block_table.py:367-373`). Both upstream shapes key the draft context to the **request**. Ours keyed it to neither. ## The change One `unordered_map<string, DflashReqCtx>` holding the store, the context length and the disabled flag. Every row permutation the batch can perform — today's two and any future one — is now a no-op for the draft context. The reused-slot test goes away with it. "Has this row's occupant changed" was only ever a proxy for "is this state this request's", and the proxy is precisely what broke: after a condense move the answer was yes for a request whose context was perfectly valid. Keyed by id the question cannot be asked wrongly; a first sight of an id constructs the entry, and that construction *is* the reset. `dflash_ctx_disabled_`'s own comment — "the flag is a property of the REQUEST, not of the row" — becomes literally true. The **decode-first reorder** is fixed for free, and it is a second live trigger rather than a hypothetical one. `reorder_batch_to_split_decodes_and_prefills` runs unconditionally every step (`runner.cpp:1324`) and swaps live rows through `swap_states`. It emits no swap only while arrival order already puts decodes before prefills — the common case, since condense keeps older decoding requests at low rows and a new arrival appends at the end as a prefill — which is why the #2008 measurement met the condense move first. A batch whose regions are out of order does swap, and on the pre-change code that exchanged two live requests' draft contexts. (The commit body and an earlier draft of the spec called this path "inert for a Qwen3 target today"; that was wrong and the spec now says so — the reorder has no model-family gate at its call site.) Entries are pruned each propose against `InputBatch`'s membership — the authority on residency, unlike `exec_state_.req_ids`, which lists only the rows scheduled this step. Each entry owns a device allocation, so pruning is part of the change rather than a follow-up. One detail a reviewer will want checked: the prune is guarded by `size() > num_reqs()`, and a departure paired with an arrival in the same step can leave the sizes equal with a stale entry present. That entry survives **exactly one further step** and cannot be read in the meantime, because lookup is by current request id. It is then reclaimed: the resolve loop inserts the newcomer, so the next prune sees `size() > num_reqs()` and fires. The lag is bounded at one step and is self-correcting; it does not accumulate. **Adopting upstream's paged shape** — the draft context as a real KV cache group carried by `MultiGroupBlockTable::move_row` — is the right end state and is recorded as owed, not attempted. It replaces `DflashDeviceKVStore`, changes how #1919 sizes the draft budget and how #2007 splits the pools, and would land the concurrency repair behind a rewrite. ## The gate `tests/vllm/v1/spec_decode/test_dflash2_concurrency.cpp` drives two concurrent requests through the synchronous production front and **reproduces the discontinuity on CPU**, which is what converts this from something only a GPU run can see. Its load-bearing leg is not "it did not throw". Marking the moved row disabled — the #1919 fallback, which `continue`s *before* the invariant — also makes the throw stop and emits **identical tokens**, because the verify is lossless and a request that stops speculating just runs slower. So the gate asserts that the survivor keeps proposing, and at every step it is alive for. ## Mutations Each rival repair was **built on the pre-change code and measured**, not argued about. | Mutation | Result | |---|---| | **A′ — the fallback repair.** On a position mismatch, disable the row instead of asserting. | **CAUGHT.** Case 1 goes green and the propose count reads `none_lines == 9` — the survivor stopped proposing for all nine remaining steps. | | **B′ — "silence the check."** Delete the position invariant, let the reset context stand. | **NOT CAUGHT.** Filed as #2009. | | **Reachability.** Delete the `propose_drafts_block` call site in `propose_drafts_dflash`. | Red, by name. | **One leg was written, run, and removed rather than shipped.** Comparing the drafted blocks against a solo control is a **tautology on this fixture**: with the invariant deleted and the context reset at every row move, the synthetic draft still emits `12 12 12` at every step of both runs — seeded-noise weights over a 24-token vocabulary, and the selector walk collapses to one id. Nine passing string comparisons measured nothing. The test file and the spec both record it, because the next person to reach for that leg will find the same fixture. That B′ is consequently ungated is #2009, not a thing this row quietly left out. ## What this does not do, and what must be measured before it is trusted **A concurrency change does not merge here on a green compile.** The end-to-end ladder is the operator's to run — rungs c = 1, 2, 4, 8, 16 at the #2008 flags. Predictions and falsifiers are in the spec under `## Gates`. In short: c=1 must be **inert** (24.70 out tok/s, TPOT 37.90 ms), no rung may VOID or report `failed > 0`, and c=2 must not come in below c=1. A rung that lands low but completes is #2007 and the `P == 1` capture gate being measured for the first time — this change is what lets a batch reach `P > 1` at all — and those are named, owned and out of scope. ## Gates run | Gate | Result | |---|---| | `cmake --build build -j 4` (CI's CPU configuration) | **rc 0**, no `error:` lines | | `ctest --test-dir build --output-on-failure` | **rc 0 — 628 tests, 0 failed**, 5 pre-existing skips | | `test_dflash2_concurrency` in that suite | Passed, 37.73 s | | `test_dflash2_runner_reach` (sibling DFlash2 gate) | Passed, 45.78 s | | `scripts/agent-preflight.sh --staged` | **rc 0**, 0 gates failed | | `check-agent-record.py` | rc 0 | | `check-commit-trailers.py` / `check-commit-style.py` | rc 0 | Exit codes were captured explicitly rather than read off the tail, which is how the first preflight run's `role-undeclared` failure was caught behind a clean-looking output. The staged preflight's trailer check initially **SKIPPED** because `origin/main` had moved and was no longer an ancestor of HEAD — a diff-scoped gate that skips still exits zero. `origin/main` is merged (bringing #1999) and both checks were then rerun against the new base. ## Found and not fixed - **#2009** — the position invariant is ungated; deleting it leaves this row's own gate green. Closing it needs a fixture whose draft is sensitive to its context, which this one is not. - **UNVERIFIED hypothesis, labelled rather than asserted: a prefix-cache hit or a resumed request may trip the same invariant at c=1.** The reasoning is that such a request is admitted with `num_computed_tokens > 0` and has no draft context for the cache-supplied tokens, so its first propose would read `positions[rows[0]] > 0` against `L == 0`. **It was not reproduced.** A throwaway probe forced `enable_prefix_caching = true` and issued the same 20-token prompt twice; both completed, nothing threw, and the engine's own `prefix_cache_metrics()` reported `queries=40 hits=0` — the cache never engaged, so the probe measured nothing about the hypothesis. The fixture's target is a GDN hybrid, the family upstream defaults prefix caching OFF for. Reported as a hypothesis, not a defect; confirming it needs a decoder-only DFlash2-capable target. - **#2007** — the two-pool allocation. Noted, not widened into. ## Re-merge onto `main` (`b758127ec`) `origin/main` moved to `b758127ec` (#1994) and GitHub reported this PR `CONFLICTING`. Re-merged; head is `2ea92d2fc`. The sole conflict under GitHub's merge is `.agents/issue-index.md` — the `merge=union` driver resolves it locally and GitHub does not honour that driver. `tests/CMakeLists.txt` merges clean either way, and no source file conflicted, so no take-both resolution exists on this branch. `.agents/issue-index.md` was union-appended and then checked by row-ID set difference rather than by reading the diff: base 730 + ours 2 (#2008, #2009) + main 15 = 747 expected, 747 actual, **0 lost / 0 invented / 0 duplicated**, with every merged row byte-identical to a row in one of the three sources. `tests/CMakeLists.txt` was rebuilt from `origin/main` (blob `6a616d781`) with this row's edit re-applied after the `test_dflash2_ctx_capacity` source line, an anchor asserted to occur exactly once. The rebuild is byte-identical to the three-way result; `git diff origin/main` is **7 added, 0 deleted**. Nothing this row owns moved. Comparing the reviewed pre-merge delta against the post-merge delta, normalised for hunk offsets, the added and removed line sets are **identical (792 added, 74 removed)** — the only difference is two context lines, where main's #1982 and #1992 index rows now sit adjacent to this row's. Main's delta touches neither `runner.h` nor `runner.cpp`, and both, along with `test_dflash2_concurrency.cpp`, are blob-identical to the reviewed head `3d895a202`, so the request-id keying of the draft context is untouched. Re-merged again onto `2c27f0d57`, which carries #2000 and #1997. Head is `53e144f37`. #2000 is the first commit on `main` to touch `runner.h` and `runner.cpp`; #1997 touches neither. Both runner files three-way merged with no conflict because the edits are disjoint against the common base `b758127ec` — #2000 at `runner.h` 357-474 and `runner.cpp` 334-823, this row at `runner.h` 836-870 and `runner.cpp` 2752-3148 — and the merge is verified in both directions, each side's per-file delta byte-identical to its own source diff at shifted offsets. Brace balance checked arithmetically anyway: `runner.h` expected 55, merged 55; `runner.cpp` expected 337, merged 337; both for `{` and `}`. Index re-checked against the current base: 745 + 2 ours (#2008, #2009) + 5 theirs (#1963, #1966, #1984, #1985, #2002) = 752 expected, 752 actual, **0 lost / 0 invented / 0 duplicated**. `tests/CMakeLists.txt` rebuilt from blob `d4d6cff71` at the same uniqueness-asserted anchor, 7 added / 0 deleted, with #2000's and #1997's own lines intact. The reviewed change is unchanged across all three re-merges: added/removed line sets identical to `git diff 2a42cb3 3d895a2` (786/68 excluding file headers, 792/74 including them). Re-merged onto `c714b0234`. Head is `822b90b6e`. **This merge is CODE-TOUCHING and takes a fresh CI cycle:** #1977 adds 88 lines to `src/vllm/v1/worker/gpu/runner.cpp`, the file this row rewrites, so the merged translation unit is not the one CI built (green blob `59265ee22`, merged blob `f7ed973af`), and `test_runner` moves 20/544 to 22/567 with #1977's own cases. The merge took no conflict because the edits are disjoint against base `2c27f0d57` (#1977 at `runner.cpp` 353-607, this row at 2770-3166) and is verified in both directions, each side's per-file delta byte-identical to its own source diff. Brace balance expected 342, merged 342, for `{` and `}`. `runner.h`, `test_dflash2_concurrency.cpp` and the spec stay blob-identical to `53e144f37`. Index re-checked against the current base: 750 + 2 ours (#2008, #2009) + 14 theirs = 766 expected, 766 actual, **0 lost / 0 invented / 0 duplicated**. `tests/CMakeLists.txt` rebuilt from blob `21d878869` at the same uniqueness-asserted anchor, 7 added / 0 deleted. The reviewed change is unchanged across all four re-merges: added/removed line sets identical to `git diff 2a42cb3 3d895a2` (786/68 excluding file headers). Local gates on this head: `BUILD_EXIT=0`, `ctest -R dflash` 27/27, `test_dflash2_concurrency` 2/2 (21 assertions), `test_runner` 22/22 (567), `agent-preflight.sh --staged` green over 109 gates. **The full `ctest` suite has NO local verdict on this head, and CI carries it.** The box was at 98% disk; a full build drove the root filesystem to 100% with 4.1 GiB free, which is the ENOSPC state that makes unrelated checkers emit false policy refusals, so it was aborted and the space reclaimed. The focused suites were re-run after the abort from a clean rebuild — `ctest -R dflash` 27/27, `test_dflash2_concurrency` 2/2 (21 assertions), `test_dflash2_runner_reach` 8/8, `test_dflash2_ctx_capacity` 6/6, `test_runner` 20/20, every one rc=0 — and `scripts/agent-preflight.sh --staged` is green. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: CLAUDE-CODE:claude-opus-5 [Claude Code] --------- Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
… default server never sets, so a non-speculative capture cudaMallocs (#2029) (#2047) With DFlash2 speculation off the engine could not warm up at concurrency 8. It died inside CUDA graph capture, and every request after that came back 500 `[request submitted to a stopped AsyncLLM]`. ``` engine-fatal: EngineCore busy loop threw: vt cuda: cudaMalloc: operation not permitted when stream is capturing ``` That is the configuration a user gets by omitting `--speculative-config`, so this is the default server and not a benchmark artefact. Closes #2029. ## The asymmetry is not in the speculative code #2029 records that the same binary WITH `--speculative-config` warms and serves c=1/2/4 cleanly, and concludes that "the failing allocation is on a path taken only when speculation is off". That is exactly true, and the path is the **absence** of the #1380 capture pre-grow. Both Qwen3.5 decode-graph drivers compute ```cpp const bool dbuf = impl_->dbuf || spec_step; // qwen3_5.cpp:10764, :11315 ``` with `impl_->dbuf = enabled && DecodeGraphDoubleBufferEnabled()` (`:10529`, `:11082`), `DecodeGraphDoubleBufferEnabled()` false unless `VT_ASYNC_EXECUTOR=1` (`:10302-10308`), and `spec_step = gdn_meta.num_spec_decodes > 0` (`:10715`, `:11267`). And `Pool(b).PreGrowForCapture(b, s.demand)` sat **inside** `if (dbuf)` — `:10885`/`:10907` in the 35B MoE driver, `:11439`/`:11461` in the 27B dense one, which is the driver the #1574 subject runs on. So on the default server neither disjunct holds, the pre-grow never executed, and the driver opened `vt::GraphCaptureScope` over a `DevicePool` nobody had prepared. A `Get` miss inside that region is `DevicePool::Get`'s `b.Alloc(key)` (`device_pool.h:212`), which on CUDA is the `cudaMalloc` in the message. #1380 named the same message from the same driver. `s.demand` and `Pool(b).MarkStepBoundary()` were already recorded **unconditionally** (`:11607`/`:11251`, `:11046`/`:10697`). Only the consumer was gated, and the guard is an accident of history rather than a decision: that block exists for the `VT_ASYNC_EXECUTOR` parity ring's drain and its persistent step inputs, and #1393 replaced the single-block pre-grow in place without revisiting what it sat under. **The `!dbuf` arm needs it at least as much as the `dbuf` arm.** It passes `persistent_sdi == nullptr` (`:11531`, `:10974`), so `BuildStepDevInputs` and `MaybeBuildAttnCosSin` run from the main pool INSIDE the captured region. That is also what makes `s.demand` exact for it: the cold step takes the identical `nullptr` branch, so its per-class peak IS the capture's demand. On the `dbuf` arm the captured region's main-pool demand is a strict subset of the cold step's, which is the containment #1393 recorded. ## The change One statement moves in each driver, out of `if (dbuf)` and in front of it. Everything else stays: `b.Synchronize` drains an in-flight replay only the parity ring can leave behind, and the `StepDevInputs` / `s.pin` construction is the persistent-input path the ring and the spec capture need. The pre-grow now precedes that `b.Synchronize` rather than following it. A pre-grow is `Backend::Alloc` and nothing else — it enqueues no work and reads no in-flight buffer — and the drain still happens before `BeginCapture`, which is the property it was added for. ## Prefix caching drops the same fault to c=1, and this finding predicts it Measured by the operator on `dgx:gpu0` while this was in flight: same `3d895a202` binary, speculation off, plus `--enable-prefix-caching --scheduling-policy lpm`, **concurrency 1** — the identical message, `illegal memory access` 0, `position discontinuity` 0, `ok=0 failed=8`. Nothing in this change was ever scoped to batch 8. The pre-grow is skipped on EVERY non-speculative step at EVERY padded size, and **this pull request's own gate is a single-request `S = 1` case**. What the two configurations differ in is only whether the free list happens to be short when the capture opens. The rival reading — that some buffer is sized from a per-step quantity — was checked against the code and does not hold on this driver: | Candidate | Why not | |---|---| | `block_table_num_cols` / `max_blocks` | fixed once by `BlockTable`'s constructor from `max_model_len` (`block_table.cpp:49`), read unchanged by `gather_block_table` (`runner.cpp:1185-1187`). It cannot move between two steps, and prefix caching does not touch it | | FA-2 decode scratch | `DecodeShapeKey` is `{batch, hq, heads, groups, head_dim, max_blocks, page_size, num_splits}` (`cuda_flash_attn_fa2.cu:1013`), all key-determined or constant — and it carries its OWN capture refusal at `:1021-1029` with a different message, so it cannot produce this one | | the forward's knowledge of prefix caching | it has none; `enable_prefix_caching` appears nowhere in the runner's forward path | **Which size class is short in either run is NOT determined here, and cannot be from a CPU box.** The fix does not depend on the answer — an unprepared pool is unprepared whatever empties it — but the issue stays open until a device says so. See *What is not verified* below. Not the same fault as #2042 (prefix caching + DFlash2 at c=1, the draft's position invariant). With speculation ON, prefix caching fails through #2042; with speculation OFF, through this. ## The gate Two cases in `tests/vllm/models/test_qwen3_5_decode_graph_seam.cpp`, one per driver, on the existing CPU seam in `decode_graph_seam_harness.h`. The assertion is the guarantee, not the call: **zero `Backend::Alloc` between `BeginCapture` and `EndCaptureGraph`**, on a step where `num_spec_decodes == 0` and `VT_ASYNC_EXECUTOR` is unset. `CHECK(the pre-grow was called)` would be a transcription — it stays green when the demand profile is wrong, which is the half #1393's own body recorded as ungated: *"the fix rests on the captured forward demanding no more blocks of any size class than the eager forward at that shape did ... no test asserts it."* `CaptureCapableCpuBackend` gains the capture-window split for it: one counter, incremented under a flag the existing `BeginCapture` / `EndCaptureGraph` overrides set. **The pool is drained between the cold step and the capture step**, and that is the load-bearing part of the construction. In production the free list is SHORT rather than empty — every captured `SizeSlot` retains its `[S, vocab]` logits and `[S, H]` hidden forever, `DevicePool` is keyed by SIZE CLASS, and a ramping server captures more shapes. Reproducing that by arithmetic would make the case depend on whether two of the tiny model's tensors happen to collide in a class, i.e. on a coincidence rather than on the rule. `Drain` is the same condition taken to its limit, it is a production API called at phase changes, and it is what also settles the premise above: an empty free list can only be served by the pre-grow, so green says the cold step's profile COVERS the capture. `CHECK(allocs() > 0)` is the non-vacuity guard. The pre-grow is itself a driver allocation made outside the region, so a fixed driver must allocate in this step and must allocate none of it under capture. ## The `sanitize-cpu (thread)` red, and a correction That job refused the two new cases on the PRECONDITION, not on the guarantee: ``` tests/vllm/models/test_qwen3_5_decode_graph_seam.cpp:948: FATAL ERROR: REQUIRE( freed > 0 ) is NOT correct! values: REQUIRE( 0 > 0 ) ``` **It is not ThreadSanitizer and not its allocator.** `.github/workflows/ci.yml:1598` sets `VT_POOL_BYPASS: "1"` for that job, on **both** the `address,undefined` and the `thread` lane. The identical `REQUIRE( 0 > 0 )` reproduces at the same two lines on an ordinary **non-sanitized** Release build with that one variable set and nothing else changed. (The `address,undefined` lane read `pending`, not green — it had not finished, so it never contradicted this.) Under bypass every `Get` is a raw `Backend::Alloc` and every `Put` a real `Free` (`device_pool.h:113-127`, `:246-252`), so there is no free list, `Drain` reports 0, and `PreGrowForCapture` returns before it grows anything (`:429`). The guarantee these cases assert is **false by design** there, and false identically for the fixed and the unfixed driver. So both cases now carry `doctest::skip(PoolBypassLane())`, with the reason in the case NAME so a reader who sees the skip count can recover it, and the predicate mirroring `DevicePool::Bypass()` byte for byte rather than approximating it. `REQUIRE(freed > 0)` is **unchanged** — M3 measured that it is what stops the pooled lane from asserting nothing. **A correction, because the first reading of this failure was backwards.** The guard did **not** save a vacuous pass. Removed, the cases reach the capture assertion and **FAIL** it, at **107** and **195** driver allocations inside the capture, because under bypass every `Get` is a driver call. What the guard buys is an inevitable failure that names the PRECONDITION instead of the SYMPTOM. Worth having, and a smaller claim than the one first made for it. **The guarantee is therefore not exercised by `sanitize-cpu` — and that is the job's configuration, not a limit of the instrument.** Measured on a real TSan build, `-DVLLM_CPP_SANITIZE=thread`, `setarch -R`: | Environment | `test_qwen3_5_decode_graph_seam` | |---|---| | `VT_POOL_BYPASS=1`, as CI sets it | exit **0**, 8 passed / **2 skipped**, 138 assertions, 0 TSan warnings | | bypass unset, the pool ENABLED | exit **0**, **10 passed / 0 skipped**, 156 assertions, `0` driver allocations inside both captures, **0 TSan warnings** | ThreadSanitizer runs this guarantee green when the pool is on. Nothing in the test file can opt one case back in — `Bypass()` is read once into a process-wide function-local static (`device_pool.h:480-486`), so no scope, no locally constructed `DevicePool` and no `ActivePoolScope` reaches it — and unsetting the variable for these cases would be worse than the gap, because the pool would then retain blocks that the job's own `ASAN_OPTIONS=detect_leaks=1` reports as leaks. Filed as **#2059**. ## Evidence `mudler-ubuntu-box`, x86-64, CPU-only. `Release`, `-DVLLM_CPP_CUDA=OFF -DVLLM_CPP_TRITON=OFF -DVLLM_CPP_SERVER=OFF`, Ninja, `-j 4`. | Reading | Result | |---|---| | RED, both new cases, before the fix | exit **1**, 2 cases / 0 passed / **2 failed**. `45` driver allocations inside the dense capture, `42` inside the MoE one | | GREEN, both new cases | exit **0**, `0` and `0` inside the capture, `49` in the step — all of them the pre-grow, outside the region | | `test_qwen3_5_decode_graph_seam`, whole file, three runs | exit **0** each, 10 cases / 156 assertions | | `test_qwen3_decode_graph_seam` | exit 0, 4 cases / 231 assertions | | `test_qwen3_moe_decode_graph_seam` | exit 0, 3 / 228 | | `test_deepseek_v2_decode_graph_seam` | exit 0, 3 / 230 | | `test_voxtral_decode_graph_seam` | exit 0, 3 / 230 | | `test_qwen3_dflash_decode_graph_seam` | exit 0, 4 / 23 | | `test_qwen3_dflash2_draft` | exit 0, 43 / 449 | | `test_moe_async_device_ids` | exit 0, 6 / 191 | | `test_device_pool` | exit 0, 11 / 59 | | `test_breakable_graph` | exit 0, 30 / 265 | | `test_persistent_step_input` | exit 0, 10 / 66 | | `test_qwen35_paged_forward` | exit 0, 7 / 63 | | `test_qwen3_5_gdn_spec_routing` | exit 0, 6 / 52 | | `test_qwen3_5_fa2_class` | exit 0, 6 / 15 | | `scripts/agent-preflight.sh` | exit **0**, every gate green, none skipped | | bypass lane, `VT_POOL_BYPASS=1`, non-sanitized | exit **0**, 8 passed / **2 skipped**, 138 assertions | | TSan build, CI environment | exit **0**, 8 passed / 2 skipped, 0 TSan warnings | | TSan build, pool ENABLED | exit **0**, 10 passed / 0 skipped, 156 assertions, 0 TSan warnings | Every row above was re-measured on the merged tree after `origin/main` moved to `2c27f0d57` (#1994, #2000, #1997), because merge-tree clean is not merge-tree builds and that merge touched `qwen3_5.cpp` from both sides. The four `test_qwen3_5_decode_graph_seam` runs also read on #1390, which reports that file exiting 139 with a non-reproducible assertion line: four runs here, exit 0 every time, 10 cases and 156 assertions every time. That is one build on one box and it does not close #1390; it is recorded because a flaky neighbour is otherwise something a reviewer has to re-derive. ## Mutations Each compiled clean, each restored by `sha256sum -c` against the baseline before the next. | Mutation | Result | |---|---| | **M1**, the production call site: put `PreGrowForCapture` back inside `if (dbuf)` | **RED** — 10 cases / 8 passed / **2 failed**, and the two failures are the new cases and nothing else | | **M2**, the pool half: `PreGrowForCapture` returns 0 before it grows anything | **RED** — `test_qwen3_5_decode_graph_seam` 8/10 and `test_device_pool` 10/11 | | **M3**, the case's own construction: remove the `Drain`, on the UNFIXED driver | the capture-window assertion goes **vacuously green** — `0` allocations inside the capture and `0` in the whole step — and only `allocs() > 0` fires | | **M4**, the skip predicate: `PoolBypassLane()` returns false, run under `VT_POOL_BYPASS=1` | **RED** — 10 cases / 8 passed / **2 failed**, **146 assertions**: a byte-for-byte reproduction of the `sanitize-cpu (thread)` reading, on a non-sanitized build. The skip is load-bearing, not a decorative no-op | **All four were re-run after the fixture changed**, because a fixture change can disarm a mutation proof. M1, M2 and M3 read identically before and after. **M3 is the one worth reading.** It says the drain is load-bearing rather than decorative: without it this case cannot detect the defect at all, and without the non-vacuity guard beside it the case would have reported a pass while measuring nothing. M1 is also the reachability mutation "Nothing lands dead" asks for: deleting the production call site reds the focused gate. ## What is not verified **No GPU was available to this implementer**, and nothing here is reported as device-verified. The change edits no `.cu`, so the CUDA arm is a `cuda-fat-build` compile verdict and never a run verdict either way. #2029 therefore stays **open**. This removes a proven, non-speculative-only gap that produces exactly its message, at every padded size including the c=1 one prefix caching exposes. It does not prove that gap was the only one. Three experiments settle it, in this order, each on the operator's own invocation with `--speculative-config` removed and `--enable-prefix-caching --scheduling-policy lpm` added, at c=1: 1. **`VT_ASYNC_EXECUTOR=1` on the UNCHANGED `3d895a202` binary.** That flips `impl_->dbuf` true and turns the pre-grow on with no rebuild and no patch. A run that SERVES proves the missing pre-grow is the cause, on the device, against the exact binary #2029 was measured on. A run that still dies is **inconclusive rather than a refutation**, and the asymmetry has to be stated when the result is read: the same flag opens the parity ring, which retains a second `[S, vocab]` logits and `[S, H]` hidden per size and can empty the free list by a route of its own. 2. **The same configuration on a binary built from this branch**, environment untouched. Serving says this closes that entry point; dying says a second site exists. 3. **`VLLM_CPP_QWEN3_DENSE_DECODE_GRAPH=0`, as the control.** Serving confirms the fault is inside this driver's capture; dying says the capture that allocates is elsewhere and 1 and 2 answered the wrong question. If 2 still dies, the next instrument is a backtrace at `CudaBackend::Alloc` — how #1380 was located, and exactly what #2037 exists to make unnecessary. `VT_POOL_STATS` cannot substitute: it prints per-pool totals at destruction, after the engine is already dead, and names no class. ## Found and NOT fixed, each with an issue - **#2035** — seven decode-graph drivers (`qwen3.cpp`, `qwen3_moe.cpp`, `deepseek_v2.cpp`, `deepseek_v4.cpp`, `voxtral.cpp`, `laguna.cpp`, `qwen3_dflash.cpp`) open a capture with **no pre-grow and no demand profile at all**. A grep for the #1380 machinery outside `device_pool.h` returns `qwen3_5.cpp` and nothing else. Three of them argue in comments that their cold step makes the capture allocation-free, which is the reasoning #1380 measured and refuted. Each needs its own `MarkStepBoundary` / `StepDemandProfile` wiring and its own gate, so it is a different change with a different blast radius. - **#2036** — `DenseAlignFor` (`qwen3_5.cpp:2825-2849`) makes five raw `d.b.Alloc` calls **and calls `d.b.Synchronize(d.q)` at `:2846`** on an `M` miss, and `EnsureCtmp` (`cuda_marlin_dense.cu:74-89`) grows with `cudaMallocAsync` at `:85` — both with no `cudaStreamIsCapturing` refusal, unlike the six sibling shape-keyed caches that have one. Neither is #2029's cause: both are keyed by a quantity the cold step already visits at the same shape. - **#2037** — the fatal handler prints `e.what()` and no backtrace while `core_client.h:63` promises "See stack trace (above)". #1380 closed only because somebody instrumented `CudaBackend::Alloc` by hand; #2028 and #2029 both record the gap; this change was located by reading the tree rather than by reading the failure. - **#2059** — `sanitize-cpu` sets `VT_POOL_BYPASS=1` on **both** lanes, so the `DevicePool` free list, size-class ladder, best-fit borrow (#1922) and capture pre-grow (#1380) are unexecuted under ASan *and* TSan. The stated justification is ASan's `detect_leaks`; ThreadSanitizer has no leak detector and gains nothing, while losing the one allocator whose `std::mutex` and shared maps that lane exists to inspect. Measured above: with the pool enabled the `thread` lane is 10/10 and silent. Out of scope and deliberately untouched: #2028 (the illegal memory access with speculation ON), #2007 (two pools), and the `P == 1` capture gate at `qwen3_dflash.cpp:1577`. ## Records `.agents/specs/cudagraph-pregrow-nonspec.md` is committed **before** the implementation, and `.agents/issue-index.md` gains four appended rows (#2029, #2035, #2036, #2037). `origin/main` moved three times while this was in flight; the last merge brought #1994 (`b758127ec`), #2000 (`61ba99ffd`) and #1997 (`2c27f0d57`). **The `.agents/issue-index.md` union is verified by row-ID SET DIFFERENCE rather than by a clean automerge**, because GitHub ignores this repository's union driver and two relocations can automerge into a duplicate: 750 rows before, **755** after, **+5 added and 0 removed**, every key `origin/main` holds still present, the only keys not on `origin/main` being this branch's own five, and **755 unique numeric keys for 755 rows** — no duplicate. One of those rows is then EDITED, which the file's own rule forbids, and the commit that does it argues for the exception rather than hiding it: `check-agent-record.py` refused a hyphen where the schema wants an em dash, the row had never left this branch so no other branch can hold a copy to merge against, and the net diff against `origin/main` is still a pure append. An edit to a row that exists on `main` is the case the rule is about, and this is not it. ## Re-merge onto `main` (`dde045419`) Re-merged after #2010 landed. Head is `8defcf5c5`. **CODE-TOUCHING, so this head takes a fresh CI cycle:** 108 files differ from the CI-green head `625b11b83`, 58 of them compiled sources, and one is this row's own `src/vllm/model_executor/models/qwen3_5.cpp` — #2019 (QWEN4-EXP W6a) edits it, so the merged translation unit is not the one CI built (green blob `b8b99b7ef`, merged blob `d86206193`). **The #2010 interaction is now real and is gated rather than assumed.** #2010 landed as `dde045419`, so its `runner.h`, `runner.cpp` and `test_dflash2_concurrency.cpp` are in this tree for the first time. No file is touched by both rows, so there is no textual interaction; the semantic one is covered by running both surfaces on the merged tree — `test_runner` 22/22 (567 assertions), `test_dflash2_concurrency` 2/2 (21), `test_dflash2_runner_reach` 8/8 (144), all rc=0. The merge took no conflict because the edits are disjoint against base `2c27f0d57`: #2019 at `qwen3_5.cpp` 1194-9202, this row at 10848-11409. Verified in both directions, each side's per-file delta byte-identical to its own source diff at shifted offsets. The spec, the seam harness and the seam test are blob-identical to `625b11b83`. Brace balance 1543 base / 1543 ours / 1542 theirs, expected 1542, merged 1542 for `{` and `}` — though the authority there is the compiler, and `cmake --build` returns 0 with no warnings. Index re-checked against the current base: 750 + 5 ours (#2029, #2035, #2036, #2037, #2059) + 16 theirs = 771 expected, 771 actual, **0 lost / 0 invented / 0 duplicated**. `tests/CMakeLists.txt` is not in this row's change, so no rebuild of it is owed. The `VT_POOL_BYPASS=1` lane this row already repaired still behaves as designed on the merged tree: the seam test is 10 cases / 156 assertions pooled, and 8 cases with 2 skipped / 138 assertions under the flag. The skip is scoped and the remaining assertions still run, so the green is not bought by weakening a check. Local gates: `test_qwen3_5_decode_graph_seam` 10/10, `test_device_pool` 11/11, `test_breakable_graph` 30/30, `agent-preflight.sh --staged` green over 109 gates. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [Claude Code] --------- Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
On
Qwen/Qwen3.8-27BvLLM draws its next token from 20 candidates and we draw ours from 248,320, because we read the checkpoint'sgeneration_config.jsonforeos_token_idand discard every sampling key in it.vllm bench serveno longer sends--temperature, so both engines resolve 1.0 and both take the random-sampling path: that is a correctness divergence on the workload the parity gate runs, not a cosmetic default.The same fact explains the second defect.
RandomSampleKernelwas launched<<<n, 1>>>and walked the whole vocabulary on one lane, computing two SplitMix64 rounds and an f64logper element. Eleven lines above it,cuda_sample.cualready records that a single-block single-thread scan of a ~151k vocab cost ~7.5 ms/token, and that is why greedy argmax was rewritten into the two-pass grid-strided reduction sitting there now. The Gumbel draw never got the same treatment, and its answer is the same lowest-index argmax that reduction already computes.What changed
The defaults (#1985). The sibling read that already produced
generation_config_eos_idsnow also yields the six keysModelConfig.get_diff_sampling_paramnarrows.GetDiffSamplingParammirrors that narrowing and vLLM's--generation-config auto|vllm|<dir>selector. Both OpenAI handlers resolve a request against it with upstream's exact precedence: an explicitly sent field wins, an omitted field takes the checkpoint's value, and only a knob neither declares reaches the neutral OpenAI default.nullptrand an emptyDefaultSamplingParamsboth reproduce the old resolution, which is what--generation-config vllmgives a user who wants it back.The kernel (#1984).
ArgmaxPartialKernelis parameterised on the score, andrandom_sampleinstantiates it rather than growing a second hand-written reduction. The output is bit-identical, not merely equivalent: every element's score is the sameGumbelScore(prob, seed, row, j)on the same device libm, and only the order in which those identical floats are combined changes, whileArgReducecompares the true global index rather than thread or block order. The serial kernel is retained behindVT_FAST_RANDOM_SAMPLE=0, mirroringVT_FAST_ARGMAX, so the equality gate is a same-binary A/B rather than a claim.SplitMix64,ExpNoise,ArgReduceand the launch partition moved toinclude/vt/sample_common.h, whichcpu_sample.cppandcuda_sample.cuboth include. They had been written out three times, and the CPU copy is the reference the device copies are gated against, so a drift between any two of them was a token difference nothing looked for.Red before green
test_openai_protocolfailed 3 cases / 9 assertions for the intended reason against a stubbed resolution, then passed 37/37 over 269 assertions. One expectation had to be corrected rather than the code: temperature 0 is greedy and__post_init__clearstop_p,top_kandmin_pupstream and here, so a checkpoint'stop_k: 20cannot survive a--temperature 0request. That interaction now has its own case, because it is what keeps the SACRED greedy path unchanged.Six mutations, each applied, measured, and restored byte for byte:
bi < aifromArgReducetest_ops_sampleprob * qinstead ofprob / qinGumbelScoretest_ops_samplehf_config.cpptest_generation_config/test_hf_config/test_openai_servingto_sampling_params(..., &default_sampling_params_)in the completion handlertest_openai_servingtest_openai_serving--generation-configargument branchtest_serve_recipe_argsThe second row is the honest one, and it is why the table names where each mutation landed.
GumbelScoreis shared by the CPU reference and by the decomposition the equivalence cases drive, so a change to the score moves both sides together and those cases cannot see it. What caught it was the pre-existinglarge-N empirical frequency approximates softmax probscase. That is the right division of labour rather than a gap: the equivalence cases own the ORDER, the distribution case owns the FORMULA.What is not gated here
There is no CUDA toolkit on the implementing host, so
cuda_sample.cuwas not compiled locally. Its first compile is this pull request'scuda-fat-build, and its first execution is the operator's leased box. The property the kernel rests on is gated on CPU against the production inlines —ArgReduce,GumbelScoreandArgBlocksPerRowunder the kernel's exact two-pass partition, compared withvt::RandomSampleover ten vocabulary widths including 248,320, six row shapes chosen for ties and masked zeros, and four seeds. The kernel's launch geometry, shared-memory sizing and scratch lifetime are not, andcompute-sanitizeris requested for exactly that reason: this change adds a second persistent device scratch and a new grid geometry, and #1958 is an illegal memory access on this same sampler surface.Reachability is proven at three hops of four. The CLI hop re-execs the real
VllmServerMain. The handler hop enters throughcreate_completion/create_chat_completionwith the defaults produced by the realLoadHfConfigandGetDiffSamplingParamoff a realgeneration_config.json. The two-line join inserver_main.cppsits after model load and no committed generative checkpoint fixture can reach it, so no CPU test turns red when it is deleted. Stated as a gap, not waived: the smallest thing that would close it is a tiny on-disk causal-LM fixture of thetests/vllm/models/fixtures/llama_embed_e2ekind, which this row did not add.The measurement this does not merge without
AGENTS.mdand #1975 settle it between them: a performance change here does not merge on a green compile, and a per-kernel figure is not evidence. #1929 landed a 708 us to 40 us top-k kernel with fresh review, mutation proofs and a green CUDA build, and cost 16.9 tok/s end to end.The request is therefore stated before it is run, and it is in
## Nowof the row's spec.vllm bench serveagainst our server and the pinned vLLM on the identicalQwen3.8-27Bartifact, vLLM's graphed production configuration as the denominator, no--temperatureon either side, in four same-binary arms so each half of the row is attributable on its own:--generation-configVT_FAST_RANDOM_SAMPLEvllm0auto0vllm1auto1--speculative-configwould measure nothing either, and the standing baseline recipe carries it (#2002, filed from this row).GPUModelRunner::sample_tokensbranches onexec_state_.step.num_draft_tokens > 0alone and returns the greedy-onlyRejectionSampler's output, so with drafts presentSampler::forward— and with itvt::RandomSample— is never called, whatever the temperature. Every arm above therefore runs without it. The same routing is a correctness divergence in its own right: at temperature 1.0 under spec decode we emit the target argmax where vLLM draws from the residual distribution, andinclude/vllm/v1/spec_decode/rejection_sampler.halready states the contract nothing enforces.--temperature 0would measure nothing, and it is what every harness intools/bench/passes today (run_qwen35_4b_ab.shsends--temperature 0,profile_vllm_online_gate.pysetstemperature=0.0). At temperature 0PostInitclearstop_p,top_kandmin_p, the batch is all-greedy, andRandomSampleis never called, so all four arms are the same run and four identical numbers read exactly like "no effect".Predicted, and marked as prediction: D >= C > A, with C - A the large term, of order 10 ms/step recovered at B=32. That figure is INFERRED from this file's own recorded ~7.5 ms/151k anchor scaled by vocabulary and by the extra per-element transcendental, not measured. B - A is predicted small and possibly negative on throughput; the reason to want it is that it makes the two engines sample the same distribution.
Two outcomes would falsify the design rather than the tuning, and both are named in advance: D materially slower than C means the top-k path costs more than the distribution it saves; D no faster than A means the sampler was never the bottleneck and the per-step term lives in the
cudaMalloc/cudaFreeand the blocking download recorded under## Owed.Landing this re-baselines every benchmark number we have
Do not compare a number taken before this merges with one taken after. The benchmark checkpoint
qwen3.8-27b-nvfp4-mtp-sm121-r0b0tlabships ageneration_config.jsoncontaining{"do_sample": true, "temperature": 1.0, "top_k": 20, "top_p": 0.95}. Today we ignore it and sample over the full 248,320-token vocabulary while vLLM samples from top-20 — so our arm has been paying a per-token cost we inflict on ourselves, on a live benchmark asymmetry, and the tokens we emit are drawn from a different distribution than the oracle's. After this lands, an unparameterised request on that checkpoint samples top-20 / top-p-0.95 like vLLM's.That is the correct behaviour and it is the point of #1985, but it moves the numbers. Our arm must be re-baselined at the merge commit, and any figure quoted across the boundary is comparing two different sampling configurations rather than two engines.
--generation-config vllmrestores the old resolution exactly if an old number ever needs reproducing.What #1994 does and does not invalidate here
Nothing in this pull request or in the row's spec quotes a TTFT or a TPOT figure, so there is nothing to void. Checked rather than assumed: the only measured numbers either document carries are
~7.5 ms/token—cuda_sample.cu's own long-standing anchor for the single-thread greedy scan, a per-kernel decode-path attribution rather than a harness TPOT — and16.9 tok/s, an end-to-end throughput figure from #1929. Throughput figures are unaffected by #1994. Everything else here is explicitly marked as prediction.The acceptance measurement in
## Nowis likewise safe, and the reason is in #1994's own spec rather than in its summary: it lists/v1/completionsunder what it does not touch, and scopes the defect tovllm bench serve --backend openai-chat. Should any arm be run on the chat backend instead, the residual bias is bounded and small — TTFT was understated by roughly one decode step, andTPOT = (E2E - TTFT)/(tokens - 1)spreads that over 63 tokens, so the greedy arm inflates by about 1.7 ms and the default arm by about 5.4 ms, overstating the greedy-versus-default gap by about 1.6%. That shifts the acceptance bar by well under a millisecond and changes no verdict.Owed, with anchors
src/vllm/v1/sample/sampler.cpp:344allocates the[n, vocab]probs buffer throughBackend::Alloc/Free(sampler.cpp:41,47tosrc/vt/cuda/cuda_backend.cu:80-85) — a 30.3 MiB rawcudaMallocplus a device-synchronisingcudaFreeevery decode step at B=32.src/vllm/v1/sample/sampler.cpp:358rs.download(...)is a blockingSynchronizeevery step, because the zero-sync device-resident path atsampler.cpp:456-461requiressm.all_greedyand is structurally unreachable at temperature 1.0.src/vt/rocm/rocm_sample.hip::RandomSampleKcarries the identical<<<n, 1>>>defect and its own copy ofExpNoise(rocm_sample.hip:38). Left untouched rather than changed on hardware this row cannot gate.ExpNoisecomputes-log(u)in double, where upstream's default is f32 (use_fp64_gumbel: bool = False). That is an unannotated widening of the kind.agents/porting.mdsays a token gate cannot see, and on a part with 1:64 f64 throughput it is the dominant remaining cost of the parallel kernel. Narrowing it changes which token is drawn, so it is a separate row with its own gate.--override-generation-config, themax_new_tokensserver-wide output cap, and an offlineLLM.get_default_sampling_paramsequivalent for the C ABI.Closes #1984
Closes #1985
FOLLOWING_AGENTS_PROTOCOL
Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: claude-code:claude-opus-5-1m [Claude Code]