From ef696d6a2709513d6f1492d2fc17ebf69ba078b9 Mon Sep 17 00:00:00 2001 From: John Calderon Date: Thu, 23 Apr 2026 14:41:57 -0400 Subject: [PATCH 01/10] init changes Signed-off-by: John Calderon --- vllm/model_executor/models/qwen2_5_vl.py | 373 ++++++++++++++++++++++- 1 file changed, 361 insertions(+), 12 deletions(-) diff --git a/vllm/model_executor/models/qwen2_5_vl.py b/vllm/model_executor/models/qwen2_5_vl.py index c11684b4b89b..e7cdfd81ba71 100644 --- a/vllm/model_executor/models/qwen2_5_vl.py +++ b/vllm/model_executor/models/qwen2_5_vl.py @@ -85,11 +85,13 @@ from vllm.utils.platform_utils import is_pin_memory_available from vllm.utils.tensor_schema import TensorSchema, TensorShape from vllm.v1.attention.backends.registry import AttentionBackendEnum +from vllm.v1.worker.encoder_cudagraph_defs import EncoderCudaGraphReplayBuffers from .interfaces import ( MultiModalEmbeddings, SupportsEagle, SupportsEagle3, + SupportsEncoderCudaGraph, SupportsLoRA, SupportsMRoPE, SupportsMultiModal, @@ -771,21 +773,45 @@ def invert_permutation(perm: torch.Tensor) -> torch.Tensor: inv[perm] = torch.arange(perm.numel(), device=perm.device, dtype=perm.dtype) return inv - def forward( + def prepare_encoder_metadata( self, - x: torch.Tensor, grid_thw: list[list[int]], - ) -> torch.Tensor: + *, + max_batch_size: int | None = None, + max_frames_per_batch: int | None = None, + device: torch.device | None = None, + ) -> dict[str, torch.Tensor]: + """Compute encoder metadata from grid_thw. + + Shared by the eager forward path, CUDA graph capture, and + CUDA graph replay to avoid duplicated implementation. + + Args: + seq_len: Sequence length. + grid_thw: Grid configurations as list of [t, h, w]. + max_batch_size: If set, pad cu_seqlens to this size + (needed for CUDA graph capture/replay). + max_frames_per_batch: If set, overrides max_batch_size for + cu_seqlens padding. For video inputs each item contributes + T attention sequences (frames); this sizes the buffer to + the total frame budget so video replays never overflow. + max_seqlen_override: If set, use this value for max_seqlen + instead of computing from cu_seqlens (needed for CUDA + graph capture to cover worst-case replay scenarios). + device: Device to place tensors on. Defaults to self.device. + """ + + if device is None: + device = self.device + metadata: dict[str, torch.Tensor] = {} + # patchify - seq_len, _ = x.size() rotary_pos_emb_cos = [] rotary_pos_emb_sin = [] window_index: list = [] cu_window_seqlens: list = [torch.tensor([0], dtype=torch.int32)] cu_seqlens: list = [] - hidden_states = x.to(device=self.device, dtype=self.dtype) - hidden_states = self.patch_embed(hidden_states) window_index_id = 0 cu_window_seqlens_last = 0 @@ -825,24 +851,79 @@ def forward( cu_seqlens = torch.cumsum(cu_seqlens, dim=0, dtype=torch.int32) cu_seqlens = F.pad(cu_seqlens, (1, 0), "constant", 0) + # Pad cu_seqlens to the required number of sequences. + # For videos each item contributes T frames = T attention sequences, + # so the total can exceed max_batch_size. max_frames_per_batch + # overrides the pad target when set. + pad_to = ( + max_frames_per_batch if max_frames_per_batch is not None else max_batch_size + ) + if pad_to is not None: + num_seqs = len(cu_seqlens) - 1 + if num_seqs < pad_to: + cu_seqlens = np.concatenate( + [ + cu_seqlens, + np.full(pad_to - num_seqs, cu_seqlens[-1], dtype=np.int32), + ] + ) + # transformers # pre-compute seqlens for window/full attn to reduce cuMemcpy operations max_seqlen_full = self.compute_attn_mask_seqlen(cu_seqlens) max_seqlen_window = self.compute_attn_mask_seqlen(cu_window_seqlens) - cu_seqlens = cu_seqlens.to(device=self.device, non_blocking=True) - cu_window_seqlens = cu_window_seqlens.to(device=self.device, non_blocking=True) + cu_seqlens = cu_seqlens.to(device=device, non_blocking=True) + cu_window_seqlens = cu_window_seqlens.to(device=device, non_blocking=True) rotary_pos_emb_cos = rotary_pos_emb_cos.to( - device=self.device, non_blocking=True + device=device, non_blocking=True ) rotary_pos_emb_sin = rotary_pos_emb_sin.to( - device=self.device, non_blocking=True + device=device, non_blocking=True ) - window_index = window_index.to(device=hidden_states.device, non_blocking=True) + window_index = window_index.to(device=device, non_blocking=True) reverse_indices = reverse_indices.to( - device=hidden_states.device, non_blocking=True + device=device, non_blocking=True ) + metadata["rotary_pos_emb_cos"] = rotary_pos_emb_cos + metadata["rotary_pos_emb_sin"] = rotary_pos_emb_sin + metadata["window_index"] = window_index + metadata["reverse_indices"] = reverse_indices + metadata["cu_seqlens"] = cu_seqlens + metadata["cu_window_seqlens"] = cu_window_seqlens + metadata["max_seqlen_full"] = max_seqlen_full + metadata["max_seqlen_window"] = max_seqlen_window + + + return metadata + + def forward( + self, + x: torch.Tensor, + grid_thw: list[list[int]], + *, + encoder_metadata: dict[str, torch.Tensor] | None = None, + ) -> torch.Tensor: + hidden_states = x.to(device=self.device, dtype=self.dtype) + hidden_states = self.patch_embed(hidden_states) + + #### change from here #### + seq_len, _ = x.shape + if encoder_metadata is None: + encoder_metadata = self.prepare_encoder_metadata(grid_thw) + + rotary_pos_emb_cos = encoder_metadata["rotary_pos_emb_cos"] + rotary_pos_emb_sin = encoder_metadata["rotary_pos_emb_sin"] + window_index = encoder_metadata["window_index"] + reverse_indices = encoder_metadata["reverse_indices"] + cu_seqlens = encoder_metadata["cu_seqlens"] + cu_window_seqlens = encoder_metadata["cu_window_seqlens"] + max_seqlen_full = encoder_metadata["max_seqlen_full"] + max_seqlen_window = encoder_metadata["max_seqlen_window"] + + #### change to here #### + hidden_states = hidden_states.reshape( seq_len // self.spatial_merge_unit, self.spatial_merge_unit, -1 ) @@ -1003,6 +1084,7 @@ def get_replacement_qwen2vl(item_idx: int, modality: str): class Qwen2_5_VLForConditionalGeneration( nn.Module, SupportsMultiModal, + SupportsEncoderCudaGraph, SupportsLoRA, SupportsPP, SupportsQuant, @@ -1447,6 +1529,273 @@ def embed_multimodal(self, **kwargs: object) -> MultiModalEmbeddings: multimodal_embeddings += tuple(video_embeddings) return multimodal_embeddings + # -- SupportsEncoderCudaGraph protocol methods -- + + def get_encoder_cudagraph_config(self): + from vllm.v1.worker.encoder_cudagraph_defs import ( + EncoderCudaGraphConfig, + ) + + modalities = ["image"] + # NOTE: When EVS (Efficient Video Sampling) pruning is enabled, the number + # of tokens becomes data-dependent (i.e., the retained tokens are + # dynamically selected based on inter-frame differences) and therefore + # cannot be captured by CUDA Graphs. As a result, video CUDA Graphs are + # only enabled when EVS is disabled. + if not self.is_multimodal_pruning_enabled: + modalities.append("video") + + return EncoderCudaGraphConfig( + modalities=modalities, + input_key_by_modality={ + "image": "pixel_values", + "video": "pixel_values_videos", + }, + buffer_keys=[ + "rotary_pos_emb_cos", + "rotary_pos_emb_sin", + "window_index", + "reverse_indices", + "cu_seqlens", + "cu_window_seqlens", + "max_seqlen_full", + "max_seqlen_window", + ], + out_hidden_size=self.visual.out_hidden_size, + ) + + + def get_input_modality( + self, + mm_kwargs: dict[str, Any], + ) -> str: + if "image_grid_thw" in mm_kwargs: + return "image" + return "video" + + def get_max_frames_per_video(self) -> int: + mm_registry = MULTIMODAL_REGISTRY + info = mm_registry.get_processing_info(self.model_config) + max_frames_per_video = info.get_num_frames_with_most_features( + seq_len=self.model_config.max_model_len, + mm_counts={"video": self.multimodal_config.get_limit_per_prompt("video")}, + ) + return max_frames_per_video + + def get_encoder_cudagraph_budget_range( + self, + vllm_config: VllmConfig, + ) -> tuple[int, int]: + # Min: estimated smallest possible encoder input. + # 224x224 image → 16x16 patches (patch_size=14) + # spatial_merge_size=2 → 8x8 = 64 tokens + min_budget = 64 + # Max: capped by max_num_batched_tokens + max_budget = vllm_config.scheduler_config.max_num_batched_tokens + return (min_budget, max_budget) + + def _get_pixel_values_by_modality( + self, + mm_kwargs: dict[str, Any], + ) -> torch.Tensor: + if self.get_input_modality(mm_kwargs) == "image": + pixel_values = mm_kwargs["pixel_values"] + else: + pixel_values = mm_kwargs["pixel_values_videos"] + return pixel_values + + def _get_grid_thw_by_modality( + self, + mm_kwargs: dict[str, Any], + ) -> list[tuple[int, int, int]]: + grid_thw_key = f"{self.get_input_modality(mm_kwargs)}_grid_thw" + grid_thw = mm_kwargs[grid_thw_key] + if not isinstance(grid_thw, list): + grid_thw = grid_thw.tolist() + return grid_thw + + def get_encoder_cudagraph_num_items( + self, + mm_kwargs: dict[str, Any], + ) -> int: + return len(self._get_grid_thw_by_modality(mm_kwargs)) + + def get_encoder_cudagraph_per_item_output_tokens( + self, + mm_kwargs: dict[str, Any], + ) -> list[int]: + m = self.visual.spatial_merge_size + grid_thw = self._get_grid_thw_by_modality(mm_kwargs) + return [t * (h // m) * (w // m) for t, h, w in grid_thw] + + def get_encoder_cudagraph_per_item_input_sizes( + self, + mm_kwargs: dict[str, Any], + ) -> list[int]: + grid_thw = self._get_grid_thw_by_modality(mm_kwargs) + return [t * h * w for t, h, w in grid_thw] + + def select_encoder_cudagraph_items( + self, + mm_kwargs: dict[str, Any], + indices: list[int], + ) -> dict[str, Any]: + grid_thw = self._get_grid_thw_by_modality(mm_kwargs) + pixel_values = self._get_pixel_values_by_modality(mm_kwargs) + + if len(indices) == 0: + if self.get_input_modality(mm_kwargs) == "image": + return { + "pixel_values": pixel_values[:0], + "image_grid_thw": [], + } + else: + return { + "pixel_values_videos": pixel_values[:0], + "video_grid_thw": [], + } + + # Compute cumulative patch offsets for slicing pixel_values + patches_per_item = [t * h * w for t, h, w in grid_thw] + cum_patches = [0] + for p in patches_per_item: + cum_patches.append(cum_patches[-1] + p) + + selected_pv = torch.cat( + [pixel_values[cum_patches[i] : cum_patches[i + 1]] for i in indices] + ) + selected_grid = [grid_thw[i] for i in indices] + + if self.get_input_modality(mm_kwargs) == "image": + return { + "pixel_values": selected_pv, + "image_grid_thw": selected_grid, + } + else: + return { + "pixel_values_videos": selected_pv, + "video_grid_thw": selected_grid, + } + + def prepare_encoder_cudagraph_capture_inputs( + self, + token_budget: int, + max_batch_size: int, + max_frames_per_batch: int, + device: torch.device, + dtype: torch.dtype, + ): + from vllm.v1.worker.encoder_cudagraph_defs import ( + EncoderCudaGraphCaptureInputs, + ) + + spatial_merge_size = self.visual.spatial_merge_size + per_mm_item_output = token_budget // max_batch_size + + frames_per_item = max_frames_per_batch // max_batch_size + if frames_per_item > 1: + # Build the capture grid using a video-format layout so that + # cu_seqlens is sized for video replays from the start. + # cu_seqlens has one entry per attention sequence (one per frame), + # so using T > 1 per item makes the buffer large enough without + # relying solely on padding. + # Ceiling ensures frames_per_item * tokens_per_frame >= per_mm_item_output + # so the pixel_values buffer covers any valid single-item replay. + tokens_per_frame = ( + per_mm_item_output + frames_per_item - 1 + ) // frames_per_item + # Video-format grid_config (T=frames_per_item). + grid_config = [ + [ + frames_per_item, + spatial_merge_size, + tokens_per_frame * spatial_merge_size, + ] + for _ in range(max_batch_size) + ] + else: + # Image-format grid_config (T=1). + grid_config = [ + [1, spatial_merge_size, per_mm_item_output * spatial_merge_size] + for _ in range(max_batch_size) + ] + + # Create dummy pixel_values + patch_embed = self.visual.patch_embed + in_channels = patch_embed.proj.in_channels + patch_size = patch_embed.patch_size + temporal_patch_size = patch_embed.temporal_patch_size + total_patches = sum(t * h * w for t, h, w in grid_config) + flattened_patch_size = ( + in_channels * temporal_patch_size * patch_size * patch_size + ) + dummy_pixel_values = torch.randn( + total_patches, flattened_patch_size, device=device, dtype=dtype + ) + + # Override max_seqlen with a safe upper bound for capture. + # max_seqlen.item() gets baked into the CUDA graph (not replayed), + # so the capture value must cover any replay scenario. + # Worst case: 1 item consuming the full budget -> + # seq_len = token_budget * spatial_merge_size^2. + buffers = self.visual.prepare_encoder_metadata( + grid_config, + max_batch_size=max_batch_size, + max_frames_per_batch=max_frames_per_batch, + device=device, + ) + + # Just use image-modality dummy input_buffer for capturing, since it's also + # compatible for video inputs (has the same shape: [num_patches, C*T*P*P]). + mm_kwargs = { + "pixel_values": dummy_pixel_values, + "image_grid_thw": grid_config, + } + + return EncoderCudaGraphCaptureInputs( + mm_kwargs=mm_kwargs, + buffers=buffers, + ) + + def prepare_encoder_cudagraph_replay_buffers( + self, + mm_kwargs: dict[str, Any], + max_batch_size: int, + max_frames_per_batch: int, + ): + modality = self.get_input_modality(mm_kwargs) + grid_thw_list = self._get_grid_thw_by_modality(mm_kwargs) + + if modality == "image": + buffers = self.visual.prepare_encoder_metadata( + grid_thw_list, + max_batch_size=max_batch_size, + ) + else: + buffers = self.visual.prepare_encoder_metadata( + grid_thw_list, + max_frames_per_batch=max_frames_per_batch, + ) + + return EncoderCudaGraphReplayBuffers(buffers=buffers) + + def encoder_cudagraph_forward( + self, + mm_kwargs: dict[str, Any], + buffers: dict[str, torch.Tensor], + ) -> torch.Tensor: + pixel_values = self._get_pixel_values_by_modality(mm_kwargs) + grid_thw = self._get_grid_thw_by_modality(mm_kwargs) + return self.visual(pixel_values, grid_thw, encoder_metadata=buffers) + + def encoder_eager_forward( + self, + mm_kwargs: dict[str, Any], + ) -> torch.Tensor: + pixel_values = self._get_pixel_values_by_modality(mm_kwargs) + grid_thw = self._get_grid_thw_by_modality(mm_kwargs) + return self.visual(pixel_values, grid_thw) + def forward( self, input_ids: torch.Tensor | None, From 129d2883be3394b94cda86d7421cdcc5506f595c Mon Sep 17 00:00:00 2001 From: John Calderon Date: Thu, 23 Apr 2026 16:17:21 -0400 Subject: [PATCH 02/10] initial fixes and adjustments Signed-off-by: John Calderon --- vllm/model_executor/models/qwen2_5_vl.py | 30 ++++++++++++++---------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/vllm/model_executor/models/qwen2_5_vl.py b/vllm/model_executor/models/qwen2_5_vl.py index e7cdfd81ba71..4d970ffeec22 100644 --- a/vllm/model_executor/models/qwen2_5_vl.py +++ b/vllm/model_executor/models/qwen2_5_vl.py @@ -779,15 +779,15 @@ def prepare_encoder_metadata( *, max_batch_size: int | None = None, max_frames_per_batch: int | None = None, + max_seqlen_override: int | None = None, device: torch.device | None = None, ) -> dict[str, torch.Tensor]: """Compute encoder metadata from grid_thw. - + Shared by the eager forward path, CUDA graph capture, and CUDA graph replay to avoid duplicated implementation. Args: - seq_len: Sequence length. grid_thw: Grid configurations as list of [t, h, w]. max_batch_size: If set, pad cu_seqlens to this size (needed for CUDA graph capture/replay). @@ -861,16 +861,24 @@ def prepare_encoder_metadata( if pad_to is not None: num_seqs = len(cu_seqlens) - 1 if num_seqs < pad_to: - cu_seqlens = np.concatenate( - [ + cu_seqlens = torch.cat( + ( cu_seqlens, - np.full(pad_to - num_seqs, cu_seqlens[-1], dtype=np.int32), - ] + torch.full( + (pad_to - num_seqs,), + cu_seqlens[-1], + dtype=cu_seqlens.dtype, + device=cu_seqlens.device, + ), + ) ) # transformers # pre-compute seqlens for window/full attn to reduce cuMemcpy operations - max_seqlen_full = self.compute_attn_mask_seqlen(cu_seqlens) + if max_seqlen_override is None: + max_seqlen_full = self.compute_attn_mask_seqlen(cu_seqlens) + else: + max_seqlen_full = torch.tensor(max_seqlen_override, dtype=torch.int32) max_seqlen_window = self.compute_attn_mask_seqlen(cu_window_seqlens) cu_seqlens = cu_seqlens.to(device=device, non_blocking=True) @@ -907,9 +915,8 @@ def forward( ) -> torch.Tensor: hidden_states = x.to(device=self.device, dtype=self.dtype) hidden_states = self.patch_embed(hidden_states) - - #### change from here #### - seq_len, _ = x.shape + + seq_len = hidden_states.shape[0] if encoder_metadata is None: encoder_metadata = self.prepare_encoder_metadata(grid_thw) @@ -922,8 +929,6 @@ def forward( max_seqlen_full = encoder_metadata["max_seqlen_full"] max_seqlen_window = encoder_metadata["max_seqlen_window"] - #### change to here #### - hidden_states = hidden_states.reshape( seq_len // self.spatial_merge_unit, self.spatial_merge_unit, -1 ) @@ -1742,6 +1747,7 @@ def prepare_encoder_cudagraph_capture_inputs( grid_config, max_batch_size=max_batch_size, max_frames_per_batch=max_frames_per_batch, + max_seqlen_override=token_budget * (spatial_merge_size**2), device=device, ) From 493a4247d50ae173d9c52e19b3e8005923bb49c8 Mon Sep 17 00:00:00 2001 From: John Calderon Date: Thu, 23 Apr 2026 16:21:10 -0400 Subject: [PATCH 03/10] fix missing attr Signed-off-by: John Calderon --- vllm/model_executor/models/qwen2_5_vl.py | 1 + 1 file changed, 1 insertion(+) diff --git a/vllm/model_executor/models/qwen2_5_vl.py b/vllm/model_executor/models/qwen2_5_vl.py index 4d970ffeec22..979ece71a0fa 100644 --- a/vllm/model_executor/models/qwen2_5_vl.py +++ b/vllm/model_executor/models/qwen2_5_vl.py @@ -1211,6 +1211,7 @@ def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""): self.use_data_parallel = multimodal_config.mm_encoder_tp_mode == "data" self.config = config + self.model_config = vllm_config.model_config self.vllm_config = vllm_config self.multimodal_config = multimodal_config self.video_pruning_rate = multimodal_config.video_pruning_rate From c37f5139a53b7d711b269ad4f5cb0b3ae82217ac Mon Sep 17 00:00:00 2001 From: John Calderon Date: Thu, 23 Apr 2026 17:07:02 -0400 Subject: [PATCH 04/10] fix bug cuda replay Signed-off-by: John Calderon --- vllm/model_executor/models/qwen2_5_vl.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/vllm/model_executor/models/qwen2_5_vl.py b/vllm/model_executor/models/qwen2_5_vl.py index 979ece71a0fa..144faa96eebe 100644 --- a/vllm/model_executor/models/qwen2_5_vl.py +++ b/vllm/model_executor/models/qwen2_5_vl.py @@ -1696,7 +1696,13 @@ def prepare_encoder_cudagraph_capture_inputs( ) spatial_merge_size = self.visual.spatial_merge_size - per_mm_item_output = token_budget // max_batch_size + # Use ceil here (not floor) so total captured capacity is never smaller + # than token_budget when token_budget is not divisible by max_batch_size + # (e.g., 324 budget with max_batch_size=8). Floor under-allocates + # input_buffer and can fail replay copy for valid single-item batches. + per_mm_item_output = ( + token_budget + max_batch_size - 1 + ) // max_batch_size frames_per_item = max_frames_per_batch // max_batch_size if frames_per_item > 1: From 6ded9d78b076cb1400d39b642b5226d7154386e8 Mon Sep 17 00:00:00 2001 From: John Calderon Date: Fri, 24 Apr 2026 10:12:30 -0400 Subject: [PATCH 05/10] fix format Signed-off-by: John Calderon --- vllm/model_executor/models/qwen2_5_vl.py | 37 +++++++++--------------- 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/vllm/model_executor/models/qwen2_5_vl.py b/vllm/model_executor/models/qwen2_5_vl.py index 144faa96eebe..228293ed59b2 100644 --- a/vllm/model_executor/models/qwen2_5_vl.py +++ b/vllm/model_executor/models/qwen2_5_vl.py @@ -812,7 +812,6 @@ def prepare_encoder_metadata( cu_window_seqlens: list = [torch.tensor([0], dtype=torch.int32)] cu_seqlens: list = [] - window_index_id = 0 cu_window_seqlens_last = 0 for t, h, w in grid_thw: @@ -883,16 +882,10 @@ def prepare_encoder_metadata( cu_seqlens = cu_seqlens.to(device=device, non_blocking=True) cu_window_seqlens = cu_window_seqlens.to(device=device, non_blocking=True) - rotary_pos_emb_cos = rotary_pos_emb_cos.to( - device=device, non_blocking=True - ) - rotary_pos_emb_sin = rotary_pos_emb_sin.to( - device=device, non_blocking=True - ) + rotary_pos_emb_cos = rotary_pos_emb_cos.to(device=device, non_blocking=True) + rotary_pos_emb_sin = rotary_pos_emb_sin.to(device=device, non_blocking=True) window_index = window_index.to(device=device, non_blocking=True) - reverse_indices = reverse_indices.to( - device=device, non_blocking=True - ) + reverse_indices = reverse_indices.to(device=device, non_blocking=True) metadata["rotary_pos_emb_cos"] = rotary_pos_emb_cos metadata["rotary_pos_emb_sin"] = rotary_pos_emb_sin @@ -903,7 +896,6 @@ def prepare_encoder_metadata( metadata["max_seqlen_full"] = max_seqlen_full metadata["max_seqlen_window"] = max_seqlen_window - return metadata def forward( @@ -1550,7 +1542,7 @@ def get_encoder_cudagraph_config(self): # only enabled when EVS is disabled. if not self.is_multimodal_pruning_enabled: modalities.append("video") - + return EncoderCudaGraphConfig( modalities=modalities, input_key_by_modality={ @@ -1558,19 +1550,18 @@ def get_encoder_cudagraph_config(self): "video": "pixel_values_videos", }, buffer_keys=[ - "rotary_pos_emb_cos", - "rotary_pos_emb_sin", - "window_index", - "reverse_indices", - "cu_seqlens", - "cu_window_seqlens", - "max_seqlen_full", - "max_seqlen_window", + "rotary_pos_emb_cos", + "rotary_pos_emb_sin", + "window_index", + "reverse_indices", + "cu_seqlens", + "cu_window_seqlens", + "max_seqlen_full", + "max_seqlen_window", ], out_hidden_size=self.visual.out_hidden_size, ) - def get_input_modality( self, mm_kwargs: dict[str, Any], @@ -1700,9 +1691,7 @@ def prepare_encoder_cudagraph_capture_inputs( # than token_budget when token_budget is not divisible by max_batch_size # (e.g., 324 budget with max_batch_size=8). Floor under-allocates # input_buffer and can fail replay copy for valid single-item batches. - per_mm_item_output = ( - token_budget + max_batch_size - 1 - ) // max_batch_size + per_mm_item_output = (token_budget + max_batch_size - 1) // max_batch_size frames_per_item = max_frames_per_batch // max_batch_size if frames_per_item > 1: From 30d218cc413b6b0dbd52a71ceb78ae8d750a51ba Mon Sep 17 00:00:00 2001 From: John Calderon Date: Fri, 24 Apr 2026 16:07:17 -0400 Subject: [PATCH 06/10] fix format Signed-off-by: John Calderon --- .../multimodal/generation/test_qwen2_5_vl.py | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/models/multimodal/generation/test_qwen2_5_vl.py b/tests/models/multimodal/generation/test_qwen2_5_vl.py index 3ba665710af4..6fe3e613ada6 100644 --- a/tests/models/multimodal/generation/test_qwen2_5_vl.py +++ b/tests/models/multimodal/generation/test_qwen2_5_vl.py @@ -4,6 +4,7 @@ import pytest from vllm.multimodal.video import sample_frames_from_video +from vllm.platforms import current_platform from ....conftest import VIDEO_ASSETS @@ -28,6 +29,15 @@ def qwen2_5_vl_chat_template(*query): ) +def _vision_encoder_cudagraph_compilation_config(*, cudagraph_mm_encoder: bool) -> dict: + # When `compilation_config` is passed, VllmRunner does not add defaults; keep + # decoder cudagraph sizes aligned with conftest and only toggle ViT paths. + return { + "cudagraph_capture_sizes": [4], + "cudagraph_mm_encoder": cudagraph_mm_encoder, + } + + @pytest.mark.core_model @pytest.mark.parametrize("model", models) @pytest.mark.parametrize("video_pruning_rate", [0.0, 0.75]) @@ -146,3 +156,63 @@ def test_qwen2_5_vl_evs_batched_videos( # Ensure the output is a string assert isinstance(output_text, str) + + +@pytest.mark.core_model +@pytest.mark.skipif( + not current_platform.is_cuda(), reason="ViT encoder CUDA graphs require CUDA" +) +def test_qwen2_5_vl_vision_transformer_cudagraph_matches_eager( + vllm_runner, + video_assets, +) -> None: + """Greedy token ids should match with and without encoder CUDA graph replay. + + Video EVS pruning must be off so the vision encoder is eligible for CUDA + graph capture (see ``get_encoder_cudagraph_config`` on Qwen2.5-VL). + """ + model = models[0] + dtype = target_dtype + num_frames = 16 + max_tokens = 64 + # No EVS: ``video_pruning_rate`` must not be > 0, or video CUDA graphs + # are disabled and this test would not exercise the vision encoder path. + sampled_vids = [ + sample_frames_from_video(asset.np_ndarrays, num_frames) + for asset in video_assets + ] + prompts = [VIDEO_PROMPTS[0]] + videos = [sampled_vids[0]] + # Encoder CUDA graphs add substantial graph-pool memory; cap concurrency and + # GPU fraction so init (decoder graphs + optional encoder graphs + KV) fits. + common_kwargs = dict( + runner="generate", + max_model_len=4000, + max_num_seqs=16, + gpu_memory_utilization=0.85, + dtype=dtype, + limit_mm_per_prompt={"video": 1}, + video_pruning_rate=0.0, + ) + + with vllm_runner( + model, + **common_kwargs, + compilation_config=_vision_encoder_cudagraph_compilation_config( + cudagraph_mm_encoder=False + ), + ) as mm_eager: + eager_ids = mm_eager.generate_greedy(prompts, max_tokens, videos=videos)[0][0] + + with vllm_runner( + model, + **common_kwargs, + compilation_config=_vision_encoder_cudagraph_compilation_config( + cudagraph_mm_encoder=True + ), + ) as mm_cudagraph: + graph_ids = mm_cudagraph.generate_greedy(prompts, max_tokens, videos=videos)[0][ + 0 + ] + + assert eager_ids == graph_ids From cdcb46bb3fc13b1ec51a89babe95180bd78cedc5 Mon Sep 17 00:00:00 2001 From: John Calderon Date: Mon, 27 Apr 2026 09:55:55 -0400 Subject: [PATCH 07/10] fix format Signed-off-by: John Calderon --- docs/design/cuda_graphs_multimodal.md | 1 + examples/offline_inference/vision_language.py | 1 + .../multimodal/generation/test_qwen2_5_vl.py | 70 ------------------- vllm/model_executor/models/qwen2_5_vl.py | 5 +- 4 files changed, 6 insertions(+), 71 deletions(-) diff --git a/docs/design/cuda_graphs_multimodal.md b/docs/design/cuda_graphs_multimodal.md index e32010232ef0..4020e294bbde 100644 --- a/docs/design/cuda_graphs_multimodal.md +++ b/docs/design/cuda_graphs_multimodal.md @@ -86,6 +86,7 @@ Models opt-in to encoder CUDA Graphs by implementing the [SupportsEncoderCudaGra | Architecture | Models | CG for Image | CG for Video | | ------------ | ------ | ------------ | ------------ | | `Qwen3VLForConditionalGeneration` | `Qwen3-VL` | ✅︎ | ✅︎ | +| `Qwen2_5_VLForConditionalGeneration` | `Qwen2.5-VL` | ✅︎ | ✅︎ | !!! note Encoder CUDA Graphs have currently been tested with `--mm-encoder-attn-backend=FLASH_ATTN` and `--mm-encoder-attn-backend=FLASHINFER` on Blackwell GPUs. diff --git a/examples/offline_inference/vision_language.py b/examples/offline_inference/vision_language.py index cfeda8804a04..6bc2710d85ae 100755 --- a/examples/offline_inference/vision_language.py +++ b/examples/offline_inference/vision_language.py @@ -2466,6 +2466,7 @@ def run_tarsier2(questions: list[str], modality: str) -> ModelRequestData: MODELS_SUPPORT_VIT_CUDA_GRAPH = [ "qwen3_vl", "qwen3_vl_moe", + "qwen2_5_vl", ] diff --git a/tests/models/multimodal/generation/test_qwen2_5_vl.py b/tests/models/multimodal/generation/test_qwen2_5_vl.py index 6fe3e613ada6..3ba665710af4 100644 --- a/tests/models/multimodal/generation/test_qwen2_5_vl.py +++ b/tests/models/multimodal/generation/test_qwen2_5_vl.py @@ -4,7 +4,6 @@ import pytest from vllm.multimodal.video import sample_frames_from_video -from vllm.platforms import current_platform from ....conftest import VIDEO_ASSETS @@ -29,15 +28,6 @@ def qwen2_5_vl_chat_template(*query): ) -def _vision_encoder_cudagraph_compilation_config(*, cudagraph_mm_encoder: bool) -> dict: - # When `compilation_config` is passed, VllmRunner does not add defaults; keep - # decoder cudagraph sizes aligned with conftest and only toggle ViT paths. - return { - "cudagraph_capture_sizes": [4], - "cudagraph_mm_encoder": cudagraph_mm_encoder, - } - - @pytest.mark.core_model @pytest.mark.parametrize("model", models) @pytest.mark.parametrize("video_pruning_rate", [0.0, 0.75]) @@ -156,63 +146,3 @@ def test_qwen2_5_vl_evs_batched_videos( # Ensure the output is a string assert isinstance(output_text, str) - - -@pytest.mark.core_model -@pytest.mark.skipif( - not current_platform.is_cuda(), reason="ViT encoder CUDA graphs require CUDA" -) -def test_qwen2_5_vl_vision_transformer_cudagraph_matches_eager( - vllm_runner, - video_assets, -) -> None: - """Greedy token ids should match with and without encoder CUDA graph replay. - - Video EVS pruning must be off so the vision encoder is eligible for CUDA - graph capture (see ``get_encoder_cudagraph_config`` on Qwen2.5-VL). - """ - model = models[0] - dtype = target_dtype - num_frames = 16 - max_tokens = 64 - # No EVS: ``video_pruning_rate`` must not be > 0, or video CUDA graphs - # are disabled and this test would not exercise the vision encoder path. - sampled_vids = [ - sample_frames_from_video(asset.np_ndarrays, num_frames) - for asset in video_assets - ] - prompts = [VIDEO_PROMPTS[0]] - videos = [sampled_vids[0]] - # Encoder CUDA graphs add substantial graph-pool memory; cap concurrency and - # GPU fraction so init (decoder graphs + optional encoder graphs + KV) fits. - common_kwargs = dict( - runner="generate", - max_model_len=4000, - max_num_seqs=16, - gpu_memory_utilization=0.85, - dtype=dtype, - limit_mm_per_prompt={"video": 1}, - video_pruning_rate=0.0, - ) - - with vllm_runner( - model, - **common_kwargs, - compilation_config=_vision_encoder_cudagraph_compilation_config( - cudagraph_mm_encoder=False - ), - ) as mm_eager: - eager_ids = mm_eager.generate_greedy(prompts, max_tokens, videos=videos)[0][0] - - with vllm_runner( - model, - **common_kwargs, - compilation_config=_vision_encoder_cudagraph_compilation_config( - cudagraph_mm_encoder=True - ), - ) as mm_cudagraph: - graph_ids = mm_cudagraph.generate_greedy(prompts, max_tokens, videos=videos)[0][ - 0 - ] - - assert eager_ids == graph_ids diff --git a/vllm/model_executor/models/qwen2_5_vl.py b/vllm/model_executor/models/qwen2_5_vl.py index 228293ed59b2..8a15079d01c8 100644 --- a/vllm/model_executor/models/qwen2_5_vl.py +++ b/vllm/model_executor/models/qwen2_5_vl.py @@ -1588,7 +1588,10 @@ def get_encoder_cudagraph_budget_range( # spatial_merge_size=2 → 8x8 = 64 tokens min_budget = 64 # Max: capped by max_num_batched_tokens - max_budget = vllm_config.scheduler_config.max_num_batched_tokens + max_budget = min( + vllm_config.scheduler_config.max_num_batched_tokens, + self.model_config.max_model_len, + ) return (min_budget, max_budget) def _get_pixel_values_by_modality( From 5ad6e74dc17c7d33235cd500d3484f142955a707 Mon Sep 17 00:00:00 2001 From: John Calderon Date: Wed, 29 Apr 2026 10:59:03 -0400 Subject: [PATCH 08/10] address PR comments, modify max_seqlen_window and cu_window_seqlens Signed-off-by: John Calderon --- docs/design/cuda_graphs_multimodal.md | 1 + vllm/model_executor/models/qwen2_5_vl.py | 72 +++++++++++++++++++++--- 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/docs/design/cuda_graphs_multimodal.md b/docs/design/cuda_graphs_multimodal.md index 4020e294bbde..f44ef359df38 100644 --- a/docs/design/cuda_graphs_multimodal.md +++ b/docs/design/cuda_graphs_multimodal.md @@ -90,6 +90,7 @@ Models opt-in to encoder CUDA Graphs by implementing the [SupportsEncoderCudaGra !!! note Encoder CUDA Graphs have currently been tested with `--mm-encoder-attn-backend=FLASH_ATTN` and `--mm-encoder-attn-backend=FLASHINFER` on Blackwell GPUs. + For Qwen2.5-VL only FA2 and FA3 has been tested. ## Configuration diff --git a/vllm/model_executor/models/qwen2_5_vl.py b/vllm/model_executor/models/qwen2_5_vl.py index 8a15079d01c8..54334c91bfa6 100644 --- a/vllm/model_executor/models/qwen2_5_vl.py +++ b/vllm/model_executor/models/qwen2_5_vl.py @@ -779,7 +779,9 @@ def prepare_encoder_metadata( *, max_batch_size: int | None = None, max_frames_per_batch: int | None = None, + max_window_seqs_per_batch: int | None = None, max_seqlen_override: int | None = None, + max_seqlen_window_override: int | None = None, device: torch.device | None = None, ) -> dict[str, torch.Tensor]: """Compute encoder metadata from grid_thw. @@ -795,9 +797,16 @@ def prepare_encoder_metadata( cu_seqlens padding. For video inputs each item contributes T attention sequences (frames); this sizes the buffer to the total frame budget so video replays never overflow. + max_window_seqs_per_batch: If set, pad cu_window_seqlens to this + number of window sequences. This keeps cu_window_seqlens shape + stable across capture/replay for CUDA graph safety. max_seqlen_override: If set, use this value for max_seqlen instead of computing from cu_seqlens (needed for CUDA graph capture to cover worst-case replay scenarios). + max_seqlen_window_override: If set, use this value for + window-attention max_seqlen instead of computing from + cu_window_seqlens (needed for CUDA graph capture to + cover worst-case replay scenarios). device: Device to place tensors on. Defaults to self.device. """ @@ -872,13 +881,36 @@ def prepare_encoder_metadata( ) ) + # Pad cu_window_seqlens to a stable number of window sequences. + # Like cu_seqlens, we repeat the last cumulative offset so padded + # entries represent empty sequences. + if max_window_seqs_per_batch is not None: + num_window_seqs = len(cu_window_seqlens) - 1 + if num_window_seqs < max_window_seqs_per_batch: + cu_window_seqlens = torch.cat( + ( + cu_window_seqlens, + torch.full( + (max_window_seqs_per_batch - num_window_seqs,), + cu_window_seqlens[-1], + dtype=cu_window_seqlens.dtype, + device=cu_window_seqlens.device, + ), + ) + ) + # transformers # pre-compute seqlens for window/full attn to reduce cuMemcpy operations if max_seqlen_override is None: max_seqlen_full = self.compute_attn_mask_seqlen(cu_seqlens) else: max_seqlen_full = torch.tensor(max_seqlen_override, dtype=torch.int32) - max_seqlen_window = self.compute_attn_mask_seqlen(cu_window_seqlens) + if max_seqlen_window_override is None: + max_seqlen_window = self.compute_attn_mask_seqlen(cu_window_seqlens) + else: + max_seqlen_window = torch.tensor( + max_seqlen_window_override, dtype=torch.int32 + ) cu_seqlens = cu_seqlens.to(device=device, non_blocking=True) cu_window_seqlens = cu_window_seqlens.to(device=device, non_blocking=True) @@ -1534,14 +1566,12 @@ def get_encoder_cudagraph_config(self): EncoderCudaGraphConfig, ) - modalities = ["image"] - # NOTE: When EVS (Efficient Video Sampling) pruning is enabled, the number - # of tokens becomes data-dependent (i.e., the retained tokens are - # dynamically selected based on inter-frame differences) and therefore - # cannot be captured by CUDA Graphs. As a result, video CUDA Graphs are - # only enabled when EVS is disabled. - if not self.is_multimodal_pruning_enabled: - modalities.append("video") + # NOTE: With EVS pruning enabled, multimodal embeddings are post-processed + # (append positions for image and prune+append positions for video) in + # embed_multimodal(). The encoder CUDA graph path bypasses that postprocess + # hook, so disable CUDA graph for all modalities to avoid inconsistent + # embedding formats between eager and cudagraph paths. + modalities = [] if self.is_multimodal_pruning_enabled else ["image", "video"] return EncoderCudaGraphConfig( modalities=modalities, @@ -1690,6 +1720,10 @@ def prepare_encoder_cudagraph_capture_inputs( ) spatial_merge_size = self.visual.spatial_merge_size + max_window_seqs_per_batch = min( + self.vllm_config.scheduler_config.max_num_batched_tokens, + self.model_config.max_model_len, + ) # Use ceil here (not floor) so total captured capacity is never smaller # than token_budget when token_budget is not divisible by max_batch_size # (e.g., 324 budget with max_batch_size=8). Floor under-allocates @@ -1742,11 +1776,23 @@ def prepare_encoder_cudagraph_capture_inputs( # so the capture value must cover any replay scenario. # Worst case: 1 item consuming the full budget -> # seq_len = token_budget * spatial_merge_size^2. + # For window-attention, each local window is bounded by fixed geometry: + # (window_size / patch_size / spatial_merge_size)^2 windows in merged + # token space, multiplied by spatial_merge_size^2 to map back to the + # unmerged sequence length used by attention kernels. + vit_merger_window_size = ( + self.visual.window_size + // self.visual.spatial_merge_size + // self.visual.patch_size + ) + max_seqlen_window_override = vit_merger_window_size**2 * (spatial_merge_size**2) buffers = self.visual.prepare_encoder_metadata( grid_config, max_batch_size=max_batch_size, max_frames_per_batch=max_frames_per_batch, + max_window_seqs_per_batch=max_window_seqs_per_batch, max_seqlen_override=token_budget * (spatial_merge_size**2), + max_seqlen_window_override=max_seqlen_window_override, device=device, ) @@ -1775,11 +1821,19 @@ def prepare_encoder_cudagraph_replay_buffers( buffers = self.visual.prepare_encoder_metadata( grid_thw_list, max_batch_size=max_batch_size, + max_window_seqs_per_batch=min( + self.vllm_config.scheduler_config.max_num_batched_tokens, + self.model_config.max_model_len, + ), ) else: buffers = self.visual.prepare_encoder_metadata( grid_thw_list, max_frames_per_batch=max_frames_per_batch, + max_window_seqs_per_batch=min( + self.vllm_config.scheduler_config.max_num_batched_tokens, + self.model_config.max_model_len, + ), ) return EncoderCudaGraphReplayBuffers(buffers=buffers) From d6c8a4085ca204bb8e3b03e66956861ac0f5eedc Mon Sep 17 00:00:00 2001 From: John Calderon Date: Wed, 29 Apr 2026 15:50:48 -0400 Subject: [PATCH 09/10] complement unit tests Signed-off-by: John Calderon --- .../multimodal/generation/test_qwen2_5_vl.py | 86 +++++++++++++++++++ .../generation/test_vit_cudagraph.py | 13 ++- 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/tests/models/multimodal/generation/test_qwen2_5_vl.py b/tests/models/multimodal/generation/test_qwen2_5_vl.py index 3ba665710af4..80334a495710 100644 --- a/tests/models/multimodal/generation/test_qwen2_5_vl.py +++ b/tests/models/multimodal/generation/test_qwen2_5_vl.py @@ -3,6 +3,7 @@ import pytest +from vllm.assets.image import ImageAsset from vllm.multimodal.video import sample_frames_from_video from ....conftest import VIDEO_ASSETS @@ -11,6 +12,7 @@ target_dtype = "bfloat16" VIDEO_PLACEHOLDER = "<|vision_start|><|video_pad|><|vision_end|>" +IMAGE_PLACEHOLDER = "<|vision_start|><|image_pad|><|vision_end|>" def qwen2_5_vl_chat_template(*query): @@ -28,6 +30,18 @@ def qwen2_5_vl_chat_template(*query): ) +WINDOW_ATTN_IMAGE_PROMPT = qwen2_5_vl_chat_template( + IMAGE_PLACEHOLDER, + "Describe the image.", +) + + +def _window_attention_regression_image(): + # image from regression issue: https://github.com/vllm-project/vllm/issues/15122 + image = ImageAsset("hato").pil_image + return image.resize((image.width // 2, image.height // 2)) + + @pytest.mark.core_model @pytest.mark.parametrize("model", models) @pytest.mark.parametrize("video_pruning_rate", [0.0, 0.75]) @@ -146,3 +160,75 @@ def test_qwen2_5_vl_evs_batched_videos( # Ensure the output is a string assert isinstance(output_text, str) + + +@pytest.mark.core_model +@pytest.mark.parametrize("model", models) +@pytest.mark.parametrize("dtype", [target_dtype]) +@pytest.mark.parametrize("max_tokens", [128]) +@pytest.mark.parametrize("use_bytecode_hook", [True, False]) +def test_qwen2_5_vl_window_attention_image( + vllm_runner, + model, + dtype: str, + max_tokens: int, + use_bytecode_hook: bool, + monkeypatch, +) -> None: + """Regression test for Qwen2.5 window-attention image path.""" + monkeypatch.setenv("VLLM_USE_BYTECODE_HOOK", "1" if use_bytecode_hook else "0") + + prompt = [WINDOW_ATTN_IMAGE_PROMPT] + images = [[_window_attention_regression_image()]] + + with vllm_runner( + model, + runner="generate", + max_model_len=4096, + dtype=dtype, + limit_mm_per_prompt={"image": 1}, + ) as vllm_model: + outputs = vllm_model.generate_greedy(prompt, max_tokens, images=images) + + assert len(outputs) == 1 + output_ids, output_text = outputs[0] + assert len(output_ids) > 0 + assert len(output_text) > 0 + assert isinstance(output_text, str) + + +@pytest.mark.core_model +@pytest.mark.parametrize("model", models) +@pytest.mark.parametrize("dtype", [target_dtype]) +@pytest.mark.parametrize("max_tokens", [128]) +@pytest.mark.parametrize("use_bytecode_hook", [True, False]) +def test_qwen2_5_vl_window_attention_image_batch( + vllm_runner, + model, + dtype: str, + max_tokens: int, + use_bytecode_hook: bool, + monkeypatch, +) -> None: + """Regression test window-attention with a small image batch.""" + monkeypatch.setenv("VLLM_USE_BYTECODE_HOOK", "1" if use_bytecode_hook else "0") + + image = _window_attention_regression_image() + prompts = [WINDOW_ATTN_IMAGE_PROMPT, WINDOW_ATTN_IMAGE_PROMPT] + images = [[image], [image]] + + with vllm_runner( + model, + runner="generate", + max_model_len=4096, + max_num_seqs=2, + dtype=dtype, + limit_mm_per_prompt={"image": 1}, + ) as vllm_model: + outputs = vllm_model.generate_greedy(prompts, max_tokens, images=images) + + assert len(outputs) == 2 + for output_ids, output_text in outputs: + assert len(output_ids) > 0 + assert len(output_text) > 0 + assert isinstance(output_text, str) diff --git a/tests/models/multimodal/generation/test_vit_cudagraph.py b/tests/models/multimodal/generation/test_vit_cudagraph.py index 7adea0771b6d..fb7bdfc8625d 100644 --- a/tests/models/multimodal/generation/test_vit_cudagraph.py +++ b/tests/models/multimodal/generation/test_vit_cudagraph.py @@ -54,7 +54,18 @@ def qwen_vl_chat_template(content: str) -> str: needs_video_metadata=True, marks=[pytest.mark.core_model], ), - # TODO: Add more models below. + "qwen2_5_vl": VitCudagraphTestConfig( + model="Qwen/Qwen2.5-VL-3B-Instruct", + image_prompt=qwen_vl_chat_template( + "<|vision_start|><|image_pad|><|vision_end|>What is in this image?" + ), + video_prompt=qwen_vl_chat_template( + "<|vision_start|><|video_pad|><|vision_end|>" + "Describe this video in one sentence." + ), + needs_video_metadata=False, + marks=[pytest.mark.core_model], + ), } From 5645b72910d1f6f9307123acdadefd59ddc941a0 Mon Sep 17 00:00:00 2001 From: John Calderon Date: Wed, 29 Apr 2026 17:36:37 -0400 Subject: [PATCH 10/10] added encoder cudagraph to tests Signed-off-by: John Calderon --- tests/models/multimodal/generation/test_qwen2_5_vl.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/models/multimodal/generation/test_qwen2_5_vl.py b/tests/models/multimodal/generation/test_qwen2_5_vl.py index 80334a495710..791bb3b3088f 100644 --- a/tests/models/multimodal/generation/test_qwen2_5_vl.py +++ b/tests/models/multimodal/generation/test_qwen2_5_vl.py @@ -42,6 +42,13 @@ def _window_attention_regression_image(): return image.resize((image.width // 2, image.height // 2)) +def _encoder_cudagraph_config(*, max_vision_items: int) -> dict: + return { + "cudagraph_mm_encoder": True, + "encoder_cudagraph_max_vision_items_per_batch": max_vision_items, + } + + @pytest.mark.core_model @pytest.mark.parametrize("model", models) @pytest.mark.parametrize("video_pruning_rate", [0.0, 0.75]) @@ -187,6 +194,7 @@ def test_qwen2_5_vl_window_attention_image( max_model_len=4096, dtype=dtype, limit_mm_per_prompt={"image": 1}, + compilation_config=_encoder_cudagraph_config(max_vision_items=1), ) as vllm_model: outputs = vllm_model.generate_greedy(prompt, max_tokens, images=images) @@ -224,6 +232,7 @@ def test_qwen2_5_vl_window_attention_image_batch( max_num_seqs=2, dtype=dtype, limit_mm_per_prompt={"image": 1}, + compilation_config=_encoder_cudagraph_config(max_vision_items=2), ) as vllm_model: outputs = vllm_model.generate_greedy(prompts, max_tokens, images=images)