main
py 1,115 lines 39.7 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Unit tests for EncoderCudaGraphManager.
4
5 Test organization:
6 No GPU required:
7 - TestFindBudgetGraph — greedy budget selection logic
8 - TestGetCumulativeStats — hit/miss rate statistics
9 - TestGetInputModality — modality routing from mm_kwargs keys
10 GPU required:
11 - TestEncoderCudaGraphCaptureReplay — capture, replay, fallback, counters, chunking
12 - TestEncoderCudaGraphVideoReplay — video modality capture, replay
13 """
14
15 from collections.abc import Hashable
16 from types import SimpleNamespace
17 from typing import Any, cast
18 from unittest.mock import MagicMock, patch
19
20 import pytest
21 import torch
22
23 from vllm.model_executor.models.interfaces import SupportsEncoderCudaGraph
24 from vllm.platforms import current_platform
25 from vllm.v1.worker.encoder_cudagraph import (
26 EncoderCudaGraphManager,
27 )
28 from vllm.v1.worker.encoder_cudagraph_defs import (
29 EncoderCudaGraphCaptureInputs,
30 EncoderCudaGraphConfig,
31 EncoderCudaGraphReplayBuffers,
32 EncoderItemSpec,
33 )
34
35 # ---------------------------------------------------------------------------
36 # Helpers
37 # ---------------------------------------------------------------------------
38
39
40 class _MockCompilationConfig:
41 """Minimal mock for VllmConfig.compilation_config."""
42
43 def __init__(
44 self,
45 token_budgets: list[int] | None = None,
46 max_mm_items: int = 0,
47 ):
48 self.encoder_cudagraph_token_budgets = token_budgets or []
49 self.encoder_cudagraph_max_vision_items_per_batch = max_mm_items
50 self.encoder_cudagraph_max_frames_per_batch = None
51
52
53 class _MockMultimodalConfig:
54 mm_encoder_tp_mode = "replicate"
55
56 def get_limit_per_prompt(self, modality: str) -> int:
57 # Image-only mocks — return 0 for "video" to short-circuit the
58 # max_frames_per_batch branch, so tests don't need a video-frame mock.
59 return 0
60
61
62 class _MockModelConfig:
63 multimodal_config = _MockMultimodalConfig()
64
65
66 class _MockParallelConfig:
67 tensor_parallel_size = 1
68
69
70 class _MockVllmConfig:
71 """Minimal mock for VllmConfig used in __init__ tests."""
72
73 def __init__(
74 self,
75 token_budgets: list[int] | None = None,
76 max_mm_items: int = 0,
77 ):
78 self.compilation_config = _MockCompilationConfig(token_budgets, max_mm_items)
79 self.model_config = _MockModelConfig()
80 self.parallel_config = _MockParallelConfig()
81
82
83 class _MockModel(SupportsEncoderCudaGraph):
84 """Minimal mock implementing SupportsEncoderCudaGraph for __init__."""
85
86 def __init__(self, min_budget: int = 4, max_budget: int = 128):
87 self._min_budget = min_budget
88 self._max_budget = max_budget
89
90 def get_encoder_cudagraph_config(self) -> EncoderCudaGraphConfig:
91 return EncoderCudaGraphConfig(
92 modalities=["image"],
93 buffer_keys=[
94 "pixel_values",
95 "dummy_buf",
96 ],
97 out_hidden_size=32,
98 )
99
100 def get_encoder_cudagraph_budget_range(self, vllm_config):
101 return (self._min_budget, self._max_budget)
102
103 def get_max_frames_per_video(self) -> int:
104 return 0
105
106 def get_encoder_cudagraph_item_specs(
107 self, mm_kwargs: dict[str, Any]
108 ) -> list[EncoderItemSpec]:
109 return []
110
111 def prepare_encoder_cudagraph_capture_inputs(
112 self,
113 token_budget: int,
114 max_batch_size: int,
115 max_frames_per_batch: int,
116 device: torch.device,
117 dtype: torch.dtype,
118 path: str = "default",
119 axis_keys: tuple[Hashable, ...] | None = None,
120 ) -> EncoderCudaGraphCaptureInputs:
121 return EncoderCudaGraphCaptureInputs(values={})
122
123 def prepare_encoder_cudagraph_replay_buffers(
124 self,
125 mm_kwargs: dict[str, Any],
126 max_batch_size: int,
127 max_frames_per_batch: int,
128 path: str = "default",
129 ) -> EncoderCudaGraphReplayBuffers:
130 return EncoderCudaGraphReplayBuffers(values={})
131
132 def encoder_cudagraph_forward(
133 self, inputs: dict[str, torch.Tensor], path: str = "default"
134 ) -> torch.Tensor:
135 return torch.zeros(0)
136
137 def encoder_eager_forward(
138 self, mm_kwargs: dict[str, Any], path: str = "default"
139 ) -> torch.Tensor:
140 return torch.zeros(0)
141
142 def select_encoder_cudagraph_items(
143 self, mm_kwargs: dict[str, Any], indices: list[int]
144 ) -> dict[str, Any]:
145 return {}
146
147
148 def _make_manager_with_budgets(budgets: list[int]) -> EncoderCudaGraphManager:
149 """Create a minimal EncoderCudaGraphManager with only token_budgets set.
150
151 Skips the parts of __init__ that require a real VllmConfig / model
152 by patching the attributes directly after construction.
153 """
154 mgr = object.__new__(EncoderCudaGraphManager)
155 mgr.token_budgets = sorted(budgets)
156 mgr.path_token_budgets = {"default": mgr.token_budgets}
157 mgr.max_batch_size = 16
158 mgr.use_dp = False
159 mgr.config = EncoderCudaGraphConfig(
160 modalities=["image"],
161 buffer_keys=[],
162 out_hidden_size=32,
163 )
164 mgr.budget_graphs = {"default": {}}
165 mgr.graph_pool = None
166 mgr._capture_axes = ()
167 mgr.graph_hits = 0
168 mgr.graph_misses = 0
169 mgr.log_stats_interval = 100
170 mgr.config = EncoderCudaGraphConfig(
171 modalities=["image"],
172 buffer_keys=[],
173 out_hidden_size=32,
174 )
175 return mgr
176
177
178 # ---------------------------------------------------------------------------
179 # _generate_budgets
180 # ---------------------------------------------------------------------------
181
182
183 class TestGenerateBudgets:
184 """Auto-generate power-of-2 budgets from min to max."""
185
186 def test_exact_powers_of_2(self):
187 result = EncoderCudaGraphManager._generate_budgets(64, 1024)
188 assert result == [64, 128, 256, 512, 1024]
189
190 def test_max_not_power_of_2(self):
191 result = EncoderCudaGraphManager._generate_budgets(64, 800)
192 assert result == [64, 128, 256, 512, 800]
193
194 def test_min_equals_max(self):
195 result = EncoderCudaGraphManager._generate_budgets(64, 64)
196 assert result == [64]
197
198 def test_large_range(self):
199 result = EncoderCudaGraphManager._generate_budgets(64, 8192)
200 assert result == [64, 128, 256, 512, 1024, 2048, 4096, 8192]
201
202
203 # ---------------------------------------------------------------------------
204 # _find_smallest_fitting_budget_given_tokens
205 # ---------------------------------------------------------------------------
206
207
208 class TestFindBudgetGraph:
209 """Budget greedy selection: smallest budget >= total_tokens."""
210
211 @pytest.mark.parametrize(
212 "total_tokens,budgets,expected",
213 [
214 # Exact match
215 (2048, [2048, 4096, 8192], 2048),
216 # Below smallest budget — picks smallest
217 (100, [2048, 4096, 8192], 2048),
218 # Zero tokens — picks smallest
219 (0, [2048, 4096, 8192], 2048),
220 # Between budgets — picks next one up
221 (2049, [2048, 4096, 8192], 4096),
222 (4097, [2048, 4096, 8192], 8192),
223 # Exceeds all budgets — returns None (eager fallback)
224 (9000, [2048, 4096, 8192], None),
225 # Single budget, fits
226 (1000, [2048], 2048),
227 # Single budget, does not fit
228 (3000, [2048], None),
229 ],
230 )
231 def test_find_budget(self, total_tokens, budgets, expected):
232 mgr = _make_manager_with_budgets(budgets)
233 result = mgr._find_smallest_fitting_budget_given_tokens(total_tokens)
234 assert result == expected
235
236 def test_budgets_are_sorted(self):
237 """Manager always sorts budgets ascending at init."""
238 mgr = _make_manager_with_budgets([8192, 2048, 4096])
239 assert mgr.token_budgets == [2048, 4096, 8192]
240 # Budget selection still works correctly after sorting
241 assert mgr._find_smallest_fitting_budget_given_tokens(3000) == 4096
242
243 def test_num_graphs_to_capture_tracks_budgets(self):
244 mgr = _make_manager_with_budgets([8192, 2048, 4096])
245 assert mgr.get_num_graphs_to_capture() == 3
246
247
248 # ---------------------------------------------------------------------------
249 # get_cumulative_stats
250 # ---------------------------------------------------------------------------
251
252
253 class TestGetCumulativeStats:
254 """Statistics tracking and reporting."""
255
256 def test_initial_stats_are_zero(self):
257 mgr = _make_manager_with_budgets([2048])
258 stats = mgr.get_cumulative_stats()
259 assert stats["graph_hits"] == 0
260 assert stats["graph_misses"] == 0
261 assert stats["hit_rate"] == 0.0
262
263 def test_hit_rate_calculation(self):
264 mgr = _make_manager_with_budgets([2048])
265 mgr.graph_hits = 75
266 mgr.graph_misses = 25
267 stats = mgr.get_cumulative_stats()
268 assert stats["graph_hits"] == 75
269 assert stats["graph_misses"] == 25
270 assert stats["hit_rate"] == pytest.approx(0.75)
271
272 def test_all_hits(self):
273 mgr = _make_manager_with_budgets([2048])
274 mgr.graph_hits = 100
275 mgr.graph_misses = 0
276 assert mgr.get_cumulative_stats()["hit_rate"] == pytest.approx(1.0)
277
278 def test_all_misses(self):
279 mgr = _make_manager_with_budgets([2048])
280 mgr.graph_hits = 0
281 mgr.graph_misses = 50
282 assert mgr.get_cumulative_stats()["hit_rate"] == pytest.approx(0.0)
283
284 def test_stats_report_budget_info(self):
285 budgets = [2048, 4096, 8192]
286 mgr = _make_manager_with_budgets(budgets)
287 stats = mgr.get_cumulative_stats()
288 assert stats["num_budgets"] == 0 # no graphs captured yet
289 assert stats["token_budgets"] == budgets
290
291
292 # ---------------------------------------------------------------------------
293 # GPU fixtures and helpers
294 # ---------------------------------------------------------------------------
295
296 # Mock encoder parameters (kept small for fast capture)
297 _SPATIAL_MERGE = 2
298 _HIDDEN = 32
299 _PATCH_SIZE = 4 # H/W per patch in grid_thw units
300 _TEMPORAL_PATCH = 1
301 _IN_CHANNELS = 3
302 # flattened_patch_size = in_channels * temporal_patch * patch_size^2
303 _FLAT = _IN_CHANNELS * _TEMPORAL_PATCH * _PATCH_SIZE * _PATCH_SIZE # 48
304
305 # Test budgets: small to keep capture fast
306 _BUDGETS = [16, 64]
307 _MAX_BATCH = 4
308
309
310 def _count_input_patches(grid_thw_list: list[list[int]]) -> int:
311 return sum(t * h * w for t, h, w in grid_thw_list)
312
313
314 def _count_output_tokens(
315 grid_thw_list: list[list[int]], spatial_merge_size: int
316 ) -> int:
317 m = spatial_merge_size
318 return sum(t * (h // m) * (w // m) for t, h, w in grid_thw_list)
319
320
321 class SimpleMockViTModel(torch.nn.Module, SupportsEncoderCudaGraph):
322 """Minimal ViT model for CUDA graph tests.
323
324 Implements the SupportsEncoderCudaGraph protocol by providing
325 all required methods. The forward pass projects patches and
326 simulates spatial merge by averaging groups of m^2 patches.
327 """
328
329 def __init__(self):
330 super().__init__()
331 self.proj = torch.nn.Linear(_FLAT, _HIDDEN)
332 self.spatial_merge_size = _SPATIAL_MERGE
333 self.out_hidden_size = _HIDDEN
334
335 def get_encoder_cudagraph_config(self) -> EncoderCudaGraphConfig:
336 return EncoderCudaGraphConfig(
337 modalities=["image"],
338 buffer_keys=["dummy_buf"],
339 out_hidden_size=_HIDDEN,
340 )
341
342 def get_input_modality(
343 self,
344 mm_kwargs: dict[str, Any],
345 ) -> str:
346 return "image"
347
348 def get_encoder_cudagraph_budget_range(
349 self,
350 vllm_config,
351 ) -> tuple[int, int]:
352 # For tests: min=4, max=128 (small values for fast capture)
353 return (4, 128)
354
355 def get_encoder_cudagraph_item_specs(
356 self,
357 mm_kwargs: dict[str, Any],
358 ) -> list[EncoderItemSpec]:
359 m = _SPATIAL_MERGE
360 return [
361 EncoderItemSpec(
362 input_size=t * h * w,
363 output_tokens=t * (h // m) * (w // m),
364 )
365 for t, h, w in mm_kwargs["image_grid_thw"]
366 ]
367
368 def select_encoder_cudagraph_items(
369 self,
370 mm_kwargs: dict[str, Any],
371 indices: list[int],
372 ) -> dict[str, Any]:
373 grid_thw = mm_kwargs["image_grid_thw"]
374 pixel_values = mm_kwargs["pixel_values"]
375
376 if len(indices) == 0:
377 return {
378 "pixel_values": pixel_values[:0],
379 "image_grid_thw": [],
380 }
381
382 patches_per_item = [t * h * w for t, h, w in grid_thw]
383 cum_patches = [0]
384 for p in patches_per_item:
385 cum_patches.append(cum_patches[-1] + p)
386
387 selected_pv = torch.cat(
388 [pixel_values[cum_patches[i] : cum_patches[i + 1]] for i in indices]
389 )
390 selected_grid = [grid_thw[i] for i in indices]
391 return {
392 "pixel_values": selected_pv,
393 "image_grid_thw": selected_grid,
394 }
395
396 def prepare_encoder_cudagraph_capture_inputs(
397 self,
398 token_budget: int,
399 max_batch_size: int,
400 max_frames_per_batch: int,
401 device: torch.device,
402 dtype: torch.dtype,
403 path: str = "default",
404 axis_keys: tuple[Hashable, ...] | None = None,
405 ) -> EncoderCudaGraphCaptureInputs:
406 per_image_output = token_budget // max_batch_size
407 grid_config = [
408 [1, _SPATIAL_MERGE, per_image_output * _SPATIAL_MERGE]
409 for _ in range(max_batch_size)
410 ]
411 total_patches = _count_input_patches(grid_config)
412 dummy_pixel_values = torch.randn(
413 total_patches, _FLAT, device=device, dtype=dtype
414 )
415 n_out = _count_output_tokens(grid_config, _SPATIAL_MERGE)
416 dummy_buf = torch.zeros(n_out, _HIDDEN, device=device, dtype=dtype)
417 return EncoderCudaGraphCaptureInputs(
418 values={
419 "pixel_values": dummy_pixel_values,
420 "dummy_buf": dummy_buf,
421 },
422 )
423
424 def prepare_encoder_cudagraph_replay_buffers(
425 self,
426 mm_kwargs: dict[str, Any],
427 max_batch_size: int,
428 max_frames_per_batch: int,
429 path: str = "default",
430 ) -> EncoderCudaGraphReplayBuffers:
431 grid_thw = mm_kwargs["image_grid_thw"]
432 n_out = _count_output_tokens(grid_thw, _SPATIAL_MERGE)
433 p = next(self.parameters())
434 dummy_buf = torch.zeros(n_out, _HIDDEN, device=p.device, dtype=p.dtype)
435 return EncoderCudaGraphReplayBuffers(
436 values={
437 "pixel_values": mm_kwargs["pixel_values"],
438 "dummy_buf": dummy_buf,
439 }
440 )
441
442 def encoder_cudagraph_forward(
443 self,
444 values: dict[str, torch.Tensor],
445 path: str = "default",
446 ) -> torch.Tensor:
447 return self._forward(values["pixel_values"])
448
449 def encoder_eager_forward(
450 self,
451 mm_kwargs: dict[str, Any],
452 path: str = "default",
453 ) -> torch.Tensor:
454 return self._forward(mm_kwargs["pixel_values"])
455
456 def _forward(self, pixel_values: torch.Tensor) -> torch.Tensor:
457 m2 = _SPATIAL_MERGE**2
458 out = self.proj(pixel_values)
459 n_out = out.shape[0] // m2
460 return out[: n_out * m2].view(n_out, m2, _HIDDEN).mean(dim=1)
461
462
463 def _make_manager_for_gpu(
464 model: SimpleMockViTModel,
465 token_budgets: list[int],
466 max_batch_size: int,
467 device: torch.device,
468 dtype: torch.dtype,
469 *,
470 max_frames_per_batch: int | None = None,
471 ) -> EncoderCudaGraphManager:
472 """Create EncoderCudaGraphManager bypassing VllmConfig for GPU tests."""
473 mgr = object.__new__(EncoderCudaGraphManager)
474 mgr.token_budgets = sorted(token_budgets)
475 mgr.path_token_budgets = {"default": mgr.token_budgets}
476 mgr.max_batch_size = max_batch_size
477 mgr.max_frames_per_batch = (
478 max_frames_per_batch if max_frames_per_batch is not None else max_batch_size * 2
479 )
480 mgr.use_dp = False
481 mgr.budget_graphs = {"default": {}}
482 mgr.graph_pool = None
483 mgr._capture_axes = ()
484 mgr.graph_hits = 0
485 mgr.graph_misses = 0
486 mgr.log_stats_interval = 100
487 mgr.model = model
488 mgr.config = model.get_encoder_cudagraph_config()
489 mgr.device = device
490 mgr.dtype = dtype
491 return mgr
492
493
494 def _make_pixel_values(
495 grid_thw_list: list[list[int]],
496 device: torch.device,
497 dtype: torch.dtype,
498 ) -> torch.Tensor:
499 """Random pixel_values matching the total input patch count."""
500 n = _count_input_patches(grid_thw_list)
501 return torch.randn(n, _FLAT, device=device, dtype=dtype)
502
503
504 def _make_mm_kwargs(
505 grid_thw_list: list[list[int]],
506 device: torch.device,
507 dtype: torch.dtype,
508 ) -> dict[str, Any]:
509 """Create mm_kwargs for testing."""
510 return {
511 "pixel_values": _make_pixel_values(grid_thw_list, device, dtype),
512 "image_grid_thw": grid_thw_list,
513 }
514
515
516 def _make_video_mm_kwargs(
517 grid_thw_list: list[list[int]],
518 device: torch.device,
519 dtype: torch.dtype,
520 ) -> dict[str, Any]:
521 """Create video mm_kwargs (pixel_values_videos / video_grid_thw) for testing."""
522 return {
523 "pixel_values_videos": _make_pixel_values(grid_thw_list, device, dtype),
524 "video_grid_thw": grid_thw_list,
525 }
526
527
528 # ---------------------------------------------------------------------------
529 # GPU tests — capture, replay, fallback, counters, chunking
530 # ---------------------------------------------------------------------------
531
532
533 @pytest.mark.skipif(
534 not current_platform.is_cuda_alike(), reason="Skip if not cuda or rocm"
535 )
536 class TestEncoderCudaGraphCaptureReplay:
537 def setup_method(self):
538 self.device = torch.device("cuda:0")
539 self.dtype = torch.float16
540 self.model = SimpleMockViTModel().to(self.device).half()
541 self.mgr = _make_manager_for_gpu(
542 self.model, _BUDGETS, _MAX_BATCH, self.device, self.dtype
543 )
544 self.graph_pool = current_platform.graph_pool_handle()
545 self.mgr.capture(graph_pool=self.graph_pool)
546
547 # --- capture ---
548
549 def test_capture_creates_one_graph_per_budget(self):
550 assert len(self.mgr.budget_graphs["default"]) == len(_BUDGETS)
551 assert set(self.mgr.budget_graphs["default"].keys()) == set(_BUDGETS)
552
553 def test_capture_uses_supplied_graph_pool(self):
554 assert self.mgr.graph_pool is self.graph_pool
555
556 def test_clear_releases_graphs_and_pool(self):
557 self.mgr.clear()
558 assert self.mgr.budget_graphs == {"default": {}}
559 assert self.mgr.graph_pool is None
560
561 # --- output shape ---
562
563 def test_execute_returns_one_tensor_per_image(self):
564 grid_thw = [[1, 4, 4], [1, 4, 4]]
565 mm_kwargs = _make_mm_kwargs(grid_thw, self.device, self.dtype)
566 result = self.mgr.execute(mm_kwargs)
567 assert result is not None
568 assert len(result) == 2
569
570 def test_execute_output_tokens_per_image(self):
571 # [1,4,4] → 1*(4//2)*(4//2) = 4 tokens; [1,8,8] → 16 tokens
572 grid_thw = [[1, 4, 4], [1, 8, 8]]
573 mm_kwargs = _make_mm_kwargs(grid_thw, self.device, self.dtype)
574 result = self.mgr.execute(mm_kwargs)
575 assert result is not None
576 assert result[0].shape == (4, _HIDDEN)
577 assert result[1].shape == (16, _HIDDEN)
578
579 # --- budget fallback ---
580
581 def test_eager_fallback_when_tokens_exceed_all_budgets(self):
582 # [1,18,18] → 1*(18//2)*(18//2) = 81 tokens > max budget 64.
583 # Greedy packing handles the fallback internally: the oversized image
584 # gets an eager forward pass and is returned as part of the output list
585 # (execute() no longer returns None for individual image misses).
586 grid_thw = [[1, 18, 18]]
587 mm_kwargs = _make_mm_kwargs(grid_thw, self.device, self.dtype)
588 result = self.mgr.execute(mm_kwargs)
589 assert result is not None
590 assert len(result) == 1
591 # Eager output: SimpleMockViTModel produces n_out = 81 tokens
592 assert result[0].shape == (81, _HIDDEN)
593 assert self.mgr.graph_misses == 1
594
595 # --- counters ---
596
597 def test_hit_counter_increments_by_num_images(self):
598 grid_thw = [[1, 4, 4], [1, 4, 4]]
599 mm_kwargs = _make_mm_kwargs(grid_thw, self.device, self.dtype)
600 self.mgr.execute(mm_kwargs)
601 assert self.mgr.graph_hits == 2
602
603 def test_miss_counter_increments_by_num_images(self):
604 grid_thw = [[1, 18, 18]] # 81 tokens > 64
605 mm_kwargs = _make_mm_kwargs(grid_thw, self.device, self.dtype)
606 self.mgr.execute(mm_kwargs)
607 assert self.mgr.graph_misses == 1
608
609 # --- chunking ---
610
611 def test_chunking_when_images_exceed_max_batch(self):
612 # 8 images > max_batch_size=4 → 2 chunks of 4
613 # each chunk: 4 * 4 = 16 tokens → fits budget 16
614 n_images = _MAX_BATCH * 2
615 grid_thw = [[1, 4, 4]] * n_images
616 mm_kwargs = _make_mm_kwargs(grid_thw, self.device, self.dtype)
617 result = self.mgr.execute(mm_kwargs)
618 assert result is not None
619 assert len(result) == n_images
620 for out in result:
621 assert out.shape == (4, _HIDDEN)
622
623
624 # ---------------------------------------------------------------------------
625 # E-only capture and output lifecycle
626 # ---------------------------------------------------------------------------
627
628
629 @pytest.mark.skipif(
630 not current_platform.is_cuda_alike(), reason="Skip if not cuda or rocm"
631 )
632 @pytest.mark.usefixtures("dist_init", "workspace_init")
633 @pytest.mark.parametrize("profile_only", [False, True])
634 @torch.inference_mode()
635 def test_eonly_capture_preserves_outputs_across_replay_and_fallback(profile_only):
636 """The E-only entry captures only the encoder and preserves cached outputs."""
637 from vllm.distributed.ec_transfer.ec_connector.base import (
638 ECConnectorBase,
639 ECConnectorMetadata,
640 )
641 from vllm.v1.core.sched.output import SchedulerOutput
642 from vllm.v1.worker.gpu.ec_connector import ActiveECConnector
643 from vllm.v1.worker.gpu.mm.encoder_runner import EncoderRunner
644 from vllm.v1.worker.mm_encoder_model_runner import MMEncoderModelRunner
645 from vllm.v1.worker.workspace import lock_workspace
646
647 device = torch.device("cuda:0")
648 dtype = torch.float16
649 model = SimpleMockViTModel().to(device).half()
650 manager = _make_manager_for_gpu(model, _BUDGETS, _MAX_BATCH, device, dtype)
651 encoder = object.__new__(EncoderRunner)
652 encoder.device = device
653 encoder.cudagraph_manager = manager
654 runner = object.__new__(MMEncoderModelRunner)
655 runner.model_state = SimpleNamespace(encoder_runner=encoder)
656 # No decoder manager is installed: capture must be encoder-only.
657 with patch(
658 "vllm.v1.worker.mm_encoder_model_runner.lock_workspace", wraps=lock_workspace
659 ) as lock:
660 runner.capture_model(profile_only=profile_only)
661 assert lock.call_count == int(not profile_only)
662 assert len(manager.budget_graphs["default"]) == len(_BUDGETS)
663
664 cache: dict[str, torch.Tensor] = {}
665 pending_sends: dict[str, torch.Tensor] = {}
666 connector = MagicMock(spec=ECConnectorBase)
667 connector.is_producer = True
668 connector.is_consumer = False
669 connector.get_finished.return_value = (None, None)
670 connector.save_caches.side_effect = lambda *, encoder_cache, mm_hash: (
671 pending_sends.update({mm_hash: encoder_cache[mm_hash]})
672 )
673 with patch(
674 "vllm.v1.worker.gpu.ec_connector.get_ec_transfer", return_value=connector
675 ):
676 ec = ActiveECConnector(SimpleNamespace(), cache)
677 scheduled = cast(
678 SchedulerOutput,
679 SimpleNamespace(
680 ec_connector_metadata=ECConnectorMetadata(), finished_req_ids=frozenset()
681 ),
682 )
683 saved_outputs: list[tuple[torch.Tensor, torch.Tensor]] = []
684 # Mixed sizes exercise packing order; 64 is the boundary, 81 falls back.
685 for grids in ([[1, 8, 8], [1, 4, 4]], [[1, 16, 16]], [[1, 18, 18]], [[1, 4, 4]]):
686 inputs = _make_mm_kwargs(grids, device, dtype)
687 expected = model.encoder_eager_forward(inputs).split(
688 [t * (h // 2) * (w // 2) for t, h, w in grids]
689 )
690 with ec.maybe_get_output(scheduled) as ec_output:
691 outputs = manager.execute(inputs)
692 assert outputs is not None
693 cache[str(len(cache))] = outputs[0]
694 assert ec_output is not None
695 assert ec_output.finished_sending is None
696 assert outputs is not None
697 for actual, eager in zip(outputs, expected):
698 torch.testing.assert_close(actual, eager)
699 for previous, snapshot in saved_outputs:
700 torch.testing.assert_close(previous, snapshot, rtol=0, atol=0)
701 saved_outputs.extend((output, output.clone()) for output in outputs)
702 assert manager.graph_hits == 4
703 assert manager.graph_misses == 1
704 assert pending_sends.keys() == cache.keys()
705 connector.get_finished.return_value = (set(cache), None)
706 with ec.maybe_get_output(scheduled) as ec_output:
707 pass
708 assert ec_output is not None
709 assert ec_output.finished_sending == set(cache)
710
711
712 def test_eonly_without_encoder_graph_skips_capture():
713 from vllm.v1.worker.gpu.mm.encoder_runner import EncoderRunner
714 from vllm.v1.worker.mm_encoder_model_runner import MMEncoderModelRunner
715
716 encoder = object.__new__(EncoderRunner)
717 encoder.cudagraph_manager = None
718 runner = object.__new__(MMEncoderModelRunner)
719 runner.model_state = SimpleNamespace(encoder_runner=encoder)
720 assert runner.capture_model() == 0
721
722
723 # ---------------------------------------------------------------------------
724 # SimpleMockViTVideoModel — extends SimpleMockViTModel with video support
725 # ---------------------------------------------------------------------------
726
727
728 class SimpleMockViTVideoModel(SimpleMockViTModel):
729 """ViT mock that supports both image and video modalities.
730
731 Reuses SimpleMockViTModel's NN weights and _forward() logic.
732 Only the protocol methods that are key-dependent are overridden.
733 """
734
735 def get_encoder_cudagraph_config(self) -> EncoderCudaGraphConfig:
736 return EncoderCudaGraphConfig(
737 modalities=["image", "video"],
738 buffer_keys=["dummy_buf"],
739 out_hidden_size=_HIDDEN,
740 )
741
742 def get_input_modality(self, mm_kwargs: dict[str, Any]) -> str:
743 return "video" if "video_grid_thw" in mm_kwargs else "image"
744
745 # ------------------------------------------------------------------
746 # Private helpers — route to the correct mm_kwargs keys
747 # ------------------------------------------------------------------
748
749 def _get_grid_thw(self, mm_kwargs: dict[str, Any]) -> list[list[int]]:
750 key = (
751 "video_grid_thw"
752 if self.get_input_modality(mm_kwargs) == "video"
753 else "image_grid_thw"
754 )
755 return mm_kwargs[key]
756
757 def _get_pixel_values(self, mm_kwargs: dict[str, Any]) -> torch.Tensor:
758 key = (
759 "pixel_values_videos"
760 if self.get_input_modality(mm_kwargs) == "video"
761 else "pixel_values"
762 )
763 return mm_kwargs[key]
764
765 # ------------------------------------------------------------------
766 # Protocol overrides that depend on modality keys
767 # ------------------------------------------------------------------
768
769 def get_encoder_cudagraph_item_specs(
770 self,
771 mm_kwargs: dict[str, Any],
772 ) -> list[EncoderItemSpec]:
773 m = _SPATIAL_MERGE
774 return [
775 EncoderItemSpec(
776 input_size=t * h * w,
777 output_tokens=t * (h // m) * (w // m),
778 )
779 for t, h, w in self._get_grid_thw(mm_kwargs)
780 ]
781
782 def select_encoder_cudagraph_items(
783 self, mm_kwargs: dict[str, Any], indices: list[int]
784 ) -> dict[str, Any]:
785 modality = self.get_input_modality(mm_kwargs)
786 pv_key = "pixel_values_videos" if modality == "video" else "pixel_values"
787 grid_key = "video_grid_thw" if modality == "video" else "image_grid_thw"
788
789 grid_thw = self._get_grid_thw(mm_kwargs)
790 pixel_values = self._get_pixel_values(mm_kwargs)
791
792 if len(indices) == 0:
793 return {pv_key: pixel_values[:0], grid_key: []}
794
795 patches_per_item = [t * h * w for t, h, w in grid_thw]
796 cum_patches = [0]
797 for p in patches_per_item:
798 cum_patches.append(cum_patches[-1] + p)
799
800 selected_pv = torch.cat(
801 [pixel_values[cum_patches[i] : cum_patches[i + 1]] for i in indices]
802 )
803 return {pv_key: selected_pv, grid_key: [grid_thw[i] for i in indices]}
804
805 def prepare_encoder_cudagraph_capture_inputs(
806 self,
807 token_budget: int,
808 max_batch_size: int,
809 max_frames_per_batch: int,
810 device: torch.device,
811 dtype: torch.dtype,
812 path: str = "default",
813 axis_keys: tuple[Hashable, ...] | None = None,
814 ) -> EncoderCudaGraphCaptureInputs:
815 per_item_output = token_budget // max_batch_size
816 frames_per_item = max_frames_per_batch // max_batch_size
817 if frames_per_item > 1:
818 # Video-format capture: size cu_seqlens for T frames per item.
819 tokens_per_frame = (
820 per_item_output + frames_per_item - 1
821 ) // frames_per_item
822 grid_config = [
823 [frames_per_item, _SPATIAL_MERGE, tokens_per_frame * _SPATIAL_MERGE]
824 for _ in range(max_batch_size)
825 ]
826 else:
827 grid_config = [
828 [1, _SPATIAL_MERGE, per_item_output * _SPATIAL_MERGE]
829 for _ in range(max_batch_size)
830 ]
831 total_patches = _count_input_patches(grid_config)
832 # Use pixel_values (image key) for capture — same patch shape as video.
833 dummy_pixel_values = torch.randn(
834 total_patches, _FLAT, device=device, dtype=dtype
835 )
836 n_out = _count_output_tokens(grid_config, _SPATIAL_MERGE)
837 dummy_buf = torch.zeros(n_out, _HIDDEN, device=device, dtype=dtype)
838 return EncoderCudaGraphCaptureInputs(
839 values={
840 "pixel_values": dummy_pixel_values,
841 "dummy_buf": dummy_buf,
842 },
843 )
844
845 def prepare_encoder_cudagraph_replay_buffers(
846 self,
847 mm_kwargs: dict[str, Any],
848 max_batch_size: int,
849 max_frames_per_batch: int,
850 path: str = "default",
851 ) -> EncoderCudaGraphReplayBuffers:
852 n_out = _count_output_tokens(self._get_grid_thw(mm_kwargs), _SPATIAL_MERGE)
853 p = next(self.parameters())
854 dummy_buf = torch.zeros(n_out, _HIDDEN, device=p.device, dtype=p.dtype)
855 return EncoderCudaGraphReplayBuffers(
856 values={
857 "pixel_values": self._get_pixel_values(mm_kwargs),
858 "dummy_buf": dummy_buf,
859 }
860 )
861
862 def encoder_cudagraph_forward(
863 self,
864 values: dict[str, torch.Tensor],
865 path: str = "default",
866 ) -> torch.Tensor:
867 return self._forward(values["pixel_values"])
868
869 def encoder_eager_forward(
870 self,
871 mm_kwargs: dict[str, Any],
872 path: str = "default",
873 ) -> torch.Tensor:
874 return self._forward(self._get_pixel_values(mm_kwargs))
875
876
877 # ---------------------------------------------------------------------------
878 # No-GPU tests — get_input_modality routing
879 # ---------------------------------------------------------------------------
880
881
882 class TestGetInputModality:
883 """get_input_modality returns correct modality based on mm_kwargs keys."""
884
885 def test_image_only_model_always_returns_image(self):
886 model = SimpleMockViTModel()
887 mm_kwargs = {
888 "pixel_values": torch.zeros(1, _FLAT),
889 "image_grid_thw": [[1, 4, 4]],
890 }
891 assert model.get_input_modality(mm_kwargs) == "image"
892
893 def test_video_model_returns_image_for_image_kwargs(self):
894 model = SimpleMockViTVideoModel()
895 mm_kwargs = {
896 "pixel_values": torch.zeros(1, _FLAT),
897 "image_grid_thw": [[1, 4, 4]],
898 }
899 assert model.get_input_modality(mm_kwargs) == "image"
900
901 def test_video_model_returns_video_for_video_kwargs(self):
902 model = SimpleMockViTVideoModel()
903 mm_kwargs = {
904 "pixel_values_videos": torch.zeros(8, _FLAT),
905 "video_grid_thw": [[2, 4, 4]],
906 }
907 assert model.get_input_modality(mm_kwargs) == "video"
908
909
910 # ---------------------------------------------------------------------------
911 # GPU tests — video capture, replay, fallback, and mixed image+video
912 # ---------------------------------------------------------------------------
913
914 _VIDEO_MAX_BATCH = 4
915 _VIDEO_MAX_FRAMES = 8 # 2 frames per item at max_batch_size=4
916
917
918 @pytest.mark.skipif(
919 not current_platform.is_cuda_alike(), reason="Skip if not cuda or rocm"
920 )
921 class TestEncoderCudaGraphVideoReplay:
922 def setup_method(self):
923 self.device = torch.device("cuda:0")
924 self.dtype = torch.float16
925 self.model = SimpleMockViTVideoModel().to(self.device).half()
926 self.mgr = _make_manager_for_gpu(
927 self.model,
928 _BUDGETS,
929 _VIDEO_MAX_BATCH,
930 self.device,
931 self.dtype,
932 max_frames_per_batch=_VIDEO_MAX_FRAMES,
933 )
934 self.graph_pool = current_platform.graph_pool_handle()
935 self.mgr.capture(graph_pool=self.graph_pool)
936
937 # --- capture ---
938
939 def test_capture_creates_one_graph_per_budget(self):
940 assert len(self.mgr.budget_graphs["default"]) == len(_BUDGETS)
941 assert set(self.mgr.budget_graphs["default"].keys()) == set(_BUDGETS)
942
943 # --- output shape ---
944
945 def test_video_execute_returns_one_tensor_per_video(self):
946 # T=2, 4x4 → 2*(4//2)*(4//2) = 8 tokens per video
947 grid_thw = [[2, 4, 4], [2, 4, 4]]
948 mm_kwargs = _make_video_mm_kwargs(grid_thw, self.device, self.dtype)
949 result = self.mgr.execute(mm_kwargs)
950 assert result is not None
951 assert len(result) == 2
952
953 def test_video_output_tokens_per_item(self):
954 # T=2,4x4 → 8 tokens; T=1,4x4 → 4 tokens
955 grid_thw = [[2, 4, 4], [1, 4, 4]]
956 mm_kwargs = _make_video_mm_kwargs(grid_thw, self.device, self.dtype)
957 result = self.mgr.execute(mm_kwargs)
958 assert result is not None
959 assert result[0].shape == (8, _HIDDEN)
960 assert result[1].shape == (4, _HIDDEN)
961
962 # --- budget fallback ---
963
964 def test_video_eager_fallback_when_tokens_exceed_all_budgets(self):
965 # T=2, 18x18 → 2*(18//2)*(18//2) = 162 tokens > max budget 64
966 grid_thw = [[2, 18, 18]]
967 mm_kwargs = _make_video_mm_kwargs(grid_thw, self.device, self.dtype)
968 result = self.mgr.execute(mm_kwargs)
969 assert result is not None
970 assert len(result) == 1
971 assert result[0].shape == (162, _HIDDEN)
972 assert self.mgr.graph_misses == 1
973
974 # --- counters ---
975
976 def test_video_hit_counter_increments_by_num_videos(self):
977 grid_thw = [[2, 4, 4], [1, 4, 4]]
978 mm_kwargs = _make_video_mm_kwargs(grid_thw, self.device, self.dtype)
979 self.mgr.execute(mm_kwargs)
980 assert self.mgr.graph_hits == 2
981
982 def test_video_miss_counter_increments_for_oversized_video(self):
983 grid_thw = [[2, 18, 18]] # 162 tokens > 64
984 mm_kwargs = _make_video_mm_kwargs(grid_thw, self.device, self.dtype)
985 self.mgr.execute(mm_kwargs)
986 assert self.mgr.graph_misses == 1
987
988 # --- image and video sharing the same manager ---
989
990 def test_image_and_video_share_manager(self):
991 """Image and video inputs can both be executed through the same manager."""
992 img_grid = [[1, 4, 4], [1, 4, 4]]
993 img_result = self.mgr.execute(
994 _make_mm_kwargs(img_grid, self.device, self.dtype)
995 )
996
997 vid_grid = [[2, 4, 4]]
998 vid_result = self.mgr.execute(
999 _make_video_mm_kwargs(vid_grid, self.device, self.dtype)
1000 )
1001
1002 assert len(img_result) == 2
1003 assert len(vid_result) == 1
1004 assert img_result[0].shape == (4, _HIDDEN)
1005 assert vid_result[0].shape == (8, _HIDDEN)
1006
1007
1008 # ---------------------------------------------------------------------------
1009 # __init__ invariant validation tests (no GPU required)
1010 # ---------------------------------------------------------------------------
1011
1012
1013 class TestInitInvariantValidation:
1014 """Ensure max_batch_size <= min(token_budgets) for all config paths."""
1015
1016 def _make_mgr(
1017 self,
1018 token_budgets=None,
1019 max_mm_items=0,
1020 min_budget=4,
1021 max_budget=128,
1022 ):
1023 vllm_config = _MockVllmConfig(token_budgets, max_mm_items)
1024 model = _MockModel(min_budget, max_budget)
1025 return EncoderCudaGraphManager(
1026 vllm_config=vllm_config,
1027 device=torch.device("cpu"),
1028 dtype=torch.float32,
1029 model=model,
1030 )
1031
1032 # --- Finding 1: fully auto-inferred ---
1033
1034 def test_auto_inferred_invariant_holds(self):
1035 mgr = self._make_mgr(min_budget=64, max_budget=16384)
1036 assert mgr.max_batch_size <= min(mgr.token_budgets)
1037
1038 def test_auto_inferred_small_range(self):
1039 mgr = self._make_mgr(min_budget=4, max_budget=128)
1040 assert mgr.max_batch_size <= min(mgr.token_budgets)
1041
1042 # --- Finding 2: fully user-specified, bad combo ---
1043
1044 def test_user_specified_bad_combo_raises(self):
1045 with pytest.raises(ValueError, match="must be <= smallest token budget"):
1046 self._make_mgr(token_budgets=[64], max_mm_items=256)
1047
1048 def test_user_specified_valid_combo(self):
1049 mgr = self._make_mgr(token_budgets=[64, 128], max_mm_items=32)
1050 assert mgr.max_batch_size == 32
1051 assert mgr.token_budgets == [64, 128]
1052
1053 def test_user_specified_exact_boundary(self):
1054 # max_mm_items == min(budgets) is OK (per_image_output = 1)
1055 mgr = self._make_mgr(token_budgets=[64, 128], max_mm_items=64)
1056 assert mgr.max_batch_size == 64
1057
1058 # --- Finding 3: user provides only max_mm_items ---
1059
1060 def test_user_max_mm_items_only_adjusts_budgets(self):
1061 # model min_budget=64, user max_mm_items=128 → budgets start at 128
1062 mgr = self._make_mgr(max_mm_items=128, min_budget=64, max_budget=16384)
1063 assert mgr.max_batch_size == 128
1064 assert min(mgr.token_budgets) >= 128
1065
1066 def test_user_max_mm_items_smaller_than_min_budget(self):
1067 # max_mm_items=2, model min=4 → budgets start at 4 (>= 2), OK
1068 mgr = self._make_mgr(max_mm_items=2, min_budget=4, max_budget=128)
1069 assert mgr.max_batch_size == 2
1070 assert min(mgr.token_budgets) >= 2
1071
1072 # --- Finding 4: user provides only budgets ---
1073
1074 def test_user_budgets_only_caps_max_batch_size(self):
1075 # user budgets start at 32, model min_budget=64
1076 # without fix: max_batch_size = min(128//64, 64) = 2 → OK
1077 # but if user budgets=[16, 64]:
1078 # without fix: max_batch_size = min(128//4, 4) = 4 > 16? No.
1079 # Let's use a case that triggers it:
1080 # model min=64, max=16384 → max_budget//min_budget = 256
1081 # user budgets=[32, 64] → min = 32
1082 # without fix: max_batch_size = min(256, 64) = 64 > 32 → BUG
1083 # with fix: max_batch_size = min(256, 32) = 32 → OK
1084 mgr = self._make_mgr(token_budgets=[32, 64], min_budget=64, max_budget=16384)
1085 assert mgr.max_batch_size <= min(mgr.token_budgets)
1086 assert mgr.max_batch_size == 32
1087
1088 # --- Finding 5/6: bad model budget range ---
1089
1090 def test_zero_min_budget_raises(self):
1091 with pytest.raises(ValueError, match="Both must be positive"):
1092 self._make_mgr(min_budget=0, max_budget=128)
1093
1094 def test_negative_max_budget_raises(self):
1095 with pytest.raises(ValueError, match="Both must be positive"):
1096 self._make_mgr(min_budget=4, max_budget=-1)
1097
1098 def test_min_greater_than_max_raises(self):
1099 with pytest.raises(ValueError, match="min_budget=200 > max_budget=100"):
1100 self._make_mgr(min_budget=200, max_budget=100)
1101
1102 # --- Finding 7: user-provided budgets with non-positive values ---
1103
1104 def test_user_budgets_zero_raises(self):
1105 """Non-positive budgets should be caught at config validation."""
1106 from vllm.config.compilation import CompilationConfig
1107
1108 with pytest.raises(ValueError, match="must be positive"):
1109 CompilationConfig(encoder_cudagraph_token_budgets=[0, 128])
1110
1111 def test_user_budgets_negative_raises(self):
1112 from vllm.config.compilation import CompilationConfig
1113
1114 with pytest.raises(ValueError, match="must be positive"):
1115 CompilationConfig(encoder_cudagraph_token_budgets=[-1, 64])