A high-throughput and memory-efficient inference and serving engine for LLMs
[MM][Perf][CG] Support ViT full CUDA graph for glm4_1v image and video inference (#40576)
Signed-off-by: grYe99 <guorongye99@gmail.com> Co-authored-by: grYe99 <guorongye99@gmail.com> Co-authored-by: Isotr0py <mozf@mail2.sysu.edu.cn>
GuorongYe committed
Jun 9, 2026 at 11:13 UTC
9f153aa781f07d74b8574b57eab71551374c9498
4 files changed
+480
-25
docs/design/cuda_graphs_multimodal.md
+1
@@ -88,6 +88,7 @@ Models opt-in to encoder CUDA Graphs by implementing the [SupportsEncoderCudaGra
88
| `Qwen3VLForConditionalGeneration` | `Qwen3-VL` | ✅︎ | ✅︎ |
89
| `Qwen3_5ForConditionalGeneration` | `Qwen3.5` | ✅︎ | ✅︎ |
90
| `Step3VLForConditionalGeneration` | `Step3-VL` | ✅︎ | ❌︎ |
91
+| `Glm4vForConditionalGeneration` | `GLM-4.1V, GLM-4.6V-Flash` | ✅︎ | ✅︎ |
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.
examples/generate/multimodal/vision_language_offline.py
+1
@@ -2562,6 +2562,7 @@ MODELS_SUPPORT_VIT_CUDA_GRAPH = [
2562
"qwen3_5",
2563
"qwen3_5_moe",
2564
"stepvl",
2565
+ "glm4_1v",
2566
]
2567
2568
tests/models/multimodal/generation/test_vit_cudagraph.py
+22
@@ -137,6 +137,28 @@ MODEL_CONFIGS: dict[str, VitCudagraphTestConfig] = {
137
),
138
},
139
),
140
+ "glm4_1v": VitCudagraphTestConfig(
141
+ model="zai-org/GLM-4.1V-9B-Thinking",
142
+ image_prompt=(
143
+ "[gMASK]<sop><|system|>\nYou are a helpful assistant.<|user|>\n"
144
+ "<|begin_of_image|><|image|><|end_of_image|>"
145
+ "What is in this image?<|assistant|>assistant\n"
146
+ ),
147
+ video_prompt=(
148
+ "[gMASK]<sop><|system|>\nYou are a helpful assistant.<|user|>\n"
149
+ "<|begin_of_video|><|video|><|end_of_video|>"
150
+ "Describe this video in one sentence<|assistant|>assistant\n"
151
+ ),
152
+ needs_video_metadata=True,
153
+ marks=[pytest.mark.core_model],
154
+ vllm_runner_kwargs={
155
+ "load_format": "dummy",
156
+ "hf_overrides": partial(
157
+ dummy_hf_overrides,
158
+ model_arch="Glm4vForConditionalGeneration",
159
+ ),
160
+ },
161
+ ),
162
}
163
164
vllm/model_executor/models/glm4_1v.py
+456
-25
@@ -97,10 +97,12 @@ from vllm.multimodal.processing import (
97
from vllm.sequence import IntermediateTensors
98
from vllm.utils.tensor_schema import TensorSchema, TensorShape
99
from vllm.v1.attention.backends.registry import AttentionBackendEnum
100
+from vllm.v1.worker.encoder_cudagraph_defs import EncoderCudaGraphReplayBuffers
101
102
from ..layers.activation import SiluAndMul
103
from .interfaces import (
104
MultiModalEmbeddings,
105
+ SupportsEncoderCudaGraph,
106
SupportsLoRA,
107
SupportsMRoPE,
108
SupportsMultiModal,
@@ -626,6 +628,11 @@ class Glm4vVisionTransformer(nn.Module):
628
) -> None:
629
super().__init__()
630
631
+ use_data_parallel = is_vit_use_data_parallel()
632
+ self.tp_size = (
633
+ 1 if use_data_parallel else get_tensor_model_parallel_world_size()
634
+ )
635
+
636
patch_size = vision_config.patch_size
637
temporal_patch_size = vision_config.temporal_patch_size
638
in_channels = vision_config.in_channels
@@ -701,7 +708,7 @@ class Glm4vVisionTransformer(nn.Module):
708
return self.patch_embed.proj.weight.device
709
710
def rot_pos_emb(
704
- self, grid_thw: torch.Tensor
711
+ self, grid_thw: list[list[int]]
712
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
713
pos_ids = []
714
for t, h, w in grid_thw:
@@ -729,7 +736,7 @@ class Glm4vVisionTransformer(nn.Module):
736
)
737
pos_ids.append(torch.stack([hpos_ids, wpos_ids], dim=-1).repeat(t, 1))
738
pos_ids = torch.cat(pos_ids, dim=0)
732
- max_grid_size = grid_thw[:, 1:].max()
739
+ max_grid_size = max(max(h, w) for _, h, w in grid_thw)
740
741
# Use pre-computed cos_sin_cache from RotaryEmbedding
742
cos, sin = self.rotary_pos_emb.get_cos_sin(max_grid_size)
@@ -752,45 +759,192 @@ class Glm4vVisionTransformer(nn.Module):
759
max_seqlen = (cu_seqlens[1:] - cu_seqlens[:-1]).max()
760
return max_seqlen
761
762
+ def pos_embeds_interpolate(self, grid_thw: list[list[int]]) -> torch.Tensor:
763
+ """Pre-compute absolute position embeddings for all input samples.
764
+ The original `self.embeddings` fused token embeddings and position embeddings
765
+ in one call, which prevented preparing position embeddings as static metadata
766
+ required by CUDA graph capture / replay. This method decouples the two by
767
+ feeding an all-zero token tensor to `self.embeddings`. The module therefore only
768
+ performs bicubic interpolation based on the coordinates and returns pure
769
+ position embeddings. These are cached in `prepare_encoder_metadata` and later
770
+ added to the patch tokens in `forward` via `x = x + pos_embeds`, keeping the
771
+ forward graph compatible with CUDA graph replay. Coordinate generation matches
772
+ `rot_pos_emb` exactly to guarantee spatial alignment.
773
+ """
774
+
775
+ device = self.embeddings.position_embedding.weight.device
776
+ dtype = self.dtype
777
+ all_embeds = []
778
+
779
+ for t, h, w in grid_thw:
780
+ # Use the same coordinate generation logic as rot_pos_emb
781
+ # to ensure consistent positional embedding interpolation
782
+ h_coords = torch.arange(h).unsqueeze(1).expand(-1, w)
783
+ w_coords = torch.arange(w).unsqueeze(0).expand(h, -1)
784
+ h_coords = (
785
+ h_coords.reshape(
786
+ h // self.spatial_merge_size,
787
+ self.spatial_merge_size,
788
+ w // self.spatial_merge_size,
789
+ self.spatial_merge_size,
790
+ )
791
+ .permute(0, 2, 1, 3)
792
+ .flatten()
793
+ )
794
+ w_coords = (
795
+ w_coords.reshape(
796
+ h // self.spatial_merge_size,
797
+ self.spatial_merge_size,
798
+ w // self.spatial_merge_size,
799
+ self.spatial_merge_size,
800
+ )
801
+ .permute(0, 2, 1, 3)
802
+ .flatten()
803
+ )
804
+
805
+ lengths = [h * w] * t
806
+ image_shapes = torch.tensor([[t, h, w]], device=device)
807
+
808
+ h_coords_repeated = h_coords.repeat(t)
809
+ w_coords_repeated = w_coords.repeat(t)
810
+
811
+ embeds = self.embeddings(
812
+ embeddings=torch.zeros(
813
+ h * w * t, self.hidden_size, device=device, dtype=dtype
814
+ ),
815
+ lengths=lengths,
816
+ image_shapes=image_shapes,
817
+ h_coords=h_coords_repeated,
818
+ w_coords=w_coords_repeated,
819
+ )
820
+ all_embeds.append(embeds)
821
+
822
+ return torch.cat(all_embeds, dim=0).to(dtype)
823
+
824
+ def prepare_encoder_metadata(
825
+ self,
826
+ grid_thw_list: list[list[int]],
827
+ *,
828
+ max_batch_size: int | None = None,
829
+ max_frames_per_batch: int | None = None,
830
+ max_seqlen_override: int | None = None,
831
+ device: torch.device | None = None,
832
+ ) -> dict[str, torch.Tensor | None]:
833
+ """Compute encoder metadata from grid_thw_list.
834
+
835
+ Shared by the eager forward path, CUDA graph capture, and
836
+ CUDA graph replay to avoid duplicated implementation.
837
+
838
+ Args:
839
+ grid_thw_list: Grid configurations as list of [t, h, w].
840
+ max_batch_size: If set, pad cu_seqlens to this size
841
+ (needed for CUDA graph capture/replay).
842
+ max_frames_per_batch: If set, overrides max_batch_size for
843
+ cu_seqlens padding. For video inputs each item contributes
844
+ T attention sequences (frames); this sizes the buffer to
845
+ the total frame budget so video replays never overflow.
846
+ max_seqlen_override: If set, use this value for max_seqlen
847
+ instead of computing from cu_seqlens (needed for CUDA
848
+ graph capture to cover worst-case replay scenarios).
849
+ device: Device to place tensors on. Defaults to self.device.
850
+ """
851
+ if device is None:
852
+ device = self.device
853
+
854
+ metadata: dict[str, torch.Tensor | None] = {}
855
+
856
+ # Positional embeddings
857
+ metadata["pos_embeds"] = self.pos_embeds_interpolate(grid_thw_list)
858
+ rotary_cos, rotary_sin, _ = self.rot_pos_emb(grid_thw_list)
859
+ metadata["rotary_pos_emb_cos"] = rotary_cos
860
+ metadata["rotary_pos_emb_sin"] = rotary_sin
861
+
862
+ # cu_seqlens from grid_thw
863
+ grid_thw_np = np.array(grid_thw_list, dtype=np.int32)
864
+ patches_per_frame = grid_thw_np[:, 1] * grid_thw_np[:, 2]
865
+ cu_seqlens = np.repeat(patches_per_frame, grid_thw_np[:, 0]).cumsum(
866
+ dtype=np.int32
867
+ )
868
+ cu_seqlens = np.concatenate([np.zeros(1, dtype=np.int32), cu_seqlens])
869
+
870
+ # Pad cu_seqlens to the required number of sequences.
871
+ # For videos each item contributes T frames = T attention sequences,
872
+ # so the total can exceed max_batch_size. max_frames_per_batch
873
+ # overrides the pad target when set.
874
+ pad_to = (
875
+ max_frames_per_batch if max_frames_per_batch is not None else max_batch_size
876
+ )
877
+ if pad_to is not None:
878
+ num_seqs = len(cu_seqlens) - 1
879
+ if num_seqs < pad_to:
880
+ cu_seqlens = np.concatenate(
881
+ [
882
+ cu_seqlens,
883
+ np.full(
884
+ pad_to - num_seqs,
885
+ cu_seqlens[-1],
886
+ dtype=np.int32,
887
+ ),
888
+ ]
889
+ )
890
+
891
+ # sequence_lengths (backend-specific)
892
+ metadata["sequence_lengths"] = MMEncoderAttention.maybe_compute_seq_lens(
893
+ self.attn_backend, cu_seqlens, device
894
+ )
895
+
896
+ # max_seqlen
897
+ if max_seqlen_override is not None:
898
+ max_seqlen_val = max_seqlen_override
899
+ else:
900
+ max_seqlen_val = MMEncoderAttention.compute_max_seqlen(
901
+ self.attn_backend, cu_seqlens
902
+ )
903
+ # Keep max_seqlen on CPU: attention wrappers call .item() on it,
904
+ # and having it on GPU would capture a wasteful D2H copy in CUDA
905
+ # graphs without changing behavior (the scalar is baked at capture).
906
+ metadata["max_seqlen"] = torch.tensor(max_seqlen_val, dtype=torch.int32)
907
+
908
+ # Recompute cu_seqlens (backend-specific transformation)
909
+ metadata["cu_seqlens"] = MMEncoderAttention.maybe_recompute_cu_seqlens(
910
+ self.attn_backend,
911
+ cu_seqlens,
912
+ self.hidden_size,
913
+ self.tp_size,
914
+ device,
915
+ )
916
+
917
+ return metadata
918
+
919
def forward(
920
self,
921
x: torch.Tensor,
922
grid_thw: torch.Tensor | list[list[int]],
923
+ *,
924
+ encoder_metadata: dict[str, torch.Tensor] | None = None,
925
) -> torch.Tensor:
760
- if isinstance(grid_thw, list):
761
- grid_thw = torch.tensor(grid_thw, dtype=torch.int32)
926
+ if encoder_metadata is None:
927
+ if not isinstance(grid_thw, list):
928
+ grid_thw = grid_thw.tolist()
929
+ encoder_metadata = self.prepare_encoder_metadata(grid_thw)
930
931
# patchify
932
x = x.to(device=self.device, dtype=self.dtype)
933
x = self.patch_embed(x)
934
x = self.post_conv_layernorm(x)
935
768
- # compute position embedding
769
- rotary_pos_emb_cos, rotary_pos_emb_sin, image_type_ids = self.rot_pos_emb(
770
- grid_thw
771
- )
772
- # compute cu_seqlens
773
- cu_seqlens = torch.repeat_interleave(
774
- grid_thw[:, 1] * grid_thw[:, 2], grid_thw[:, 0]
775
- ).cumsum(dim=0, dtype=torch.int32)
776
- cu_seqlens = torch.cat([cu_seqlens.new_zeros(1), cu_seqlens])
777
- # pre-compute max_seqlen for attn mask to reduce cuMemcpy operations
778
- max_seqlen = self.compute_attn_mask_seqlen(cu_seqlens)
779
- seqlens = (cu_seqlens[1:] - cu_seqlens[:-1]).tolist()
780
- cu_seqlens = cu_seqlens.to(self.device, non_blocking=True)
781
- x = self.embeddings(
782
- x, seqlens, grid_thw, image_type_ids[:, 0], image_type_ids[:, 1]
783
- )
936
+ pos_embeds = encoder_metadata["pos_embeds"]
937
+ x = x + pos_embeds
938
939
# transformers
940
x = x.unsqueeze(1)
941
for blk in self.blocks:
942
x = blk(
943
x,
790
- cu_seqlens=cu_seqlens,
791
- rotary_pos_emb_cos=rotary_pos_emb_cos,
792
- rotary_pos_emb_sin=rotary_pos_emb_sin,
793
- max_seqlen=max_seqlen,
944
+ cu_seqlens=encoder_metadata["cu_seqlens"],
945
+ rotary_pos_emb_cos=encoder_metadata["rotary_pos_emb_cos"],
946
+ rotary_pos_emb_sin=encoder_metadata["rotary_pos_emb_sin"],
947
+ max_seqlen=encoder_metadata["max_seqlen"],
948
)
949
950
# adapter
@@ -1587,7 +1741,12 @@ class Glm4vMultiModalProcessor(BaseMultiModalProcessor[Glm4vProcessingInfo]):
1741
dummy_inputs=Glm4vDummyInputsBuilder,
1742
)
1743
class Glm4vForConditionalGeneration(
1590
- nn.Module, SupportsMultiModal, SupportsLoRA, SupportsPP, SupportsMRoPE
1744
+ nn.Module,
1745
+ SupportsMultiModal,
1746
+ SupportsEncoderCudaGraph,
1747
+ SupportsLoRA,
1748
+ SupportsPP,
1749
+ SupportsMRoPE,
1750
):
1751
packed_modules_mapping = {
1752
"qkv_proj": [
@@ -1625,8 +1784,12 @@ class Glm4vForConditionalGeneration(
1784
multimodal_config = vllm_config.model_config.multimodal_config
1785
1786
self.config = config
1787
+ self.model_config = vllm_config.model_config
1788
self.multimodal_config = multimodal_config
1789
self.use_data_parallel = multimodal_config.mm_encoder_tp_mode == "data"
1790
+ self.is_multimodal_pruning_enabled = (
1791
+ multimodal_config.is_multimodal_pruning_enabled()
1792
+ )
1793
1794
with self._mark_tower_model(vllm_config, {"image", "video"}):
1795
self.visual = Glm4vVisionTransformer(
@@ -1752,6 +1915,274 @@ class Glm4vForConditionalGeneration(
1915
sizes = (grid_thw.prod(-1) // merge_size // merge_size).tolist()
1916
return video_embeds.split(sizes)
1917
1918
+ # -- SupportsEncoderCudaGraph protocol methods --
1919
+
1920
+ def get_encoder_cudagraph_config(self):
1921
+ from vllm.v1.worker.encoder_cudagraph_defs import (
1922
+ EncoderCudaGraphConfig,
1923
+ )
1924
+
1925
+ # When EVS pruning is enabled, embed_multimodal post-processes both
1926
+ # image and video embeddings (mrope positions are appended for image,
1927
+ # prune+append for video). The encoder CUDA graph path bypasses that
1928
+ # post-process, producing inconsistent embedding formats vs eager. So
1929
+ # disable CUDA graph for all modalities when pruning is on.
1930
+ modalities = [] if self.is_multimodal_pruning_enabled else ["image", "video"]
1931
+
1932
+ # Compute max_frames_per_video for budget sizing.
1933
+ max_frames = self.get_max_frames_per_video() if "video" in modalities else 1
1934
+
1935
+ return EncoderCudaGraphConfig(
1936
+ modalities=modalities,
1937
+ buffer_keys=[
1938
+ "pixel_values",
1939
+ "pos_embeds",
1940
+ "rotary_pos_emb_cos",
1941
+ "rotary_pos_emb_sin",
1942
+ "cu_seqlens",
1943
+ "max_seqlen",
1944
+ "sequence_lengths",
1945
+ ],
1946
+ out_hidden_size=self.visual.out_hidden_size,
1947
+ max_frames_per_video=max_frames,
1948
+ )
1949
+
1950
+ def get_input_modality(
1951
+ self,
1952
+ mm_kwargs: dict[str, Any],
1953
+ ) -> str:
1954
+ if "image_grid_thw" in mm_kwargs:
1955
+ return "image"
1956
+ elif "video_grid_thw" in mm_kwargs:
1957
+ return "video"
1958
+ raise AssertionError("This line should be unreachable.")
1959
+
1960
+ def get_max_frames_per_video(self) -> int:
1961
+ mm_registry = MULTIMODAL_REGISTRY
1962
+ info = mm_registry.get_processing_info(self.model_config)
1963
+ max_frames_per_video = info.get_num_frames_with_most_features(
1964
+ seq_len=self.model_config.max_model_len,
1965
+ mm_counts={"video": self.multimodal_config.get_limit_per_prompt("video")},
1966
+ )
1967
+ # Small 'max_frames_per_video' will cause 'tensor mismatch' in PR#43403
1968
+ # 16 is the default 'num_frames' of '_get_vision_info'
1969
+ return max(max_frames_per_video, 16)
1970
+
1971
+ def get_encoder_cudagraph_budget_range(
1972
+ self,
1973
+ vllm_config,
1974
+ ) -> tuple[int, int]:
1975
+ # Min: estimated smallest possible encoder input.
1976
+ # 224x224 image → 16x16 patches (patch_size=14)
1977
+ # spatial_merge_size=2 → 8x8 = 64 tokens
1978
+ min_budget = 64
1979
+ # Max: capped by max_num_batched_tokens
1980
+ max_budget = min(
1981
+ vllm_config.scheduler_config.max_num_batched_tokens,
1982
+ vllm_config.model_config.max_model_len,
1983
+ )
1984
+ return (min_budget, max_budget)
1985
+
1986
+ def _get_pixel_values_by_modality(
1987
+ self,
1988
+ mm_kwargs: dict[str, Any],
1989
+ ) -> torch.Tensor:
1990
+ if self.get_input_modality(mm_kwargs) == "image":
1991
+ pixel_values = mm_kwargs["pixel_values"]
1992
+ else:
1993
+ pixel_values = mm_kwargs["pixel_values_videos"]
1994
+ return pixel_values
1995
+
1996
+ def _get_grid_thw_by_modality(
1997
+ self,
1998
+ mm_kwargs: dict[str, Any],
1999
+ ) -> list[tuple[int, int, int]]:
2000
+ grid_thw_key = f"{self.get_input_modality(mm_kwargs)}_grid_thw"
2001
+ grid_thw = mm_kwargs[grid_thw_key]
2002
+ if not isinstance(grid_thw, list):
2003
+ grid_thw = grid_thw.tolist()
2004
+ return grid_thw
2005
+
2006
+ def get_encoder_cudagraph_item_specs(
2007
+ self,
2008
+ mm_kwargs: dict[str, Any],
2009
+ ):
2010
+ from vllm.v1.worker.encoder_cudagraph_defs import EncoderItemSpec
2011
+
2012
+ m = self.visual.spatial_merge_size
2013
+ grid_thw = self._get_grid_thw_by_modality(mm_kwargs)
2014
+ return [
2015
+ EncoderItemSpec(
2016
+ input_size=t * h * w,
2017
+ output_tokens=t * (h // m) * (w // m),
2018
+ )
2019
+ for t, h, w in grid_thw
2020
+ ]
2021
+
2022
+ def select_encoder_cudagraph_items(
2023
+ self,
2024
+ mm_kwargs: dict[str, Any],
2025
+ indices: list[int],
2026
+ ) -> dict[str, Any]:
2027
+ grid_thw = self._get_grid_thw_by_modality(mm_kwargs)
2028
+ pixel_values = self._get_pixel_values_by_modality(mm_kwargs)
2029
+
2030
+ if len(indices) == 0:
2031
+ if self.get_input_modality(mm_kwargs) == "image":
2032
+ return {
2033
+ "pixel_values": pixel_values[:0],
2034
+ "image_grid_thw": [],
2035
+ }
2036
+ else:
2037
+ return {
2038
+ "pixel_values_videos": pixel_values[:0],
2039
+ "video_grid_thw": [],
2040
+ }
2041
+
2042
+ # Compute cumulative patch offsets for slicing pixel_values
2043
+ patches_per_item = [t * h * w for t, h, w in grid_thw]
2044
+ cum_patches = [0]
2045
+ for p in patches_per_item:
2046
+ cum_patches.append(cum_patches[-1] + p)
2047
+
2048
+ selected_pv = torch.cat(
2049
+ [pixel_values[cum_patches[i] : cum_patches[i + 1]] for i in indices]
2050
+ )
2051
+ selected_grid = [grid_thw[i] for i in indices]
2052
+
2053
+ if self.get_input_modality(mm_kwargs) == "image":
2054
+ return {
2055
+ "pixel_values": selected_pv,
2056
+ "image_grid_thw": selected_grid,
2057
+ }
2058
+ else:
2059
+ return {
2060
+ "pixel_values_videos": selected_pv,
2061
+ "video_grid_thw": selected_grid,
2062
+ }
2063
+
2064
+ def prepare_encoder_cudagraph_capture_inputs(
2065
+ self,
2066
+ token_budget: int,
2067
+ max_batch_size: int,
2068
+ max_frames_per_batch: int,
2069
+ device: torch.device,
2070
+ dtype: torch.dtype,
2071
+ ):
2072
+ from vllm.v1.worker.encoder_cudagraph_defs import (
2073
+ EncoderCudaGraphCaptureInputs,
2074
+ )
2075
+
2076
+ spatial_merge_size = self.visual.spatial_merge_size
2077
+ per_mm_item_output = token_budget // max_batch_size
2078
+
2079
+ frames_per_item = max_frames_per_batch // max_batch_size
2080
+ if frames_per_item > 1:
2081
+ # Build the capture grid using a video-format layout so that
2082
+ # cu_seqlens is sized for video replays from the start.
2083
+ # cu_seqlens has one entry per attention sequence (one per frame),
2084
+ # so using T > 1 per item makes the buffer large enough without
2085
+ # relying solely on padding.
2086
+ # Ceiling ensures frames_per_item * tokens_per_frame >= per_mm_item_output
2087
+ # so the pixel_values buffer covers any valid single-item replay.
2088
+ tokens_per_frame = (
2089
+ per_mm_item_output + frames_per_item - 1
2090
+ ) // frames_per_item
2091
+ # Video-format grid_config (T=frames_per_item).
2092
+ grid_config = [
2093
+ [
2094
+ frames_per_item,
2095
+ spatial_merge_size,
2096
+ tokens_per_frame * spatial_merge_size,
2097
+ ]
2098
+ for _ in range(max_batch_size)
2099
+ ]
2100
+ else:
2101
+ # Image-format grid_config (T=1).
2102
+ grid_config = [
2103
+ [1, spatial_merge_size, per_mm_item_output * spatial_merge_size]
2104
+ for _ in range(max_batch_size)
2105
+ ]
2106
+
2107
+ # Create dummy pixel_values
2108
+ patch_embed = self.visual.patch_embed
2109
+ in_channels = patch_embed.proj.in_channels
2110
+ patch_size = patch_embed.patch_size
2111
+ temporal_patch_size = patch_embed.temporal_patch_size
2112
+ total_patches = sum(t * h * w for t, h, w in grid_config)
2113
+ flattened_patch_size = (
2114
+ in_channels * temporal_patch_size * patch_size * patch_size
2115
+ )
2116
+ dummy_pixel_values = torch.randn(
2117
+ total_patches, flattened_patch_size, device=device, dtype=dtype
2118
+ )
2119
+
2120
+ # Override max_seqlen with a safe upper bound for capture.
2121
+ # max_seqlen.item() gets baked into the CUDA graph (not replayed),
2122
+ # so the capture value must cover any replay scenario.
2123
+ # Worst case: 1 item consuming the full budget ->
2124
+ # seq_len = token_budget * spatial_merge_size^2.
2125
+ metadata = self.visual.prepare_encoder_metadata(
2126
+ grid_config,
2127
+ max_batch_size=max_batch_size,
2128
+ max_frames_per_batch=max_frames_per_batch,
2129
+ max_seqlen_override=token_budget * (spatial_merge_size**2),
2130
+ device=device,
2131
+ )
2132
+
2133
+ # Just use image-modality dummy input_buffer for capturing, since it's also
2134
+ # compatible for video inputs (has the same shape: [num_patches, C*T*P*P]).
2135
+ values = metadata | {
2136
+ "pixel_values": dummy_pixel_values,
2137
+ }
2138
+
2139
+ return EncoderCudaGraphCaptureInputs(
2140
+ values=values,
2141
+ )
2142
+
2143
+ def prepare_encoder_cudagraph_replay_buffers(
2144
+ self,
2145
+ mm_kwargs: dict[str, Any],
2146
+ max_batch_size: int,
2147
+ max_frames_per_batch: int,
2148
+ ):
2149
+ modality = self.get_input_modality(mm_kwargs)
2150
+ grid_thw_list = self._get_grid_thw_by_modality(mm_kwargs)
2151
+
2152
+ if modality == "image":
2153
+ metadata = self.visual.prepare_encoder_metadata(
2154
+ grid_thw_list,
2155
+ max_batch_size=max_batch_size,
2156
+ )
2157
+ elif modality == "video":
2158
+ metadata = self.visual.prepare_encoder_metadata(
2159
+ grid_thw_list,
2160
+ max_frames_per_batch=max_frames_per_batch,
2161
+ )
2162
+ else:
2163
+ raise AssertionError("This line should be unreachable.")
2164
+
2165
+ values = metadata | {
2166
+ "pixel_values": self._get_pixel_values_by_modality(mm_kwargs),
2167
+ }
2168
+ return EncoderCudaGraphReplayBuffers(values=values)
2169
+
2170
+ def encoder_cudagraph_forward(
2171
+ self,
2172
+ values: dict[str, torch.Tensor],
2173
+ ) -> torch.Tensor:
2174
+ pixel_values = values.pop("pixel_values")
2175
+ metadata = values
2176
+ return self.visual(pixel_values, None, encoder_metadata=metadata)
2177
+
2178
+ def encoder_eager_forward(
2179
+ self,
2180
+ mm_kwargs: dict[str, Any],
2181
+ ) -> torch.Tensor:
2182
+ pixel_values = self._get_pixel_values_by_modality(mm_kwargs)
2183
+ grid_thw = self._get_grid_thw_by_modality(mm_kwargs)
2184
+ return self.visual(pixel_values, grid_thw)
2185
+
2186
def _parse_and_validate_multimodal_inputs(self, **kwargs: object) -> dict:
2187
mm_input_by_modality = {}
2188