Skip to content

feat(SAMPLE-CORE): the checkpoint's sampling defaults, and the Gumbel draw that scanned 248,320 tokens on one thread (#1984, #1985) - #1997

Merged
localai-bot merged 11 commits into
mainfrom
row/SAMPLE-CORE-GENCFG-GUMBEL
Aug 27, 2026
Merged

feat(SAMPLE-CORE): the checkpoint's sampling defaults, and the Gumbel draw that scanned 248,320 tokens on one thread (#1984, #1985)#1997
localai-bot merged 11 commits into
mainfrom
row/SAMPLE-CORE-GENCFG-GUMBEL

Conversation

@localai-bot

@localai-bot localai-bot commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

On Qwen/Qwen3.8-27B vLLM draws its next token from 20 candidates and we draw ours from 248,320, because we read the checkpoint's generation_config.json for eos_token_id and discard 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: that is a correctness divergence on the workload the parity gate runs, not a cosmetic default.

The same fact explains the second defect. RandomSampleKernel was launched <<<n, 1>>> and walked the whole vocabulary on one lane, computing two SplitMix64 rounds and an f64 log per element. Eleven lines above it, cuda_sample.cu 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.

What changed

The defaults (#1985). The sibling read that already produced generation_config_eos_ids 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. 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. nullptr and an empty DefaultSamplingParams both reproduce the old resolution, which is what --generation-config vllm gives a user who wants it back.

The kernel (#1984). ArgmaxPartialKernel is parameterised on the score, and random_sample instantiates it rather than growing a second hand-written reduction. The output is bit-identical, not merely equivalent: every element's score is the same GumbelScore(prob, seed, row, j) on the same device libm, and only the order in which those identical floats are combined changes, while 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, 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_protocol failed 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__ clears top_p, top_k and min_p upstream and here, so a checkpoint's top_k: 20 cannot survive a --temperature 0 request. 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:

mutation gate result
drop bi < ai from ArgReduce test_ops_sample RED, 35 assertions
prob * q instead of prob / q in GumbelScore test_ops_sample RED, 2 assertions
delete the sibling sampling read in hf_config.cpp test_generation_config / test_hf_config / test_openai_serving RED 3 / RED 1 / RED 3
revert to_sampling_params(..., &default_sampling_params_) in the completion handler test_openai_serving RED (reachability)
the same in the chat handler test_openai_serving RED (reachability)
delete the --generation-config argument branch test_serve_recipe_args RED, 12 assertions

The second row is the honest one, and it is why the table names where each mutation landed. GumbelScore is 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-existing large-N empirical frequency approximates softmax probs case. 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.cu was not compiled locally. Its first compile is this pull request's cuda-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, GumbelScore and ArgBlocksPerRow under the kernel's exact two-pass partition, compared with vt::RandomSample over 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, and compute-sanitizer is 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 through create_completion / create_chat_completion with the defaults produced by the real LoadHfConfig and GetDiffSamplingParam off a real generation_config.json. The two-line join in server_main.cpp sits 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 the tests/vllm/models/fixtures/llama_embed_e2e kind, which this row did not add.

The measurement this does not merge without

AGENTS.md and #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 ## Now of the row's spec. vllm bench serve against our server and the pinned vLLM on the identical Qwen3.8-27B artifact, vLLM's graphed production configuration as the denominator, no --temperature on either side, in four same-binary arms so each half of the row is attributable on its own:

arm --generation-config VT_FAST_RANDOM_SAMPLE isolates
A vllm 0 the pre-change baseline
B auto 0 the config read alone
C vllm 1 the kernel alone
D auto 1 the shipping default

--speculative-config would measure nothing either, and the standing baseline recipe carries it (#2002, filed from this row). 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. 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, and include/vllm/v1/spec_decode/rejection_sampler.h already states the contract nothing enforces.

--temperature 0 would measure nothing, and it is what every harness in tools/bench/ passes today (run_qwen35_4b_ab.sh sends --temperature 0, profile_vllm_online_gate.py sets temperature=0.0). At temperature 0 PostInit clears top_p, top_k and min_p, the batch is all-greedy, and RandomSample is 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/cudaFree and 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-r0b0tlab ships a generation_config.json containing {"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 vllm restores 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/tokencuda_sample.cu's own long-standing anchor for the single-thread greedy scan, a per-kernel decode-path attribution rather than a harness TPOT — and 16.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 ## Now is likewise safe, and the reason is in #1994's own spec rather than in its summary: it lists /v1/completions under what it does not touch, and scopes the defect to vllm 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, and TPOT = (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:344 allocates the [n, vocab] probs buffer through Backend::Alloc/Free (sampler.cpp:41,47 to src/vt/cuda/cuda_backend.cu:80-85) — a 30.3 MiB raw cudaMalloc plus a device-synchronising cudaFree every decode step at B=32.
  • src/vllm/v1/sample/sampler.cpp:358 rs.download(...) is a blocking Synchronize every step, because the zero-sync device-resident path at sampler.cpp:456-461 requires sm.all_greedy and is structurally unreachable at temperature 1.0.
  • src/vt/rocm/rocm_sample.hip::RandomSampleK carries the identical <<<n, 1>>> defect and its own copy of ExpNoise (rocm_sample.hip:38). Left untouched rather than changed on hardware this row cannot gate.
  • ExpNoise computes -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.md says 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, the max_new_tokens server-wide output cap, and an offline LLM.get_default_sampling_params equivalent 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]

@localai-bot

Copy link
Copy Markdown
Collaborator Author

PRE-CHECK RESULT — the premise holds, and it was understated by 12x

This 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 --temperature 0? And it set the falsifier itself: under 3 ms refutes the premise and this must not merge on a speed claim.

Run on dgx:gpu0 (GB10) idle under an rc lease, binary build12 = main @ 993b62e4e (pre-fix), artifact-gated. --num-blocks 3744 --max-num-seqs 4 --max-model-len 8192 --no-enable-prefix-caching, no --speculative-config, 852-token prompt, 64 output, warm request first, two samples per arm:

path ms/token
--temperature 0 → device GreedyArgmax (sampler.cpp:456-461) 105.08, 104.89
server default 1.0 → vt::RandomSample (cuda_sample.cu:271-296) 341.12, 340.75

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

RandomSampleKernel is launched <<<n, 1>>> with if (threadIdx.x != 0) return; — one thread per row, serially scanning all 248,320 vocabulary entries, evaluating two SplitMix64 rounds and an f64 log per element. 236 ms over 248,320 iterations is ~950 ns per element, which is the right order for one GPU thread doing that work.

The tree already recorded this shape as catastrophic eleven lines above the kernel: cuda_sample.cu:84-88 notes a single-thread serial scan of a ~151k vocab cost ~7.5 ms/token and is why greedy argmax was rewritten. The random path never got the same treatment. The measured 236 ms against that 7.5 ms anchor is consistent once the f64 log and the two 64-bit mixes are added.

Upstream is fully parallel — q.exponential_() then argmax(probs/q) (vllm/v1/sample/ops/topk_topp_sampler.py:199-204,426-433). This is a mirror obligation, not an optimisation.

Scope of the impact, stated precisely

This does not affect the #1574 DFlash2 ladder. As this PR established, GPUModelRunner::sample_tokens (runner.cpp:2347) branches on num_draft_tokens > 0 and returns the greedy-only RejectionSampler's output, so vt::RandomSample is never called with a speculative config at any temperature. Our spec-on c=1 row (24.70 out tok/s, TPOT 37.90 ms) is unaffected.

It affects every non-speculative request at the default temperature, which is the common serving configuration. vllm bench serve no longer sets temperature (vllm/benchmarks/serve.py:2073-2079), so any benchmark of this engine without a draft has been paying 3.25x.

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 merge

The 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.

compute-sanitizer on the new kernel is also still owed, as this PR requested.

@localai-bot

Copy link
Copy Markdown
Collaborator Author

Success criterion, agreed and recorded BEFORE the run

I 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:

bar residual allowed arm C must reach
pass <= 11.8 ms <= 116.8 ms/token (>=95% recovery)
hard fail > 23.6 ms > 128.6 ms/token (<90% recovery)

Floor 104.99 ms/token (measured, --temperature 0), pre-fix 340.94 (measured), gap 235.95.

The reason 95% is defensible rather than optimistic: the temperature-0 arm already runs this exact reduction. GreedyArgmax at 104.99 ms/token is ArgmaxPartialKernel/ArgmaxFinalKernel over the same 248,320-wide row, so that reduction demonstrably does not cost tens of milliseconds. The fix makes the random path run the same reduction with a Gumbel score substituted for the raw logit; what remains on top is ComputeProbs (one block, three passes), the 993 KB probs cudaMalloc/cudaFree and the blocking download — the two items already listed under ## Owed.

Three arms, not two — the earlier framing was wrong

I 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 top_k=20 / top_p=0.95, adding an ApplyTopKTopP pass that arm A never ran (pre-fix, both filters resolved to disabled).

arm configuration judged against
A pre-fix binary, server default temperature the 340.94 ms/token baseline
C --generation-config vllm, VT_FAST_RANDOM_SAMPLE=1 the >=95% bar above — this is the kernel measured alone, and it is the throughput headline
D full parity, checkpoint defaults active within 10 ms/token of C

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 runs

The residual becomes measurable for the first time. At 341 ms/token the serial scan was ~69% of the step, so the probs cudaMalloc/cudaFree (src/vllm/v1/sample/sampler.cpp:344, feeding a device-synchronising cudaFree) and the blocking rs.download (:358) were hidden underneath it. If C lands at 112 against the 105 floor, that ~7 ms is those two, and it deserves a row rather than being rounded away.

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 generalises

The 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 log would partly overlap it. On a single lane they overlap nothing: it is a fully serial dependent chain through a software f64 log with emulated 64-bit multiplies. The measured 236 ms over 248,320 elements is ~0.95 us per element on one thread, which is what that chain costs. The post-fix estimate is a throughput calculation over 65,536 threads and is far better conditioned — which is precisely why the author named a falsifier they could actually fail.

mudler added 10 commits August 27, 2026 00:08
… 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]
@localai-bot
localai-bot force-pushed the row/SAMPLE-CORE-GENCFG-GUMBEL branch from e5b9b00 to d0b3f7c Compare August 27, 2026 00:10
…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]
@localai-bot
localai-bot marked this pull request as ready for review August 27, 2026 04:57
@localai-bot
localai-bot merged commit 2c27f0d into main Aug 27, 2026
27 of 29 checks passed
localai-bot pushed a commit that referenced this pull request Aug 27, 2026
…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]
localai-bot pushed a commit that referenced this pull request Aug 27, 2026
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]
localai-bot added a commit that referenced this pull request Aug 27, 2026
…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>
localai-bot added a commit that referenced this pull request Aug 27, 2026
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants