@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / b3c69595a6
Fork 0

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 3, 19:00 UTC

[MM][CG] Support ViT CG for Qwen2-VL (#41736)

Signed-off-by: John Calderon <jcalderon@nvidia.com> Co-authored-by: Roger Wang <hey@rogerw.io>

John Calderon committed May 13, 2026 at 13:52 UTC b3c69595a63f14243388d8632e263490457d6126
4 files changed +315 -21
docs/design/cuda_graphs_multimodal.md
+2 -1
@@ -85,13 +85,14 @@ Models opt-in to encoder CUDA Graphs by implementing the [SupportsEncoderCudaGra
85
86 | Architecture | Models | CG for Image | CG for Video |
87 | ------------ | ------ | ------------ | ------------ |
88 +| `Qwen2VLForConditionalGeneration` | `Qwen2-VL` | ✅︎ | ✅︎ |
89 | `Qwen2_5_VLForConditionalGeneration` | `Qwen2.5-VL` | ✅︎ | ✅︎ |
90 | `Qwen3VLForConditionalGeneration` | `Qwen3-VL` | ✅︎ | ✅︎ |
91 | `Qwen3_5ForConditionalGeneration` | `Qwen3.5` | ✅︎ | ✅︎ |
92
93 !!! note
94 Encoder CUDA Graphs have currently been tested with `--mm-encoder-attn-backend=FLASH_ATTN` and `--mm-encoder-attn-backend=FLASHINFER` on Blackwell GPUs.
94 - For Qwen2.5-VL only FA2 and FA3 has been tested.
95 + For Qwen2-VL and Qwen2.5-VL only FA2 and FA3 has been tested.
96
97 ## Configuration
98
examples/generate/multimodal/vision_language_offline.py
+1
@@ -2557,6 +2557,7 @@ MODELS_SUPPORT_VIT_CUDA_GRAPH = [
2557 "qwen2_5_vl",
2558 "qwen3_vl",
2559 "qwen3_vl_moe",
2560 + "qwen2_vl",
2561 "qwen3_5",
2562 "qwen3_5_moe",
2563 ]
tests/models/multimodal/generation/test_vit_cudagraph.py
+12
@@ -78,6 +78,18 @@ MODEL_CONFIGS: dict[str, VitCudagraphTestConfig] = {
78 needs_video_metadata=True,
79 marks=[pytest.mark.core_model],
80 ),
81 + "qwen2_vl": VitCudagraphTestConfig(
82 + model="Qwen/Qwen2-VL-2B-Instruct",
83 + image_prompt=qwen_vl_chat_template(
84 + "<|vision_start|><|image_pad|><|vision_end|>What is in this image?"
85 + ),
86 + video_prompt=qwen_vl_chat_template(
87 + "<|vision_start|><|video_pad|><|vision_end|>"
88 + "Describe this video in one sentence."
89 + ),
90 + needs_video_metadata=False,
91 + marks=[pytest.mark.core_model],
92 + ),
93 }
94
95
vllm/model_executor/models/qwen2_vl.py
+300 -20
@@ -89,9 +89,11 @@ from vllm.sequence import IntermediateTensors
89 from vllm.tokenizers import TokenizerLike
90 from vllm.utils.tensor_schema import TensorSchema, TensorShape
91 from vllm.v1.attention.backends.registry import AttentionBackendEnum
92 +from vllm.v1.worker.encoder_cudagraph_defs import EncoderCudaGraphReplayBuffers
93
94 from .interfaces import (
95 MultiModalEmbeddings,
96 + SupportsEncoderCudaGraph,
97 SupportsLoRA,
98 SupportsMRoPE,
99 SupportsMultiModal,
@@ -646,38 +648,84 @@ class Qwen2VisionTransformer(nn.Module):
648 max_seqlen = (cu_seqlens[1:] - cu_seqlens[:-1]).max()
649 return max_seqlen
650
651 + def prepare_encoder_metadata(
652 + self,
653 + grid_thw: list[list[int]],
654 + *,
655 + max_batch_size: int | None = None,
656 + max_frames_per_batch: int | None = None,
657 + max_seqlen_override: int | None = None,
658 + device: torch.device | None = None,
659 + ) -> dict[str, torch.Tensor]:
660 + if device is None:
661 + device = self.device
662 +
663 + # Position embeddings.
664 + rotary_pos_emb_cos, rotary_pos_emb_sin = self.rot_pos_emb(grid_thw)
665 +
666 + grid_thw_np = np.array(grid_thw, dtype=np.int32)
667 + cu_seqlens = np.repeat(
668 + grid_thw_np[:, 1] * grid_thw_np[:, 2],
669 + grid_thw_np[:, 0],
670 + ).cumsum(dtype=np.int32)
671 + cu_seqlens = np.concatenate([np.zeros(1, dtype=np.int32), cu_seqlens])
672 + cu_seqlens = torch.from_numpy(cu_seqlens)
673 +
674 + # Keep cu_seqlens shape stable across capture/replay.
675 + pad_to = (
676 + max_frames_per_batch if max_frames_per_batch is not None else max_batch_size
677 + )
678 + if pad_to is not None:
679 + num_seqs = len(cu_seqlens) - 1
680 + if num_seqs < pad_to:
681 + cu_seqlens = torch.cat(
682 + (
683 + cu_seqlens,
684 + torch.full(
685 + (pad_to - num_seqs,),
686 + cu_seqlens[-1],
687 + dtype=cu_seqlens.dtype,
688 + device=cu_seqlens.device,
689 + ),
690 + )
691 + )
692 +
693 + # Compute (or override) max seqlen used by FA-style backends.
694 + if max_seqlen_override is None:
695 + max_seqlen = self.compute_attn_mask_seqlen(cu_seqlens)
696 + else:
697 + max_seqlen = torch.tensor(max_seqlen_override, dtype=torch.int32)
698 +
699 + return {
700 + "rotary_pos_emb_cos": rotary_pos_emb_cos,
701 + "rotary_pos_emb_sin": rotary_pos_emb_sin,
702 + "cu_seqlens": cu_seqlens.to(device=device, non_blocking=True),
703 + "max_seqlen": max_seqlen,
704 + }
705 +
706 def forward(
707 self,
708 x: torch.Tensor,
709 grid_thw: torch.Tensor | list[list[int]],
710 + *,
711 + encoder_metadata: dict[str, torch.Tensor] | None = None,
712 ) -> torch.Tensor:
713 # patchify
714 x = x.to(device=self.device, dtype=self.dtype)
715 x = self.patch_embed(x)
716
658 - if isinstance(grid_thw, list):
659 - grid_thw_list = grid_thw
660 - grid_thw = np.array(grid_thw, dtype=np.int32)
661 - else:
662 - grid_thw_list = grid_thw.tolist()
663 - grid_thw = grid_thw.numpy()
717 + grid_thw_list = grid_thw if isinstance(grid_thw, list) else grid_thw.tolist()
718
665 - # compute position embedding
666 - rotary_pos_emb_cos, rotary_pos_emb_sin = self.rot_pos_emb(grid_thw_list)
719 + if encoder_metadata is None:
720 + encoder_metadata = self.prepare_encoder_metadata(grid_thw_list)
721
668 - # compute cu_seqlens
669 - cu_seqlens = np.repeat(grid_thw[:, 1] * grid_thw[:, 2], grid_thw[:, 0]).cumsum(
670 - axis=0, dtype=np.int32
671 - )
672 - cu_seqlens = np.concatenate([np.zeros(1, dtype=np.int32), cu_seqlens])
673 - cu_seqlens = torch.from_numpy(cu_seqlens)
722 + rotary_pos_emb_cos = encoder_metadata["rotary_pos_emb_cos"]
723 + rotary_pos_emb_sin = encoder_metadata["rotary_pos_emb_sin"]
724 + cu_seqlens = encoder_metadata["cu_seqlens"]
725 + max_seqlen = encoder_metadata["max_seqlen"]
726
727 # transformers
728 x = x.unsqueeze(1)
677 -
678 - # pre-compute seqlens for attn mask to reduce cuMemcpy operations
679 - max_seqlen = self.compute_attn_mask_seqlen(cu_seqlens)
680 - cu_seqlens = cu_seqlens.to(self.device, non_blocking=True)
729 for blk in self.blocks:
730 x = blk(
731 x,
@@ -1127,7 +1175,12 @@ class Qwen2VLMultiModalProcessor(BaseMultiModalProcessor[Qwen2VLProcessingInfo])
1175 dummy_inputs=Qwen2VLDummyInputsBuilder,
1176 )
1177 class Qwen2VLForConditionalGeneration(
1130 - nn.Module, SupportsMultiModal, SupportsLoRA, SupportsPP, SupportsMRoPE
1178 + nn.Module,
1179 + SupportsMultiModal,
1180 + SupportsLoRA,
1181 + SupportsPP,
1182 + SupportsMRoPE,
1183 + SupportsEncoderCudaGraph,
1184 ):
1185 # To ensure correct weight loading and mapping.
1186 hf_to_vllm_mapper = WeightsMapper(
@@ -1234,7 +1287,7 @@ class Qwen2VLForConditionalGeneration(
1287 config: Qwen2VLConfig = vllm_config.model_config.hf_config
1288 quant_config = vllm_config.quant_config
1289 multimodal_config = vllm_config.model_config.multimodal_config
1237 -
1290 + self.model_config = vllm_config.model_config
1291 self.use_data_parallel = multimodal_config.mm_encoder_tp_mode == "data"
1292 self.config = config
1293 self.multimodal_config = multimodal_config
@@ -1396,6 +1449,233 @@ class Qwen2VLForConditionalGeneration(
1449
1450 return multimodal_embeddings
1451
1452 + # -- SupportsEncoderCudaGraph protocol methods --
1453 +
1454 + def get_encoder_cudagraph_config(self):
1455 + from vllm.v1.worker.encoder_cudagraph_defs import (
1456 + EncoderCudaGraphConfig,
1457 + )
1458 +
1459 + return EncoderCudaGraphConfig(
1460 + modalities=["image", "video"],
1461 + input_key_by_modality={
1462 + "image": "pixel_values",
1463 + "video": "pixel_values_videos",
1464 + },
1465 + buffer_keys=[
1466 + "rotary_pos_emb_cos",
1467 + "rotary_pos_emb_sin",
1468 + "cu_seqlens",
1469 + "max_seqlen",
1470 + ],
1471 + out_hidden_size=self.visual.out_hidden_size,
1472 + )
1473 +
1474 + def get_input_modality(self, mm_kwargs: dict[str, Any]) -> str:
1475 + if "image_grid_thw" in mm_kwargs:
1476 + return "image"
1477 + return "video"
1478 +
1479 + def get_max_frames_per_video(self) -> int:
1480 + mm_registry = MULTIMODAL_REGISTRY
1481 + info = mm_registry.get_processing_info(self.model_config)
1482 + max_frames_per_video = info.get_num_frames_with_most_features(
1483 + seq_len=self.model_config.max_model_len,
1484 + mm_counts={"video": self.multimodal_config.get_limit_per_prompt("video")},
1485 + )
1486 + return max_frames_per_video
1487 +
1488 + def get_encoder_cudagraph_budget_range(
1489 + self,
1490 + vllm_config: VllmConfig,
1491 + ) -> tuple[int, int]:
1492 + # Min: estimated smallest possible encoder input.
1493 + # 224x224 image -> 16x16 patches (patch_size=14)
1494 + # spatial_merge_size=2 -> 8x8 = 64 tokens
1495 + min_budget = 64
1496 + # Max: capped by max_num_batched_tokens
1497 + max_budget = min(
1498 + vllm_config.scheduler_config.max_num_batched_tokens,
1499 + self.model_config.max_model_len,
1500 + )
1501 + return (min_budget, max_budget)
1502 +
1503 + def _get_pixel_values_by_modality(self, mm_kwargs: dict[str, Any]) -> torch.Tensor:
1504 + if self.get_input_modality(mm_kwargs) == "image":
1505 + pixel_values = mm_kwargs["pixel_values"]
1506 + else:
1507 + pixel_values = mm_kwargs["pixel_values_videos"]
1508 + return pixel_values
1509 +
1510 + def _get_grid_thw_by_modality(self, mm_kwargs: dict[str, Any]) -> list[list[int]]:
1511 + grid_thw_key = f"{self.get_input_modality(mm_kwargs)}_grid_thw"
1512 + grid_thw = mm_kwargs[grid_thw_key]
1513 + if not isinstance(grid_thw, list):
1514 + grid_thw = grid_thw.tolist()
1515 + return grid_thw
1516 +
1517 + def get_encoder_cudagraph_num_items(self, mm_kwargs: dict[str, Any]) -> int:
1518 + return len(self._get_grid_thw_by_modality(mm_kwargs))
1519 +
1520 + def get_encoder_cudagraph_per_item_output_tokens(
1521 + self, mm_kwargs: dict[str, Any]
1522 + ) -> list[int]:
1523 + m = self.visual.spatial_merge_size
1524 + grid_thw = self._get_grid_thw_by_modality(mm_kwargs)
1525 + return [t * (h // m) * (w // m) for t, h, w in grid_thw]
1526 +
1527 + def get_encoder_cudagraph_per_item_input_sizes(
1528 + self, mm_kwargs: dict[str, Any]
1529 + ) -> list[int]:
1530 + grid_thw = self._get_grid_thw_by_modality(mm_kwargs)
1531 + return [t * h * w for t, h, w in grid_thw]
1532 +
1533 + def select_encoder_cudagraph_items(
1534 + self, mm_kwargs: dict[str, Any], indices: list[int]
1535 + ) -> dict[str, Any]:
1536 + grid_thw = self._get_grid_thw_by_modality(mm_kwargs)
1537 + pixel_values = self._get_pixel_values_by_modality(mm_kwargs)
1538 +
1539 + if len(indices) == 0:
1540 + if self.get_input_modality(mm_kwargs) == "image":
1541 + return {
1542 + "pixel_values": pixel_values[:0],
1543 + "image_grid_thw": [],
1544 + }
1545 + else:
1546 + return {
1547 + "pixel_values_videos": pixel_values[:0],
1548 + "video_grid_thw": [],
1549 + }
1550 +
1551 + # Compute cumulative patch offsets for slicing pixel_values.
1552 + patches_per_item = [t * h * w for t, h, w in grid_thw]
1553 + cum_patches = [0]
1554 + for p in patches_per_item:
1555 + cum_patches.append(cum_patches[-1] + p)
1556 +
1557 + selected_pv = torch.cat(
1558 + [pixel_values[cum_patches[i] : cum_patches[i + 1]] for i in indices]
1559 + )
1560 + selected_grid = [grid_thw[i] for i in indices]
1561 +
1562 + if self.get_input_modality(mm_kwargs) == "image":
1563 + return {
1564 + "pixel_values": selected_pv,
1565 + "image_grid_thw": selected_grid,
1566 + }
1567 + else:
1568 + return {
1569 + "pixel_values_videos": selected_pv,
1570 + "video_grid_thw": selected_grid,
1571 + }
1572 +
1573 + def prepare_encoder_cudagraph_capture_inputs(
1574 + self,
1575 + token_budget: int,
1576 + max_batch_size: int,
1577 + max_frames_per_batch: int,
1578 + device: torch.device,
1579 + dtype: torch.dtype,
1580 + ):
1581 + from vllm.v1.worker.encoder_cudagraph_defs import (
1582 + EncoderCudaGraphCaptureInputs,
1583 + )
1584 +
1585 + spatial_merge_size = self.visual.spatial_merge_size
1586 + # Use ceil so captured capacity is never smaller than token_budget.
1587 + per_mm_item_output = (token_budget + max_batch_size - 1) // max_batch_size
1588 +
1589 + frames_per_item = max_frames_per_batch // max_batch_size
1590 + if frames_per_item > 1:
1591 + tokens_per_frame = (
1592 + per_mm_item_output + frames_per_item - 1
1593 + ) // frames_per_item
1594 + grid_config = [
1595 + [
1596 + frames_per_item,
1597 + spatial_merge_size,
1598 + tokens_per_frame * spatial_merge_size,
1599 + ]
1600 + for _ in range(max_batch_size)
1601 + ]
1602 + else:
1603 + grid_config = [
1604 + [1, spatial_merge_size, per_mm_item_output * spatial_merge_size]
1605 + for _ in range(max_batch_size)
1606 + ]
1607 +
1608 + # Create dummy pixel_values.
1609 + patch_embed = self.visual.patch_embed
1610 + in_channels = patch_embed.proj.in_channels
1611 + patch_size = patch_embed.patch_size
1612 + temporal_patch_size = patch_embed.temporal_patch_size
1613 + total_patches = sum(t * h * w for t, h, w in grid_config)
1614 + flattened_patch_size = (
1615 + in_channels * temporal_patch_size * patch_size * patch_size
1616 + )
1617 + dummy_pixel_values = torch.randn(
1618 + total_patches, flattened_patch_size, device=device, dtype=dtype
1619 + )
1620 +
1621 + # max_seqlen.item() gets baked into the CUDA graph at capture time.
1622 + buffers = self.visual.prepare_encoder_metadata(
1623 + grid_config,
1624 + max_batch_size=max_batch_size,
1625 + max_frames_per_batch=max_frames_per_batch,
1626 + max_seqlen_override=token_budget * (spatial_merge_size**2),
1627 + device=device,
1628 + )
1629 +
1630 + # Capture with image-format kwargs; pixel_values shape is compatible with
1631 + # both image and video replay paths.
1632 + mm_kwargs = {
1633 + "pixel_values": dummy_pixel_values,
1634 + "image_grid_thw": grid_config,
1635 + }
1636 +
1637 + return EncoderCudaGraphCaptureInputs(
1638 + mm_kwargs=mm_kwargs,
1639 + buffers=buffers,
1640 + )
1641 +
1642 + def prepare_encoder_cudagraph_replay_buffers(
1643 + self,
1644 + mm_kwargs: dict[str, Any],
1645 + max_batch_size: int,
1646 + max_frames_per_batch: int,
1647 + ) -> EncoderCudaGraphReplayBuffers:
1648 + modality = self.get_input_modality(mm_kwargs)
1649 + grid_thw_list = self._get_grid_thw_by_modality(mm_kwargs)
1650 +
1651 + if modality == "image":
1652 + buffers = self.visual.prepare_encoder_metadata(
1653 + grid_thw_list,
1654 + max_batch_size=max_batch_size,
1655 + )
1656 + else:
1657 + buffers = self.visual.prepare_encoder_metadata(
1658 + grid_thw_list,
1659 + max_frames_per_batch=max_frames_per_batch,
1660 + )
1661 +
1662 + return EncoderCudaGraphReplayBuffers(buffers=buffers)
1663 +
1664 + def encoder_cudagraph_forward(
1665 + self, mm_kwargs: dict[str, Any], buffers: dict[str, torch.Tensor]
1666 + ) -> torch.Tensor:
1667 + pixel_values = self._get_pixel_values_by_modality(mm_kwargs)
1668 + grid_thw = self._get_grid_thw_by_modality(mm_kwargs)
1669 + return self.visual(pixel_values, grid_thw, encoder_metadata=buffers)
1670 +
1671 + def encoder_eager_forward(
1672 + self,
1673 + mm_kwargs: dict[str, Any],
1674 + ) -> torch.Tensor:
1675 + pixel_values = self._get_pixel_values_by_modality(mm_kwargs)
1676 + grid_thw = self._get_grid_thw_by_modality(mm_kwargs)
1677 + return self.visual(pixel_values, grid_thw)
1678 +
1679 def forward(
1680 self,
1681 input_ids: torch.Tensor | None,