fix(FIX-KV-GROUP-LAYER-COUNT): resolve the placeholder KV group names, so the budget bounds the allocation again (#1963, #1966) - #2000
Conversation
…lder names, so a 1 GiB budget allocates 8.5 GiB (#1963, #1966) `KVCacheGroupSpec::layer_names` is upstream's per-layer name list, and upstream bounds its KV allocation with `max(len(group.layer_names) ...)` over that same list, so the count that divides the budget and the count that sizes the allocation are one expression and the product is bounded by construction (`kv_cache_utils.py:1399`, `:1005-1008`, `:1409-1416`). Thirty-three of our thirty-four registries publish a single PLACEHOLDER string per group instead — `{"fa"}`, `{"gdn"}`, `{"mla"}`, `{"kda"}`, `{"fa_draft"}`, `{"encoder"}` — and three consumers read `layer_names.size()` as if it were a layer count. Two of them are wrong: `KVBytesPerBlock` divides an absolute `--kv-cache-memory` budget by ONE layer's page while the runner allocates one buffer per layer (#1963, measured 8.5 GiB for a 1 GiB budget on the 27B), and `recurrent_state_bytes` reports 0.90 GiB against a 43.40 GiB allocation, so the #371 OOM guard does not fire on the only family it can fire for (#1966). Together they account arithmetically for the 98.2 GiB the watchdog saw. The spec records the narrowing that matters and holds: of the two product call sites of `KVBytesPerBlock`, only `ResolveNumBlocks` arm 2 is defective. In `ResolveMaxModelLen` the factor stands on both sides of every comparison and cancels. The design is one resolver, called from the single loader funnel every architecture returns through, which rewrites the placeholders using the runner's OWN allocation predicate rather than a second derivation of the model's shape — and which stands aside for `NemotronHForCausalLM`, the one registry that already publishes real names and knows more than the fallback can. Implementation follows in this pull request; this commit is the spec, committed first so the order proves it. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: Claude:claude-opus-5 [Claude Code]
…, so the budget bounds the allocation again (#1963, #1966) `ResolveKVCacheGroupLayerNames` rewrites a registry's PLACEHOLDER group names into the real per-layer names the runner's own allocation classification implies, and `LoadedEngine::MakeKVCacheMaybeSpec` calls it. That is the single funnel every architecture returns through — the speculative branch and `ModelRegistry::MakeKVCache` both — and `MakeKVCacheResolved` passes both the probe and the resized config through it, so one call site reaches all thirty-four registries. The classification is the runner's own predicate, not a second derivation of the model's shape: a layer is recurrent iff the config has a Mamba group and `layer_types[l] == "linear_attention"`; the target attention group is the first non-eagle attention group and covers every other layer; a second attention group is the speculative draft head, one layer at index `num_hidden_layers`; a third gets an empty list, because the runner allocates no buffer for it. Reproducing the allocator's predicate is what makes the accounting unable to disagree with the allocation, which is the property upstream gets for free by reading one list twice (`kv_cache_utils.py:1399`, `:1409-1416`). `NemotronHForCausalLM` already publishes real names and is left alone: one resolvable name anywhere in the config and the resolver returns untouched. Its `layer_types` is empty and its MoE blocks cache nothing, so the fallback would re-introduce the 52-against-6 mis-classification #810 removed. `LayerIndexOfName` moves out of the runner's anonymous namespace to `vllm::v1::KVCacheLayerIndexOfName` so both sides parse a name with one function. `GPUModelRunner::kv_cache_allocated_bytes()` and its paged half sum the size every `CacheBuffer` was constructed with. They exist so the gate compares the sizing arithmetic against what the allocator DID rather than against a second copy of the same formula. Six cases, all driven from `MakeQwen3_5KVCacheSpec` through the `LoadedEngine` constructor rather than from a hand-built `KVCacheGroupSpec`, on a 6-layer hybrid with TWO full-attention and FOUR GDN layers — the 4-layer config the file already had has exactly one full-attention layer, which is the config in which this bug is invisible. With the resolver call site deleted: CHECK( allocated <= kv_cache_memory_bytes ) 2097152 <= 1048576 CHECK( KVBytesPerBlock * num_blocks == ... ) 1048576 == 2097152 CHECK( groups[0].layer_names.size() == 2 ) 1 == 2 CHECK( groups[1].layer_names.size() == 4 ) 1 == 4 CHECK( recurrent_state_bytes(cfg,4) == ... ) 4992 == 19968 Exactly 2x on the paged half and 4x on the recurrent half, which is the defect in bytes. Restored: 21 of 21 assertions pass, and 627 of 627 ctest cases. `--kv-cache-memory` now buys as many times fewer blocks as the model has attention layers, because those blocks were never inside the budget. `docs/USAGE.md` says so beside the flag, and its `8589934592` example becomes honest rather than a footgun; `docs/FEATURES.md` corrects the "group-aware divisor" claim that named the wrong instrument. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: Claude:claude-opus-5 [Claude Code]
Bring the branch up to the current tip before the gate run, so the trailer and commit-style gates have origin/main as an ancestor and actually execute. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: Claude:claude-opus-5 [Claude Code]
… state the device prediction before the run (#1963, #1966) Three record repairs to the row's own spec, riding in the pull request whose change made them stale. The collapse figure mixed GiB and GB. At `--kv-cache-memory 6GiB`, k=8, `--max-num-seqs 32` the base tree allocates 54,760,833,024 B of paged pool, which is 51.00 GiB and not 54.8; with 43.40 GiB of recurrent state that is 94.40 GiB of the 100.6 GiB the watchdog measured, not 98.2. The residue is weights and transients, which this row does not claim. `## Now` now names the pull request and carries the device confirmation as a table of PREDICTED values beside the measured base ones, written before any run so the prediction cannot be fitted afterwards. It also says what the predictions assume — 16 target full-attention layers, which is what the base measurement implies rather than something read off the checkpoint — and what a mismatch would mean. `## Gates` records both full-suite results: 627/627 on the pre-merge head, and 625/627 after merging `origin/main` on a box under heavy contention, where `test_dflash2_ctx_capacity` and `test_async_llm` are red under `ctest -j 4` and green when re-run serially on the same binary. That is the starvation case verification.md names. Neither test reads `layer_names`, `KVBytesPerBlock` or `recurrent_state_bytes`. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: Claude:claude-opus-5 [Claude Code]
DEVICE EVIDENCE — the prediction was stated first, and it is exactOperator run on dgx:gpu0 (GB10), idle under an Launch:
We now allocate what the operator asks for. Alongside this, the #371 guard repair is also confirmed on deviceSeparately measured on the same integrated binary at The guard that computed 0.90 GiB before this row now computes 43.40 GiB — the 48x repair — and refuses by name rather than letting the box march to an OOM reboot, which is what it did earlier the same day. That is #1966 closed on hardware. Note on merge order
|
…-LAYER-COUNT #1999 landed first and the two rows overlap in five files. Four three-way-merged and one conflicted, and every one was resolved by the AGENTS.md `## Records` rule rather than by accepting the automatic merge: take origin/main's complete file, prove it byte-identical, re-apply this row's scoped edit at an anchor asserted unique, then confirm `git diff origin/main -- <file>` carries only this row's lines. `ResolveMaxNumSeqs` and every other line #1999 added is byte-identical to origin/main here, and `tests/vllm/entrypoints/test_loaded_engine_dense.cpp`, `docs/USAGE.md` and `tests/CMakeLists.txt` take zero deletions. The issue index was union-appended and checked by row-ID set difference rather than by reading the diff: 729 base + 2 ours + 1 theirs = 732, 0 lost, 0 invented, 0 duplicated. Case 4 changed, and it is a correctness change rather than a textual one. It asserted `recurrent_state_bytes(cfg, params.max_num_seqs)` while #1999 makes the constructor hand the runner the RESOLVED concurrency. The two agree only while `ResolveMaxNumSeqs` does not clamp, which for this config is true by a 64x margin — 256 seats against the 4 asked. It now reads `eng.max_num_seqs()`, which is right by construction. THE FIRST ATTEMPT AT THE CONFLICTED FILE PASSED EVERY STATIC CHECK AND DID NOT COMPILE. Taking the two conflict sides verbatim looked right: git had hoisted the closing brace both blocks end with out of the conflict region as shared trailing context, so each side arrived one brace short and a single brace closed the pair. Marker count zero, TEST_CASE names unique, identifiers unique, includes unique, and a diff that was clean and purely additive — six agreeing instruments, none of which was measuring whether the file parsed. The compiler was, in ten lines. A brace-balance check against the pre-merge file is added for it, and the `## Records` procedure is the real remedy, because it never reconstructs a block from a conflict region. The two fixes compose. `ComputeHybridKvBudget` never reads `layer_names`; the only input of its arithmetic this row moves is `kv_cfg.num_blocks`, and upstream's `num_blocks` is a PER-LAYER count (`kv_cache_utils.py:1008` divides by `num_layers`) — the meaning its unification against one attention page assumes. Before this row the byte-budget path handed it a count inflated by the layer count, so its clamp was too permissive. The spec works the integrated prediction through and checks it against #1999's own printed line: 3072 blocks -> 118 unified pages of 832 tokens, which the same formula reproduces. Resolved-tree evidence so far: `test_loaded_engine_dense` builds clean and runs 30 of 30 cases, 128 of 128 assertions, this row's 5 and #1999's among them. The full suite is still owed; the box is at 100% disk with another session consuming it, and this commit is pushed now rather than risking the resolution to a reap. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: Claude:claude-opus-5 [Claude Code]
…et (#1963, #1966) The row's spec carried only the pre-merge results. This records the head that merges KV-GDN-STATE-BUDGET (#1999): `BUILD rc=0` and 628 of 628 ctest cases, `CTEST rc=0`, with no flake to re-run and a disk guard that never fired — the two starvation reds the pre-merge run hit did not recur. The interaction surface is also gated target by target with the exit code captured per binary rather than inferred from a summary line, and `test_hybrid_kv_budget` is the one that matters: #1983's own gate, 8 of 8, green against a tree where `num_blocks` means something different from what it meant when that gate was written. That is the executable form of the claim that the two rows compose rather than fight, and it is better evidence than the arithmetic in `## Now`, which only predicts it. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: Claude:claude-opus-5 [Claude Code]
Confirmed independently: the take-both resolution really does lose a braceThis PR warned that the rehearsed "take both blocks" resolution of I made the same resolution on my measurement branch and pushed it. Measured on Exactly the described defect, in a tree that passed the six checks I ran at the time — 0 markers, 30 It escaped notice only because my device build targets The remedy stands as this PR states it: the §Records procedure — take the target-branch file whole, re-apply scoped edits at uniquely-asserted anchors — never reconstructs a block from a conflict region and so cannot lose a brace. That it produced a On the corrected recurrent figureAccepted, and the falsifiability is what makes it credible: the formula reproduces the engine's own printed line ( So on the integrated tree the whole-engine KV at that flag is 3.71 GiB against the base tree's 51.90 GiB, and the guard's 43.40 GiB figure I measured belongs to the |
main advanced 45 files past this row's last merge base (2a42cb3) and GitHub reported the PR `CONFLICTING`. Four of this row's twelve files overlap that delta, and every one was resolved by the AGENTS.md `## Records` rule rather than by accepting the automatic merge. `.agents/issue-index.md` was union-appended and then checked by row-ID set difference rather than by reading the diff: 730 base + 2 ours (#1963, #1966) + 15 theirs = 747 expected, 747 actual, 0 lost, 0 invented, 0 duplicated. Every merged row is byte-identical to a row in one of the three sources, and the preamble is byte-identical to base on all three sides. GitHub does not honour the `merge=union` driver, which is why this file is the whole reason the PR read dirty. `tests/CMakeLists.txt` was rebuilt rather than trusted: origin/main's complete file (blob 6a616d7) with this row's five lines re-applied after `vllm_cpp_add_test(test_loaded_engine_dense ...)`, an anchor asserted to occur exactly once. The rebuild is byte-identical to the three-way result, and `git diff origin/main -- tests/CMakeLists.txt` carries those five lines and nothing else. `docs/FEATURES.md` and `src/vllm/entrypoints/model_loader.cpp` take edits in disjoint regions. Both were checked in both directions: `git diff origin/main` carries only this row's hunks, byte-identical to `git diff 2a42cb3 4f8638a`, and `git diff 4f8638a` reproduces origin/main's entire 45-file, 6785-insertion, 333-deletion delta with nothing dropped. No conflict region was reconstructed, so the brace-hoist failure this branch already recorded cannot recur here; the resolved tree was built and run anyway. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: Claude:claude-opus-5 [Claude Code]
KVCacheGroupSpec::layer_namesis upstream's per-layer name list, and upstreambounds its KV allocation with
max(len(group.layer_names) ...)over that samelist (
vllm/v1/core/kv_cache_utils.py:1399), dividing the budget by the countit multiplies the allocation by (
:1005-1008,:1409-1416). The count is oneexpression over one list, so the product is bounded by construction.
Thirty-three of our thirty-four registries publish a single PLACEHOLDER string
per group instead —
{"fa"},{"gdn"},{"mla"},{"kda"},{"fa_draft"},{"encoder"}— and two consumers readlayer_names.size()as if it were alayer count.
KVBytesPerBlockdivides an absolute--kv-cache-memorybudget by ONE layer'spage while
GPUModelRunner::initialize_kv_cacheallocates one buffer perlayer. Measured on
dgx:gpu0before this change:--kv-cache-memory 1073741824loggedpage_size_bytes=131072 num_blocks=4096, and 16full-attention layers plus the draft layer make that 8.5 GiB of buffers for a
1 GiB budget (#1963).
recurrent_state_bytesreports 0.90 GiB against a43.40 GiB allocation at
--max-num-seqs 32,k=8, so the #371 guard — whichexists to turn an OOM reboot into a refusal — does not fire on the only family
it can fire for (#1966). Together they account for the 98.2 GiB the watchdog
saw at
--kv-cache-memory 6GiB: 51.00 GiB of paged pool plus 43.40 GiB ofrecurrent state, 94.40 GiB of the 100.6 GiB the watchdog measured.
ResolveKVCacheGroupLayerNamesrewrites the placeholders into real per-layernames, and
LoadedEngine::MakeKVCacheMaybeSpeccalls it. That is the singlefunnel every architecture returns through — the speculative branch and
ModelRegistry::MakeKVCacheboth — andMakeKVCacheResolvedpasses the probeand the resized config through it, so one call site reaches all thirty-four
registries.
The classification is the runner's own predicate, not a second derivation of
the model's shape: a layer is recurrent iff the config has a Mamba group and
layer_types[l] == "linear_attention"; the target attention group is the firstnon-eagle attention group and covers every other layer; a second attention
group is the speculative draft head, one layer at index
num_hidden_layers; athird gets an empty list, because the runner allocates no buffer for it.
Reproducing the allocator's predicate is what makes the accounting unable to
disagree with the allocation.
NemotronHForCausalLMalready publishes real names and is left alone: oneresolvable name anywhere and the resolver returns untouched. Its
layer_typesis empty and its MoE blocks cache nothing, so the fallback would re-introduce
the 52-against-6 mis-classification #810 removed.
LayerIndexOfNamemoves outof the runner's anonymous namespace to
vllm::v1::KVCacheLayerIndexOfNamesoboth sides parse a name with one function.
GPUModelRunner::kv_cache_allocated_bytes()and its paged half sum the sizeevery
CacheBufferwas constructed with, so the gate compares the sizingarithmetic against what the allocator DID rather than against a second copy of
the same formula.
Six cases, driven from
MakeQwen3_5KVCacheSpecthrough theLoadedEngineconstructor rather than from a hand-built
KVCacheGroupSpec, on a 6-layerhybrid with TWO full-attention and FOUR GDN layers. The 4-layer config that
file already had has exactly one full-attention layer, which is the config in
which this bug is invisible — and
tests/vllm/v1/test_kv_cache_interface.cpphands the function
KVCacheGroupSpec{{"layer1", "layer2"}, ref}, a shape noregistry emits, which is why the pre-existing unit coverage could not see it.
With the resolver call site deleted in a scratch copy:
Exactly 2x on the paged half and 4x on the recurrent half, which is the defect
in bytes. Restored: 21 of 21 assertions pass and 627 of 627 ctest cases.
--kv-cache-memorynow buys as many times fewer blocks as the model hasattention layers, because those blocks were never inside the budget.
docs/USAGE.mdsays so beside the flag, and its8589934592example becomeshonest rather than a footgun;
docs/FEATURES.mdcorrects the "group-awaredivisor" claim, which named the wrong instrument.
Merged with
KV-GDN-STATE-BUDGET(#1999), which landed firstFive files overlap; four three-way-merged and one conflicted. Every one was
resolved by the AGENTS.md
## Recordsrule rather than by accepting theautomatic merge: take
origin/main's complete file, prove it byte-identical,re-apply this row's scoped edit at an anchor asserted unique, then confirm
git diff origin/main -- <file>carries only this row's lines. The index wasunion-appended and checked by row-ID set difference (729 base + 2 ours + 1
theirs = 732; 0 lost, 0 invented, 0 duplicated).
ResolveMaxNumSeqsand everyother line #1999 added is byte-identical to
origin/mainin this branch.Case 4 changed, and it is a correctness change rather than a textual one. It
asserted
recurrent_state_bytes(cfg, params.max_num_seqs)while #1999 makes theconstructor hand the runner the RESOLVED concurrency. The two agree only while
ResolveMaxNumSeqsdoes not clamp — true here by a 64x margin — so it now readseng.max_num_seqs(), which is right by construction.The first attempt at the conflicted file passed every static check and did not
compile. Taking the two conflict sides verbatim looked right: git had hoisted
the closing
}both blocks end with out of the conflict region as sharedtrailing context, so each side arrived one brace short. Marker count zero,
TEST_CASEnames unique, identifiers unique, includes unique,git diffcleanand purely additive — six agreeing instruments, none measuring whether the file
parsed. The compiler was, in ten lines. The check added for it is a brace
balance against the pre-merge file, and the real remedy is the
## Recordsprocedure above, which never reconstructs a block from a conflict region.
The two fixes compose; they do not fight
ComputeHybridKvBudgetnever readslayer_names. The only input of itsarithmetic this row moves is
kv_cfg.num_blocks— and upstream'snum_blocksis a PER-LAYER count (
kv_cache_utils.py:1008divides bynum_layers), whichis the meaning its unification against one attention page assumes. Before this
row the byte-budget path handed it a count inflated by the layer count, so its
clamp was too permissive. Feeding it a truthful pool is what makes its seat
count correct.
Worked through for the device run below:
unified_block_tokens= 32 xceil(3371008 / 131072) = 832,
unified_num_blocks= 481 x 32 / 832 = 18,slots_per_seq= 9, so 2 seats andmax_num_seqsclamps 32 -> 2; therecurrent allocation is 3371008 x 48 x 18 = 2.71 GiB. Whole-engine KV
3.71 GiB against the base tree's 51.90 GiB at the same flag. That arithmetic
is checkable against #1999's own output rather than against itself: its engine
prints
The KV pool (3072 blocks) holds 118 unified pages of 832 tokens, and3072 x 32 / 832 = 118. The formula reproduces both numbers.
Consequence worth stating because it looks like a regression and is not: at a
fixed
--kv-cache-memorythe seat count now falls by the same factor the pooldoes, 8.5x on this spec-on launch. To seat 32 sequences at k=8 the budget must
be >= 16684941312 (15.54 GiB).
--num-blocksis unaffected and always was —ResolveNumBlocksarm 1 returns it verbatim, and only the byte-budget pathconverts differently.
Device confirmation, predicted before the run
At
--kv-cache-memory 1073741824on the 27B the[kv-alloc]line should readnum_blocks=481rather than4096, for 1071775744 B (0.998 GiB) paged againstthe 1 GiB asked, plus 2.71 GiB recurrent on the integrated tree.
Closes #1963
Closes #1966
FOLLOWING_AGENTS_PROTOCOL
Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: Claude:claude-opus-5 [Claude Code]