[Core] Pre-size cudagraph output staging buffers to the max capture descriptor - #47925
Closed
matteso1 wants to merge 1 commit into
Closed
[Core] Pre-size cudagraph output staging buffers to the max capture descriptor#47925matteso1 wants to merge 1 commit into
matteso1 wants to merge 1 commit into
Conversation
…escriptor The output staging buffers (hidden_states, aux_hidden_states, intermediate_tensors) are lazily allocated inside the first warmup forward via empty_like, so their capacity is whatever num_tokens the first descriptor happens to have. The descending capture-order sort masks this: any smaller-first order under-allocates and crashes the later, larger warmup at the staging copy. Size the leading dim from the max across all capture descriptors instead (all known at init), keeping dtype/device/trailing dims from the live tensor. No behavior change with the current descending order (first descriptor is already the max); this removes the hidden coupling so capture-order changes fail by measurement, not by crash. Signed-off-by: Nils Matteson <nilsmatteson@icloud.com>
matteso1
force-pushed
the
presize-capture-staging
branch
from
July 31, 2026 03:19
ec7d967 to
1f76d51
Compare
Contributor
Author
|
Closing this as a standalone change. Current main deliberately captures the largest descriptors first, so the existing order already avoids the under-allocation. This patch makes alternate orders safe, but it does not improve latency or memory under the current order. I will bring it back with the capture-order optimization if that work produces a measured user-visible benefit. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Purpose
Capture order came up in the #feat-startup-ux discussions about parallelizing and reordering CUDA-graph capture. @galv raised that capture order affects pool fragmentation. The in-tree comment in
CudaGraphManager.capture()already orders PIECEWISE before FULL for pool-reuse reasons. So ordering is a real memory-tuning knob.Today it is also a silent correctness invariant. You cannot measure fragmentation under a different order, because any non-descending order crashes the boot.
The mechanism:
ModelCudaGraphManagerallocates its output staging buffers (hidden_states,aux_hidden_states,intermediate_tensors) lazily inside the first warmup forward, throughtorch.empty_like. Their capacity becomes whatevernum_tokensthe first descriptor happens to have. The descending sort in_init_candidatesmasks this, because the first descriptor is the max and everything fits. Under any smaller-first order, a later and larger warmup crashes at the staging copyself.hidden_states[:num_tokens] = hidden_stateswith a shape mismatch.Measured on a vLLM 0.24.0 wheel, H100 NVL, Qwen3-8B. The lazy-allocation pattern is unchanged on current main.
vllm servepath and cross-model on Qwen3-0.6B.So a bad order costs memory, not seconds. With this fix it costs memory measurably instead of crashing.
Changes
CudaGraphManager._staging_buffer_tokens()returns the maxnum_tokensacross all registered capture descriptors. All of them are known at__init__time.new_empty.Allocations are byte-identical under the current descending order. The sizing rule just stops depending on iteration order. This holds in every reachable config, not only by luck of the sort. PIECEWISE descriptors get the raw
cudagraph_capture_sizes. Decode FULL descriptors are filtered byrounded_num_tokens > max_cg_capture_size.compilation.pyassertscudagraph_capture_sizes[-1] == max_cudagraph_capture_size. So the first descriptor to reach the staging allocation is already the global max today, and there is zero capture-time memory delta.What this removes is the hidden coupling. Future capture-order changes, such as fragmentation tuning or parallel-capture experiments, then fail by measurement rather than by boot crash.
V1 and V2 note: this is the Model Runner V2 manager in
vllm/v1/worker/gpu/. The V1 path stages outputs per-graph insideCUDAGraphWrapperand does not share the first-descriptor sizing pattern.Test Plan
Folded into
tests/v1/spec_decode/test_dynamic_sd_cug.py, which exercises the MRv2CudaGraphManagerdirectly. Correction to an earlier version of this description: that is not the only test file importingvllm.v1.worker.gpu.cudagraph_utils.tests/v1/cudagraph/test_breakable_cudagraph.pyalso does, andtests/v1/cudagraph/test_cudagraph_manager.pyarrived on 2026-07-21 with #48843.tests/v1/cudagraph/test_cudagraph_manager.pyis arguably the better home now, and I am happy to move these cases there if reviewers prefer it.The added cases cover three things: capacity equals the global max regardless of which descriptor allocates first, a descriptor larger than every registered one still fits, and the empty-descriptor fallback.
Test Result
I verified the logic locally against the extracted helper, because this machine has no GPU dependencies available. The folded test is CPU-only and runs in CI.