[Bugfix][V2] Warm up kernels before capturing CUDA graphs (#55341)
Signed-off-by: aoshen02 <aoshen02@users.noreply.github.com> Signed-off-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: aoshen02 <aoshen02@users.noreply.github.com> Co-authored-by: Nick Hill <nickhill123@gmail.com>
aoshen02 committed
Sep 5, 2026 at 06:44 UTC
685074cd61d6b8432575d11b38341559992cf9dd
7 files changed
+129
-16
tests/v1/worker/test_gpu_model_runner_v2.py
+82
@@ -1,6 +1,7 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
+import contextlib
5
from types import SimpleNamespace
6
7
import pytest
@@ -213,3 +214,84 @@ def test_append_block_ids_rejects_write_past_row_capacity():
214
)
215
216
assert block_tables.num_blocks.np[0, 1] == 3
217
+
218
+
219
+def _make_capture_runner(captured: bool) -> GPUModelRunner:
220
+ """Minimal V2 runner for capture_model: fakes everything except the
221
+ cudagraph_manager's needs_capture decision."""
222
+ runner = GPUModelRunner.__new__(GPUModelRunner)
223
+ runner.model_state = SimpleNamespace(supports_mm_inputs=False)
224
+ runner.cudagraph_manager = SimpleNamespace(
225
+ needs_capture=lambda: captured,
226
+ capture=lambda *args, **kwargs: None,
227
+ )
228
+ runner.lora_config = None
229
+ runner.maybe_setup_dummy_loras = lambda _cfg: contextlib.nullcontext()
230
+ runner.speculator = None
231
+ runner.adaptive_verification = None
232
+ runner.model = None
233
+ runner.input_buffers = None
234
+ runner.pcp_manager = None
235
+ runner.intermediate_tensors = None
236
+ runner.block_tables = None
237
+ runner.attn_groups = None
238
+ runner.kv_cache_config = None
239
+ runner.use_aux_hidden_state_outputs = False
240
+ return runner
241
+
242
+
243
+def test_capture_model_locks_workspace_after_capture(monkeypatch):
244
+ """A workspace resize after capture frees the buffer the captured graphs
245
+ baked in, so capture_model must lock the workspace before returning
246
+ (https://github.com/vllm-project/vllm/issues/55336)."""
247
+ runner = _make_capture_runner(captured=True)
248
+ monkeypatch.setattr(
249
+ model_runner_module, "freeze_gc_for_cudagraph_capture", contextlib.nullcontext
250
+ )
251
+ monkeypatch.setattr(torch.accelerator, "empty_cache", lambda: None)
252
+ monkeypatch.setattr(
253
+ torch.accelerator, "get_memory_info", lambda: (1 << 30, 1 << 30)
254
+ )
255
+ lock_calls = []
256
+ monkeypatch.setattr(
257
+ model_runner_module, "lock_workspace", lambda: lock_calls.append("lock")
258
+ )
259
+
260
+ runner.capture_model()
261
+
262
+ assert lock_calls == ["lock"]
263
+
264
+
265
+def test_capture_model_skips_lock_when_nothing_captured(monkeypatch):
266
+ """With no graphs to capture (e.g. enforce_eager) there is nothing baked
267
+ into the workspace, so the early return must not lock it."""
268
+ runner = _make_capture_runner(captured=False)
269
+ lock_calls = []
270
+ monkeypatch.setattr(
271
+ model_runner_module, "lock_workspace", lambda: lock_calls.append("lock")
272
+ )
273
+
274
+ assert runner.capture_model() == 0
275
+ assert lock_calls == []
276
+
277
+
278
+def test_capture_model_profile_only_skips_lock(monkeypatch):
279
+ """The memory-profiling capture pass runs before kernel warmup and the
280
+ real capture; locking there would stop the warmup from growing the
281
+ workspace to its scheduler-realistic size."""
282
+ runner = _make_capture_runner(captured=True)
283
+ monkeypatch.setattr(
284
+ model_runner_module, "freeze_gc_for_cudagraph_capture", contextlib.nullcontext
285
+ )
286
+ monkeypatch.setattr(torch.accelerator, "empty_cache", lambda: None)
287
+ monkeypatch.setattr(
288
+ torch.accelerator, "get_memory_info", lambda: (1 << 30, 1 << 30)
289
+ )
290
+ lock_calls = []
291
+ monkeypatch.setattr(
292
+ model_runner_module, "lock_workspace", lambda: lock_calls.append("lock")
293
+ )
294
+
295
+ runner.capture_model(profile_only=True)
296
+
297
+ assert lock_calls == []
tests/v1/worker/test_gpu_model_runner_v2_cudagraph_profiling.py
+9
-8
@@ -67,7 +67,8 @@ def _make_profiling_runner(
67
events: list[str] = []
68
runner.events = events
69
70
- def _capture_model() -> int:
70
+ def _capture_model(*, profile_only: bool = False) -> int:
71
+ assert profile_only
72
events.append("capture")
73
# Simulate the manager's per-FULL-graph memory sampling.
74
samples = runner.cudagraph_manager._capture_mem_samples
@@ -183,7 +184,7 @@ def test_profile_cudagraph_memory_tears_down_on_capture_error(monkeypatch):
184
_patch_module(monkeypatch)
185
runner = _make_profiling_runner(CUDAGraphMode.FULL)
186
186
- def _boom() -> int:
187
+ def _boom(*, profile_only: bool = False) -> int:
188
runner.events.append("capture")
189
raise RuntimeError("capture failed")
190
@@ -204,7 +205,7 @@ def test_profile_cudagraph_memory_restores_compilation_counters(monkeypatch):
205
_patch_module(monkeypatch)
206
runner = _make_profiling_runner(CUDAGraphMode.FULL)
207
207
- def _capture_model() -> int:
208
+ def _capture_model(*, profile_only: bool = False) -> int:
209
compilation_counter.num_cudagraph_captured += 5
210
compilation_counter.num_gpu_runner_capture_triggers += 1
211
return 1 << 30
@@ -288,9 +289,9 @@ def test_profile_cudagraph_memory_redirects_wrapper_pools(monkeypatch):
289
try:
290
capture_model = runner.capture_model
291
291
- def _capture_model() -> int:
292
+ def _capture_model(*, profile_only: bool = False) -> int:
293
wrapper.pool_during_capture = wrapper.graph_pool
293
- return capture_model()
294
+ return capture_model(profile_only=profile_only)
295
296
runner.capture_model = _capture_model
297
@@ -328,9 +329,9 @@ def test_profile_cudagraph_memory_swaps_and_drops_speculator_managers(monkeypatc
329
pools_seen: list[Any] = []
330
capture_model = runner.capture_model
331
331
- def _capture_model() -> int:
332
+ def _capture_model(*, profile_only: bool = False) -> int:
333
pools_seen.append(runner.speculator.cudagraph_manager.pool)
333
- return capture_model()
334
+ return capture_model(profile_only=profile_only)
335
336
runner.capture_model = _capture_model
337
@@ -379,7 +380,7 @@ def test_profile_cudagraph_memory_frees_throwaway_pool(monkeypatch):
380
manager.pool = cgu.current_platform.get_global_graph_pool()
381
r.speculator = SimpleNamespace(cudagraph_manager=manager)
382
382
- def _capture_model() -> int:
383
+ def _capture_model(*, profile_only: bool = False) -> int:
384
for owner in (
385
runner.cudagraph_manager,
386
runner.speculator.cudagraph_manager,
tests/v1/worker/test_workspace.py
+24
@@ -82,6 +82,30 @@ def test_workspace_lanes_compose_with_ubatches(monkeypatch) -> None:
82
assert len(pointers) == 4
83
84
85
+def test_workspace_lock_blocks_growth_and_unlock_restores(monkeypatch) -> None:
86
+ """Once locked, oversized requests fail loudly instead of reallocating the
87
+ buffer that captured CUDA graphs point at; unlock restores growth."""
88
+ monkeypatch.setattr(workspace, "dbo_current_ubatch_id", lambda: 0)
89
+ manager = workspace.WorkspaceManager(torch.device("cpu"), num_lanes=1)
90
+
91
+ (buf,) = manager.get_simultaneous(((256,), torch.uint8))
92
+ manager.lock()
93
+ assert manager.is_locked()
94
+
95
+ # Requests within the reserved size still reuse the same buffer.
96
+ (same,) = manager.get_simultaneous(((256,), torch.uint8))
97
+ (smaller,) = manager.get_simultaneous(((8,), torch.uint8))
98
+ assert same.data_ptr() == buf.data_ptr()
99
+ assert smaller.data_ptr() == buf.data_ptr()
100
+
101
+ with pytest.raises(AssertionError, match="Workspace is locked"):
102
+ manager.get_simultaneous(((512,), torch.uint8))
103
+
104
+ manager.unlock()
105
+ (grown,) = manager.get_simultaneous(((512,), torch.uint8))
106
+ assert grown.numel() == 512
107
+
108
+
109
def test_workspace_lane_validation(monkeypatch) -> None:
110
monkeypatch.setattr(workspace, "dbo_current_ubatch_id", lambda: 0)
111
manager = workspace.WorkspaceManager(torch.device("cpu"), num_lanes=1)
vllm/v1/worker/gpu/cudagraph_utils.py
+1
-1
@@ -828,7 +828,7 @@ def profile_cudagraph_memory(runner: "GPUModelRunner") -> int:
828
mem_samples: list[int] = []
829
manager._capture_mem_samples = mem_samples
830
831
- measured = int(runner.capture_model())
831
+ measured = int(runner.capture_model(profile_only=True))
832
833
# The measured delta covers PIECEWISE, encoder and speculator graphs
834
# plus the sampled FULL graphs; swap the sampled FULL cost for the
vllm/v1/worker/gpu/model_runner.py
+7
-2
@@ -173,7 +173,7 @@ from vllm.v1.worker.utils import (
173
copy_kv_cache_blocks_inplace,
174
get_uniform_decode_token_count,
175
)
176
-from vllm.v1.worker.workspace import use_workspace_lane
176
+from vllm.v1.worker.workspace import lock_workspace, use_workspace_lane
177
178
logger = init_logger(__name__)
179
@@ -928,7 +928,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
928
return _profile_cudagraph_memory(self)
929
930
@torch.inference_mode()
931
- def capture_model(self) -> int:
931
+ def capture_model(self, *, profile_only: bool = False) -> int:
932
assert self.cudagraph_manager is not None
933
capture_encoder = (
934
self.model_state.supports_mm_inputs
@@ -986,6 +986,11 @@ class GPUModelRunner(LoRAModelRunnerMixin):
986
987
end_free_gpu_memory = torch.accelerator.get_memory_info()[0]
988
989
+ if not profile_only:
990
+ # Lock workspace to prevent resizing during execution. A resize after
991
+ # capture frees the static cuda graph buffer.
992
+ lock_workspace()
993
+
994
end_time = time.perf_counter()
995
elapsed_time = end_time - start_time
996
cuda_graph_size = start_free_gpu_memory - end_free_gpu_memory
vllm/v1/worker/gpu_worker.py
+5
-4
@@ -790,6 +790,10 @@ class Worker(WorkerBase):
790
# cuda graph capture.
791
kernel_warmup(self)
792
793
+ if self.use_v2_model_runner:
794
+ # A workspace resize after capture frees what the graphs point at.
795
+ warmup_kernels(self.model_runner, self.execute_model, self.sample_tokens)
796
+
797
cuda_graph_memory_bytes = 0
798
if not self.model_config.enforce_eager:
799
cuda_graph_memory_bytes = self.model_runner.capture_model()
@@ -868,10 +872,7 @@ class Worker(WorkerBase):
872
873
maybe_save_startup_plan(self, kv_cache_memory_bytes_to_requested_limit)
874
871
- if self.use_v2_model_runner:
872
- # V2: Run full execute_model + sample_tokens to JIT compile triton kernels.
873
- warmup_kernels(self.model_runner, self.execute_model, self.sample_tokens)
874
- elif get_pp_group().is_last_rank:
875
+ if not self.use_v2_model_runner and get_pp_group().is_last_rank:
876
# V1: Warm up sampler and preallocate memory buffer for logits and other
877
# sampling related tensors of max possible shape to avoid memory
878
# fragmentation issue.
vllm/v1/worker/mm_encoder_model_runner.py
+1
-1
@@ -67,7 +67,7 @@ class MMEncoderModelRunner(GPUModelRunner):
67
def get_kv_cache_spec(self) -> dict[str, KVCacheSpec]:
68
return {}
69
70
- def capture_model(self) -> int:
70
+ def capture_model(self, *, profile_only: bool = False) -> int:
71
return 0
72
73
def _dummy_run(