Skip to content

[Bugfix][Core] Profile and validate kv_cache_memory_bytes instead of skipping the profile - #56698

Open
lesj0610 wants to merge 19 commits into
vllm-project:mainfrom
lesj0610:lesj/kv-cache-memory-bytes-profiling
Open

lesj0610 wants to merge 19 commits into
vllm-project:mainfrom
lesj0610:lesj/kv-cache-memory-bytes-profiling

Conversation

@lesj0610

@lesj0610 lesj0610 commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Purpose

--kv-cache-memory-bytes never checked the size it was handed. The branch returned before memory_profiling ran, logged "skipped memory profiling", and passed the number straight through. Nothing compared it against what the model needs for itself, so a size that is too large starts fine, serves short requests, and then OOMs in the middle of one once traffic reaches the activation peak.

The same measurement gap exists under gpu_memory_utilization, but it stays hidden there: the KV size is derived from a budget that is only a fraction of the device, so whatever the profile missed lands in the part the utilization fraction left unallocated. A pinned size has no such slack — the operator sized it to use what was free — so the pinned cache and the unprofiled activation peak compete for the same free memory.

There is a second half to it. Even when the profile does run, it forwards with skip_attn=True, because there is no KV cache yet to attend against. The attention activations, and the device memory those kernels take the first time they launch, are simply not in the figure. Harmless where slack absorbs it; not harmless when the cache was pinned to that figure.

What this changes

Run the profile for a pinned size too, and check the value against the result. If it does not fit, fail at startup and report the largest size admitted by this profile. Refusing rather than quietly capping is deliberate: a pinned size is an explicit capacity decision, and silently reducing it hides the one number this option exists to control. Starting anyway is worse — the bytes are spent either way, and the failure just moves to a request.

When a size is pinned, extend the profile with attention: build the same minimal KV cache the CUDA graph profiling already uses, run a short ladder of batch widths through it, then tear it down. The ladder is not redundant with its widest rung, since the caching allocator keeps whole segments per size class and a served mix of widths costs more than the widest one alone. Then account for what the allocator holds beyond the live peak — memory_profiling measures allocated_bytes.all.peak, which is the right call where slack absorbs the difference and the wrong one where there is none.

All of it sits inside the pinned branch; outside it, the only change is the shared minimal-KV helper below, which behaves the same when nothing fails.

The attention profile is best effort: if the minimal KV cache cannot be built, startup continues with the old estimate. Building it forces num_gpu_blocks_override to one block per sequence, and V1 restored the override only on the line after the call, so a failure there left it in place for the real KV cache sizing; with a single GPU the engine reads the same config object. Both runner generations now build the minimal config through one helper that restores the override in a finally, as V2 already did. V1's profiling teardown reuses clear_layer_kv_caches(), which now clears quantized scale views independently of kv_cache.

What it deliberately does not do

The reported figure is an upper bound, not a safe setting, and the error message says so. The profile forwards against a minimal KV cache, so peaks that scale with context length are still outside it. For the serve described below, the unmargined bound was 3.53 GiB. A pinned size at that bound still OOMed under four concurrent requests, while 3.07 GiB completed the workload without failure. I did not measure where the real boundary between those two is, so no margin is applied here — the number is what the profile can account for, and the operator is told to leave room under it.

A percentage margin was the first thing I tried, and I dropped it. The shortfall scales with the model's activation footprint rather than with the cache, so a fraction of the cache size wastes memory wherever the cache is large; and a fraction fitted to a single measurement is not evidence, it just reproduces that measurement.

Test Plan

Two-rank pipeline-parallel serve with an unbalanced layer split and the KV cache pinned. The rank carrying the larger share is the one that runs out. Workload: text prompts from 1.6k to 259k tokens, images from 360 to 14k tokens, and mixed batches at concurrency 3 and 4.

Unit tests cover the override restore when the minimal config raises, and the V1 profiling teardown.

Test Result

Profiled non-KV usage on that rank, against an observed non-KV peak of 59.24 GiB under the same workload:

profile non-KV
before this change (figure from a later probe, since nothing was measured) 57.52 GiB
with attention in the profile 58.28 GiB
with allocator overhead as well 59.11 GiB

A pinned 4.1 GiB that used to start and OOM later is now refused at startup:

ValueError: kv_cache_memory_bytes=4400000000 (4.1 GiB) does not fit on this rank. Of 62.93 GiB free at startup the model needs 59.11 GiB for weights, persistent workspaces and its activation peak, 0.21 GiB for CUDA graphs and 0.08 GiB the caching allocator holds beyond that peak, leaving 3.53 GiB. Set --kv-cache-memory-bytes=3785959936 or lower, or give this rank more room (fewer layers on it, a smaller max_num_batched_tokens or max_model_len). That figure is an upper bound, not a safe setting: the profile runs against a minimal KV cache, so peaks that scale with context length are not in it. Leave headroom below it.

At a pinned size below the bound the same serve finishes the whole workload with no failures, including a 258,953-token prompt and four concurrent image requests.

pytest tests/v1/worker/test_cudagraph_memory_profiling.py tests/v1/worker/test_gpu_worker.py: 28 passed. With a forced failure in the V1 minimal config build, num_gpu_blocks_override used to keep the profiling block count after the catch; it is now restored.

ruff check and ruff format pass. The mypy hook reports five errors in this file; all five are present on the unmodified file at the same base and none are on the added lines.

All results above come from local runs on one two-GPU host.

lesj0610 and others added 2 commits September 13, 2026 15:58
Setting `--kv-cache-memory-bytes` skipped memory profiling entirely, so the
pinned size was never checked against what the model needs for itself. The
rank starts, serves short requests, then OOMs mid-request once traffic
reaches the activation peak.

Under `gpu_memory_utilization` this stays invisible: the KV size is derived
from a budget that is only a fraction of the device, and whatever the profile
missed lands in the slice left unallocated. A pinned size has no such slice.

Two things were missing from the pinned path:

- The profile did not run at all. It now does, and the pinned size is checked
  against its result. If it does not fit, startup fails and names the largest
  size that does, rather than deferring the failure to a request.
- `profile_run` forwards with `skip_attn=True`, so attention never runs and
  neither its activations nor the device memory its first launch takes are in
  the profile. Only when a size is pinned, build the minimal KV cache the CUDA
  graph profiling already uses and run a ladder of batch widths through it,
  and account for what the caching allocator holds beyond the live peak.

On one rank of a pipeline-parallel Qwen3.8 serve, profiled non-KV usage goes
from 57.52 GiB to 59.11 GiB against a measured 59.24 GiB, and a pinned 4.1 GiB
that used to OOM under load is now refused at startup.

The `gpu_memory_utilization` path is unchanged: every addition sits inside the
pinned branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Signed-off-by: lesj0610 <lesj0610@godoiksan.org>
@lesj0610
lesj0610 requested a review from njhill as a code owner September 13, 2026 12:40

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@mergify mergify Bot added the bug Something isn't working label Sep 13, 2026
…mory-bytes-profiling

Signed-off-by: lesj0610 <lesj0610@users.noreply.github.com>
…mory-bytes-profiling

Signed-off-by: lesj0610 <lesj0610@users.noreply.github.com>
…mory-bytes-profiling

Signed-off-by: lesj0610 <lesj0610@users.noreply.github.com>
…mory-bytes-profiling

Signed-off-by: lesj0610 <lesj0610@users.noreply.github.com>
…mory-bytes-profiling

Signed-off-by: lesj0610 <lesj0610@users.noreply.github.com>
…mory-bytes-profiling

Signed-off-by: lesj0610 <lesj0610@users.noreply.github.com>
…mory-bytes-profiling

Signed-off-by: lesj0610 <lesj0610@users.noreply.github.com>
…mory-bytes-profiling

Signed-off-by: lesj0610 <lesj0610@users.noreply.github.com>
@mergify

mergify Bot commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @lesj0610.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@mergify mergify Bot added the needs-rebase label Sep 22, 2026
…bytes-profiling

Only conflict is the import block in vllm/v1/worker/gpu_worker.py: vllm-project#57891 added
`from fnmatch import filter as fnmatch_filter` at the same insertion point where
this branch added `from functools import partial`. Both sides kept, order left
to isort.

Signed-off-by: lesj0610 <lesj0610@users.noreply.github.com>
…mory-bytes-profiling

Signed-off-by: lesj0610 <lesj0610@users.noreply.github.com>
@mergify mergify Bot removed the needs-rebase label Sep 23, 2026
…mory-bytes-profiling

Signed-off-by: lesj0610 <lesj0610@users.noreply.github.com>
…mory-bytes-profiling

Signed-off-by: lesj0610 <lesj0610@users.noreply.github.com>
…bytes-profiling

Upstream now passes randomize_inputs to profile_run(). The pinned-KV early
return it touched is gone on this branch, so the argument lands on the one
profiling call, ahead of the attention ladder.

Signed-off-by: lesj0610 <lesj0610@users.noreply.github.com>
…mory-bytes-profiling

Signed-off-by: lesj0610 <lesj0610@users.noreply.github.com>
@mergify

mergify Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @lesj0610.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@mergify mergify Bot added the needs-rebase label Oct 5, 2026
…bytes-profiling

The only conflict is the pinned-KV early return this branch removes. vllm-project#58411 and
vllm-project#58014 changed the profile_run call inside it, and both changes are already on
the surviving call in the memory_profiling block, so the removal stands.

Signed-off-by: lesj0610 <lesj0610@users.noreply.github.com>
@mergify mergify Bot removed the needs-rebase label Oct 5, 2026
…fig fails

The minimal KV cache the CUDA graph profiler bootstraps is built by forcing
`num_gpu_blocks_override` to a block per sequence and restoring it afterwards.
V1 restores it on the line after the call, so anything `get_kv_cache_config_
from_groups` raises leaves the profiling override in place, and the real KV
cache sizing that follows reads it. V2 already wraps that in `try/finally`.

Both now go through one helper that does the restore in a `finally`, which also
drops the copy of the block-count computation each of them carried.

V1's profiling teardown also walked the layers itself to detach the KV tensors
and the quantized scale views. It skips a layer with no `kv_cache` attribute,
so a layer holding only `_k_scale_cache`/`_v_scale_cache` keeps the profiling
scales installed. It reuses `clear_layer_kv_caches()` now, which V2's teardown
and the runner shutdown path already use, with the scale clearing lifted out of
the `kv_cache` branch so those layers are covered too.

Signed-off-by: lesj0610 <lesj0610@users.noreply.github.com>
@mergify

mergify Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @lesj0610.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@mergify mergify Bot added the needs-rebase label Oct 8, 2026
…bytes-profiling

The only conflict is the `vllm.v1.worker.utils` import list in
gpu_model_runner.py. vllm-project#60517 dropped `is_residual_scattered_for_sp`, and this
branch adds `build_minimal_kv_cache_config` and `clear_layer_kv_caches`, so
the resolution keeps the two additions without the removed helper.

Signed-off-by: lesj0610 <lesj0610@gmail.com>
@mergify mergify Bot removed the needs-rebase label Oct 9, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working mrv2 Model Runner V2 specific nvidia

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant