Skip to content

[memory] KV-capacity accounting: runner-init buffers escape the budget; capture residue double-counted; per-graph extrapolation over-reserves; GLM page-size branch asymmetry #201

Description

@malaiwah

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:407init_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-573profile_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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions