[MM][CG] Support ViT full CUDA graph for Ernie-4.5-VL image inference (#45254)
Signed-off-by: Qiuyang Yue <yueqiuyang1389@gmail.com>
Qiuyang Yue committed
Aug 5, 2026 at 22:04 UTC
777b01d1a86e4036fdd7368a63ca0c4d4803b488
4 files changed
+289
-13
docs/design/cuda_graphs_multimodal.md
+1
@@ -118,6 +118,7 @@ Models opt-in to encoder CUDA Graphs by implementing the [SupportsEncoderCudaGra
118
| Architecture | Models | CG for Image | CG for Video | Dual-Path Graph |
119
| ------------ | ------ | ------------ | ------------ | --------------- |
120
| `DeepseekOCRForCausalLM` | `DeepSeek-OCR` | ✅︎ | ❌︎ | ✅︎ |
121
+| `Ernie4_5_VLMoeForConditionalGeneration` | `ERNIE-4.5-VL` | ✅︎ | ❌︎ | ❌︎ |
122
| `Gemma3ForConditionalGeneration` | `Gemma3` | ✅︎ | ❌︎ | ❌︎ |
123
| `Glm4vForConditionalGeneration` | `GLM-4.1V, GLM-4.6V-Flash` | ✅︎ | ✅︎ | ❌︎ |
124
| `Gemma4ForConditionalGeneration` | `Gemma-4` | ✅︎ | ✅︎ | ❌︎ |
examples/generate/multimodal/vision_language_offline.py
+1
@@ -2426,6 +2426,7 @@ MODELS_SUPPORT_VIT_CUDA_GRAPH = [
2426
"stepvl",
2427
"glm4_1v",
2428
"deepseek_ocr",
2429
+ "ernie45_vl",
2430
]
2431
2432
tests/models/multimodal/generation/test_vit_cudagraph.py
+27
@@ -66,6 +66,14 @@ def gemma3_chat_template(content: str) -> str:
66
return f"<bos><start_of_turn>user\n{content}<end_of_turn>\n<start_of_turn>model\n"
67
68
69
+def ernie45_vl_chat_template(content: str) -> str:
70
+ return (
71
+ f"<|begin_of_sentence|>User: {content}"
72
+ "Picture 1:<|IMAGE_START|><|image@placeholder|><|IMAGE_END|>\n"
73
+ "Assistant: <think></think>"
74
+ )
75
+
76
+
77
MODEL_CONFIGS: dict[str, VitCudagraphTestConfig] = {
78
"gemma3": VitCudagraphTestConfig(
79
model="google/gemma-3-4b-it",
@@ -208,6 +216,25 @@ MODEL_CONFIGS: dict[str, VitCudagraphTestConfig] = {
216
),
217
},
218
),
219
+ "ernie45_vl": VitCudagraphTestConfig(
220
+ model="baidu/ERNIE-4.5-VL-28B-A3B-PT",
221
+ # Image only: Ernie's resampler applies a temporal conv for video
222
+ # that changes the output token count, so video uses the eager path.
223
+ modalities=["image"],
224
+ image_prompt=ernie45_vl_chat_template("What is in this image?"),
225
+ # Shrink to 1 text + 1 vision layer with random weights so the test
226
+ # runs on any CI GPU and skips the ~56 GiB weight download. The test
227
+ # only validates encoder CG capture/replay, not output quality.
228
+ vllm_runner_kwargs={
229
+ "load_format": "dummy",
230
+ "trust_remote_code": True,
231
+ "revision": "refs/pr/17",
232
+ "hf_overrides": partial(
233
+ dummy_hf_overrides,
234
+ model_arch="Ernie4_5_VLMoeForConditionalGeneration",
235
+ ),
236
+ },
237
+ ),
238
"glm4_1v": VitCudagraphTestConfig(
239
model="zai-org/GLM-4.1V-9B-Thinking",
240
image_prompt=(
vllm/model_executor/models/ernie45_vl.py
+260
-13
@@ -77,12 +77,18 @@ from vllm.v1.attention.backends.registry import AttentionBackendEnum
77
from .ernie45_vl_moe import Ernie4_5_VLMoeForCausalLM
78
from .interfaces import (
79
MultiModalEmbeddings,
80
+ SupportsEncoderCudaGraph,
81
SupportsLoRA,
82
SupportsMRoPE,
83
SupportsMultiModal,
84
SupportsPP,
85
)
85
-from .utils import AutoWeightsLoader, WeightsMapper, maybe_prefix
86
+from .utils import (
87
+ AutoWeightsLoader,
88
+ WeightsMapper,
89
+ maybe_prefix,
90
+ scatter_output_slices,
91
+)
92
from .vision import get_vit_attn_backend
93
94
logger = init_logger(__name__)
@@ -453,13 +459,43 @@ class Ernie4_5_VisionTransformer(nn.Module):
459
max_seqlen = (cu_seqlens[1:] - cu_seqlens[:-1]).max()
460
return max_seqlen
461
456
- def forward(
457
- self, hidden_states: torch.Tensor, grid_thw: torch.Tensor, num_pad=0
458
- ) -> torch.Tensor:
459
- hidden_states = self.patch_embed(hidden_states)
460
-
461
- rotary_pos_emb = self.rot_pos_emb(grid_thw)
462
- rotary_pos_emb = rotary_pos_emb.to(hidden_states.device)
462
+ def prepare_encoder_metadata(
463
+ self,
464
+ grid_thw: torch.Tensor | list[list[int]],
465
+ *,
466
+ num_pad: int = 0,
467
+ max_batch_size: int | None = None,
468
+ max_seqlen_override: int | None = None,
469
+ device: torch.device | None = None,
470
+ ) -> dict[str, torch.Tensor]:
471
+ """Compute encoder metadata outside the CUDA graph.
472
+
473
+ Splits the rotary embeddings, ``cu_seqlens`` and ``max_seqlen`` out of
474
+ `forward` so they can be precomputed on the host side and fed into a
475
+ captured graph through fixed buffers. Shared by the eager path, CUDA
476
+ graph capture and CUDA graph replay
477
+
478
+ Args:
479
+ grid_thw: Per-frame grid sizes as a ``[num_frames, 3]`` tensor or
480
+ an equivalent list of ``[t, h, w]``.
481
+ num_pad: Legacy ``cu_seqlens`` padding
482
+ max_batch_size: If set, pad ``cu_seqlens`` to ``max_batch_size + 1``
483
+ entries for a fixed graph buffer shape.
484
+ max_seqlen_override: If set, use this value for ``max_seqlen``
485
+ instead of deriving it from ``cu_seqlens``. CUDA graph capture
486
+ bakes ``max_seqlen`` at capture time, so it must cover the
487
+ worst-case replay.
488
+ device: Device for the returned tensors. Defaults to ``self.device``.
489
+
490
+ Returns:
491
+ Dict with ``rotary_pos_emb``, ``cu_seqlens`` and ``max_seqlen``.
492
+ """
493
+ if device is None:
494
+ device = self.device
495
+ if not isinstance(grid_thw, torch.Tensor):
496
+ grid_thw = torch.tensor(grid_thw, dtype=torch.int32)
497
+
498
+ rotary_pos_emb = self.rot_pos_emb(grid_thw).to(device)
499
500
cu_seqlens = torch.repeat_interleave(
501
grid_thw[:, 1] * grid_thw[:, 2], grid_thw[:, 0]
@@ -472,14 +508,60 @@ class Ernie4_5_VisionTransformer(nn.Module):
508
else:
509
cu_seqlens = torch.cat([zeros, cu_seqlens])
510
511
+ # Pad cu_seqlens to a fixed number of sequences so the CUDA graph
512
+ # replay buffer keeps a constant shape across batches.
513
+ if max_batch_size is not None:
514
+ num_seqs = cu_seqlens.numel() - 1
515
+ if num_seqs < max_batch_size:
516
+ cu_seqlens = torch.cat(
517
+ [
518
+ cu_seqlens,
519
+ cu_seqlens.new_full(
520
+ (max_batch_size - num_seqs,), cu_seqlens[-1]
521
+ ),
522
+ ]
523
+ )
524
+
525
+ # max_seqlen is baked into the graph at capture time, so capture passes
526
+ # a worst-case override. compute_attn_mask_seqlen keeps it on CPU
527
+ # (consumed via .item()), avoiding a captured D2H copy.
528
+ if max_seqlen_override is not None:
529
+ max_seqlen = torch.tensor(max_seqlen_override, dtype=torch.int32)
530
+ else:
531
+ max_seqlen = self.compute_attn_mask_seqlen(cu_seqlens)
532
+
533
+ cu_seqlens = cu_seqlens.to(device)
534
+
535
+ return {
536
+ "rotary_pos_emb": rotary_pos_emb,
537
+ "cu_seqlens": cu_seqlens,
538
+ "max_seqlen": max_seqlen,
539
+ }
540
+
541
+ def forward(
542
+ self,
543
+ hidden_states: torch.Tensor,
544
+ grid_thw: torch.Tensor | None = None,
545
+ num_pad: int = 0,
546
+ *,
547
+ encoder_metadata: dict[str, torch.Tensor] | None = None,
548
+ ) -> torch.Tensor:
549
+ hidden_states = self.patch_embed(hidden_states)
550
+
551
+ if encoder_metadata is None:
552
+ # Eager path: compute metadata inline
553
+ encoder_metadata = self.prepare_encoder_metadata(
554
+ grid_thw, num_pad=num_pad, device=hidden_states.device
555
+ )
556
+ rotary_pos_emb = encoder_metadata["rotary_pos_emb"]
557
+ cu_seqlens = encoder_metadata["cu_seqlens"]
558
+ max_seqlen = encoder_metadata["max_seqlen"]
559
+
560
# add batch size
561
if hidden_states.ndim == 2:
562
hidden_states = hidden_states.unsqueeze(dim=1)
563
479
- # pre-compute max_seqlen for attn mask to reduce cuMemcpy operations
480
- max_seqlen = self.compute_attn_mask_seqlen(cu_seqlens)
481
-
482
- for i, blk in enumerate(self.blocks):
564
+ for blk in self.blocks:
565
hidden_states = blk(
566
hidden_states,
567
cu_seqlens=cu_seqlens,
@@ -1256,7 +1338,12 @@ class Ernie4_5_VLDummyInputsBuilder(BaseDummyInputsBuilder[Ernie4_5_VLProcessing
1338
dummy_inputs=Ernie4_5_VLDummyInputsBuilder,
1339
)
1340
class Ernie4_5_VLMoeForConditionalGeneration(
1259
- nn.Module, SupportsMultiModal, SupportsLoRA, SupportsPP, SupportsMRoPE
1341
+ nn.Module,
1342
+ SupportsMultiModal,
1343
+ SupportsLoRA,
1344
+ SupportsPP,
1345
+ SupportsMRoPE,
1346
+ SupportsEncoderCudaGraph,
1347
):
1348
packed_modules_mapping = {
1349
"qkv_proj": [
@@ -1489,6 +1576,166 @@ class Ernie4_5_VLMoeForConditionalGeneration(
1576
video_grid_thw=video_grid_thw,
1577
)
1578
1579
+ # -- SupportsEncoderCudaGraph protocol methods --
1580
+ #
1581
+ # Only the vision transformer (self.vision_model) is captured into the
1582
+ # CUDA graph. The VariableResolutionResamplerModel (spatial merge +
1583
+ # projection) runs eagerly in encoder_eager_forward / postprocess_
1584
+ # encoder_output because its temporal path performs host-side (numpy)
1585
+ # indexing that cannot be captured. Image only: video uses a temporal
1586
+ # conv that changes the output token count, so it falls back to the
1587
+ # eager multimodal path for now.
1588
+
1589
+ def get_encoder_cudagraph_config(self):
1590
+ from vllm.v1.worker.encoder_cudagraph_defs import EncoderCudaGraphConfig
1591
+
1592
+ return EncoderCudaGraphConfig(
1593
+ modalities=["image"],
1594
+ # Ernie consumes a single rotary freqs tensor (not cos/sin)
1595
+ buffer_keys=[
1596
+ "pixel_values",
1597
+ "rotary_pos_emb",
1598
+ "cu_seqlens",
1599
+ "max_seqlen",
1600
+ ],
1601
+ # Post-merge embeddings, produced in postprocess_encoder_output,
1602
+ # are at the LM hidden dim, used only for DP gather sizing.
1603
+ out_hidden_size=self.config.hidden_size,
1604
+ )
1605
+
1606
+ def get_encoder_cudagraph_budget_range(self, vllm_config) -> tuple[int, int]:
1607
+ # Min: a 224x224 image -> 16x16 patches (patch_size=14),
1608
+ # spatial_merge_size=2 -> 8x8 = 64 output tokens.
1609
+ min_budget = 64
1610
+ max_budget = min(
1611
+ vllm_config.scheduler_config.max_num_batched_tokens,
1612
+ vllm_config.model_config.max_model_len,
1613
+ )
1614
+ return (min_budget, max_budget)
1615
+
1616
+ def get_encoder_cudagraph_item_specs(self, mm_kwargs: dict[str, Any]):
1617
+ from vllm.v1.worker.encoder_cudagraph_defs import EncoderItemSpec
1618
+
1619
+ m = self.vision_model.spatial_merge_size
1620
+ grid_thw = mm_kwargs["image_grid_thw"].tolist()
1621
+ return [
1622
+ EncoderItemSpec(
1623
+ input_size=t * h * w,
1624
+ output_tokens=(t * h * w) // m // m,
1625
+ )
1626
+ for t, h, w in grid_thw
1627
+ ]
1628
+
1629
+ def select_encoder_cudagraph_items(
1630
+ self, mm_kwargs: dict[str, Any], indices: list[int]
1631
+ ) -> dict[str, Any]:
1632
+ grid_thw = mm_kwargs["image_grid_thw"]
1633
+ pixel_values = mm_kwargs["pixel_values"]
1634
+
1635
+ if len(indices) == 0:
1636
+ return {"pixel_values": pixel_values[:0], "image_grid_thw": grid_thw[:0]}
1637
+
1638
+ # Cumulative patch offsets for slicing the concatenated pixel_values.
1639
+ patches_per_item = (grid_thw[:, 0] * grid_thw[:, 1] * grid_thw[:, 2]).tolist()
1640
+ cum_patches = [0]
1641
+ for p in patches_per_item:
1642
+ cum_patches.append(cum_patches[-1] + p)
1643
+ selected_pv = torch.cat(
1644
+ [pixel_values[cum_patches[i] : cum_patches[i + 1]] for i in indices]
1645
+ )
1646
+ return {"pixel_values": selected_pv, "image_grid_thw": grid_thw[indices]}
1647
+
1648
+ def prepare_encoder_cudagraph_capture_inputs(
1649
+ self,
1650
+ token_budget,
1651
+ max_batch_size,
1652
+ max_frames_per_batch,
1653
+ device,
1654
+ dtype,
1655
+ path: str = "default",
1656
+ ):
1657
+ from vllm.v1.worker.encoder_cudagraph_defs import (
1658
+ EncoderCudaGraphCaptureInputs,
1659
+ )
1660
+
1661
+ m = self.vision_model.spatial_merge_size
1662
+ # Ceil so the buffer fits the worst case of one item using the full
1663
+ # budget.
1664
+ per_mm_item_output = (token_budget + max_batch_size - 1) // max_batch_size
1665
+ # Image-format grid (T=1): h=m, w=per_mm_item_output*m, so the merged
1666
+ # token count per item equals per_mm_item_output.
1667
+ grid_config = [[1, m, per_mm_item_output * m] for _ in range(max_batch_size)]
1668
+
1669
+ patch_embed = self.vision_model.patch_embed
1670
+ in_channels = patch_embed.in_channels
1671
+ patch_size = patch_embed.patch_size
1672
+ total_patches = sum(t * h * w for t, h, w in grid_config)
1673
+ flattened_patch_size = in_channels * patch_size * patch_size
1674
+ dummy_pixel_values = torch.randn(
1675
+ total_patches, flattened_patch_size, device=device, dtype=dtype
1676
+ )
1677
+
1678
+ # max_seqlen is baked at capture: worst case is one item consuming the
1679
+ # full budget - seq_len = token_budget * spatial_merge_size**2.
1680
+ metadata = self.vision_model.prepare_encoder_metadata(
1681
+ grid_config,
1682
+ max_batch_size=max_batch_size,
1683
+ max_seqlen_override=token_budget * (m**2),
1684
+ device=device,
1685
+ )
1686
+ values = metadata | {"pixel_values": dummy_pixel_values}
1687
+ return EncoderCudaGraphCaptureInputs(values=values)
1688
+
1689
+ def prepare_encoder_cudagraph_replay_buffers(
1690
+ self, mm_kwargs, max_batch_size, max_frames_per_batch, path: str = "default"
1691
+ ):
1692
+ from vllm.v1.worker.encoder_cudagraph_defs import (
1693
+ EncoderCudaGraphReplayBuffers,
1694
+ )
1695
+
1696
+ metadata = self.vision_model.prepare_encoder_metadata(
1697
+ mm_kwargs["image_grid_thw"].tolist(), max_batch_size=max_batch_size
1698
+ )
1699
+ values = metadata | {
1700
+ "pixel_values": mm_kwargs["pixel_values"],
1701
+ }
1702
+ return EncoderCudaGraphReplayBuffers(values=values)
1703
+
1704
+ def encoder_cudagraph_forward(
1705
+ self, values: dict[str, torch.Tensor], path: str = "default"
1706
+ ) -> torch.Tensor:
1707
+ # Graph captures the ViT only, and the resampler runs in
1708
+ # postprocess_encoder_output.
1709
+ pixel_values = values.pop("pixel_values")
1710
+ return self.vision_model(pixel_values, encoder_metadata=values)
1711
+
1712
+ def encoder_eager_forward(
1713
+ self, mm_kwargs: dict[str, Any], path: str = "default"
1714
+ ) -> torch.Tensor:
1715
+ # Eager fallback: run the full pipeline (ViT + resampler). The result
1716
+ # is scattered directly, so it must be the post-merge embeddings.
1717
+ pixel_values = mm_kwargs["pixel_values"].type(self.vision_model.dtype)
1718
+ grid_thw = mm_kwargs["image_grid_thw"].to(self.vision_model.device)
1719
+ image_features = self.vision_model(pixel_values, grid_thw)
1720
+ return self.resampler_model(image_features, grid_thw)
1721
+
1722
+ def postprocess_encoder_output(
1723
+ self,
1724
+ output: torch.Tensor,
1725
+ indices: list[int],
1726
+ per_item_out_tokens: list[int],
1727
+ dest,
1728
+ clone: bool = False,
1729
+ batch_mm_kwargs: dict[str, Any] | None = None,
1730
+ ) -> None:
1731
+ # The graph output is the raw ViT features (pre-merge), padded to the
1732
+ # token budget. Run the resampler eagerly on the valid portion using
1733
+ # the actual batch grid_thw, then scatter the post-merge embeddings.
1734
+ grid_thw = batch_mm_kwargs["image_grid_thw"].to(output.device)
1735
+ num_valid = int((grid_thw[:, 0] * grid_thw[:, 1] * grid_thw[:, 2]).sum())
1736
+ image_embeds = self.resampler_model(output[:num_valid], grid_thw)
1737
+ scatter_output_slices(image_embeds, indices, per_item_out_tokens, dest, clone)
1738
+
1739
def _process_image_input(
1740
self, image_input: Ernie4_5_VLImageInputs
1741
) -> tuple[torch.Tensor, ...]: