[Model Runner V2] Reserve CUDA graph memory (#53306)

Signed-off-by: Anh Tran <anh.tran.3889@gmail.com> Signed-off-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: Anh Tran <anh.tran.3889@gmail.com> Co-authored-by: anhtra3889 <anhtra@nvidia.com>

Nick Hill committed Aug 23, 2026 at 23:11 UTC a4d70bef3724edb068c8206804154065acaa4cd4
13 files changed +573 -31
tests/test_config.py
+35
@@ -347,6 +347,41 @@ def test_resolve_cudagraph_mode_adjusts_spec_decode_sizes_only_for_v1(
347 assert compilation_config.cudagraph_capture_sizes == expected_capture_sizes
348
349
350 +def test_resolve_cudagraph_mode_skips_mamba_block_check_while_profiling():
351 + """Cudagraph memory profiling uses a minimal KV cache, so the Mamba
352 + block-count guard must only fire for the real cache sizing."""
353 + kv_cache_config = SimpleNamespace(has_mamba_layers=True, num_blocks=4)
354 +
355 + compilation_config = CompilationConfig(
356 + cudagraph_mode=CUDAGraphMode.FULL_AND_PIECEWISE,
357 + )
358 + with pytest.raises(ValueError, match="exceeds available Mamba cache blocks"):
359 + compilation_config.resolve_cudagraph_mode_and_sizes(
360 + AttentionCGSupport.ALWAYS,
361 + "FakeAttentionBackend",
362 + uniform_decode_query_len=1,
363 + use_v2_model_runner=True,
364 + tensor_parallel_size=1,
365 + kv_cache_config=kv_cache_config,
366 + max_num_reqs=256,
367 + )
368 +
369 + compilation_config = CompilationConfig(
370 + cudagraph_mode=CUDAGraphMode.FULL_AND_PIECEWISE,
371 + )
372 + cudagraph_mode = compilation_config.resolve_cudagraph_mode_and_sizes(
373 + AttentionCGSupport.ALWAYS,
374 + "FakeAttentionBackend",
375 + uniform_decode_query_len=1,
376 + use_v2_model_runner=True,
377 + tensor_parallel_size=1,
378 + kv_cache_config=kv_cache_config,
379 + max_num_reqs=256,
380 + is_profiling=True,
381 + )
382 + assert cudagraph_mode == CUDAGraphMode.FULL_AND_PIECEWISE
383 +
384 +
385 @pytest.mark.parametrize(
386 ("model_config", "expected"),
387 [
tests/v1/sample/test_logprobs.py
+4 -3
@@ -535,8 +535,9 @@ def test_logprobs_mode(logprobs_mode: LogprobsMode):
535 "facebook/opt-125m",
536 max_logprobs=5,
537 enable_prefix_caching=False,
538 - # 2 other llms alive during whole session
539 - gpu_memory_utilization=0.05,
538 + # 2 other llms alive during whole session; must also cover the
539 + # cudagraph memory reservation from startup profiling.
540 + gpu_memory_utilization=0.1,
541 max_model_len=16,
542 logprobs_mode=logprobs_mode,
543 )
@@ -575,7 +576,7 @@ def test_prompt_logprobs_mode():
576 llm = LLM(
577 "facebook/opt-125m",
578 enable_prefix_caching=False,
578 - gpu_memory_utilization=0.05,
579 + gpu_memory_utilization=0.1,
580 max_model_len=16,
581 logprobs_mode=mode,
582 )
tests/v1/worker/test_gpu_model_runner_v2_cudagraph_profiling.py new
+286
@@ -0,0 +1,286 @@
1 +#!/usr/bin/env python3
2 +# SPDX-License-Identifier: Apache-2.0
3 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
4 +"""Unit tests for GPUModelRunner (V2) CUDA graph memory profiling.
5 +
6 +These exercise the orchestration of ``profile_cudagraph_memory`` on CPU by
7 +building a runner via ``__new__`` and faking the GPU-only helpers, so the
8 +control flow (bootstrap -> sample FULL graphs into a throwaway pool ->
9 +extrapolate -> teardown) is covered without a GPU.
10 +See https://github.com/vllm-project/vllm/issues/49224.
11 +"""
12 +
13 +import contextlib
14 +from types import SimpleNamespace
15 +from typing import Any
16 +
17 +from vllm.compilation.counter import compilation_counter
18 +from vllm.config.compilation import CUDAGraphMode
19 +from vllm.v1.worker.gpu import cudagraph_utils as cgu
20 +from vllm.v1.worker.gpu import model_runner as mrv2
21 +
22 +GLOBAL_POOL = "global-pool"
23 +THROWAWAY_POOL = "throwaway-pool"
24 +
25 +
26 +class _FakeCudaGraphManager:
27 + def __init__(
28 + self, needs_capture: bool, num_full_descs: int, piecewise_only: bool = False
29 + ) -> None:
30 + self._needs_capture = needs_capture
31 + self.pool: Any = GLOBAL_POOL
32 + descs = [object() for _ in range(num_full_descs)]
33 + if piecewise_only:
34 + self._capture_descs = {CUDAGraphMode.PIECEWISE: descs}
35 + else:
36 + self._capture_descs = {CUDAGraphMode.FULL: descs} if needs_capture else {}
37 + # Profiling hooks set by profile_cudagraph_memory.
38 + self._max_full_descs_to_capture: int | None = None
39 + self._capture_mem_samples: list[int] | None = None
40 + self.use_breakable_cg = False
41 +
42 + def needs_capture(self) -> bool:
43 + return self._needs_capture
44 +
45 +
46 +def _make_profiling_runner(
47 + cudagraph_mode: CUDAGraphMode,
48 + *,
49 + needs_capture: bool = True,
50 + num_full_descs: int = 3,
51 + piecewise_only: bool = False,
52 + captured_bytes: int = 7 << 30,
53 + mem_samples: list[int] | None = None,
54 +) -> Any:
55 + runner: Any = mrv2.GPUModelRunner.__new__(mrv2.GPUModelRunner)
56 + runner.compilation_config = SimpleNamespace(cudagraph_mode=cudagraph_mode)
57 + runner.cudagraph_manager = _FakeCudaGraphManager(
58 + needs_capture, num_full_descs, piecewise_only
59 + )
60 + runner.vllm_config = SimpleNamespace()
61 +
62 + events: list[str] = []
63 + runner.events = events
64 +
65 + def _capture_model() -> int:
66 + events.append("capture")
67 + # Simulate the manager's per-FULL-graph memory sampling.
68 + samples = runner.cudagraph_manager._capture_mem_samples
69 + if samples is not None:
70 + samples.extend(mem_samples or [])
71 + return captured_bytes
72 +
73 + runner.capture_model = _capture_model
74 + return runner
75 +
76 +
77 +def _patch_module(monkeypatch) -> None:
78 + @contextlib.contextmanager
79 + def _fake_set_current_vllm_config(_cfg):
80 + yield
81 +
82 + monkeypatch.setattr(cgu, "set_current_vllm_config", _fake_set_current_vllm_config)
83 + monkeypatch.setattr(
84 + cgu,
85 + "current_platform",
86 + SimpleNamespace(graph_pool_handle=lambda: THROWAWAY_POOL),
87 + )
88 + monkeypatch.setattr(
89 + cgu, "_init_minimal_kv_cache_for_profiling", lambda r: r.events.append("init")
90 + )
91 + monkeypatch.setattr(
92 + cgu, "_teardown_profiling_state", lambda r: r.events.append("teardown")
93 + )
94 + # The profiler reads free GPU memory before/after to compute what it
95 + # retained; default to a constant (nothing retained).
96 + monkeypatch.setattr(cgu.torch.accelerator, "empty_cache", lambda: None)
97 + monkeypatch.setattr(
98 + cgu.torch.accelerator, "get_memory_info", lambda: (1 << 30, 1 << 30)
99 + )
100 +
101 +
102 +def test_profile_cudagraph_memory_disabled_returns_zero(monkeypatch):
103 + _patch_module(monkeypatch)
104 + runner = _make_profiling_runner(CUDAGraphMode.NONE)
105 +
106 + result = cgu.profile_cudagraph_memory(runner)
107 +
108 + assert result == 0
109 + # No KV-cache bootstrap or teardown when cudagraphs are disabled.
110 + assert runner.events == []
111 +
112 +
113 +def test_profile_cudagraph_memory_no_graphs_tears_down(monkeypatch):
114 + _patch_module(monkeypatch)
115 + runner = _make_profiling_runner(CUDAGraphMode.FULL, needs_capture=False)
116 +
117 + result = cgu.profile_cudagraph_memory(runner)
118 +
119 + assert result == 0
120 + # Bootstrapped then cleaned up, without capturing or touching the pool.
121 + assert runner.events == ["init", "teardown"]
122 + assert runner.cudagraph_manager.pool == GLOBAL_POOL
123 +
124 +
125 +def test_profile_cudagraph_memory_samples_and_extrapolates(monkeypatch):
126 + _patch_module(monkeypatch)
127 + gib = 1 << 30
128 + # Measured delta 1000 MiB includes the sampled FULL graphs (100 + 20 MiB).
129 + # Extrapolated FULL cost for 3 graphs: 100 + 2 * 20 = 140 MiB.
130 + runner = _make_profiling_runner(
131 + CUDAGraphMode.FULL,
132 + num_full_descs=3,
133 + captured_bytes=1000 * gib,
134 + mem_samples=[100 * gib, 20 * gib],
135 + )
136 +
137 + result = cgu.profile_cudagraph_memory(runner)
138 +
139 + assert result == (1000 - (100 + 20) + (100 + 2 * 20)) * gib
140 + # Bootstrap, capture, and teardown run in order.
141 + assert runner.events == ["init", "capture", "teardown"]
142 + # Capture must use a throwaway pool, not the persistent global pool.
143 + assert runner.cudagraph_manager.pool == THROWAWAY_POOL
144 + # FULL capture must be limited to the largest few graphs.
145 + assert (
146 + runner.cudagraph_manager._max_full_descs_to_capture
147 + == cgu._FULL_GRAPH_PROFILING_SAMPLES
148 + )
149 +
150 +
151 +def test_profile_cudagraph_memory_piecewise_only_returns_measured(monkeypatch):
152 + _patch_module(monkeypatch)
153 + captured_bytes = 5 << 30
154 + runner = _make_profiling_runner(
155 + CUDAGraphMode.FULL_AND_PIECEWISE,
156 + piecewise_only=True,
157 + captured_bytes=captured_bytes,
158 + )
159 +
160 + result = cgu.profile_cudagraph_memory(runner)
161 +
162 + # No FULL graphs to sample or extrapolate: the measured delta is exact.
163 + assert result == captured_bytes
164 +
165 +
166 +def test_profile_cudagraph_memory_tears_down_on_capture_error(monkeypatch):
167 + _patch_module(monkeypatch)
168 + runner = _make_profiling_runner(CUDAGraphMode.FULL)
169 +
170 + def _boom() -> int:
171 + runner.events.append("capture")
172 + raise RuntimeError("capture failed")
173 +
174 + runner.capture_model = _boom
175 +
176 + try:
177 + cgu.profile_cudagraph_memory(runner)
178 + except RuntimeError:
179 + pass
180 + else:
181 + raise AssertionError("expected capture error to propagate")
182 +
183 + # Teardown still runs even if capture raises.
184 + assert runner.events == ["init", "capture", "teardown"]
185 +
186 +
187 +def test_profile_cudagraph_memory_restores_compilation_counters(monkeypatch):
188 + _patch_module(monkeypatch)
189 + runner = _make_profiling_runner(CUDAGraphMode.FULL)
190 +
191 + def _capture_model() -> int:
192 + compilation_counter.num_cudagraph_captured += 5
193 + compilation_counter.num_gpu_runner_capture_triggers += 1
194 + return 1 << 30
195 +
196 + runner.capture_model = _capture_model
197 + captured_before = compilation_counter.num_cudagraph_captured
198 + triggers_before = compilation_counter.num_gpu_runner_capture_triggers
199 +
200 + cgu.profile_cudagraph_memory(runner)
201 +
202 + # Profiling captures are discarded, so they must not inflate the
203 + # compilation counters; the real capture_model() runs later.
204 + assert compilation_counter.num_cudagraph_captured == captured_before
205 + assert compilation_counter.num_gpu_runner_capture_triggers == triggers_before
206 +
207 +
208 +def test_model_runner_delegates_to_cudagraph_utils(monkeypatch):
209 + runner = mrv2.GPUModelRunner.__new__(mrv2.GPUModelRunner)
210 + monkeypatch.setattr(mrv2, "_profile_cudagraph_memory", lambda r: 42)
211 + assert runner.profile_cudagraph_memory() == 42
212 +
213 +
214 +def test_extrapolate_full_graph_memory():
215 + mib = 1 << 20
216 + # No samples (e.g. no FULL graphs): nothing to add.
217 + assert cgu._extrapolate_full_graph_memory([], 0) == 0
218 + # A single graph costs exactly its sample.
219 + assert cgu._extrapolate_full_graph_memory([100 * mib], 1) == 100 * mib
220 + # First capture + per-graph cost for the rest.
221 + assert (
222 + cgu._extrapolate_full_graph_memory([100 * mib, 20 * mib], 5)
223 + == (100 + 4 * 20) * mib
224 + )
225 + # Per-graph cost is floored to account for driver overhead.
226 + assert cgu._extrapolate_full_graph_memory([100 * mib, 0], 3) == (100 + 2 * 1) * mib
227 +
228 +
229 +def test_profile_cudagraph_memory_clears_captured_graphs(monkeypatch):
230 + _patch_module(monkeypatch)
231 + runner = _make_profiling_runner(CUDAGraphMode.FULL_AND_PIECEWISE)
232 +
233 + cleared: list[str] = []
234 + monkeypatch.setattr(
235 + cgu.CUDAGraphWrapper,
236 + "clear_all_graphs",
237 + classmethod(lambda cls: cleared.append("piecewise")),
238 + )
239 + monkeypatch.setattr(
240 + cgu.BreakableCUDAGraphWrapper,
241 + "clear_all_graphs",
242 + classmethod(lambda cls: cleared.append("breakable")),
243 + )
244 +
245 + cgu.profile_cudagraph_memory(runner)
246 +
247 + # Profiling captures are discarded so the real capture re-captures them
248 + # against the KV cache.
249 + assert cleared == ["piecewise", "breakable"]
250 +
251 +
252 +def test_profile_cudagraph_memory_redirects_wrapper_pools(monkeypatch):
253 + """Piecewise wrappers must capture into the throwaway pool too.
254 +
255 + Profiling graphs captured into the persistent global pool and then
256 + discarded drop the pool's use_count to 0, tripping the c10 allocator's
257 + create_or_incref_pool assert when the real capture reuses that pool.
258 + """
259 + _patch_module(monkeypatch)
260 + runner = _make_profiling_runner(CUDAGraphMode.FULL_AND_PIECEWISE)
261 +
262 + class _FakeWrapper:
263 + def __init__(self) -> None:
264 + self.graph_pool: Any = GLOBAL_POOL
265 + self.pool_during_capture: Any = None
266 +
267 + def clear_graphs(self) -> None:
268 + pass
269 +
270 + wrapper = _FakeWrapper()
271 + cgu.CUDAGraphWrapper._all_instances.add(wrapper)
272 + try:
273 + capture_model = runner.capture_model
274 +
275 + def _capture_model() -> int:
276 + wrapper.pool_during_capture = wrapper.graph_pool
277 + return capture_model()
278 +
279 + runner.capture_model = _capture_model
280 +
281 + cgu.profile_cudagraph_memory(runner)
282 +
283 + assert wrapper.pool_during_capture == THROWAWAY_POOL
284 + assert wrapper.graph_pool == GLOBAL_POOL
285 + finally:
286 + cgu.CUDAGraphWrapper._all_instances.discard(wrapper)
vllm/model_executor/layers/mamba/gdn/kimi_gdn_linear_attn.py
+4 -1
@@ -416,7 +416,10 @@ class KimiGatedDeltaNetAttention(GatedDeltaNetAttention):
416 )
417
418 assert isinstance(attn_metadata_raw, dict)
419 - attn_metadata_narrowed = attn_metadata_raw[self.prefix]
419 + attn_metadata_narrowed = attn_metadata_raw.get(self.prefix)
420 + if attn_metadata_narrowed is None:
421 + # Profile/warmup dummy runs skip mamba-family metadata.
422 + return
423 assert isinstance(attn_metadata_narrowed, GDNAttentionMetadata)
424 m = attn_metadata_narrowed
425 has_initial_state = m.has_initial_state
vllm/model_executor/layers/mamba/gdn/olmo_gdn_linear_attn.py
+4 -1
@@ -305,7 +305,10 @@ class OlmoHybridGatedDeltaNetAttention(GatedDeltaNetAttention):
305 return
306
307 assert isinstance(attn_metadata, dict)
308 - attn_metadata = attn_metadata[self.prefix] # type: ignore[assignment]
308 + attn_metadata = attn_metadata.get(self.prefix) # type: ignore[assignment]
309 + if attn_metadata is None:
310 + # Profile/warmup dummy runs skip mamba-family metadata.
311 + return
312 assert isinstance(attn_metadata, GDNAttentionMetadata)
313 has_initial_state = attn_metadata.has_initial_state
314 spec_query_start_loc = attn_metadata.spec_query_start_loc
vllm/model_executor/layers/mamba/gdn/qwen_gdn_linear_attn.py
+16 -12
@@ -1202,13 +1202,14 @@ class QwenGatedDeltaNetAttention(GatedDeltaNetAttention):
1202 forward_context = get_forward_context()
1203 attn_metadata_raw = forward_context.attn_metadata
1204
1205 - if attn_metadata_raw is None:
1205 + attn_metadata = None
1206 + if isinstance(attn_metadata_raw, dict):
1207 + attn_metadata = attn_metadata_raw.get(self.prefix)
1208 + if attn_metadata is None:
1209 v_dim = core_attn_out.shape[-1] * core_attn_out.shape[-2]
1210 self._warmup_prefill_kernels(qkvz, v_dim)
1211 return
1212
1210 - assert isinstance(attn_metadata_raw, dict)
1211 - attn_metadata = attn_metadata_raw[self.prefix] # type: ignore[index]
1213 assert isinstance(attn_metadata, GDNAttentionMetadata)
1214
1215 # The AITER fused reshape/conv kernel expects Qwen3-Next's interleaved
@@ -1259,12 +1260,13 @@ class QwenGatedDeltaNetAttention(GatedDeltaNetAttention):
1260 forward_context = get_forward_context()
1261 attn_metadata_raw = forward_context.attn_metadata
1262
1262 - if attn_metadata_raw is None:
1263 + attn_metadata = None
1264 + if isinstance(attn_metadata_raw, dict):
1265 + attn_metadata = attn_metadata_raw.get(self.prefix)
1266 + if attn_metadata is None:
1267 self._warmup_prefill_kernels(mixed_qkv, 0)
1268 return
1269
1266 - assert isinstance(attn_metadata_raw, dict)
1267 - attn_metadata = attn_metadata_raw[self.prefix] # type: ignore[index]
1270 assert isinstance(attn_metadata, GDNAttentionMetadata)
1271
1272 if (
@@ -1773,12 +1775,13 @@ class QwenGatedDeltaNetAttention(GatedDeltaNetAttention):
1775 forward_context = get_forward_context()
1776 attn_metadata_raw = forward_context.attn_metadata
1777 qkv_size = (self.key_dim * 2 + self.value_dim) // self.tp_size
1776 - if attn_metadata_raw is None:
1778 + attn_metadata = None
1779 + if isinstance(attn_metadata_raw, dict):
1780 + attn_metadata = attn_metadata_raw.get(self.prefix)
1781 + if attn_metadata is None:
1782 self._warmup_prefill_kernels(mixed_qkvz[:, :qkv_size], 0)
1783 return
1784
1780 - assert isinstance(attn_metadata_raw, dict)
1781 - attn_metadata = attn_metadata_raw[self.prefix] # type: ignore[index]
1785 assert isinstance(attn_metadata, GDNAttentionMetadata)
1786 mixed_qkv, output_gate_flat = mixed_qkvz.split(
1787 [qkv_size, self.value_dim // self.tp_size], dim=-1
@@ -1856,12 +1859,13 @@ class QwenGatedDeltaNetAttention(GatedDeltaNetAttention):
1859 ) -> None:
1860 forward_context = get_forward_context()
1861 attn_metadata_raw = forward_context.attn_metadata
1859 - if attn_metadata_raw is None:
1862 + attn_metadata = None
1863 + if isinstance(attn_metadata_raw, dict):
1864 + attn_metadata = attn_metadata_raw.get(self.prefix)
1865 + if attn_metadata is None:
1866 self._warmup_prefill_kernels(mixed_qkv, 0)
1867 return
1868
1863 - assert isinstance(attn_metadata_raw, dict)
1864 - attn_metadata = attn_metadata_raw[self.prefix] # type: ignore[index]
1869 assert isinstance(attn_metadata, GDNAttentionMetadata)
1870 if (
1871 self._can_use_fused_gdn_mtp_decode(attn_metadata)
vllm/model_executor/layers/mamba/linear/bailing_linear_attn.py
+2 -1
@@ -542,7 +542,8 @@ class BailingMoELinearAttention(LinearAttention):
542 attn_metadata = forward_context.attn_metadata
543 if attn_metadata is not None:
544 assert isinstance(attn_metadata, dict)
545 - attn_metadata = attn_metadata[self.prefix] # type: ignore
545 + attn_metadata = attn_metadata.get(self.prefix) # type: ignore
546 + if attn_metadata is not None:
547 assert isinstance(attn_metadata, LinearAttentionMetadata)
548 num_actual_tokens = (
549 attn_metadata.num_prefill_tokens + attn_metadata.num_decode_tokens
vllm/model_executor/layers/mamba/linear/minimax_linear_attn.py
+2 -1
@@ -282,7 +282,8 @@ class MiniMaxText01LinearAttention(LinearAttention):
282 attn_metadata: AttentionMetadata | None = None
283 if attn_metadata_raw is not None:
284 assert isinstance(attn_metadata_raw, dict)
285 - attn_metadata = attn_metadata_raw[self.prefix]
285 + attn_metadata = attn_metadata_raw.get(self.prefix)
286 + if attn_metadata is not None:
287 assert isinstance(attn_metadata, LinearAttentionMetadata)
288 num_actual_tokens = (
289 attn_metadata.num_prefill_tokens + attn_metadata.num_decode_tokens
vllm/model_executor/layers/mamba/mamba_mixer.py
+2 -1
@@ -269,7 +269,8 @@ class MambaMixer(MambaBase, PluggableLayer):
269 attn_metadata: AttentionMetadata | None = None
270 if attn_metadata_raw is not None:
271 assert isinstance(attn_metadata_raw, dict)
272 - attn_metadata = attn_metadata_raw[self.prefix]
272 + attn_metadata = attn_metadata_raw.get(self.prefix)
273 + if attn_metadata is not None:
274 assert isinstance(attn_metadata, Mamba1AttentionMetadata)
275 query_start_loc_p = attn_metadata.query_start_loc_p
276 state_indices_tensor_p = attn_metadata.state_indices_tensor_p
vllm/model_executor/layers/mamba/mamba_mixer2.py
+2 -1
@@ -710,7 +710,8 @@ class MambaMixer2(MambaBase, PluggableLayer):
710 attn_metadata: AttentionMetadata | None = None
711 if attn_metadata_raw is not None:
712 assert isinstance(attn_metadata_raw, dict)
713 - attn_metadata = attn_metadata_raw[self.prefix]
713 + attn_metadata = attn_metadata_raw.get(self.prefix)
714 + if attn_metadata is not None:
715 assert isinstance(attn_metadata, Mamba2AttentionMetadata)
716 # conv_state must be (..., dim, width-1) for the conv kernels.
717 # DS layout stores it that way directly; SD layout needs a
vllm/model_executor/layers/mamba/short_conv.py
+6 -3
@@ -113,8 +113,10 @@ class ShortConv(MambaBase, PluggableLayer):
113 attn_metadata: AttentionMetadata | None = None
114 if attn_metadata_raw is not None:
115 assert isinstance(attn_metadata_raw, dict)
116 - attn_metadata = attn_metadata_raw[self.prefix]
117 - assert isinstance(attn_metadata, ShortConvAttentionMetadata)
116 + attn_metadata = attn_metadata_raw.get(self.prefix)
117 + assert attn_metadata is None or isinstance(
118 + attn_metadata, ShortConvAttentionMetadata
119 + )
120
121 BCx, _ = self.in_proj(hidden_states)
122 B, C, x = BCx.chunk(3, dim=-1)
@@ -225,7 +227,8 @@ class ShortConv(MambaBase, PluggableLayer):
227 attn_metadata: AttentionMetadata | None = None
228 if attn_metadata_raw is not None:
229 assert isinstance(attn_metadata_raw, dict)
228 - attn_metadata = attn_metadata_raw[self.prefix]
230 + attn_metadata = attn_metadata_raw.get(self.prefix)
231 + if attn_metadata is not None:
232 assert isinstance(attn_metadata, ShortConvAttentionMetadata)
233 conv_state = (
234 self.kv_cache[0]
vllm/v1/worker/gpu/cudagraph_utils.py
+187 -2
@@ -1,10 +1,11 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +import gc
4 from collections import defaultdict
5 from collections.abc import Callable
6 from dataclasses import dataclass
7 from itertools import product
7 -from typing import Any, NamedTuple, Protocol
8 +from typing import TYPE_CHECKING, Any, NamedTuple, Protocol
9
10 import torch
11 import torch.nn as nn
@@ -15,7 +16,8 @@ from vllm.compilation.breakable_cudagraph import (
16 is_breakable_cudagraph_enabled,
17 )
18 from vllm.compilation.counter import compilation_counter
18 -from vllm.config import VllmConfig
19 +from vllm.compilation.cuda_graph import CUDAGraphWrapper
20 +from vllm.config import VllmConfig, set_current_vllm_config
21 from vllm.config.compilation import CUDAGraphMode
22 from vllm.distributed.device_communicators.pynccl_allocator import set_graph_pool_id
23 from vllm.distributed.parallel_state import (
@@ -37,6 +39,9 @@ from vllm.v1.worker.gpu.input_batch import InputBatch, InputBuffers
39 from vllm.v1.worker.gpu.model_states.interface import ModelState
40 from vllm.v1.worker.utils import AttentionGroup
41
42 +if TYPE_CHECKING:
43 + from vllm.v1.worker.gpu.model_runner import GPUModelRunner
44 +
45 logger = init_logger(__name__)
46
47
@@ -132,6 +137,12 @@ class CudaGraphManager:
137
138 self._graphs_captured = False
139
140 + # Profiling hooks, set only by profile_cudagraph_memory() below: cap
141 + # FULL-mode capture at the N largest descriptors and record each
142 + # captured FULL graph's memory delta for extrapolation.
143 + self._max_full_descs_to_capture: int | None = None
144 + self._capture_mem_samples: list[int] | None = None
145 +
146 self._candidates: dict[tuple[int, int], list[BatchExecutionDescriptor]] = {}
147 self._capture_descs: dict[CUDAGraphMode, list[BatchExecutionDescriptor]] = {}
148
@@ -327,6 +338,14 @@ class CudaGraphManager:
338 continue
339
340 descs = self._capture_descs[mode]
341 + if (
342 + mode == CUDAGraphMode.FULL
343 + and self._max_full_descs_to_capture is not None
344 + ):
345 + # Profiling only: capture a sample of the largest FULL
346 + # graphs; the total cost is extrapolated from their
347 + # per-graph memory deltas.
348 + descs = descs[: self._max_full_descs_to_capture]
349 if is_global_first_rank():
350 descs = tqdm(descs, desc=f"{progress_bar_desc} ({mode.name})")
351 for desc in descs:
@@ -362,6 +381,9 @@ class CudaGraphManager:
381 set_graph_pool_id(self.pool)
382 else:
383 set_graph_pool_id(current_platform.graph_pool_handle())
384 + if self._capture_mem_samples is not None:
385 + torch.accelerator.synchronize()
386 + free_before = torch.accelerator.get_memory_info()[0]
387 with torch.cuda.graph(graph, self.pool):
388 forward_fn(CUDAGraphMode.NONE)
389 # Join offloader's copy stream after forward to avoid
@@ -369,6 +391,10 @@ class CudaGraphManager:
391 # forks copy_stream, but wait_prefetch only happens in
392 # the next forward pass.
393 get_offloader().join_after_forward()
394 + if self._capture_mem_samples is not None:
395 + torch.accelerator.synchronize()
396 + free_after = torch.accelerator.get_memory_info()[0]
397 + self._capture_mem_samples.append(free_before - free_after)
398 self.graphs[desc] = graph
399 compilation_counter.num_cudagraph_captured += 1
400 self._graphs_captured = True
@@ -668,3 +694,162 @@ def prepare_inputs_to_capture(
694 for_capture=full_cudagraph,
695 )
696 return AttentionState(attn_metadata, slot_mappings_by_layer)
697 +
698 +
699 +# ---------------------------------------------------------------------------
700 +# CUDA graph memory profiling
701 +# ---------------------------------------------------------------------------
702 +
703 +# Number of FULL graphs captured during profiling; the total FULL capture
704 +# cost is extrapolated from this sample to avoid a second full capture.
705 +_FULL_GRAPH_PROFILING_SAMPLES = 2
706 +# Floor for the extrapolated per-graph cost (driver overhead per graph).
707 +_MIN_PER_GRAPH_BYTES = 1 << 20
708 +
709 +
710 +@torch.inference_mode()
711 +def profile_cudagraph_memory(runner: "GPUModelRunner") -> int:
712 + """Estimate the GPU memory needed for CUDA graph capture.
713 +
714 + Called during memory profiling, *before* the real KV cache is allocated,
715 + so that ``Worker.determine_available_memory`` can reserve headroom for
716 + graph capture. Bootstraps a minimal KV cache, runs ``capture_model()``
717 + once, then releases everything so the real init/capture path starts clean.
718 +
719 + FULL graphs bake in KV cache pointers, so only the largest few are
720 + captured (into a throwaway pool) and their total cost is extrapolated.
721 + PIECEWISE, encoder and speculator graphs are measured in full. All
722 + profiling captures are discarded afterwards: replaying graphs recorded
723 + against the throwaway profiling state is unsafe (e.g. inductor graph
724 + partition reclaims the storages of earlier cudagraph recordings once the
725 + real capture records new ones, leading to use-after-free crashes).
726 + """
727 + if runner.compilation_config.cudagraph_mode == CUDAGraphMode.NONE:
728 + return 0
729 +
730 + gc.collect()
731 + torch.accelerator.empty_cache()
732 +
733 + with set_current_vllm_config(runner.vllm_config):
734 + _init_minimal_kv_cache_for_profiling(runner)
735 +
736 + manager = runner.cudagraph_manager
737 + assert manager is not None
738 +
739 + # Don't count profiling captures; the real capture_model() runs later.
740 + saved_num_cudagraph_captured = compilation_counter.num_cudagraph_captured
741 + saved_capture_triggers = compilation_counter.num_gpu_runner_capture_triggers
742 + all_wrappers: list[Any] = []
743 + original_pools: dict[int, Any] = {}
744 + try:
745 + if not manager.needs_capture():
746 + return 0
747 + # Capture all profiling graphs into a throwaway pool so their memory
748 + # is reclaimed on teardown rather than retained by the persistent
749 + # global pool (which the real capture reuses). This must include the
750 + # piecewise wrappers, not just the FULL-graph manager pool: graphs
751 + # captured into the global pool and then discarded drop its use_count
752 + # to 0, and the real capture on the same pool trips the c10 allocator's
753 + # create_or_incref_pool assert ("use_count > 0 INTERNAL ASSERT FAILED").
754 + manager.pool = current_platform.graph_pool_handle()
755 + if manager.use_breakable_cg:
756 + # The breakable runner is otherwise created lazily during capture,
757 + # after the pool swap below, and would capture into the global
758 + # pool. Create it now so its pool gets swapped too.
759 + manager.init_breakable_cg_runner(runner.model)
760 + all_wrappers = list(CUDAGraphWrapper._all_instances) + list(
761 + BreakableCUDAGraphWrapper._all_instances
762 + )
763 + for wrapper in all_wrappers:
764 + original_pools[id(wrapper)] = wrapper.graph_pool
765 + wrapper.graph_pool = manager.pool
766 + manager._max_full_descs_to_capture = _FULL_GRAPH_PROFILING_SAMPLES
767 + mem_samples: list[int] = []
768 + manager._capture_mem_samples = mem_samples
769 +
770 + measured = int(runner.capture_model())
771 +
772 + # The measured delta covers PIECEWISE, encoder and speculator graphs
773 + # plus the sampled FULL graphs; swap the sampled FULL cost for the
774 + # extrapolated total. FULL and PIECEWISE share one pool here just as
775 + # they share the global pool at runtime, so the overlap is not
776 + # double-counted.
777 + num_full_graphs = len(manager._capture_descs.get(CUDAGraphMode.FULL, []))
778 + full_estimate = _extrapolate_full_graph_memory(mem_samples, num_full_graphs)
779 + return max(measured - sum(mem_samples) + full_estimate, 0)
780 + finally:
781 + compilation_counter.num_cudagraph_captured = saved_num_cudagraph_captured
782 + compilation_counter.num_gpu_runner_capture_triggers = saved_capture_triggers
783 + CUDAGraphWrapper.clear_all_graphs()
784 + BreakableCUDAGraphWrapper.clear_all_graphs()
785 + for wrapper in all_wrappers:
786 + if id(wrapper) in original_pools:
787 + wrapper.graph_pool = original_pools[id(wrapper)]
788 + _teardown_profiling_state(runner)
789 +
790 +
791 +def _extrapolate_full_graph_memory(mem_samples: list[int], total_graphs: int) -> int:
792 + """Extrapolate the total FULL capture cost from samples of the largest
793 + graphs. The first capture allocates the pool baseline; later graphs mostly
794 + reuse it, so the second sample is taken as the per-graph cost."""
795 + if not mem_samples:
796 + return 0
797 + first_capture = mem_samples[0]
798 + per_graph = max(mem_samples[1], _MIN_PER_GRAPH_BYTES) if len(mem_samples) > 1 else 0
799 + return first_capture + (total_graphs - 1) * per_graph
800 +
801 +
802 +def _init_minimal_kv_cache_for_profiling(runner: "GPUModelRunner") -> None:
803 + """Allocate the smallest KV cache that still lets every graph be captured."""
804 + from vllm.v1.core.kv_cache_utils import (
805 + get_kv_cache_config_from_groups,
806 + get_kv_cache_groups,
807 + )
808 +
809 + kv_cache_spec = runner.get_kv_cache_spec()
810 + kv_cache_groups = get_kv_cache_groups(runner.vllm_config, kv_cache_spec)
811 + # At least one block per sequence is required to capture the graphs.
812 + min_blocks = (
813 + min(runner.max_num_reqs, runner.compilation_config.max_cudagraph_capture_size)
814 + or 1
815 + )
816 + saved_override = runner.cache_config.num_gpu_blocks_override
817 + runner.cache_config.num_gpu_blocks_override = min_blocks
818 + try:
819 + minimal_config = get_kv_cache_config_from_groups(
820 + runner.vllm_config, kv_cache_groups, available_memory=0
821 + )
822 + finally:
823 + runner.cache_config.num_gpu_blocks_override = saved_override
824 +
825 + runner.initialize_kv_cache(minimal_config, is_profiling=True)
826 + runner.cache_config.num_gpu_blocks = minimal_config.num_blocks
827 +
828 +
829 +def _teardown_profiling_state(runner: "GPUModelRunner") -> None:
830 + """Release the profiling KV cache and captured graphs while keeping model
831 + weights, so the real ``initialize_kv_cache`` starts from a clean slate."""
832 + torch.accelerator.synchronize()
833 + if hasattr(runner, "kv_caches"):
834 + runner.kv_caches.clear()
835 + if hasattr(runner, "attn_groups"):
836 + runner.attn_groups.clear()
837 + if hasattr(runner, "kv_cache_config"):
838 + del runner.kv_cache_config
839 + # Dropping the manager releases the profiling graphs and throwaway pool.
840 + runner.cudagraph_manager = None
841 + # Release encoder graphs captured during profiling; the real
842 + # capture_model() re-captures them.
843 + if runner.model_state.supports_mm_inputs:
844 + runner.model_state.encoder_runner.clear()
845 + # Detach profiling KV tensors held by attention layers.
846 + for layer in runner.compilation_config.static_forward_context.values():
847 + if hasattr(layer, "kv_cache"):
848 + kv_cache = layer.kv_cache
849 + layer.kv_cache = (
850 + torch.tensor([]) if isinstance(kv_cache, torch.Tensor) else []
851 + )
852 + runner.cache_config.num_gpu_blocks = None
853 + runner.maybe_remove_all_loras(runner.lora_config)
854 + gc.collect()
855 + torch.accelerator.empty_cache()
vllm/v1/worker/gpu/model_runner.py
+23 -5
@@ -94,6 +94,9 @@ from vllm.v1.worker.gpu.cudagraph_utils import (
94 BatchExecutionDescriptor,
95 ModelCudaGraphManager,
96 )
97 +from vllm.v1.worker.gpu.cudagraph_utils import (
98 + profile_cudagraph_memory as _profile_cudagraph_memory,
99 +)
100 from vllm.v1.worker.gpu.dp_utils import dispatch_cg_and_sync_dp
101 from vllm.v1.worker.gpu.ec_connector import get_ec_connector
102 from vllm.v1.worker.gpu.eplb_utils import EPLBController, step_eplb_after
@@ -499,7 +502,9 @@ class GPUModelRunner(LoRAModelRunnerMixin):
502 def get_kv_cache_spec(self):
503 return get_kv_cache_spec(self.vllm_config)
504
502 - def initialize_kv_cache(self, kv_cache_config: KVCacheConfig) -> None:
505 + def initialize_kv_cache(
506 + self, kv_cache_config: KVCacheConfig, is_profiling: bool = False
507 + ) -> None:
508 kv_cache_config = deepcopy(kv_cache_config)
509 self.kv_cache_config = kv_cache_config
510
@@ -585,6 +590,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
590 tensor_parallel_size=self.parallel_config.tensor_parallel_size,
591 kv_cache_config=self.kv_cache_config,
592 max_num_reqs=self.max_num_reqs,
593 + is_profiling=is_profiling,
594 )
595 self.cudagraph_manager = ModelCudaGraphManager(
596 self.vllm_config,
@@ -618,7 +624,10 @@ class GPUModelRunner(LoRAModelRunnerMixin):
624 self.kernel_block_sizes,
625 self.vllm_config,
626 )
621 - self.kv_connector = get_kv_connector(self.vllm_config, kv_caches_dict)
627 + if is_profiling:
628 + self.kv_connector = NO_OP_KV_CONNECTOR
629 + else:
630 + self.kv_connector = get_kv_connector(self.vllm_config, kv_caches_dict)
631
632 def _init_kv_zero_meta(self) -> None:
633 """Build KV-block zeroing metadata; invoked from gpu_worker."""
@@ -832,9 +841,10 @@ class GPUModelRunner(LoRAModelRunnerMixin):
841 if self.pooling_runner is not None:
842 self.pooling_runner.clear()
843
844 + @torch.inference_mode()
845 def profile_cudagraph_memory(self) -> int:
836 - # NOTE(woosuk): It is TBD whether we keep this API or not.
837 - return 0
846 + """Estimate the GPU memory required to capture CUDA graphs."""
847 + return _profile_cudagraph_memory(self)
848
849 @torch.inference_mode()
850 def capture_model(self) -> int:
@@ -1521,12 +1531,20 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1531 slot_mappings, self.kv_cache_config
1532 )
1533 assert block_tables is not None
1534 + attn_groups = self.attn_groups
1535 + if dummy_run and is_profile:
1536 + # Mamba layers take a cheap warmup path with no metadata;
1537 + # attention metadata is still built so those kernels tune.
1538 + attn_groups = [
1539 + [g for g in groups if not isinstance(g.kv_cache_spec, MambaSpec)]
1540 + for groups in attn_groups
1541 + ]
1542 attn_metadata = self.model_state.prepare_attn(
1543 input_batch,
1544 batch_desc.cg_mode,
1545 block_tables,
1546 slot_mappings,
1529 - self.attn_groups,
1547 + attn_groups,
1548 self.kv_cache_config,
1549 # FULL replay reads capture-time metadata buffers. Re-stage them
1550 # from the zeroed dummy block tables instead of retaining state