Provenance: found during a memory review of the v20 r12 release image voipmonitor/vllm:gilded-gnosis-v20-vllmb46c3aa-si35aebc6-fi801d57a-cu132-20260730-r12. Code refs are against the composed r12 vllm tree (base f978d009 + PR heads per the release locks). vllm/utils/mem_utils.py, vllm/v1/worker/gpu_model_runner.py, and vllm/v1/kv_cache_interface.py are byte-identical to base f978d009 (permalinks below use that sha); vllm/v1/worker/gpu_worker.py line refs are against r12-composed (the file was last touched by the PR-198 head 703af3ffd, "profile repeatable serving peak after warmup"), so its code is quoted inline only.
Deployment used for impact numbers: GLM-5.2 753B MoE EXL3 3.0bpw, 4x RTX 6000 Pro Blackwell 96GB, TP4; production turnkey = DCP2, nvfp4_ds_mla KV (368 B/tok/layer, 78 layers -> 14,352 B/token/GPU at DCP2), V1 runner, max_num_batched_tokens=3072, max_num_seqs=8, MTP k=5, GMU 0.957 (~4.1 GiB total margin/GPU). These are proposals from a static source review, not demands — happy to be corrected on any of them.
1. Persistent runner-__init__ buffers escape every KV-budget term (OOM-side, ~36 MiB/GPU on the turnkey)
Where:
vllm/v1/worker/gpu_worker.py:407 — init_snapshot is taken in init_device() before the model runner is constructed at lines 428-442:
# take current memory snapshot
self.init_snapshot = init_snapshot = MemorySnapshot(device=self.device)
self.requested_memory = request_memory(init_snapshot, self.cache_config)
...
# Construct the model runner
if self.use_v2_model_runner:
...
else:
self.model_runner = GPUModelRunnerV1(self.vllm_config, self.device)
self.inputs_embeds = self._make_buffer(
self.max_num_tokens, self.inputs_embeds_size, dtype=self.dtype, numpy=False
)
memory_profiling entry (mem_utils.py#L284-L294) resets peak stats and takes before_profile, so before_profile.torch_peak == bytes currently allocated == weights + all runner-init buffers.
What/Why: the final budget is (gpu_worker.py:566-573, 598-602):
profile_result.torch_peak_increase = (
profile_torch_peak - profile_result.before_profile.torch_peak
)
profile_result.non_kv_cache_memory = (
profile_result.non_torch_increase
+ profile_result.torch_peak_increase
+ profile_result.weights_memory
)
...
self.available_kv_cache_memory_bytes = (
self.requested_memory
- profile_result.non_kv_cache_memory
- cudagraph_memory_estimate_applied
)
The runner-init buffers are (a) not in weights_memory (that is only model_memory_usage from load_model), (b) cancelled out of torch_peak_increase because they sit in both profile_torch_peak and before_profile.torch_peak, and (c) not in non_torch_increase because they are torch-allocator memory (inside memory_reserved, hence excluded from non_torch_memory, mem_utils.py#L156-L163). The PR-198 mid-profile reset_peak_memory_stats (second profile_run pass) does not change this: the reset re-bases the peak to current allocated, which still includes the init buffers, so they cancel out of the increase exactly as before; only first-pass transients are excluded (as intended). Net: KV capacity is over-budgeted by exactly the size of the runner-init buffers, eroding the thin 0.957-GMU margin from the OOM side.
Impact (recomputed): turnkey V1 runner (max_num_tokens=3072, max_num_reqs=8, hidden=6144, bf16, max_model_len=524288, block 64):
inputs_embeds 3072x6144x2 B = 36.0 MiB
- placeholder block table 8 x cdiv(524288,64)=8192 x int32 = 0.25 MiB (DCP-unaware until
may_reinitialize_input_batch)
positions/input_ids/req_indices/query_pos/slot_mapping/is_token_ids + req-sized buffers ≈ 0.11 MiB
- Total ≈ 36.4 MiB/GPU ≈ 2,650 KV tokens/GPU at DCP2/nvfp4.
The V2 runner (release-gate config) has the same snapshot-ordering exposure but no inputs_embeds buffer; its InputBuffers (vllm/v1/worker/gpu/input_batch.py:24-34) at 8192 tokens is only ~105 KiB, so the material impact is on the V1 turnkey path.
Suggested fix: either take init_snapshot after runner construction, or add (before_profile.torch_peak - weights_memory) (equivalently before_profile allocated minus weights) as an explicit term of non_kv_cache_memory.
Verification: log profile_result.before_profile.torch_peak - model_memory_usage right after memory_profiling entry on the turnkey config; it reports ~36 MiB that appears in no subtraction term. Alternatively compare available_kv_cache_memory_bytes before/after moving the snapshot below runner construction.
2. Non-torch CUDA-graph capture residue is double-counted (over-reserve)
Where: gpu_worker.py:541-573 — profile_cudagraph_memory() runs inside the memory_profiling context:
with memory_profiling(
self.init_snapshot,
weights_memory=int(self.model_runner.model_memory_usage),
) as profile_result:
self._profile_model_with_kernel_warmup()
profile_torch_peak = torch.accelerator.memory_stats(self.device).get(
"allocated_bytes.all.peak", 0
)
...
cudagraph_memory_estimate = self.model_runner.profile_cudagraph_memory()
# Use the pre-cudagraph torch peak to avoid double-counting.
profile_result.torch_peak_increase = (
profile_torch_peak - profile_result.before_profile.torch_peak
)
What/Why: the pre-capture peak read protects only the torch side. non_torch_increase is computed at context exit (mem_utils.py#L298-L306), i.e. after the throwaway capture probe. The probe's per-graph costs are measured as get_memory_info free-memory deltas (gpu_model_runner.py#L6754-L6770), which include non-torch (driver/NCCL) allocations made during capture. The cleanup path (gpu_model_runner.py#L6802-L6819) destroys the graphs and the profiling KV cache, and memory_profiling exit runs gc + empty_cache, which releases the torch pool — but non-torch residue that survives graph destruction (NCCL per-communicator graph-capture channels first allocated on the first captured collective; cuBLAS/cuDNN capture-mode workspaces; driver context growth) stays resident and lands in non_torch_increase and was already inside cudagraph_memory_estimate. Both are subtracted from the budget at gpu_worker.py:598-602 when VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS=1 (the default, envs.py:2242-2244), so the residue is charged twice → KV under-allocation.
Impact: mechanism is unambiguous from the code; the magnitude (residue size after graph destruction, TP4/NCCL) is not statically determinable — plausibly tens of MiB on this deployment, and it is pure over-reserve (safe side, but wasted capacity while the turnkey margin is only ~4.1 GiB total).
Suggested fix: snapshot non_torch_memory immediately before the capture probe and subtract the post-cleanup non-torch delta from cudagraph_memory_estimate (or from non_torch_increase).
Verification: log MemorySnapshot(...).non_torch_memory immediately before line 563 and after the with block; any positive delta is currently counted in both subtraction terms.
3. Per-graph cudagraph extrapolation charges the 2nd-largest graph's cost to all remaining graphs (over-reserve)
Where: gpu_model_runner.py#L6750-L6779:
for mode, descs in capture_descs:
profile_descs = descs[:2]
...
first_capture = mem_samples[0]
# Use at least 1 MiB per graph for driver overhead
per_graph = max(
mem_samples[1] if len(mem_samples) > 1 else 0, 1 << 20
)
shared_memory_estimate[mode] = first_capture
per_graph_estimate[mode] = per_graph * (len(descs) - 1)
descs are sorted largest-first (cudagraph_dispatcher.py:340-348), so the two largest graphs per mode are sampled and every remaining (smaller) graph is charged the 2nd-largest graph's marginal cost, floored at 1 MiB.
What/Why: graph memory cost is roughly monotone in num_tokens (input/output pool blocks scale with batch), so charging the 2nd-largest cost to all smaller graphs is a systematic over-estimate whenever the true marginal cost of small captures is below the 2nd-largest sample (pool-block reuse makes small captures often cost near the 1 MiB driver floor).
Impact (recomputed, turnkey): with max_num_seqs=8, MTP k=5 (uniform_decode_query_len=6), default max_cudagraph_capture_size = min(8*6*2, 512) = 96 → capture sizes [1,2,4,8,16,...,96] = 15. FULL_AND_PIECEWISE gives 15 PIECEWISE descs + 6 FULL descs (uniform-decode sizes in [6,48]: 8,16,24,32,40,48) = 21 graphs, of which 19 are extrapolated. Every 1 MiB by which the 2nd-largest sample exceeds the true small-graph cost over-reserves 19 MiB (≈1,400 DCP2/nvfp4 KV tokens/GPU); a 4 MiB gap ≈ 76 MiB.
Suggested fix: also sample the smallest desc per mode (3 probes instead of 2) and interpolate per-graph cost by num_tokens between the smallest and 2nd-largest samples.
Verification: enable the existing logger.debug at line 6781 and compare per_graph against the actual free-memory delta of the last (smallest) capture during the real capture_model() pass.
4. Page-size branch asymmetry for MLA records: glm_fp8_rope uses block_size, deepseek_v4 uses storage_block_size (latent)
Where: kv_cache_interface.py#L477-L500:
@property
def real_page_size_bytes(self) -> int:
if self.cache_dtype_str == "nvfp4_ds_mla":
...
if self.model_version == "deepseek_v4":
return self.storage_block_size * 432
if self.model_version == "glm_fp8_rope":
...
return self.block_size * 368
return self.block_size * 432
with storage_block_size = block_size // compress_ratio (line 472-474) and compress_ratio: int = 1 as the dataclass default (line 465).
What/Why: today this is byte-identical for GLM: the GLM spec is built at mla_attention.py:1963-1972 (model_version="glm_fp8_rope" when KV_FP8_ROPE=1 + nvfp4_ds_mla + glm_moe_dsa) without passing compress_ratio, so it stays 1 and storage_block_size == block_size; compress_ratio > 1 is only ever set on the DeepseekV4 layer path (models/deepseek_v4/attention.py:826-842). The asymmetry is therefore harmless in the shipped r12 image — but it is a landmine: if a compressor is ever enabled for GLM (e.g. cr=4), the glm_fp8_rope branch would silently size pages at 64*368 = 23,552 B instead of (64/4)*368 = 5,888 B, over-allocating the entire KV cache 4x. (The fp8_ds_mla V3.2 branch at line 500, block_size * 656, has the same shape.)
Impact: none today (verified compress_ratio=1 on the GLM path); 4x KV over-allocation if the compressor is ever turned on for GLM without touching this file.
Suggested fix: use storage_block_size uniformly in all real_page_size_bytes branches; with compress_ratio=1 this is a no-op for current deployments.
Verification: assert spec.storage_block_size == spec.block_size for all GLM specs today (passes); unit-test real_page_size_bytes for a hypothetical glm_fp8_rope spec with compress_ratio=4 — currently returns 23,552 instead of 5,888.
Provenance: found during a memory review of the v20 r12 release image
voipmonitor/vllm:gilded-gnosis-v20-vllmb46c3aa-si35aebc6-fi801d57a-cu132-20260730-r12. Code refs are against the composed r12 vllm tree (basef978d009+ PR heads per the release locks).vllm/utils/mem_utils.py,vllm/v1/worker/gpu_model_runner.py, andvllm/v1/kv_cache_interface.pyare byte-identical to basef978d009(permalinks below use that sha);vllm/v1/worker/gpu_worker.pyline refs are againstr12-composed(the file was last touched by the PR-198 head703af3ffd, "profile repeatable serving peak after warmup"), so its code is quoted inline only.Deployment used for impact numbers: GLM-5.2 753B MoE EXL3 3.0bpw, 4x RTX 6000 Pro Blackwell 96GB, TP4; production turnkey = DCP2,
nvfp4_ds_mlaKV (368 B/tok/layer, 78 layers -> 14,352 B/token/GPU at DCP2), V1 runner,max_num_batched_tokens=3072,max_num_seqs=8, MTP k=5, GMU 0.957 (~4.1 GiB total margin/GPU). These are proposals from a static source review, not demands — happy to be corrected on any of them.1. Persistent runner-
__init__buffers escape every KV-budget term (OOM-side, ~36 MiB/GPU on the turnkey)Where:
vllm/v1/worker/gpu_worker.py:407—init_snapshotis taken ininit_device()before the model runner is constructed at lines 428-442:GPUModelRunnerV1.__init__then allocates persistent GPU tensors: the placeholderInputBatch/block tables (gpu_model_runner.py#L705-L741) and the CUDA-graph persistent buffers (gpu_model_runner.py#L773-L820), dominated by:memory_profilingentry (mem_utils.py#L284-L294) resets peak stats and takesbefore_profile, sobefore_profile.torch_peak== bytes currently allocated == weights + all runner-init buffers.What/Why: the final budget is (
gpu_worker.py:566-573, 598-602):The runner-init buffers are (a) not in
weights_memory(that is onlymodel_memory_usagefromload_model), (b) cancelled out oftorch_peak_increasebecause they sit in bothprofile_torch_peakandbefore_profile.torch_peak, and (c) not innon_torch_increasebecause they are torch-allocator memory (insidememory_reserved, hence excluded fromnon_torch_memory, mem_utils.py#L156-L163). The PR-198 mid-profilereset_peak_memory_stats(secondprofile_runpass) does not change this: the reset re-bases the peak to current allocated, which still includes the init buffers, so they cancel out of the increase exactly as before; only first-pass transients are excluded (as intended). Net: KV capacity is over-budgeted by exactly the size of the runner-init buffers, eroding the thin 0.957-GMU margin from the OOM side.Impact (recomputed): turnkey V1 runner (
max_num_tokens=3072,max_num_reqs=8, hidden=6144, bf16,max_model_len=524288, block 64):inputs_embeds3072x6144x2 B = 36.0 MiBmay_reinitialize_input_batch)positions/input_ids/req_indices/query_pos/slot_mapping/is_token_ids+ req-sized buffers ≈ 0.11 MiBThe V2 runner (release-gate config) has the same snapshot-ordering exposure but no
inputs_embedsbuffer; itsInputBuffers(vllm/v1/worker/gpu/input_batch.py:24-34) at 8192 tokens is only ~105 KiB, so the material impact is on the V1 turnkey path.Suggested fix: either take
init_snapshotafter runner construction, or add(before_profile.torch_peak - weights_memory)(equivalentlybefore_profileallocated minus weights) as an explicit term ofnon_kv_cache_memory.Verification: log
profile_result.before_profile.torch_peak - model_memory_usageright aftermemory_profilingentry on the turnkey config; it reports ~36 MiB that appears in no subtraction term. Alternatively compareavailable_kv_cache_memory_bytesbefore/after moving the snapshot below runner construction.2. Non-torch CUDA-graph capture residue is double-counted (over-reserve)
Where:
gpu_worker.py:541-573—profile_cudagraph_memory()runs inside thememory_profilingcontext:What/Why: the pre-capture peak read protects only the torch side.
non_torch_increaseis computed at context exit (mem_utils.py#L298-L306), i.e. after the throwaway capture probe. The probe's per-graph costs are measured asget_memory_infofree-memory deltas (gpu_model_runner.py#L6754-L6770), which include non-torch (driver/NCCL) allocations made during capture. The cleanup path (gpu_model_runner.py#L6802-L6819) destroys the graphs and the profiling KV cache, andmemory_profilingexit runsgc+empty_cache, which releases the torch pool — but non-torch residue that survives graph destruction (NCCL per-communicator graph-capture channels first allocated on the first captured collective; cuBLAS/cuDNN capture-mode workspaces; driver context growth) stays resident and lands innon_torch_increaseand was already insidecudagraph_memory_estimate. Both are subtracted from the budget atgpu_worker.py:598-602whenVLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS=1(the default,envs.py:2242-2244), so the residue is charged twice → KV under-allocation.Impact: mechanism is unambiguous from the code; the magnitude (residue size after graph destruction, TP4/NCCL) is not statically determinable — plausibly tens of MiB on this deployment, and it is pure over-reserve (safe side, but wasted capacity while the turnkey margin is only ~4.1 GiB total).
Suggested fix: snapshot
non_torch_memoryimmediately before the capture probe and subtract the post-cleanup non-torch delta fromcudagraph_memory_estimate(or fromnon_torch_increase).Verification: log
MemorySnapshot(...).non_torch_memoryimmediately before line 563 and after thewithblock; any positive delta is currently counted in both subtraction terms.3. Per-graph cudagraph extrapolation charges the 2nd-largest graph's cost to all remaining graphs (over-reserve)
Where: gpu_model_runner.py#L6750-L6779:
descsare sorted largest-first (cudagraph_dispatcher.py:340-348), so the two largest graphs per mode are sampled and every remaining (smaller) graph is charged the 2nd-largest graph's marginal cost, floored at 1 MiB.What/Why: graph memory cost is roughly monotone in
num_tokens(input/output pool blocks scale with batch), so charging the 2nd-largest cost to all smaller graphs is a systematic over-estimate whenever the true marginal cost of small captures is below the 2nd-largest sample (pool-block reuse makes small captures often cost near the 1 MiB driver floor).Impact (recomputed, turnkey): with
max_num_seqs=8, MTP k=5 (uniform_decode_query_len=6), defaultmax_cudagraph_capture_size = min(8*6*2, 512) = 96→ capture sizes[1,2,4,8,16,...,96]= 15. FULL_AND_PIECEWISE gives 15 PIECEWISE descs + 6 FULL descs (uniform-decode sizes in [6,48]: 8,16,24,32,40,48) = 21 graphs, of which 19 are extrapolated. Every 1 MiB by which the 2nd-largest sample exceeds the true small-graph cost over-reserves 19 MiB (≈1,400 DCP2/nvfp4 KV tokens/GPU); a 4 MiB gap ≈ 76 MiB.Suggested fix: also sample the smallest desc per mode (3 probes instead of 2) and interpolate per-graph cost by
num_tokensbetween the smallest and 2nd-largest samples.Verification: enable the existing
logger.debugat line 6781 and compareper_graphagainst the actual free-memory delta of the last (smallest) capture during the realcapture_model()pass.4. Page-size branch asymmetry for MLA records:
glm_fp8_ropeusesblock_size,deepseek_v4usesstorage_block_size(latent)Where: kv_cache_interface.py#L477-L500:
with
storage_block_size = block_size // compress_ratio(line 472-474) andcompress_ratio: int = 1as the dataclass default (line 465).What/Why: today this is byte-identical for GLM: the GLM spec is built at
mla_attention.py:1963-1972(model_version="glm_fp8_rope"whenKV_FP8_ROPE=1+nvfp4_ds_mla+glm_moe_dsa) without passingcompress_ratio, so it stays 1 andstorage_block_size == block_size;compress_ratio > 1is only ever set on the DeepseekV4 layer path (models/deepseek_v4/attention.py:826-842). The asymmetry is therefore harmless in the shipped r12 image — but it is a landmine: if a compressor is ever enabled for GLM (e.g. cr=4), theglm_fp8_ropebranch would silently size pages at64*368 = 23,552 Binstead of(64/4)*368 = 5,888 B, over-allocating the entire KV cache 4x. (Thefp8_ds_mlaV3.2 branch at line 500,block_size * 656, has the same shape.)Impact: none today (verified
compress_ratio=1on the GLM path); 4x KV over-allocation if the compressor is ever turned on for GLM without touching this file.Suggested fix: use
storage_block_sizeuniformly in allreal_page_size_bytesbranches; withcompress_ratio=1this is a no-op for current deployments.Verification:
assert spec.storage_block_size == spec.block_sizefor all GLM specs today (passes); unit-testreal_page_size_bytesfor a hypotheticalglm_fp8_ropespec withcompress_ratio=4— currently returns 23,552 instead of 5,888.