main
py 515 lines 17.5 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 from collections import defaultdict
5 from contextlib import contextmanager
6 from types import SimpleNamespace
7 from unittest.mock import MagicMock, patch
8
9 import pytest
10 import torch
11
12 from vllm.config import (
13 CompilationConfig,
14 CUDAGraphMode,
15 ParallelConfig,
16 SchedulerConfig,
17 VllmConfig,
18 )
19 from vllm.distributed.device_communicators import pynccl_allocator
20 from vllm.v1.kv_cache_interface import KVCacheConfig
21 from vllm.v1.worker.gpu import cudagraph_utils as gpu_cudagraph_utils
22 from vllm.v1.worker.gpu.cudagraph_utils import BatchExecutionDescriptor
23 from vllm.v1.worker.gpu.input_batch import InputBuffers
24 from vllm.v1.worker.gpu.pcp_manager import PCPManager
25 from vllm.v1.worker.gpu.spec_decode.autoregressive.cudagraph_utils import (
26 SpeculatorCudaGraphManager,
27 )
28
29 pytestmark = pytest.mark.cpu_test
30
31
32 @pytest.fixture(autouse=True)
33 def _reset_graph_pool_id():
34 pynccl_allocator._graph_pool_id = None
35 yield
36 pynccl_allocator._graph_pool_id = None
37
38
39 def _create_vllm_config() -> MagicMock:
40 compilation_config = CompilationConfig(
41 cudagraph_mode=CUDAGraphMode.FULL,
42 cudagraph_capture_sizes=[4],
43 )
44 compilation_config.max_cudagraph_capture_size = 4
45 compilation_config.post_init_cudagraph_sizes()
46
47 vllm_config = MagicMock(spec=VllmConfig)
48 vllm_config.compilation_config = compilation_config
49 vllm_config.scheduler_config = SchedulerConfig.default_factory(max_num_seqs=4)
50 vllm_config.parallel_config = ParallelConfig()
51 vllm_config.speculative_config = None
52 vllm_config.num_speculative_tokens = 0
53 return vllm_config
54
55
56 def test_full_capture_sets_graph_pool_id_before_cuda_graph(monkeypatch):
57 """FULL capture must set graph_pool_id before entering torch.cuda.graph().
58
59 NCCL symmetric memory checks this global during graph capture; without
60 it, capture fails with:
61 AssertionError: graph_pool_id is not set under graph capture
62 """
63 graph_pool = object()
64 monkeypatch.setattr(
65 gpu_cudagraph_utils,
66 "get_pp_group",
67 lambda: SimpleNamespace(is_first_rank=True, is_last_rank=True),
68 )
69 monkeypatch.setattr(
70 gpu_cudagraph_utils.current_platform,
71 "get_global_graph_pool",
72 lambda: graph_pool,
73 )
74
75 manager = gpu_cudagraph_utils.CudaGraphManager(
76 vllm_config=_create_vllm_config(),
77 device=torch.device("cpu"),
78 cudagraph_mode=CUDAGraphMode.FULL,
79 decode_query_len=1,
80 )
81
82 desc = BatchExecutionDescriptor(
83 cg_mode=CUDAGraphMode.FULL,
84 num_tokens=4,
85 num_reqs=4,
86 uniform_token_count=1,
87 )
88 manager._capture_descs[CUDAGraphMode.FULL] = [desc]
89
90 def create_forward_fn(desc, warmup):
91 return lambda _mode: None
92
93 @contextmanager
94 def fake_graph_capture(*args, **kwargs):
95 yield SimpleNamespace(stream=MagicMock())
96
97 fake_offloader = MagicMock()
98
99 def cuda_graph_enter(*args, **kwargs):
100 assert pynccl_allocator._graph_pool_id is graph_pool
101
102 mock_cuda_graph_ctx = MagicMock()
103 mock_cuda_graph_ctx.__enter__ = cuda_graph_enter
104 mock_cuda_graph_ctx.__exit__ = MagicMock(return_value=False)
105
106 with (
107 patch.object(gpu_cudagraph_utils, "graph_capture", fake_graph_capture),
108 patch.object(gpu_cudagraph_utils, "get_offloader", lambda: fake_offloader),
109 patch.object(gpu_cudagraph_utils.torch.cuda, "CUDAGraph"),
110 patch.object(
111 gpu_cudagraph_utils.torch.cuda,
112 "graph",
113 return_value=mock_cuda_graph_ctx,
114 ) as mock_cuda_graph,
115 ):
116 manager.capture(create_forward_fn)
117
118 mock_cuda_graph.assert_called_once()
119
120
121 def test_piecewise_capture_uses_pcp_dummy_slot_mappings():
122 num_reqs = 32
123 num_tokens = 56
124 pcp_world_size = 2
125 input_buffers = InputBuffers(num_reqs, num_tokens, torch.device("cpu"))
126
127 pcp_block_tables = SimpleNamespace(
128 num_kv_cache_groups=1,
129 input_block_tables=(torch.zeros(num_reqs * 2, 1, dtype=torch.int32),),
130 )
131 pcp_manager = PCPManager(
132 pcp_world_size=pcp_world_size,
133 pcp_rank=0,
134 device=torch.device("cpu"),
135 shard_decode_requests=False,
136 max_num_reqs=num_reqs,
137 max_num_tokens=num_tokens,
138 block_tables=pcp_block_tables,
139 )
140
141 block_tables = MagicMock()
142 block_tables.cp_size = 1
143 block_tables.get_dummy_block_tables.return_value = ()
144 block_tables.get_dummy_slot_mappings.return_value = torch.zeros(
145 1, num_tokens, dtype=torch.int64
146 )
147 model_state = MagicMock()
148 model_state.prepare_attn.return_value = {}
149 kv_cache_config = KVCacheConfig(
150 num_blocks=0,
151 kv_cache_tensors=[],
152 kv_cache_groups=[],
153 )
154
155 gpu_cudagraph_utils.prepare_inputs_to_capture(
156 num_reqs,
157 num_tokens,
158 model_state,
159 input_buffers,
160 block_tables,
161 [],
162 kv_cache_config,
163 full_cudagraph=False,
164 pcp_manager=pcp_manager,
165 )
166
167 slot_mappings = model_state.prepare_attn.call_args.args[3]
168 assert slot_mappings.shape == (1, num_tokens * pcp_world_size)
169 block_tables.get_dummy_slot_mappings.assert_not_called()
170
171
172 @pytest.mark.parametrize(
173 "full_cudagraph,max_query_len,expected_max_query_len",
174 [(False, None, None), (True, None, 8), (True, 1, 1), (True, 3, 3)],
175 )
176 def test_capture_query_length_covers_replay_batches(
177 full_cudagraph, max_query_len, expected_max_query_len
178 ):
179 """Mixed FULL graphs must capture prefill kernels as well as decode kernels."""
180 num_tokens = num_reqs = 8
181 buffers = InputBuffers(num_reqs, num_tokens, torch.device("cpu"))
182 block_tables = MagicMock()
183 block_tables.cp_size = 1
184 block_tables.get_dummy_block_tables.return_value = ()
185 block_tables.get_dummy_slot_mappings.return_value = torch.empty(
186 0, num_tokens, dtype=torch.int64
187 )
188 model_state = MagicMock()
189 kv_cache_config = KVCacheConfig(
190 num_blocks=0, kv_cache_tensors=[], kv_cache_groups=[]
191 )
192
193 gpu_cudagraph_utils.prepare_inputs_to_capture(
194 num_reqs,
195 num_tokens,
196 model_state,
197 buffers,
198 block_tables,
199 [],
200 kv_cache_config,
201 full_cudagraph=full_cudagraph,
202 max_query_len=max_query_len,
203 )
204
205 batch = model_state.prepare_attn.call_args.args[0]
206 assert batch.num_scheduled_tokens.max() == 1
207 assert batch.max_query_len == expected_max_query_len
208
209
210 @pytest.mark.parametrize(
211 "uniform_token_count,max_query_len,expected_max_query_len",
212 [(1, None, 1), (None, 3, 3), (None, None, 8)],
213 )
214 def test_speculator_capture_preserves_decode_query_bounds(
215 monkeypatch, uniform_token_count, max_query_len, expected_max_query_len
216 ):
217 """Draft decode graphs keep narrow queries while mixed graphs cover prefill."""
218 num_tokens = num_reqs = 8
219 desc = BatchExecutionDescriptor(
220 CUDAGraphMode.FULL,
221 num_tokens,
222 num_reqs,
223 uniform_token_count=uniform_token_count,
224 max_query_len=max_query_len,
225 )
226 manager = SpeculatorCudaGraphManager.__new__(SpeculatorCudaGraphManager)
227 manager.max_num_reqs = num_reqs
228 manager.vllm_config = SimpleNamespace(
229 parallel_config=SimpleNamespace(data_parallel_size=1)
230 )
231 buffers = InputBuffers(num_reqs, num_tokens, torch.device("cpu"))
232 block_tables = MagicMock()
233 block_tables.cp_size = 1
234 block_tables.get_dummy_block_tables.return_value = ()
235 block_tables.get_dummy_slot_mappings.return_value = torch.empty(
236 0, num_tokens, dtype=torch.int64
237 )
238 model_state = MagicMock()
239 kv_cache_config = KVCacheConfig(
240 num_blocks=0, kv_cache_tensors=[], kv_cache_groups=[]
241 )
242
243 def capture(self, create_forward_fn, progress_bar_desc):
244 for warmup in (True, False):
245 create_forward_fn(desc, warmup)(desc.cg_mode)
246
247 monkeypatch.setattr(gpu_cudagraph_utils.CudaGraphManager, "capture", capture)
248 manager.capture(
249 MagicMock(), model_state, buffers, block_tables, [], kv_cache_config
250 )
251
252 assert model_state.prepare_attn.call_count == 2
253 for call in model_state.prepare_attn.call_args_list:
254 assert call.args[0].max_query_len == expected_max_query_len
255
256
257 _DECODE_QUERY_LEN = 3
258
259
260 def _create_decode_vllm_config(
261 capture_sizes: list[int],
262 num_speculative_tokens: int = 0,
263 dynamic_spec_schedule: list[tuple[int, int, int]] | None = None,
264 max_num_seqs: int = 8,
265 use_kda_recoverssm: bool = False,
266 ) -> MagicMock:
267 compilation_config = CompilationConfig(
268 cudagraph_mode=CUDAGraphMode.FULL_AND_PIECEWISE,
269 cudagraph_capture_sizes=capture_sizes,
270 )
271 compilation_config.max_cudagraph_capture_size = capture_sizes[-1]
272 compilation_config.post_init_cudagraph_sizes()
273
274 vllm_config = MagicMock(spec=VllmConfig)
275 vllm_config.compilation_config = compilation_config
276 vllm_config.scheduler_config = SchedulerConfig.default_factory(
277 max_num_seqs=max_num_seqs
278 )
279 vllm_config.parallel_config = ParallelConfig()
280 vllm_config.cache_config = SimpleNamespace(use_kda_recoverssm=use_kda_recoverssm)
281 vllm_config.num_speculative_tokens = num_speculative_tokens
282 if dynamic_spec_schedule is None:
283 vllm_config.speculative_config = None
284 else:
285 speculative_config = MagicMock()
286 speculative_config.uses_dynamic_speculative_decoding.return_value = True
287 speculative_config.num_speculative_tokens_per_batch_size = dynamic_spec_schedule
288 vllm_config.speculative_config = speculative_config
289 return vllm_config
290
291
292 def _make_spec_decode_manager(
293 monkeypatch,
294 decode_query_len: int = _DECODE_QUERY_LEN,
295 capture_sizes: list[int] | None = None,
296 num_speculative_tokens: int = 0,
297 dynamic_spec_schedule: list[tuple[int, int, int]] | None = None,
298 max_num_seqs: int = 8,
299 use_kda_recoverssm: bool = False,
300 varlen_decode: bool = False,
301 ) -> gpu_cudagraph_utils.CudaGraphManager:
302 monkeypatch.setattr(
303 gpu_cudagraph_utils,
304 "get_pp_group",
305 lambda: SimpleNamespace(is_first_rank=True, is_last_rank=True),
306 )
307 monkeypatch.setattr(
308 gpu_cudagraph_utils.current_platform,
309 "get_global_graph_pool",
310 lambda: object(),
311 )
312 manager = gpu_cudagraph_utils.CudaGraphManager(
313 vllm_config=_create_decode_vllm_config(
314 capture_sizes or [1, 2, 4, 8, 16, 24],
315 num_speculative_tokens=num_speculative_tokens,
316 dynamic_spec_schedule=dynamic_spec_schedule,
317 max_num_seqs=max_num_seqs,
318 use_kda_recoverssm=use_kda_recoverssm,
319 ),
320 device=torch.device("cpu"),
321 cudagraph_mode=CUDAGraphMode.FULL_AND_PIECEWISE,
322 decode_query_len=decode_query_len,
323 varlen_decode=varlen_decode,
324 )
325 manager._graphs_captured = True
326 return manager
327
328
329 def test_uniform_decode_pads_up_to_full_graph(monkeypatch):
330 manager = _make_spec_decode_manager(monkeypatch)
331 assert [
332 (desc.cg_mode, desc.num_tokens) for desc in manager._candidates[(12, 0)]
333 ] == [
334 (CUDAGraphMode.FULL, 18),
335 (CUDAGraphMode.PIECEWISE, 16),
336 ]
337
338 desc = manager.dispatch(
339 num_reqs=4,
340 num_tokens=12,
341 uniform_token_count=_DECODE_QUERY_LEN,
342 num_active_loras=0,
343 )
344
345 assert desc.cg_mode == CUDAGraphMode.FULL
346 assert desc.uniform_token_count == _DECODE_QUERY_LEN
347 assert desc.num_tokens == 18
348 assert desc.num_reqs == 6
349
350
351 def test_uniform_decode_exact_match_is_not_over_padded(monkeypatch):
352 manager = _make_spec_decode_manager(monkeypatch)
353
354 desc = manager.dispatch(
355 num_reqs=3,
356 num_tokens=9,
357 uniform_token_count=_DECODE_QUERY_LEN,
358 num_active_loras=0,
359 )
360
361 assert desc.cg_mode == CUDAGraphMode.FULL
362 assert desc.num_tokens == 9
363 assert desc.num_reqs == 3
364
365
366 def test_mixed_batch_never_selects_a_uniform_decode_graph(monkeypatch):
367 manager = _make_spec_decode_manager(monkeypatch)
368
369 desc = manager.dispatch(
370 num_reqs=2,
371 num_tokens=12,
372 uniform_token_count=None,
373 num_active_loras=0,
374 )
375
376 assert desc.cg_mode == CUDAGraphMode.PIECEWISE
377 assert desc.uniform_token_count is None
378 assert desc.num_tokens == 16
379
380
381 def test_varlen_decode_graph_takes_bounded_decode_batches_only(monkeypatch):
382 """A varlen decode graph replays any decode batch within its query-length
383 bound. The runner passes no bound for a batch with a prefill, which must not
384 replay one however short the prefill is."""
385 manager = _make_spec_decode_manager(monkeypatch, varlen_decode=True)
386
387 def dispatch(max_query_len):
388 return manager.dispatch(
389 num_reqs=3,
390 num_tokens=6,
391 uniform_token_count=None,
392 num_active_loras=0,
393 max_query_len=max_query_len,
394 )
395
396 desc = dispatch(_DECODE_QUERY_LEN)
397 assert desc.cg_mode == CUDAGraphMode.FULL
398 assert desc.max_query_len == _DECODE_QUERY_LEN
399 assert dispatch(None).cg_mode == CUDAGraphMode.PIECEWISE
400 assert dispatch(_DECODE_QUERY_LEN + 1).cg_mode == CUDAGraphMode.PIECEWISE
401
402
403 def test_mixed_batch_at_decode_only_token_count_still_gets_a_graph(monkeypatch):
404 """A mixed batch must not fall to eager where only decode graphs are staged.
405
406 ``round_up(size, decode_query_len)`` lands FULL decode graphs on token counts
407 the PIECEWISE ladder never uses -- with capture sizes [1, 2, 4, 8, 16, 24]
408 and query length 3, FULL covers 3, 6, 9 and 18 while PIECEWISE has only
409 1, 2, 4, 8, 16, 24. Building each mode's candidate ranges independently keeps
410 a PIECEWISE graph reachable there. Deriving the ranges from the staged sizes
411 instead offers a mixed batch nothing but decode descriptors, whose
412 ``uniform_token_count`` it can never match, so it runs fully eager.
413 """
414 manager = _make_spec_decode_manager(monkeypatch)
415
416 decode_only_token_counts = sorted(
417 {desc.num_tokens for desc in manager._capture_descs[CUDAGraphMode.FULL]}
418 - {desc.num_tokens for desc in manager._capture_descs[CUDAGraphMode.PIECEWISE]}
419 )
420 # Guard the premise: with no such token counts this test would cover nothing.
421 assert decode_only_token_counts
422
423 for num_tokens in decode_only_token_counts:
424 desc = manager.dispatch(
425 num_reqs=1,
426 num_tokens=num_tokens,
427 uniform_token_count=None,
428 num_active_loras=0,
429 )
430 assert desc.cg_mode == CUDAGraphMode.PIECEWISE, num_tokens
431 assert desc.uniform_token_count is None, num_tokens
432 assert desc.num_tokens >= num_tokens, num_tokens
433
434
435 def test_uniform_decode_beyond_capture_ladder_falls_back(monkeypatch):
436 manager = _make_spec_decode_manager(monkeypatch)
437
438 desc = manager.dispatch(
439 num_reqs=9,
440 num_tokens=27,
441 uniform_token_count=_DECODE_QUERY_LEN,
442 num_active_loras=0,
443 )
444
445 assert desc.cg_mode == CUDAGraphMode.NONE
446
447
448 def test_dynamic_spec_decode_shared_token_count_stays_reachable(monkeypatch):
449 """Every captured decode graph must remain reachable from dispatch().
450
451 Under dynamic speculative decoding ``decode_query_lens`` is a list, and the
452 staging loop rounds each capture size up to *every* query length. Several
453 decode graphs therefore land on the same ``num_tokens`` -- e.g. capture size
454 2 stages both ``round_up(2, 1) == 2`` and ``round_up(2, 2) == 2``. Building
455 the candidate ranges one descriptor at a time would give all but the first
456 of those an empty range, so they would never enter a candidate list and
457 their batches would silently fall through to PIECEWISE.
458 """
459 manager = _make_spec_decode_manager(
460 monkeypatch,
461 decode_query_len=4,
462 capture_sizes=[2, 4, 6, 8],
463 num_speculative_tokens=3,
464 # max_num_seqs is 8, and K narrows as the batch grows, so the schedule
465 # covers K 3, 2, 1 and 0, giving decode_query_lens [1, 2, 3, 4].
466 dynamic_spec_schedule=[(1, 2, 3), (3, 4, 2), (5, 6, 1), (7, 8, 0)],
467 )
468
469 full_descs = manager._capture_descs[CUDAGraphMode.FULL]
470 by_num_tokens: dict[int, list] = defaultdict(list)
471 for desc in full_descs:
472 by_num_tokens[desc.num_tokens].append(desc)
473 # Guard the premise: without collisions this test would not cover the bug.
474 assert any(len(descs) > 1 for descs in by_num_tokens.values())
475
476 for desc in full_descs:
477 assert desc.num_reqs is not None
478 assert desc in manager._candidates[(desc.num_tokens, 0)], desc
479 assert (
480 manager.dispatch(
481 num_reqs=desc.num_reqs,
482 num_tokens=desc.num_tokens,
483 uniform_token_count=desc.uniform_token_count,
484 num_active_loras=0,
485 )
486 == desc
487 ), desc
488
489
490 @pytest.mark.parametrize(
491 "decode_query_len,capture_sizes",
492 [(1, [1, 2, 4, 8]), (2, [2, 4, 8, 16]), (8, [8, 16, 32, 64])],
493 )
494 def test_divisor_query_len_dispatch_is_unchanged(
495 monkeypatch, decode_query_len, capture_sizes
496 ):
497 manager = _make_spec_decode_manager(
498 monkeypatch,
499 decode_query_len=decode_query_len,
500 capture_sizes=capture_sizes,
501 )
502
503 max_reqs = capture_sizes[-1] // decode_query_len
504 for num_reqs in range(1, max_reqs + 1):
505 num_tokens = num_reqs * decode_query_len
506 desc = manager.dispatch(
507 num_reqs=num_reqs,
508 num_tokens=num_tokens,
509 uniform_token_count=decode_query_len,
510 num_active_loras=0,
511 )
512 expected = min(s for s in capture_sizes if s >= num_tokens)
513 assert desc.cg_mode == CUDAGraphMode.FULL, num_tokens
514 assert desc.num_tokens == expected, num_tokens
515 assert desc.num_reqs == expected // decode_query_len, num_tokens