A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 5, 04:45 UTC
[MM][Perf][CG] Support ViT full CUDA graph for Qwen3-VL video inference (#38061)
Signed-off-by: shen-shanshan <467638484@qq.com> Signed-off-by: Shanshan Shen <87969357+shen-shanshan@users.noreply.github.com> Co-authored-by: Roger Wang <hey@rogerw.io>
Shanshan Shen committed
Apr 14, 2026 at 16:49 UTC
80118853f42a728805e6a861ffb50f3b876a9939
7 files changed
+583
-68
docs/design/cuda_graphs_multimodal.md
+66
-7
@@ -28,6 +28,7 @@ Multiple CUDA Graphs are pre-captured at different **token budget** levels (e.g.
28
class BudgetGraphMetadata:
29
token_budget: int
30
max_batch_size: int
31
+ max_frames_per_batch: int
32
graph: torch.cuda.CUDAGraph
33
input_buffer: torch.Tensor # e.g. pixel_values
34
metadata_buffers: dict[str, torch.Tensor] # e.g. embeddings, seq metadata
@@ -51,6 +52,15 @@ For each graph replay:
52
53
When `mm_encoder_tp_mode="data"`, the manager distributes images across TP ranks using load-balanced assignment via `get_load_balance_assignment`, executes locally on each rank, then gathers results back in the original order via `tensor_model_parallel_all_gather`.
54
55
+### Video inference support (experimental)
56
+
57
+Following <https://github.com/vllm-project/vllm/pull/35963> (ViT full CUDA graph support for image inference), <https://github.com/vllm-project/vllm/pull/38061> extends the encoder CUDA graph framework to support video inference for Qwen3-VL. Previously, the CUDA graph capture/replay path only handled image inputs (`pixel_values` + `image_grid_thw`). Video inputs use different keys (`pixel_values_videos` + `video_grid_thw`) and require larger `cu_seqlens` buffers because each video item contributes multiple frames (`T` attention sequences). This PR generalizes the protocol and manager to handle both modalities through a single shared graph manager.
58
+
59
+!!! note
60
+ Video CUDA graphs are automatically disabled when EVS (Efficient Video Sampling) pruning is enabled, since EVS makes the token count data-dependent and incompatible with CUDA graph capture.
61
+
62
+ Currently, we only support image-only or video-only inputs when enabling CUDA graph, mixed inputs (image + video) are not supported yet (we will work on it in the near future). Thus, it's recommended to turn off the image modality by `--limit-mm-per-prompt '{"image": 0}'` for video-only inputs.
63
+
64
## Model integration via `SupportsEncoderCudaGraph`
65
66
Models opt-in to encoder CUDA Graphs by implementing the [SupportsEncoderCudaGraph][vllm.model_executor.models.interfaces.SupportsEncoderCudaGraph] protocol. This protocol encapsulates all model-specific logic so that the manager remains model-agnostic. The protocol defines the following methods:
@@ -65,12 +75,17 @@ Models opt-in to encoder CUDA Graphs by implementing the [SupportsEncoderCudaGra
75
* `prepare_encoder_cudagraph_replay_buffers(...)` — computes new buffer values from actual batch inputs before replay.
76
* `encoder_cudagraph_forward(...)` — forward pass using precomputed buffers (called during capture and replay).
77
* `encoder_eager_forward(...)` — fallback eager forward when no graph fits.
68
-
69
-Currently supported: **Qwen3-VL** (see `vllm/model_executor/models/qwen3_vl.py`).
78
+* `get_input_modality(...)` - return the modality of the inputs.
79
80
!!! note
81
The `SupportsEncoderCudaGraph` protocol is designed to be model-agnostic. New vision encoder models can opt-in by implementing the protocol methods without modifying the manager.
82
83
+**Supported models:**
84
+
85
+| Architecture | Models | CG for Image | CG for Video |
86
+| ------------ | ------ | ------------ | ------------ |
87
+| `Qwen3VLForConditionalGeneration` | `Qwen3-VL` | ✅︎ | ✅︎ |
88
+
89
!!! note
90
Encoder CUDA Graphs have currently been tested with `--mm-encoder-attn-backend=FLASH_ATTN` and `--mm-encoder-attn-backend=FLASHINFER` on Blackwell GPUs.
91
@@ -80,10 +95,13 @@ Three fields in `CompilationConfig` control encoder CUDA Graphs:
95
96
* `cudagraph_mm_encoder` (`bool`, default `False`) — enable CUDA Graph capture for multimodal encoder. When enabled, captures the full encoder forward as a CUDA Graph for each token budget level.
97
* `encoder_cudagraph_token_budgets` (`list[int]`, default `[]`) — token budget levels for capture. If empty (default), auto-inferred from model architecture as power-of-2 levels. User-provided values override auto-inference.
83
-* `encoder_cudagraph_max_images_per_batch` (`int`, default `0`) — maximum number of images per batch during capture. If 0 (default), auto-inferred as `max_budget // min_budget`.
98
+* `encoder_cudagraph_max_vision_items_per_batch` (`int`, default `0`) — maximum number of images/videos per batch during capture. If 0 (default), auto-inferred as `max_budget // min_budget`.
99
+* `encoder_cudagraph_max_frames_per_batch` (`int`, default `0`) — maximum number of video frames per batch during capture. If 0 (default), auto-inferred as `encoder_cudagraph_max_vision_items_per_batch * 2` (to be optimized).
100
101
## Usage guide
102
103
+### Image inference
104
+
105
Enable encoder CUDA Graphs via `compilation_config`:
106
107
```bash
@@ -95,7 +113,7 @@ With explicit budgets:
113
114
```bash
115
vllm serve Qwen/Qwen3-VL-32B \
98
- --compilation-config '{"cudagraph_mm_encoder": true, "encoder_cudagraph_token_budgets": [2048, 4096, 8192, 13824], "encoder_cudagraph_max_images_per_batch": 8}'
116
+ --compilation-config '{"cudagraph_mm_encoder": true, "encoder_cudagraph_token_budgets": [2048, 4096, 8192, 13824], "encoder_cudagraph_max_vision_items_per_batch": 8}'
117
```
118
119
Python example:
@@ -107,7 +125,7 @@ compilation_config = {
125
"cudagraph_mm_encoder": True,
126
# Optional: override auto-inferred budgets
127
# "encoder_cudagraph_token_budgets": [2048, 4096, 8192, 13824],
110
- # "encoder_cudagraph_max_images_per_batch": 8,
128
+ # "encoder_cudagraph_max_vision_items_per_batch": 8,
129
}
130
131
model = vllm.LLM(
@@ -118,6 +136,44 @@ model = vllm.LLM(
136
137
The manager tracks hit/miss statistics and logs them periodically. A "hit" means an image was processed via CUDA Graph replay; a "miss" means eager fallback (image exceeded all budgets).
138
139
+### Video inference
140
+
141
+Enable encoder CUDA Graphs via `compilation_config`:
142
+
143
+```bash
144
+vllm serve Qwen/Qwen3-VL-32B \
145
+ --limit-mm-per-prompt '{"image": 0}' \
146
+ --compilation-config '{"cudagraph_mm_encoder": true}'
147
+```
148
+
149
+With explicit budgets:
150
+
151
+```bash
152
+vllm serve Qwen/Qwen3-VL-32B \
153
+ --limit-mm-per-prompt '{"image": 0}' \
154
+ --compilation-config '{"cudagraph_mm_encoder": true, "encoder_cudagraph_token_budgets": [2048, 4096, 8192, 13824], "encoder_cudagraph_max_vision_items_per_batch": 8, "encoder_cudagraph_max_frames_per_batch": 64}'
155
+```
156
+
157
+Python example:
158
+
159
+```python
160
+import vllm
161
+
162
+compilation_config = {
163
+ "cudagraph_mm_encoder": True,
164
+ # Optional: override auto-inferred budgets
165
+ # "encoder_cudagraph_token_budgets": [2048, 4096, 8192, 13824],
166
+ # "encoder_cudagraph_max_vision_items_per_batch": 8,
167
+ # "encoder_cudagraph_max_frames_per_batch": 64,
168
+}
169
+
170
+model = vllm.LLM(
171
+ model="Qwen/Qwen3-VL-32B",
172
+ limit_mm_per_prompt='{"image": 0}',
173
+ compilation_config=compilation_config,
174
+)
175
+```
176
+
177
## About the Performance
178
179
The following benchmarks were run on Blackwell GPUs (GB200) using `vllm bench mm-processor`. See [#35963](https://github.com/vllm-project/vllm/pull/35963) for full details.
@@ -140,7 +196,7 @@ vllm bench mm-processor \
196
--num-prompts 3000 --num-warmups 300 \
197
--max-model-len 32768 --seed 42 \
198
--mm-encoder-attn-backend FLASH_ATTN \
143
- --compilation-config '{"cudagraph_mm_encoder": true, "encoder_cudagraph_token_budgets": [512, 1024, 1536, 2048, 2560, 3072, 3584, 4096, 4864], "encoder_cudagraph_max_images_per_batch": 8}'
199
+ --compilation-config '{"cudagraph_mm_encoder": true, "encoder_cudagraph_token_budgets": [512, 1024, 1536, 2048, 2560, 3072, 3584, 4096, 4864], "encoder_cudagraph_max_vision_items_per_batch": 8}'
200
```
201
202
### Multi-GPU (4x GB200, TP=4, DP=4)
@@ -165,5 +221,8 @@ vllm bench mm-processor \
221
--max-model-len 8192 --seed 42 \
222
--mm-encoder-attn-backend FLASHINFER \
223
--tensor-parallel-size 4 --mm-encoder-tp-mode data \
168
- --compilation-config '{"cudagraph_mm_encoder": true, "encoder_cudagraph_token_budgets": [512, 1024, 1536, 2048, 2560, 3072, 3584, 4096, 4864], "encoder_cudagraph_max_images_per_batch": 8}'
224
+ --compilation-config '{"cudagraph_mm_encoder": true, "encoder_cudagraph_token_budgets": [512, 1024, 1536, 2048, 2560, 3072, 3584, 4096, 4864], "encoder_cudagraph_max_vision_items_per_batch": 8}'
225
```
226
+
227
+!!! note
228
+ Find more details about benchmarks on GPUs (A100) for video inference at [#38061](https://github.com/vllm-project/vllm/pull/38061).
tests/v1/cudagraph/test_encoder_cudagraph.py
+312
-1
@@ -6,8 +6,10 @@ 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 typing import Any
@@ -205,11 +207,19 @@ class SimpleMockViTModel(torch.nn.Module):
207
def get_encoder_cudagraph_config(self) -> EncoderCudaGraphConfig:
208
return EncoderCudaGraphConfig(
209
modalities=["image"],
208
- input_key="pixel_values",
210
+ input_key_by_modality={
211
+ "image": "pixel_values",
212
+ },
213
buffer_keys=["dummy_buf"],
214
out_hidden_size=_HIDDEN,
215
)
216
217
+ def get_input_modality(
218
+ self,
219
+ mm_kwargs: dict[str, Any],
220
+ ) -> str:
221
+ return "image"
222
+
223
def get_encoder_cudagraph_budget_range(
224
self,
225
vllm_config,
@@ -268,6 +278,7 @@ class SimpleMockViTModel(torch.nn.Module):
278
self,
279
token_budget: int,
280
max_batch_size: int,
281
+ max_frames_per_batch: int,
282
device: torch.device,
283
dtype: torch.dtype,
284
) -> EncoderCudaGraphCaptureInputs:
@@ -294,6 +305,7 @@ class SimpleMockViTModel(torch.nn.Module):
305
self,
306
mm_kwargs: dict[str, Any],
307
max_batch_size: int,
308
+ max_frames_per_batch: int,
309
) -> EncoderCudaGraphReplayBuffers:
310
grid_thw = mm_kwargs["image_grid_thw"]
311
n_out = _count_output_tokens(grid_thw, _SPATIAL_MERGE)
@@ -327,11 +339,16 @@ def _make_manager_for_gpu(
339
max_batch_size: int,
340
device: torch.device,
341
dtype: torch.dtype,
342
+ *,
343
+ max_frames_per_batch: int | None = None,
344
) -> EncoderCudaGraphManager:
345
"""Create EncoderCudaGraphManager bypassing VllmConfig for GPU tests."""
346
mgr = object.__new__(EncoderCudaGraphManager)
347
mgr.token_budgets = sorted(token_budgets)
348
mgr.max_batch_size = max_batch_size
349
+ mgr.max_frames_per_batch = (
350
+ max_frames_per_batch if max_frames_per_batch is not None else max_batch_size * 2
351
+ )
352
mgr.use_dp = False
353
mgr.budget_graphs = {}
354
mgr.graph_hits = 0
@@ -366,6 +383,18 @@ def _make_mm_kwargs(
383
}
384
385
386
+def _make_video_mm_kwargs(
387
+ grid_thw_list: list[list[int]],
388
+ device: torch.device,
389
+ dtype: torch.dtype,
390
+) -> dict[str, Any]:
391
+ """Create video mm_kwargs (pixel_values_videos / video_grid_thw) for testing."""
392
+ return {
393
+ "pixel_values_videos": _make_pixel_values(grid_thw_list, device, dtype),
394
+ "video_grid_thw": grid_thw_list,
395
+ }
396
+
397
+
398
# ---------------------------------------------------------------------------
399
# GPU tests — capture, replay, fallback, counters, chunking
400
# ---------------------------------------------------------------------------
@@ -449,3 +478,285 @@ class TestEncoderCudaGraphCaptureReplay:
478
assert len(result) == n_images
479
for out in result:
480
assert out.shape == (4, _HIDDEN)
481
+
482
+
483
+# ---------------------------------------------------------------------------
484
+# SimpleMockViTVideoModel — extends SimpleMockViTModel with video support
485
+# ---------------------------------------------------------------------------
486
+
487
+
488
+class SimpleMockViTVideoModel(SimpleMockViTModel):
489
+ """ViT mock that supports both image and video modalities.
490
+
491
+ Reuses SimpleMockViTModel's NN weights and _forward() logic.
492
+ Only the protocol methods that are key-dependent are overridden.
493
+ """
494
+
495
+ def get_encoder_cudagraph_config(self) -> EncoderCudaGraphConfig:
496
+ return EncoderCudaGraphConfig(
497
+ modalities=["image", "video"],
498
+ input_key_by_modality={
499
+ "image": "pixel_values",
500
+ "video": "pixel_values_videos",
501
+ },
502
+ buffer_keys=["dummy_buf"],
503
+ out_hidden_size=_HIDDEN,
504
+ )
505
+
506
+ def get_input_modality(self, mm_kwargs: dict[str, Any]) -> str:
507
+ return "video" if "video_grid_thw" in mm_kwargs else "image"
508
+
509
+ # ------------------------------------------------------------------
510
+ # Private helpers — route to the correct mm_kwargs keys
511
+ # ------------------------------------------------------------------
512
+
513
+ def _get_grid_thw(self, mm_kwargs: dict[str, Any]) -> list[list[int]]:
514
+ key = (
515
+ "video_grid_thw"
516
+ if self.get_input_modality(mm_kwargs) == "video"
517
+ else "image_grid_thw"
518
+ )
519
+ return mm_kwargs[key]
520
+
521
+ def _get_pixel_values(self, mm_kwargs: dict[str, Any]) -> torch.Tensor:
522
+ key = (
523
+ "pixel_values_videos"
524
+ if self.get_input_modality(mm_kwargs) == "video"
525
+ else "pixel_values"
526
+ )
527
+ return mm_kwargs[key]
528
+
529
+ # ------------------------------------------------------------------
530
+ # Protocol overrides that depend on modality keys
531
+ # ------------------------------------------------------------------
532
+
533
+ def get_encoder_cudagraph_num_items(self, mm_kwargs: dict[str, Any]) -> int:
534
+ return len(self._get_grid_thw(mm_kwargs))
535
+
536
+ def get_encoder_cudagraph_per_item_output_tokens(
537
+ self, mm_kwargs: dict[str, Any]
538
+ ) -> list[int]:
539
+ m = _SPATIAL_MERGE
540
+ return [t * (h // m) * (w // m) for t, h, w in self._get_grid_thw(mm_kwargs)]
541
+
542
+ def get_encoder_cudagraph_per_item_input_sizes(
543
+ self, mm_kwargs: dict[str, Any]
544
+ ) -> list[int]:
545
+ return [t * h * w for t, h, w in self._get_grid_thw(mm_kwargs)]
546
+
547
+ def select_encoder_cudagraph_items(
548
+ self, mm_kwargs: dict[str, Any], indices: list[int]
549
+ ) -> dict[str, Any]:
550
+ modality = self.get_input_modality(mm_kwargs)
551
+ pv_key = "pixel_values_videos" if modality == "video" else "pixel_values"
552
+ grid_key = "video_grid_thw" if modality == "video" else "image_grid_thw"
553
+
554
+ grid_thw = self._get_grid_thw(mm_kwargs)
555
+ pixel_values = self._get_pixel_values(mm_kwargs)
556
+
557
+ if len(indices) == 0:
558
+ return {pv_key: pixel_values[:0], grid_key: []}
559
+
560
+ patches_per_item = [t * h * w for t, h, w in grid_thw]
561
+ cum_patches = [0]
562
+ for p in patches_per_item:
563
+ cum_patches.append(cum_patches[-1] + p)
564
+
565
+ selected_pv = torch.cat(
566
+ [pixel_values[cum_patches[i] : cum_patches[i + 1]] for i in indices]
567
+ )
568
+ return {pv_key: selected_pv, grid_key: [grid_thw[i] for i in indices]}
569
+
570
+ def prepare_encoder_cudagraph_capture_inputs(
571
+ self,
572
+ token_budget: int,
573
+ max_batch_size: int,
574
+ max_frames_per_batch: int,
575
+ device: torch.device,
576
+ dtype: torch.dtype,
577
+ ) -> EncoderCudaGraphCaptureInputs:
578
+ per_item_output = token_budget // max_batch_size
579
+ frames_per_item = max_frames_per_batch // max_batch_size
580
+ if frames_per_item > 1:
581
+ # Video-format capture: size cu_seqlens for T frames per item.
582
+ tokens_per_frame = (
583
+ per_item_output + frames_per_item - 1
584
+ ) // frames_per_item
585
+ grid_config = [
586
+ [frames_per_item, _SPATIAL_MERGE, tokens_per_frame * _SPATIAL_MERGE]
587
+ for _ in range(max_batch_size)
588
+ ]
589
+ else:
590
+ grid_config = [
591
+ [1, _SPATIAL_MERGE, per_item_output * _SPATIAL_MERGE]
592
+ for _ in range(max_batch_size)
593
+ ]
594
+ total_patches = _count_input_patches(grid_config)
595
+ # Use pixel_values (image key) for capture — same patch shape as video.
596
+ dummy_pixel_values = torch.randn(
597
+ total_patches, _FLAT, device=device, dtype=dtype
598
+ )
599
+ n_out = _count_output_tokens(grid_config, _SPATIAL_MERGE)
600
+ dummy_buf = torch.zeros(n_out, _HIDDEN, device=device, dtype=dtype)
601
+ return EncoderCudaGraphCaptureInputs(
602
+ mm_kwargs={
603
+ "pixel_values": dummy_pixel_values,
604
+ "image_grid_thw": grid_config,
605
+ },
606
+ buffers={"dummy_buf": dummy_buf},
607
+ )
608
+
609
+ def prepare_encoder_cudagraph_replay_buffers(
610
+ self,
611
+ mm_kwargs: dict[str, Any],
612
+ max_batch_size: int,
613
+ max_frames_per_batch: int,
614
+ ) -> EncoderCudaGraphReplayBuffers:
615
+ n_out = _count_output_tokens(self._get_grid_thw(mm_kwargs), _SPATIAL_MERGE)
616
+ p = next(self.parameters())
617
+ dummy_buf = torch.zeros(n_out, _HIDDEN, device=p.device, dtype=p.dtype)
618
+ return EncoderCudaGraphReplayBuffers(buffers={"dummy_buf": dummy_buf})
619
+
620
+ def encoder_cudagraph_forward(
621
+ self, mm_kwargs: dict[str, Any], buffers: dict[str, torch.Tensor]
622
+ ) -> torch.Tensor:
623
+ return self._forward(self._get_pixel_values(mm_kwargs))
624
+
625
+ def encoder_eager_forward(self, mm_kwargs: dict[str, Any]) -> torch.Tensor:
626
+ return self._forward(self._get_pixel_values(mm_kwargs))
627
+
628
+
629
+# ---------------------------------------------------------------------------
630
+# No-GPU tests — get_input_modality routing
631
+# ---------------------------------------------------------------------------
632
+
633
+
634
+class TestGetInputModality:
635
+ """get_input_modality returns correct modality based on mm_kwargs keys."""
636
+
637
+ def test_image_only_model_always_returns_image(self):
638
+ model = SimpleMockViTModel()
639
+ mm_kwargs = {
640
+ "pixel_values": torch.zeros(1, _FLAT),
641
+ "image_grid_thw": [[1, 4, 4]],
642
+ }
643
+ assert model.get_input_modality(mm_kwargs) == "image"
644
+
645
+ def test_video_model_returns_image_for_image_kwargs(self):
646
+ model = SimpleMockViTVideoModel()
647
+ mm_kwargs = {
648
+ "pixel_values": torch.zeros(1, _FLAT),
649
+ "image_grid_thw": [[1, 4, 4]],
650
+ }
651
+ assert model.get_input_modality(mm_kwargs) == "image"
652
+
653
+ def test_video_model_returns_video_for_video_kwargs(self):
654
+ model = SimpleMockViTVideoModel()
655
+ mm_kwargs = {
656
+ "pixel_values_videos": torch.zeros(8, _FLAT),
657
+ "video_grid_thw": [[2, 4, 4]],
658
+ }
659
+ assert model.get_input_modality(mm_kwargs) == "video"
660
+
661
+ def test_video_model_config_has_both_modalities(self):
662
+ model = SimpleMockViTVideoModel()
663
+ cfg = model.get_encoder_cudagraph_config()
664
+ assert "image" in cfg.modalities
665
+ assert "video" in cfg.modalities
666
+ assert cfg.input_key_by_modality["image"] == "pixel_values"
667
+ assert cfg.input_key_by_modality["video"] == "pixel_values_videos"
668
+
669
+
670
+# ---------------------------------------------------------------------------
671
+# GPU tests — video capture, replay, fallback, and mixed image+video
672
+# ---------------------------------------------------------------------------
673
+
674
+_VIDEO_MAX_BATCH = 4
675
+_VIDEO_MAX_FRAMES = 8 # 2 frames per item at max_batch_size=4
676
+
677
+
678
+@pytest.mark.skipif(not current_platform.is_cuda(), reason="Skip if not cuda")
679
+class TestEncoderCudaGraphVideoReplay:
680
+ def setup_method(self):
681
+ self.device = torch.device("cuda:0")
682
+ self.dtype = torch.float16
683
+ self.model = SimpleMockViTVideoModel().to(self.device).half()
684
+ self.mgr = _make_manager_for_gpu(
685
+ self.model,
686
+ _BUDGETS,
687
+ _VIDEO_MAX_BATCH,
688
+ self.device,
689
+ self.dtype,
690
+ max_frames_per_batch=_VIDEO_MAX_FRAMES,
691
+ )
692
+ self.mgr.capture()
693
+
694
+ # --- capture ---
695
+
696
+ def test_capture_creates_one_graph_per_budget(self):
697
+ assert len(self.mgr.budget_graphs) == len(_BUDGETS)
698
+ assert set(self.mgr.budget_graphs.keys()) == set(_BUDGETS)
699
+
700
+ # --- output shape ---
701
+
702
+ def test_video_execute_returns_one_tensor_per_video(self):
703
+ # T=2, 4x4 → 2*(4//2)*(4//2) = 8 tokens per video
704
+ grid_thw = [[2, 4, 4], [2, 4, 4]]
705
+ mm_kwargs = _make_video_mm_kwargs(grid_thw, self.device, self.dtype)
706
+ result = self.mgr.execute(mm_kwargs)
707
+ assert result is not None
708
+ assert len(result) == 2
709
+
710
+ def test_video_output_tokens_per_item(self):
711
+ # T=2,4x4 → 8 tokens; T=1,4x4 → 4 tokens
712
+ grid_thw = [[2, 4, 4], [1, 4, 4]]
713
+ mm_kwargs = _make_video_mm_kwargs(grid_thw, self.device, self.dtype)
714
+ result = self.mgr.execute(mm_kwargs)
715
+ assert result is not None
716
+ assert result[0].shape == (8, _HIDDEN)
717
+ assert result[1].shape == (4, _HIDDEN)
718
+
719
+ # --- budget fallback ---
720
+
721
+ def test_video_eager_fallback_when_tokens_exceed_all_budgets(self):
722
+ # T=2, 18x18 → 2*(18//2)*(18//2) = 162 tokens > max budget 64
723
+ grid_thw = [[2, 18, 18]]
724
+ mm_kwargs = _make_video_mm_kwargs(grid_thw, self.device, self.dtype)
725
+ result = self.mgr.execute(mm_kwargs)
726
+ assert result is not None
727
+ assert len(result) == 1
728
+ assert result[0].shape == (162, _HIDDEN)
729
+ assert self.mgr.graph_misses == 1
730
+
731
+ # --- counters ---
732
+
733
+ def test_video_hit_counter_increments_by_num_videos(self):
734
+ grid_thw = [[2, 4, 4], [1, 4, 4]]
735
+ mm_kwargs = _make_video_mm_kwargs(grid_thw, self.device, self.dtype)
736
+ self.mgr.execute(mm_kwargs)
737
+ assert self.mgr.graph_hits == 2
738
+
739
+ def test_video_miss_counter_increments_for_oversized_video(self):
740
+ grid_thw = [[2, 18, 18]] # 162 tokens > 64
741
+ mm_kwargs = _make_video_mm_kwargs(grid_thw, self.device, self.dtype)
742
+ self.mgr.execute(mm_kwargs)
743
+ assert self.mgr.graph_misses == 1
744
+
745
+ # --- image and video sharing the same manager ---
746
+
747
+ def test_image_and_video_share_manager(self):
748
+ """Image and video inputs can both be executed through the same manager."""
749
+ img_grid = [[1, 4, 4], [1, 4, 4]]
750
+ img_result = self.mgr.execute(
751
+ _make_mm_kwargs(img_grid, self.device, self.dtype)
752
+ )
753
+
754
+ vid_grid = [[2, 4, 4]]
755
+ vid_result = self.mgr.execute(
756
+ _make_video_mm_kwargs(vid_grid, self.device, self.dtype)
757
+ )
758
+
759
+ assert len(img_result) == 2
760
+ assert len(vid_result) == 1
761
+ assert img_result[0].shape == (4, _HIDDEN)
762
+ assert vid_result[0].shape == (8, _HIDDEN)
vllm/config/compilation.py
+20
-4
@@ -519,13 +519,21 @@ class CompilationConfig:
519
User-provided values override auto-inference.
520
Example: [2048, 4096, 8192, 13824]"""
521
522
- encoder_cudagraph_max_images_per_batch: int = 0
523
- """Maximum number of images per batch for encoder CUDA graph capture.
522
+ encoder_cudagraph_max_vision_items_per_batch: int = 0
523
+ """Maximum number of images/videos per batch for encoder CUDA graph capture.
524
Determines the fixed batch size used during graph capture.
525
If 0 (default), auto-inferred as max_budget // min_budget from the
526
model's budget range. User-provided positive value overrides
527
auto-inference."""
528
529
+ encoder_cudagraph_max_frames_per_batch: int = 0
530
+ """Maximum total video frames per batch for encoder CUDA graph capture.
531
+ Controls the cu_seqlens buffer size (one entry per attention sequence,
532
+ i.e. one per video frame). If 0 (default), auto-inferred per budget
533
+ level as token_budget (tight bound: packing guarantees
534
+ sum(T_i) <= token_budget). Positive value overrides auto-inference
535
+ and applies to all budget levels."""
536
+
537
# Inductor capture
538
compile_sizes: list[int | str] | None = None
539
"""Sizes to compile for inductor. In addition
@@ -964,10 +972,18 @@ class CompilationConfig:
972
# Validate encoder CUDA graph configuration
973
if (
974
self.cudagraph_mm_encoder
967
- and self.encoder_cudagraph_max_images_per_batch < 0
975
+ and self.encoder_cudagraph_max_vision_items_per_batch < 0
976
+ ):
977
+ raise ValueError(
978
+ "encoder_cudagraph_max_vision_items_per_batch must be "
979
+ "non-negative (0 = auto-infer)"
980
+ )
981
+ if (
982
+ self.cudagraph_mm_encoder
983
+ and self.encoder_cudagraph_max_frames_per_batch < 0
984
):
985
raise ValueError(
970
- "encoder_cudagraph_max_images_per_batch must be "
986
+ "encoder_cudagraph_max_frames_per_batch must be "
987
"non-negative (0 = auto-infer)"
988
)
989
vllm/model_executor/models/interfaces.py
+10
-1
@@ -1524,6 +1524,13 @@ class SupportsEncoderCudaGraph(Protocol):
1524
1525
def get_encoder_cudagraph_config(self) -> "EncoderCudaGraphConfig": ...
1526
1527
+ def get_input_modality(
1528
+ self,
1529
+ mm_kwargs: dict[str, Any],
1530
+ ) -> str:
1531
+ """Return the modality of the inputs."""
1532
+ ...
1533
+
1534
def get_encoder_cudagraph_budget_range(
1535
self,
1536
vllm_config: "VllmConfig",
@@ -1536,7 +1543,7 @@ class SupportsEncoderCudaGraph(Protocol):
1543
(e.g. max_num_batched_tokens)
1544
1545
Used when ``encoder_cudagraph_token_budgets`` and/or
1539
- ``encoder_cudagraph_max_images_per_batch`` are not explicitly
1546
+ ``encoder_cudagraph_max_vision_items_per_batch`` are not explicitly
1547
specified by the user.
1548
"""
1549
...
@@ -1590,6 +1597,7 @@ class SupportsEncoderCudaGraph(Protocol):
1597
self,
1598
token_budget: int,
1599
max_batch_size: int,
1600
+ max_frames_per_batch: int,
1601
device: torch.device,
1602
dtype: torch.dtype,
1603
) -> "EncoderCudaGraphCaptureInputs":
@@ -1600,6 +1608,7 @@ class SupportsEncoderCudaGraph(Protocol):
1608
self,
1609
mm_kwargs: dict[str, Any],
1610
max_batch_size: int,
1611
+ max_frames_per_batch: int,
1612
) -> "EncoderCudaGraphReplayBuffers":
1613
"""Compute buffer values from actual batch inputs for replay."""
1614
...
vllm/model_executor/models/qwen3_vl.py
+138
-42
@@ -99,6 +99,7 @@ from vllm.tokenizers.registry import cached_tokenizer_from_config
99
from vllm.triton_utils import HAS_TRITON, tl, triton
100
from vllm.utils.collection_utils import is_list_of
101
from vllm.utils.math_utils import round_up
102
+from vllm.v1.worker.encoder_cudagraph_defs import EncoderCudaGraphReplayBuffers
103
104
from .interfaces import (
105
MultiModalEmbeddings,
@@ -689,6 +690,7 @@ class Qwen3_VisionTransformer(nn.Module):
690
grid_thw_list: list[list[int]],
691
*,
692
max_batch_size: int | None = None,
693
+ max_frames_per_batch: int | None = None,
694
max_seqlen_override: int | None = None,
695
device: torch.device | None = None,
696
) -> dict[str, torch.Tensor | None]:
@@ -701,6 +703,10 @@ class Qwen3_VisionTransformer(nn.Module):
703
grid_thw_list: Grid configurations as list of [t, h, w].
704
max_batch_size: If set, pad cu_seqlens to this size
705
(needed for CUDA graph capture/replay).
706
+ max_frames_per_batch: If set, overrides max_batch_size for
707
+ cu_seqlens padding. For video inputs each item contributes
708
+ T attention sequences (frames); this sizes the buffer to
709
+ the total frame budget so video replays never overflow.
710
max_seqlen_override: If set, use this value for max_seqlen
711
instead of computing from cu_seqlens (needed for CUDA
712
graph capture to cover worst-case replay scenarios).
@@ -725,15 +731,21 @@ class Qwen3_VisionTransformer(nn.Module):
731
)
732
cu_seqlens = np.concatenate([np.zeros(1, dtype=np.int32), cu_seqlens])
733
728
- # Pad cu_seqlens if max_batch_size specified
729
- if max_batch_size is not None:
734
+ # Pad cu_seqlens to the required number of sequences.
735
+ # For videos each item contributes T frames = T attention sequences,
736
+ # so the total can exceed max_batch_size. max_frames_per_batch
737
+ # overrides the pad target when set.
738
+ pad_to = (
739
+ max_frames_per_batch if max_frames_per_batch is not None else max_batch_size
740
+ )
741
+ if pad_to is not None:
742
num_seqs = len(cu_seqlens) - 1
731
- if num_seqs < max_batch_size:
743
+ if num_seqs < pad_to:
744
cu_seqlens = np.concatenate(
745
[
746
cu_seqlens,
747
np.full(
736
- max_batch_size - num_seqs,
748
+ pad_to - num_seqs,
749
cu_seqlens[-1],
750
dtype=np.int32,
751
),
@@ -1737,9 +1749,21 @@ class Qwen3VLForConditionalGeneration(
1749
EncoderCudaGraphConfig,
1750
)
1751
1752
+ modalities = ["image"]
1753
+ # NOTE: When EVS (Efficient Video Sampling) pruning is enabled, the number
1754
+ # of tokens becomes data-dependent (i.e., the retained tokens are
1755
+ # dynamically selected based on inter-frame differences) and therefore
1756
+ # cannot be captured by CUDA Graphs. As a result, video CUDA Graphs are
1757
+ # only enabled when EVS is disabled.
1758
+ if not self.is_multimodal_pruning_enabled:
1759
+ modalities.append("video")
1760
+
1761
return EncoderCudaGraphConfig(
1741
- modalities=["image"],
1742
- input_key="pixel_values",
1762
+ modalities=modalities,
1763
+ input_key_by_modality={
1764
+ "image": "pixel_values",
1765
+ "video": "pixel_values_videos",
1766
+ },
1767
buffer_keys=[
1768
"pos_embeds",
1769
"rotary_pos_emb_cos",
@@ -1751,49 +1775,86 @@ class Qwen3VLForConditionalGeneration(
1775
out_hidden_size=self.visual.out_hidden_size,
1776
)
1777
1778
+ def get_input_modality(
1779
+ self,
1780
+ mm_kwargs: dict[str, Any],
1781
+ ) -> str:
1782
+ if "image_grid_thw" in mm_kwargs:
1783
+ return "image"
1784
+ return "video"
1785
+
1786
def get_encoder_cudagraph_budget_range(
1787
self,
1788
vllm_config,
1789
) -> tuple[int, int]:
1790
# Min: estimated smallest possible encoder input.
1759
- # 224x224 image → 16x16 patches, spatial_merge_size=2 → 8x8 = 64 tokens
1791
+ # 224x224 image → 16x16 patches (patch_size=14)
1792
+ # spatial_merge_size=2 → 8x8 = 64 tokens
1793
min_budget = 64
1794
# Max: capped by max_num_batched_tokens
1795
max_budget = vllm_config.scheduler_config.max_num_batched_tokens
1796
return (min_budget, max_budget)
1797
1798
+ def _get_pixel_values_by_modality(
1799
+ self,
1800
+ mm_kwargs: dict[str, Any],
1801
+ ) -> torch.Tensor:
1802
+ if self.get_input_modality(mm_kwargs) == "image":
1803
+ pixel_values = mm_kwargs["pixel_values"]
1804
+ else:
1805
+ pixel_values = mm_kwargs["pixel_values_videos"]
1806
+ return pixel_values
1807
+
1808
+ def _get_grid_thw_by_modality(
1809
+ self,
1810
+ mm_kwargs: dict[str, Any],
1811
+ ) -> list[tuple[int, int, int]]:
1812
+ grid_thw_key = f"{self.get_input_modality(mm_kwargs)}_grid_thw"
1813
+ grid_thw = mm_kwargs[grid_thw_key]
1814
+ if not isinstance(grid_thw, list):
1815
+ grid_thw = grid_thw.tolist()
1816
+ return grid_thw
1817
+
1818
def get_encoder_cudagraph_num_items(
1819
self,
1820
mm_kwargs: dict[str, Any],
1821
) -> int:
1769
- return len(mm_kwargs["image_grid_thw"])
1822
+ return len(self._get_grid_thw_by_modality(mm_kwargs))
1823
1824
def get_encoder_cudagraph_per_item_output_tokens(
1825
self,
1826
mm_kwargs: dict[str, Any],
1827
) -> list[int]:
1828
m = self.visual.spatial_merge_size
1776
- return [t * (h // m) * (w // m) for t, h, w in mm_kwargs["image_grid_thw"]]
1829
+ grid_thw = self._get_grid_thw_by_modality(mm_kwargs)
1830
+ return [t * (h // m) * (w // m) for t, h, w in grid_thw]
1831
1832
def get_encoder_cudagraph_per_item_input_sizes(
1833
self,
1834
mm_kwargs: dict[str, Any],
1835
) -> list[int]:
1782
- return [t * h * w for t, h, w in mm_kwargs["image_grid_thw"]]
1836
+ grid_thw = self._get_grid_thw_by_modality(mm_kwargs)
1837
+ return [t * h * w for t, h, w in grid_thw]
1838
1839
def select_encoder_cudagraph_items(
1840
self,
1841
mm_kwargs: dict[str, Any],
1842
indices: list[int],
1843
) -> dict[str, Any]:
1789
- grid_thw = mm_kwargs["image_grid_thw"]
1790
- pixel_values = mm_kwargs["pixel_values"]
1844
+ grid_thw = self._get_grid_thw_by_modality(mm_kwargs)
1845
+ pixel_values = self._get_pixel_values_by_modality(mm_kwargs)
1846
1847
if len(indices) == 0:
1793
- return {
1794
- "pixel_values": pixel_values[:0],
1795
- "image_grid_thw": [],
1796
- }
1848
+ if self.get_input_modality(mm_kwargs) == "image":
1849
+ return {
1850
+ "pixel_values": pixel_values[:0],
1851
+ "image_grid_thw": [],
1852
+ }
1853
+ else:
1854
+ return {
1855
+ "pixel_values_videos": pixel_values[:0],
1856
+ "video_grid_thw": [],
1857
+ }
1858
1859
# Compute cumulative patch offsets for slicing pixel_values
1860
patches_per_item = [t * h * w for t, h, w in grid_thw]
@@ -1806,15 +1867,22 @@ class Qwen3VLForConditionalGeneration(
1867
)
1868
selected_grid = [grid_thw[i] for i in indices]
1869
1809
- return {
1810
- "pixel_values": selected_pv,
1811
- "image_grid_thw": selected_grid,
1812
- }
1870
+ if self.get_input_modality(mm_kwargs) == "image":
1871
+ return {
1872
+ "pixel_values": selected_pv,
1873
+ "image_grid_thw": selected_grid,
1874
+ }
1875
+ else:
1876
+ return {
1877
+ "pixel_values_videos": selected_pv,
1878
+ "video_grid_thw": selected_grid,
1879
+ }
1880
1881
def prepare_encoder_cudagraph_capture_inputs(
1882
self,
1883
token_budget: int,
1884
max_batch_size: int,
1885
+ max_frames_per_batch: int,
1886
device: torch.device,
1887
dtype: torch.dtype,
1888
):
@@ -1823,14 +1891,35 @@ class Qwen3VLForConditionalGeneration(
1891
)
1892
1893
spatial_merge_size = self.visual.spatial_merge_size
1826
- per_image_output = token_budget // max_batch_size
1827
-
1828
- # Synthetic rectangular grid: [1, merge, per_image_output * merge]
1829
- # produces exactly per_image_output tokens per image.
1830
- grid_config = [
1831
- [1, spatial_merge_size, per_image_output * spatial_merge_size]
1832
- for _ in range(max_batch_size)
1833
- ]
1894
+ per_mm_item_output = token_budget // max_batch_size
1895
+
1896
+ frames_per_item = max_frames_per_batch // max_batch_size
1897
+ if frames_per_item > 1:
1898
+ # Build the capture grid using a video-format layout so that
1899
+ # cu_seqlens is sized for video replays from the start.
1900
+ # cu_seqlens has one entry per attention sequence (one per frame),
1901
+ # so using T > 1 per item makes the buffer large enough without
1902
+ # relying solely on padding.
1903
+ # Ceiling ensures frames_per_item * tokens_per_frame >= per_mm_item_output
1904
+ # so the pixel_values buffer covers any valid single-item replay.
1905
+ tokens_per_frame = (
1906
+ per_mm_item_output + frames_per_item - 1
1907
+ ) // frames_per_item
1908
+ # Video-format grid_config (T=frames_per_item).
1909
+ grid_config = [
1910
+ [
1911
+ frames_per_item,
1912
+ spatial_merge_size,
1913
+ tokens_per_frame * spatial_merge_size,
1914
+ ]
1915
+ for _ in range(max_batch_size)
1916
+ ]
1917
+ else:
1918
+ # Image-format grid_config (T=1).
1919
+ grid_config = [
1920
+ [1, spatial_merge_size, per_mm_item_output * spatial_merge_size]
1921
+ for _ in range(max_batch_size)
1922
+ ]
1923
1924
# Create dummy pixel_values
1925
patch_embed = self.visual.patch_embed
@@ -1848,15 +1937,18 @@ class Qwen3VLForConditionalGeneration(
1937
# Override max_seqlen with a safe upper bound for capture.
1938
# max_seqlen.item() gets baked into the CUDA graph (not replayed),
1939
# so the capture value must cover any replay scenario.
1851
- # Worst case: 1 image consuming the full budget ->
1940
+ # Worst case: 1 item consuming the full budget ->
1941
# seq_len = token_budget * spatial_merge_size^2.
1942
buffers = self.visual.prepare_encoder_metadata(
1943
grid_config,
1944
max_batch_size=max_batch_size,
1945
+ max_frames_per_batch=max_frames_per_batch,
1946
max_seqlen_override=token_budget * (spatial_merge_size**2),
1947
device=device,
1948
)
1949
1950
+ # Just use image-modality dummy input_buffer for capturing, since it's also
1951
+ # compatible for video inputs (has the same shape: [num_patches, C*T*P*P]).
1952
mm_kwargs = {
1953
"pixel_values": dummy_pixel_values,
1954
"image_grid_thw": grid_config,
@@ -1871,17 +1963,21 @@ class Qwen3VLForConditionalGeneration(
1963
self,
1964
mm_kwargs: dict[str, Any],
1965
max_batch_size: int,
1966
+ max_frames_per_batch: int,
1967
):
1875
- from vllm.v1.worker.encoder_cudagraph_defs import (
1876
- EncoderCudaGraphReplayBuffers,
1877
- )
1968
+ modality = self.get_input_modality(mm_kwargs)
1969
+ grid_thw_list = self._get_grid_thw_by_modality(mm_kwargs)
1970
1879
- grid_thw_list = mm_kwargs["image_grid_thw"]
1880
-
1881
- buffers = self.visual.prepare_encoder_metadata(
1882
- grid_thw_list,
1883
- max_batch_size=max_batch_size,
1884
- )
1971
+ if modality == "image":
1972
+ buffers = self.visual.prepare_encoder_metadata(
1973
+ grid_thw_list,
1974
+ max_batch_size=max_batch_size,
1975
+ )
1976
+ else:
1977
+ buffers = self.visual.prepare_encoder_metadata(
1978
+ grid_thw_list,
1979
+ max_frames_per_batch=max_frames_per_batch,
1980
+ )
1981
1982
return EncoderCudaGraphReplayBuffers(buffers=buffers)
1983
@@ -1890,16 +1986,16 @@ class Qwen3VLForConditionalGeneration(
1986
mm_kwargs: dict[str, Any],
1987
buffers: dict[str, torch.Tensor],
1988
) -> torch.Tensor:
1893
- pixel_values = mm_kwargs["pixel_values"]
1894
- grid_thw = mm_kwargs["image_grid_thw"]
1989
+ pixel_values = self._get_pixel_values_by_modality(mm_kwargs)
1990
+ grid_thw = self._get_grid_thw_by_modality(mm_kwargs)
1991
return self.visual(pixel_values, grid_thw, encoder_metadata=buffers)
1992
1993
def encoder_eager_forward(
1994
self,
1995
mm_kwargs: dict[str, Any],
1996
) -> torch.Tensor:
1901
- pixel_values = mm_kwargs["pixel_values"]
1902
- grid_thw = mm_kwargs["image_grid_thw"]
1997
+ pixel_values = self._get_pixel_values_by_modality(mm_kwargs)
1998
+ grid_thw = self._get_grid_thw_by_modality(mm_kwargs)
1999
return self.visual(pixel_values, grid_thw)
2000
2001
def _parse_and_validate_image_input(
vllm/v1/worker/encoder_cudagraph.py
+33
-11
@@ -36,6 +36,7 @@ class BudgetGraphMetadata:
36
37
token_budget: int
38
max_batch_size: int # Max number of images/videos per batch
39
+ max_frames_per_batch: int # Max total frames per batch (for video)
40
graph: torch.cuda.CUDAGraph
41
# The input tensor updated before replay (e.g. pixel_values)
42
input_buffer: torch.Tensor
@@ -66,12 +67,13 @@ class EncoderCudaGraphManager:
67
68
comp_config = vllm_config.compilation_config
69
user_budgets = comp_config.encoder_cudagraph_token_budgets
69
- user_max_images = comp_config.encoder_cudagraph_max_images_per_batch
70
+ user_max_mm_items = comp_config.encoder_cudagraph_max_vision_items_per_batch
71
+ user_max_frames = comp_config.encoder_cudagraph_max_frames_per_batch
72
71
- if user_budgets and user_max_images > 0:
73
+ if user_budgets and user_max_mm_items > 0:
74
# Fully user-specified
75
self.token_budgets = sorted(user_budgets)
74
- self.max_batch_size = user_max_images
76
+ self.max_batch_size = user_max_mm_items
77
else:
78
# Auto-infer missing values from model
79
min_budget, max_budget = model.get_encoder_cudagraph_budget_range(
@@ -83,9 +85,15 @@ class EncoderCudaGraphManager:
85
else self._generate_budgets(min_budget, max_budget)
86
)
87
self.max_batch_size = (
86
- user_max_images if user_max_images > 0 else max_budget // min_budget
88
+ user_max_mm_items if user_max_mm_items > 0 else max_budget // min_budget
89
)
90
91
+ if user_max_frames > 0:
92
+ self.max_frames_per_batch = user_max_frames
93
+ else:
94
+ # TODO(shen-shanshan): optimize this auto-infer for max_frames_per_batch.
95
+ self.max_frames_per_batch = self.max_batch_size * 2
96
+
97
mm_config = vllm_config.model_config.multimodal_config
98
self.use_dp = (
99
mm_config is not None
@@ -100,9 +108,10 @@ class EncoderCudaGraphManager:
108
109
logger.info(
110
"EncoderCudaGraphManager initialized with "
103
- "budgets=%s, max_batch_size=%d, use_dp=%s",
111
+ "budgets=%s, max_batch_size=%d, max_frames_per_batch=%s, use_dp=%s",
112
self.token_budgets,
113
self.max_batch_size,
114
+ self.max_frames_per_batch if self.max_frames_per_batch > 0 else "auto",
115
self.use_dp,
116
)
117
@@ -136,13 +145,19 @@ class EncoderCudaGraphManager:
145
def _capture_budget_graph(self, token_budget: int):
146
"""Capture CUDA graph for a single token budget."""
147
logger.debug(
139
- "Capturing encoder cudagraph for budget=%d, max_batch_size=%d",
148
+ "Capturing encoder cudagraph for budget=%d, max_batch_size=%d, "
149
+ "max_frames_per_batch=%d",
150
token_budget,
151
self.max_batch_size,
152
+ self.max_frames_per_batch,
153
)
154
155
capture_inputs = self.model.prepare_encoder_cudagraph_capture_inputs(
145
- token_budget, self.max_batch_size, self.device, self.dtype
156
+ token_budget,
157
+ self.max_batch_size,
158
+ self.max_frames_per_batch,
159
+ self.device,
160
+ self.dtype,
161
)
162
163
mm_kwargs = capture_inputs.mm_kwargs
@@ -157,10 +172,14 @@ class EncoderCudaGraphManager:
172
output = self.model.encoder_cudagraph_forward(mm_kwargs, buffers)
173
output_buffer.copy_(output)
174
160
- input_key = self.config.input_key
175
+ # Since the image and video modalities share the same per-patch shape,
176
+ # so we can use the image dummy inputs to capture CUDA graph for both
177
+ # image and video.
178
+ input_key = self.config.input_key_by_modality["image"]
179
self.budget_graphs[token_budget] = BudgetGraphMetadata(
180
token_budget=token_budget,
181
max_batch_size=self.max_batch_size,
182
+ max_frames_per_batch=self.max_frames_per_batch,
183
graph=graph,
184
input_buffer=mm_kwargs[input_key],
185
metadata_buffers=buffers,
@@ -230,10 +249,11 @@ class EncoderCudaGraphManager:
249
# Copy the input tensor. Buffers are sized for the full budget;
250
# actual inputs may be smaller. Zero then slice-copy so padded
251
# positions are invisible to attention (cu_seqlens masks them out).
233
- input_key = self.config.input_key
252
+ input_key = self.config.input_key_by_modality[
253
+ self.model.get_input_modality(mm_kwargs)
254
+ ]
255
src = mm_kwargs[input_key]
256
n = src.shape[0]
236
- graph_meta.input_buffer.zero_()
257
graph_meta.input_buffer[:n].copy_(src)
258
259
# Copy metadata buffers using keys from config.buffer_keys.
@@ -362,7 +382,9 @@ class EncoderCudaGraphManager:
382
(token_budget - batch_out_tokens) / token_budget * 100,
383
)
384
replay = self.model.prepare_encoder_cudagraph_replay_buffers(
365
- batch_mm_kwargs, self.max_batch_size
385
+ batch_mm_kwargs,
386
+ self.max_batch_size,
387
+ self.max_frames_per_batch,
388
)
389
390
# graph_hits counted inside _run_budget_graph after replay.
vllm/v1/worker/encoder_cudagraph_defs.py
+4
-2
@@ -20,8 +20,10 @@ class EncoderCudaGraphConfig:
20
modalities: list[str]
21
"""Supported modalities (e.g. ["image"])."""
22
23
- input_key: str
24
- """Key in mm_kwargs for the input tensor (e.g. "pixel_values")."""
23
+ input_key_by_modality: dict[str, str]
24
+ """Per-modality input tensor key mapping, e.g.
25
+ {"image": "pixel_values", "video": "pixel_values_videos"}.
26
+ """
27
28
buffer_keys: list[str]
29
"""Keys for the tensor buffers recorded into the CUDA graph.