main
py 570 lines 21.5 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 from dataclasses import replace
4 from unittest.mock import MagicMock, patch
5
6 import pytest
7 import torch
8 import torch.nn as nn
9
10 from tests.utils import create_new_process_for_each_test
11 from vllm.compilation.cuda_graph import CUDAGraphWrapper
12 from vllm.compilation.monitor import set_cudagraph_capturing_enabled
13 from vllm.config import (
14 CompilationConfig,
15 CompilationMode,
16 CUDAGraphMode,
17 ParallelConfig,
18 SchedulerConfig,
19 VllmConfig,
20 )
21 from vllm.config.lora import LoRAConfig
22 from vllm.forward_context import BatchDescriptor, set_forward_context
23 from vllm.platforms import current_platform
24 from vllm.v1.cudagraph_dispatcher import CudagraphDispatcher
25
26 DEVICE_TYPE = current_platform.device_type
27
28
29 # Helper MLP for testing
30 class SimpleMLP(nn.Module):
31 def __init__(self):
32 super().__init__()
33 self.fc1 = nn.Linear(10, 10)
34 self.fc2 = nn.Linear(10, 10)
35
36 def forward(self, x):
37 return self.fc2(self.fc1(x))
38
39
40 def _create_vllm_config(
41 compilation_config: CompilationConfig,
42 max_num_seqs: int = 8,
43 lora_config: bool = False,
44 ) -> MagicMock:
45 mock_config = MagicMock(spec=VllmConfig)
46 mock_config.compilation_config = compilation_config
47 mock_config.scheduler_config = SchedulerConfig.default_factory(
48 max_num_seqs=max_num_seqs,
49 )
50 mock_config.parallel_config = ParallelConfig()
51 mock_config.speculative_config = None # No speculative decoding
52 mock_config.num_speculative_tokens = 0
53 if not lora_config:
54 mock_config.lora_config = None
55 else:
56 # Create a real LoRAConfig with specialize_active_lora enabled
57 mock_config.lora_config = LoRAConfig(
58 max_loras=4,
59 specialize_active_lora=True,
60 )
61 # Mimic the behavior of VllmConfig.__post_init__()
62 if compilation_config.mode == CompilationMode.VLLM_COMPILE:
63 compilation_config.set_splitting_ops_for_v1(
64 all2all_backend=mock_config.parallel_config.all2all_backend,
65 data_parallel_size=mock_config.parallel_config.data_parallel_size,
66 )
67
68 # mimic VllmConfig.__post_init__
69 if compilation_config.cudagraph_capture_sizes:
70 compilation_config.max_cudagraph_capture_size = (
71 compilation_config.cudagraph_capture_sizes[-1]
72 )
73
74 compilation_config.post_init_cudagraph_sizes()
75
76 return mock_config
77
78
79 class TestCudagraphDispatcher:
80 @pytest.mark.parametrize(
81 "cudagraph_mode_str,compilation_mode,lora_config",
82 [
83 # Test case 0: Full CG for mixed batches, no separate routine
84 ("FULL", CompilationMode.NONE, False),
85 # Test case 1: Full CG for uniform batches, piecewise for mixed
86 ("FULL_AND_PIECEWISE", CompilationMode.NONE, False),
87 # Test case 2: Full CG for uniform batches, no CG for mixed
88 ("FULL_DECODE_ONLY", CompilationMode.NONE, False),
89 # Test case 3: PIECEWISE for all
90 ("PIECEWISE", CompilationMode.VLLM_COMPILE, False),
91 # Test case 4: PIECEWISE for all, specialize LoRA cases
92 ("PIECEWISE", CompilationMode.VLLM_COMPILE, True),
93 ],
94 )
95 def test_dispatcher(self, cudagraph_mode_str, compilation_mode, lora_config):
96 # Setup dispatcher
97 comp_config = CompilationConfig(
98 cudagraph_mode=cudagraph_mode_str,
99 mode=compilation_mode,
100 cudagraph_capture_sizes=[1, 8],
101 )
102
103 config = _create_vllm_config(
104 comp_config, max_num_seqs=8, lora_config=lora_config
105 )
106 if (
107 cudagraph_mode_str == "FULL_AND_PIECEWISE"
108 and compilation_mode == CompilationMode.NONE
109 ):
110 with pytest.raises(AssertionError):
111 dispatcher = CudagraphDispatcher(config)
112 return
113
114 dispatcher = CudagraphDispatcher(config)
115 dispatcher.initialize_cudagraph_keys(
116 cudagraph_mode=comp_config.cudagraph_mode, uniform_decode_query_len=1
117 )
118
119 # Verify the key is initialized correctly
120 # With LoRA specialization (max_loras=4, specialize_active_lora=True):
121 # - lora_cases = [0, 1, 2, 4, 5] (no-lora + powers of 2 up to 4 + max_loras+1)
122 # - capture_sizes = [1, 8]
123 # - Total keys = 2 sizes × 5 lora_cases = 10
124 if cudagraph_mode_str in ["FULL_AND_PIECEWISE", "PIECEWISE"]:
125 assert len(dispatcher.cudagraph_keys[CUDAGraphMode.PIECEWISE]) == (
126 10 if lora_config else 2
127 )
128 else:
129 assert len(dispatcher.cudagraph_keys[CUDAGraphMode.PIECEWISE]) == 0
130 if cudagraph_mode_str not in ["NONE", "PIECEWISE"]:
131 assert len(dispatcher.cudagraph_keys[CUDAGraphMode.FULL]) == (
132 10 if lora_config else 2
133 )
134 else:
135 assert len(dispatcher.cudagraph_keys[CUDAGraphMode.FULL]) == 0
136
137 # Test dispatch logic
138 # 1. non-uniform batch, size in cudagraph size list
139 # FULL mode uses exact keys with num_reqs set
140 desc_full_with_reqs = BatchDescriptor(num_tokens=8, num_reqs=8, uniform=False)
141 # PIECEWISE mode uses relaxed keys with num_reqs=None
142 desc_piecewise = BatchDescriptor(num_tokens=8, num_reqs=None, uniform=False)
143 rt_mode, key = dispatcher.dispatch(
144 num_tokens=8, uniform_decode=False, has_lora=False
145 )
146 if cudagraph_mode_str == "FULL":
147 assert rt_mode == CUDAGraphMode.FULL
148 assert key == desc_full_with_reqs
149 elif cudagraph_mode_str in ["FULL_AND_PIECEWISE", "PIECEWISE"]:
150 assert rt_mode == CUDAGraphMode.PIECEWISE
151 assert key == desc_piecewise
152 else:
153 assert rt_mode == CUDAGraphMode.NONE
154
155 # 2. uniform decode batch, size in cudagraph size list
156 desc_uniform_exact = BatchDescriptor(num_tokens=8, num_reqs=8, uniform=True)
157 desc_non_uniform = BatchDescriptor(num_tokens=8, num_reqs=8, uniform=False)
158 rt_mode, key = dispatcher.dispatch(
159 num_tokens=8, uniform_decode=True, has_lora=False
160 )
161 if cudagraph_mode_str == "FULL":
162 # Pure FULL mode uses non-uniform keys for all batches
163 assert rt_mode == CUDAGraphMode.FULL
164 assert key == desc_non_uniform
165 elif cudagraph_mode_str in ["FULL_DECODE_ONLY", "FULL_AND_PIECEWISE"]:
166 # These modes have separate uniform decode keys
167 assert rt_mode == CUDAGraphMode.FULL
168 assert key == desc_uniform_exact
169 elif cudagraph_mode_str == "PIECEWISE":
170 assert rt_mode == CUDAGraphMode.PIECEWISE
171 assert key == replace(desc_uniform_exact, num_reqs=None, uniform=False)
172 else:
173 assert rt_mode == CUDAGraphMode.NONE
174
175 # 3. No key match
176 rt_mode, key = dispatcher.dispatch(
177 num_tokens=15, uniform_decode=False, has_lora=False
178 )
179 assert rt_mode == CUDAGraphMode.NONE
180 assert key == BatchDescriptor(num_tokens=15)
181
182 # 4. invalid_modes={FULL} should have a fall back mode
183 # (e.g., cascade attention)
184 desc_full_exact = BatchDescriptor(num_tokens=8, uniform=False)
185 rt_mode, key = dispatcher.dispatch(
186 num_tokens=8,
187 uniform_decode=False,
188 has_lora=False,
189 invalid_modes={CUDAGraphMode.FULL},
190 )
191
192 if "PIECEWISE" in cudagraph_mode_str: # string contains check
193 assert rt_mode == CUDAGraphMode.PIECEWISE
194 assert key == replace(desc_full_exact, num_reqs=None, uniform=False)
195 else:
196 assert rt_mode == CUDAGraphMode.NONE
197
198 # 5. valid_modes={NONE} always returns NONE even when keys exist
199 rt_mode, key = dispatcher.dispatch(
200 num_tokens=8,
201 uniform_decode=False,
202 has_lora=False,
203 valid_modes={CUDAGraphMode.NONE},
204 )
205 assert rt_mode == CUDAGraphMode.NONE
206 assert key == BatchDescriptor(num_tokens=8)
207
208 @pytest.mark.parametrize(
209 "cudagraph_mode_str,compilation_mode,expected_modes",
210 [
211 # FULL mode: only FULL keys, no PIECEWISE
212 ("FULL", CompilationMode.NONE, [CUDAGraphMode.FULL]),
213 # PIECEWISE mode: only PIECEWISE keys
214 ("PIECEWISE", CompilationMode.VLLM_COMPILE, [CUDAGraphMode.PIECEWISE]),
215 # FULL_DECODE_ONLY: only FULL keys for uniform decode
216 ("FULL_DECODE_ONLY", CompilationMode.NONE, [CUDAGraphMode.FULL]),
217 # NONE mode: no keys
218 ("NONE", CompilationMode.NONE, []),
219 ],
220 )
221 def test_get_capture_descs(
222 self, cudagraph_mode_str, compilation_mode, expected_modes
223 ):
224 """Test get_capture_descs returns correctly grouped and ordered descs."""
225 comp_config = CompilationConfig(
226 cudagraph_mode=cudagraph_mode_str,
227 mode=compilation_mode,
228 cudagraph_capture_sizes=[1, 4, 8, 16],
229 )
230
231 config = _create_vllm_config(comp_config, max_num_seqs=16)
232 dispatcher = CudagraphDispatcher(config)
233 dispatcher.initialize_cudagraph_keys(
234 cudagraph_mode=comp_config.cudagraph_mode, uniform_decode_query_len=1
235 )
236
237 capture_descs = dispatcher.get_capture_descs()
238
239 # Verify we get the expected modes
240 actual_modes = [mode for mode, _ in capture_descs]
241 assert actual_modes == expected_modes
242
243 # Verify each group is sorted largest-first
244 for mode, descs in capture_descs:
245 assert len(descs) > 0, "Each group should have at least one descriptor"
246 num_tokens_list = [d.num_tokens for d in descs]
247 assert num_tokens_list == sorted(num_tokens_list, reverse=True), (
248 f"Descriptors for {mode} should be sorted largest-first"
249 )
250
251 # All descriptors in a group should have same uniform value
252 uniform_values = [d.uniform for d in descs]
253 assert len(set(uniform_values)) == 1, (
254 "All descriptors in a group should have the same uniform value"
255 )
256
257 def test_get_capture_descs_empty_when_not_initialized(self):
258 """Test that get_capture_descs returns empty list when keys not initialized."""
259 comp_config = CompilationConfig(
260 cudagraph_mode=CUDAGraphMode.FULL,
261 mode=CompilationMode.NONE,
262 cudagraph_capture_sizes=[1, 8],
263 )
264 config = _create_vllm_config(comp_config, max_num_seqs=8)
265 dispatcher = CudagraphDispatcher(config)
266 # Don't initialize keys
267
268 assert dispatcher.get_capture_descs() == []
269
270
271 @pytest.mark.skipif(not current_platform.is_cuda(), reason="Skip if not cuda")
272 class TestCUDAGraphWrapper:
273 def setup_method(self):
274 self.vllm_config = _create_vllm_config(CompilationConfig())
275 self.model = SimpleMLP().to(DEVICE_TYPE)
276 self.persistent_input_buffer = torch.zeros(1, 10, device=DEVICE_TYPE)
277 self.input_tensor = torch.randn(1, 10, device=DEVICE_TYPE)
278
279 def test_capture_and_replay(self):
280 wrapper = CUDAGraphWrapper(
281 self.model, self.vllm_config, runtime_mode=CUDAGraphMode.FULL
282 )
283 batch_descriptor = BatchDescriptor(num_tokens=10)
284
285 # 0. global warmup
286 with set_forward_context(
287 attn_metadata=None,
288 vllm_config=self.vllm_config,
289 cudagraph_runtime_mode=CUDAGraphMode.NONE,
290 batch_descriptor=None,
291 ):
292 wrapper(self.input_tensor)
293
294 # 1. Capture
295 with (
296 set_forward_context(
297 attn_metadata=None,
298 vllm_config=self.vllm_config,
299 cudagraph_runtime_mode=CUDAGraphMode.FULL,
300 batch_descriptor=batch_descriptor,
301 ),
302 patch("torch.cuda.graph", wraps=torch.cuda.graph) as mock_cuda_graph,
303 ):
304 output1 = wrapper(self.input_tensor)
305 # capturing phase should generate a zero output
306 assert torch.allclose(output1, torch.zeros_like(output1))
307 mock_cuda_graph.assert_called_once()
308
309 assert batch_descriptor in wrapper.concrete_cudagraph_entries
310 entry = wrapper.concrete_cudagraph_entries[batch_descriptor]
311 assert entry.cudagraph is not None
312
313 # 2. Replay
314 with (
315 set_forward_context(
316 attn_metadata=None,
317 vllm_config=self.vllm_config,
318 cudagraph_runtime_mode=CUDAGraphMode.FULL,
319 batch_descriptor=batch_descriptor,
320 ),
321 patch.object(
322 entry.cudagraph, "replay", wraps=entry.cudagraph.replay
323 ) as mock_replay,
324 ):
325 output2 = wrapper(self.input_tensor)
326 mock_replay.assert_called_once()
327
328 # Compare with eager output
329 eager_output = self.model(self.input_tensor)
330 torch.testing.assert_close(eager_output, output2)
331
332 def test_bypass_on_mode_mismatch(self):
333 wrapper = CUDAGraphWrapper(
334 self.model, self.vllm_config, runtime_mode=CUDAGraphMode.FULL
335 )
336 batch_descriptor = BatchDescriptor(num_tokens=10)
337
338 with (
339 set_forward_context(
340 attn_metadata=None,
341 vllm_config=self.vllm_config,
342 cudagraph_runtime_mode=CUDAGraphMode.PIECEWISE,
343 batch_descriptor=batch_descriptor,
344 ),
345 patch("torch.cuda.graph", wraps=torch.cuda.graph) as mock_cuda_graph,
346 patch.object(
347 self.model, "forward", wraps=self.model.forward
348 ) as mock_forward,
349 ):
350 wrapper(self.input_tensor)
351 mock_cuda_graph.assert_not_called()
352 mock_forward.assert_called_once()
353 assert not wrapper.concrete_cudagraph_entries
354
355 def test_bypass_on_mode_none(self):
356 wrapper = CUDAGraphWrapper(
357 self.model, self.vllm_config, runtime_mode=CUDAGraphMode.FULL
358 )
359 batch_descriptor = BatchDescriptor(num_tokens=10)
360
361 with (
362 set_forward_context(
363 attn_metadata=None,
364 vllm_config=self.vllm_config,
365 cudagraph_runtime_mode=CUDAGraphMode.NONE,
366 batch_descriptor=batch_descriptor,
367 ),
368 patch("torch.cuda.graph", wraps=torch.cuda.graph) as mock_cuda_graph,
369 ):
370 wrapper(self.input_tensor)
371 mock_cuda_graph.assert_not_called()
372 assert not wrapper.concrete_cudagraph_entries
373
374
375 def _run_and_monitor_call(
376 wrapper, input_tensor, runtime_mode, batch_descriptor, vllm_config
377 ):
378 """Helper to run a single call and monitor the action."""
379 with (
380 patch("torch.cuda.graph", wraps=torch.cuda.graph) as mock_graph_context,
381 patch.object(wrapper, "runnable", wraps=wrapper.runnable) as mock_runnable,
382 ):
383 entry = wrapper.concrete_cudagraph_entries.get(batch_descriptor, None)
384
385 context = set_forward_context(
386 attn_metadata=None,
387 vllm_config=vllm_config,
388 cudagraph_runtime_mode=runtime_mode,
389 batch_descriptor=batch_descriptor,
390 )
391 mock_replay = MagicMock()
392 if entry and entry.cudagraph:
393 with (
394 context,
395 patch.object(
396 entry.cudagraph, "replay", new_callable=MagicMock
397 ) as mock_replay,
398 ):
399 wrapper(input_tensor)
400 else:
401 with context:
402 wrapper(input_tensor)
403
404 if mock_graph_context.called:
405 # note that this is globally mocked, so it will be detected
406 # even whether called by the inner or outer wrapper
407 return "capture_global"
408 if mock_replay.called:
409 # only for outer wrapper
410 return "replay"
411 if mock_runnable.call_count > 0:
412 # only for outer wrapper
413 return "bypass"
414 return "unknown"
415
416
417 @create_new_process_for_each_test("spawn")
418 @pytest.mark.skipif(not current_platform.is_cuda(), reason="Skip if not cuda")
419 def test_capture_replay_bypass_logic():
420 comp_config = CompilationConfig(
421 mode=CompilationMode.VLLM_COMPILE,
422 cudagraph_mode=CUDAGraphMode.FULL,
423 cudagraph_capture_sizes=[1, 2],
424 )
425 vllm_config = _create_vllm_config(comp_config)
426 dispatcher = CudagraphDispatcher(vllm_config)
427 dispatcher.initialize_cudagraph_keys(
428 comp_config.cudagraph_mode, uniform_decode_query_len=1
429 )
430 model = SimpleMLP().to(DEVICE_TYPE)
431 full_wrapper = CUDAGraphWrapper(model, vllm_config, CUDAGraphMode.FULL)
432 max_bs = 16
433 persistent_input_buffer = torch.zeros(max_bs, 10, device=DEVICE_TYPE)
434 input_1 = persistent_input_buffer[:1]
435 input_2 = persistent_input_buffer[:2]
436 input_3 = persistent_input_buffer[:3]
437
438 desc_1 = BatchDescriptor(num_tokens=1)
439 desc_2 = BatchDescriptor(num_tokens=2)
440 desc_3_unseen = BatchDescriptor(num_tokens=3)
441
442 # 0. global warmup
443 with set_forward_context(
444 attn_metadata=None,
445 vllm_config=vllm_config,
446 cudagraph_runtime_mode=CUDAGraphMode.NONE,
447 batch_descriptor=None,
448 ):
449 full_wrapper(input_1)
450
451 rt_mode, key = dispatcher.dispatch(num_tokens=desc_1.num_tokens)
452 # 1. Capture first shape
453 action = _run_and_monitor_call(full_wrapper, input_1, rt_mode, key, vllm_config)
454 assert action == "capture_global"
455
456 # 2. Replay first shape
457 action = _run_and_monitor_call(full_wrapper, input_1, rt_mode, key, vllm_config)
458 assert action == "replay"
459
460 rt_mode, key = dispatcher.dispatch(num_tokens=desc_2.num_tokens)
461 # 3. Capture second shape
462 action = _run_and_monitor_call(full_wrapper, input_2, rt_mode, key, vllm_config)
463 assert action == "capture_global"
464
465 # 4. Replay second shape
466 action = _run_and_monitor_call(
467 full_wrapper, input_2, CUDAGraphMode.FULL, key, vllm_config
468 )
469 assert action == "replay"
470
471 # 5. Bypass if no key match
472 rt_mode, key = dispatcher.dispatch(num_tokens=desc_3_unseen.num_tokens)
473 assert rt_mode == CUDAGraphMode.NONE
474 action = _run_and_monitor_call(full_wrapper, input_3, rt_mode, key, vllm_config)
475 assert action == "bypass"
476
477 # capture unseen shape is not allowed after disable
478 set_cudagraph_capturing_enabled(False)
479 with pytest.raises(RuntimeError):
480 _run_and_monitor_call(
481 full_wrapper, input_3, CUDAGraphMode.FULL, desc_3_unseen, vllm_config
482 )
483 set_cudagraph_capturing_enabled(True)
484
485
486 @create_new_process_for_each_test("spawn")
487 @pytest.mark.skipif(not current_platform.is_cuda(), reason="Skip if not cuda")
488 def test_nested_wrappers():
489 """Tests a scenario with a PIECEWISE wrapper inside a FULL one."""
490 comp_config = CompilationConfig(
491 mode=CompilationMode.VLLM_COMPILE,
492 cudagraph_mode=CUDAGraphMode.FULL,
493 cudagraph_capture_sizes=[1],
494 )
495 vllm_config = _create_vllm_config(comp_config)
496 dispatcher = CudagraphDispatcher(vllm_config)
497 dispatcher.initialize_cudagraph_keys(
498 comp_config.cudagraph_mode, uniform_decode_query_len=1
499 )
500 model = SimpleMLP().to(DEVICE_TYPE)
501 full_wrapper = CUDAGraphWrapper(model, vllm_config, CUDAGraphMode.FULL)
502 input_1 = torch.randn(1, 10, device=DEVICE_TYPE)
503
504 # Setup: Inner model is wrapped with PIECEWISE, outer with FULL
505 inner_model = SimpleMLP().to(DEVICE_TYPE)
506 piecewise_wrapper = CUDAGraphWrapper(
507 inner_model, vllm_config, CUDAGraphMode.PIECEWISE
508 )
509 inner_model.forward = MagicMock(wraps=inner_model.forward)
510 outer_model = SimpleMLP().to(DEVICE_TYPE)
511 # When outer model is called, it calls the piecewise_wrapper
512 outer_model.forward = MagicMock(
513 wraps=outer_model.forward, side_effect=piecewise_wrapper
514 )
515 full_wrapper = CUDAGraphWrapper(outer_model, vllm_config, CUDAGraphMode.FULL)
516
517 desc_1 = BatchDescriptor(num_tokens=1)
518
519 # 0. global warmup
520 with set_forward_context(
521 attn_metadata=None,
522 vllm_config=vllm_config,
523 cudagraph_runtime_mode=CUDAGraphMode.NONE,
524 batch_descriptor=None,
525 ):
526 full_wrapper(input_1)
527
528 # --- Test runtime mode FULL---
529 # Run with FULL mode context. Expect outer wrapper to capture.
530 # The inner mock should be called once inside the graph capture.
531 outer_model.forward.reset_mock()
532 inner_model.forward.reset_mock()
533 action = _run_and_monitor_call(
534 full_wrapper, input_1, CUDAGraphMode.FULL, desc_1, vllm_config
535 )
536 assert action == "capture_global"
537 assert outer_model.forward.call_count == 1
538 assert inner_model.forward.call_count == 1
539
540 # Run again. Expect outer wrapper to replay.
541 # The outer model should NOT be called because the whole graph
542 # is replayed.
543 action = _run_and_monitor_call(
544 full_wrapper, input_1, CUDAGraphMode.FULL, desc_1, vllm_config
545 )
546 assert action == "replay"
547 assert outer_model.forward.call_count == 1 # No new call
548 assert inner_model.forward.call_count == 1
549
550 # --- Test runtime mode PIECEWISE ---
551 outer_model.forward.reset_mock()
552 inner_model.forward.reset_mock()
553 # Run with PIECEWISE mode context.
554 # Expect outer wrapper to bypass and call inner wrapper.
555 # Inner wrapper should capture.
556 action = _run_and_monitor_call(
557 full_wrapper, input_1, CUDAGraphMode.PIECEWISE, desc_1, vllm_config
558 )
559 assert action == "capture_global"
560 assert outer_model.forward.call_count == 1
561 assert inner_model.forward.call_count == 1
562
563 # Run again with PIECEWISE.
564 # Outer bypasses, inner replays.
565 action = _run_and_monitor_call(
566 full_wrapper, input_1, CUDAGraphMode.PIECEWISE, desc_1, vllm_config
567 )
568 assert action == "bypass"
569 assert outer_model.forward.call_count == 2
570 assert inner_model.forward.call_count == 1