[Minimax-M3] Add Encoder CUDA graph support (#58673)

Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg> Co-authored-by: Codex <codex@openai.com>

Thien Tran committed Sep 28, 2026 at 12:17 UTC 55de40a2fc1ad2e54f13711daa83303d18f80247
5 files changed +397 -17
tests/models/multimodal/generation/test_vit_cudagraph.py
+18
@@ -175,6 +175,24 @@ MODEL_CONFIGS: dict[str, VitCudagraphTestConfig] = {
175 },
176 marks=[pytest.mark.core_model],
177 ),
178 + "minimax_m3": VitCudagraphTestConfig(
179 + model="MiniMaxAI/MiniMax-M3",
180 + modalities=["image"],
181 + image_prompt=("]~b]user\n]<]image[>[What is in this image?[e~[\n]~b]ai\n"),
182 + compilation_config_overrides={
183 + "encoder_cudagraph_token_budgets": [512],
184 + },
185 + vllm_runner_kwargs={
186 + "trust_remote_code": True,
187 + "load_format": "dummy",
188 + "hf_overrides": partial(
189 + dummy_hf_overrides,
190 + model_arch="MiniMaxM3SparseForConditionalGeneration",
191 + ),
192 + "mm_processor_kwargs": {"max_long_side_pixel": 336},
193 + },
194 + marks=[pytest.mark.core_model],
195 + ),
196 "qwen3_vl": VitCudagraphTestConfig(
197 model="Qwen/Qwen3-VL-2B-Instruct",
198 image_prompt=qwen_vl_chat_template(
vllm/models/minimax_m3/amd/model.py
+8 -1
@@ -106,6 +106,9 @@ from vllm.models.minimax_m3.amd.sparse_attention_msa import (
106 MiniMaxM3SparseAiterPAImpl,
107 MiniMaxM3SparseAiterPAPrefillMetadata,
108 )
109 +from vllm.models.minimax_m3.common.encoder_cudagraph import (
110 + MiniMaxM3EncoderCudaGraphMixin,
111 +)
112 from vllm.models.minimax_m3.common.indexer import MiniMaxM3Indexer
113 from vllm.models.minimax_m3.common.mm_preprocess import (
114 MiniMaxM3VLDummyInputsBuilder,
@@ -1685,7 +1688,11 @@ class MiniMaxM3SparseForCausalLM(nn.Module, SupportsPP, SupportsEagle3):
1688 dummy_inputs=MiniMaxM3VLDummyInputsBuilder,
1689 )
1690 class MiniMaxM3SparseForConditionalGeneration(
1688 - nn.Module, SupportsMultiModal, SupportsPP, SupportsEagle3
1691 + nn.Module,
1692 + SupportsMultiModal,
1693 + MiniMaxM3EncoderCudaGraphMixin,
1694 + SupportsPP,
1695 + SupportsEagle3,
1696 ):
1697 """Top-level (VL) entry point for MiniMax M3.
1698
vllm/models/minimax_m3/common/encoder_cudagraph.py new
+281
@@ -0,0 +1,281 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Encoder CUDA graph support for the MiniMax M3 vision tower.
4 +
5 +Mixin implementing the SupportsEncoderCudaGraph protocol for any host class
6 +that holds the vision tower as ``self.vision_tower`` (a
7 +``MiniMaxVLVisionModel``). The manager routes image items through this mixin;
8 +video items use the eager encoder path.
9 +"""
10 +
11 +from collections.abc import Hashable
12 +from typing import TYPE_CHECKING, Any
13 +
14 +import torch
15 +
16 +from vllm.model_executor.models.interfaces import SupportsEncoderCudaGraph
17 +
18 +if TYPE_CHECKING:
19 + from vllm.config import VllmConfig
20 + from vllm.config.multimodal import MultiModalConfig
21 + from vllm.models.minimax_m3.common.vision_tower import MiniMaxVLVisionModel
22 + from vllm.v1.worker.encoder_cudagraph_defs import (
23 + EncoderCudaGraphCaptureInputs,
24 + EncoderCudaGraphConfig,
25 + EncoderCudaGraphReplayBuffers,
26 + EncoderItemSpec,
27 + )
28 +
29 +
30 +class MiniMaxM3EncoderCudaGraphMixin(SupportsEncoderCudaGraph):
31 + """SupportsEncoderCudaGraph for hosts with ``self.vision_tower``."""
32 +
33 + vision_tower: "MiniMaxVLVisionModel"
34 + multimodal_config: "MultiModalConfig | None"
35 + _encoder_cg_pad_totals: dict[int, int] = {}
36 +
37 + def get_encoder_cudagraph_config(self) -> "EncoderCudaGraphConfig":
38 + from vllm.v1.attention.backends.registry import AttentionBackendEnum
39 + from vllm.v1.worker.encoder_cudagraph_defs import EncoderCudaGraphConfig
40 +
41 + if (
42 + self.multimodal_config is not None
43 + and self.multimodal_config.get_limit_per_prompt("image") == 0
44 + ):
45 + raise ValueError(
46 + "cudagraph_mm_encoder requires the MiniMax M3 vision tower, "
47 + "but the image limit is 0. Disable cudagraph_mm_encoder."
48 + )
49 +
50 + mm_cfg = self.multimodal_config
51 + if (
52 + mm_cfg is not None
53 + and mm_cfg.mm_encoder_attn_dtype == "fp8"
54 + and mm_cfg.mm_encoder_fp8_scale_path is None
55 + ):
56 + raise ValueError(
57 + "cudagraph_mm_encoder requires static FP8 scales for the "
58 + "MiniMax M3 vision encoder. Set --mm-encoder-fp8-scale-path "
59 + "or disable cudagraph_mm_encoder."
60 + )
61 +
62 + # FlashInfer reads max_seqlen on the host and TORCH_SDPA calls
63 + # .tolist() on CUDA cu_seqlens; neither can be captured.
64 + backend = self.vision_tower.vision_model.attn_backend
65 + if backend not in (
66 + AttentionBackendEnum.FLASH_ATTN,
67 + AttentionBackendEnum.ROCM_AITER_FA,
68 + ):
69 + raise ValueError(
70 + f"cudagraph_mm_encoder is not supported with the "
71 + f"{backend.name} ViT attention backend for MiniMax M3. Set "
72 + "--mm-encoder-attn-backend FLASH_ATTN or ROCM_AITER_FA "
73 + "(ROCm), or disable cudagraph_mm_encoder."
74 + )
75 +
76 + pad_totals = self._encoder_cg_pad_totals
77 +
78 + def pad_cu_seqlens(dst: torch.Tensor, src: torch.Tensor) -> None:
79 + # Varlen attention requires cu_seqlens[-1] to equal the number of
80 + # rows actually passed in. The captured buffers are sized for the
81 + # full token budget, so a smaller real batch has to be completed
82 + # with one trailing padding sequence; declaring fewer rows than the
83 + # buffer holds is undefined behaviour and returns NaN on FlashAttn.
84 + total = pad_totals.get(dst.data_ptr())
85 + if total is None:
86 + raise RuntimeError("cu_seqlens replay buffer was not registered")
87 + n = src.shape[0]
88 + dst[:n].copy_(src[:n])
89 + dst[n:] = total
90 +
91 + def pad_rope_planes(dst: torch.Tensor, src: torch.Tensor) -> None:
92 + # cos/sin planes are (3, N, half_rot_dim): the row axis is dim 1,
93 + # not dim 0 as the manager's default slice-copy assumes.
94 + dst.zero_()
95 + dst[:, : src.shape[1]].copy_(src)
96 +
97 + return EncoderCudaGraphConfig(
98 + modalities=["image"],
99 + buffer_keys=[
100 + "pixel_values",
101 + "rotary_cos",
102 + "rotary_sin",
103 + "cu_seqlens",
104 + "max_seqlen",
105 + ],
106 + out_hidden_size=self.vision_tower.out_hidden_size,
107 + padding_logics={
108 + "cu_seqlens": pad_cu_seqlens,
109 + "rotary_cos": pad_rope_planes,
110 + "rotary_sin": pad_rope_planes,
111 + },
112 + )
113 +
114 + def get_encoder_cudagraph_budget_range(
115 + self,
116 + vllm_config: "VllmConfig",
117 + ) -> tuple[int, int]:
118 + min_budget = 64
119 + max_budget = min(
120 + vllm_config.scheduler_config.max_num_batched_tokens,
121 + vllm_config.model_config.max_model_len,
122 + )
123 + return (min_budget, max_budget)
124 +
125 + @staticmethod
126 + def _get_grid_thw_list(mm_kwargs: dict[str, Any]) -> list[list[int]]:
127 + grid_thw = mm_kwargs["image_grid_thw"]
128 + if isinstance(grid_thw, torch.Tensor):
129 + return [[int(x) for x in row] for row in grid_thw.tolist()]
130 + return [[int(x) for x in row] for row in grid_thw]
131 +
132 + @staticmethod
133 + def _get_pixel_values(mm_kwargs: dict[str, Any]) -> torch.Tensor:
134 + pixel_values = mm_kwargs["pixel_values"]
135 + if isinstance(pixel_values, list):
136 + pixel_values = torch.cat(pixel_values)
137 + return pixel_values
138 +
139 + def get_encoder_cudagraph_item_specs(
140 + self, mm_kwargs: dict[str, Any]
141 + ) -> list["EncoderItemSpec"]:
142 + from vllm.v1.worker.encoder_cudagraph_defs import EncoderItemSpec
143 +
144 + merge = self.vision_tower.spatial_merge_size
145 + return [
146 + EncoderItemSpec(
147 + input_size=t * h * w,
148 + output_tokens=t * h * w // (merge * merge),
149 + )
150 + for t, h, w in self._get_grid_thw_list(mm_kwargs)
151 + ]
152 +
153 + def select_encoder_cudagraph_items(
154 + self, mm_kwargs: dict[str, Any], indices: list[int]
155 + ) -> dict[str, Any]:
156 + grid_thw_list = self._get_grid_thw_list(mm_kwargs)
157 + pixel_values = self._get_pixel_values(mm_kwargs)
158 + source_grid = mm_kwargs["image_grid_thw"]
159 +
160 + if len(indices) == 0:
161 + empty_grid = (
162 + source_grid[:0]
163 + if isinstance(source_grid, torch.Tensor)
164 + else pixel_values.new_zeros((0, 3), dtype=torch.long)
165 + )
166 + return {"pixel_values": pixel_values[:0], "image_grid_thw": empty_grid}
167 +
168 + patch_counts = [t * h * w for t, h, w in grid_thw_list]
169 + cum = [0]
170 + for pc in patch_counts:
171 + cum.append(cum[-1] + pc)
172 + selected_pv = torch.cat(
173 + [pixel_values[cum[i] : cum[i + 1]] for i in indices], dim=0
174 + )
175 + grid_device = (
176 + source_grid.device if isinstance(source_grid, torch.Tensor) else None
177 + )
178 + selected_grid = torch.tensor(
179 + [grid_thw_list[i] for i in indices],
180 + dtype=torch.long,
181 + device=grid_device,
182 + )
183 + return {"pixel_values": selected_pv, "image_grid_thw": selected_grid}
184 +
185 + def prepare_encoder_cudagraph_capture_inputs(
186 + self,
187 + token_budget: int,
188 + max_batch_size: int,
189 + max_frames_per_batch: int,
190 + device: torch.device,
191 + dtype: torch.dtype,
192 + path: str = "default",
193 + axis_keys: tuple[Hashable, ...] | None = None,
194 + ) -> "EncoderCudaGraphCaptureInputs":
195 + from vllm.v1.worker.encoder_cudagraph_defs import (
196 + EncoderCudaGraphCaptureInputs,
197 + )
198 +
199 + merge = self.vision_tower.spatial_merge_size
200 + # Output tokens per item in the dummy grid (ceiling so total >= budget).
201 + per_item_out = (token_budget + max_batch_size - 1) // max_batch_size
202 + grid_thw_list = [
203 + [1, merge, per_item_out * merge] for _ in range(max_batch_size)
204 + ]
205 +
206 + embeddings = self.vision_tower.vision_model.embeddings
207 + patch_dim = (
208 + embeddings.num_channels
209 + * embeddings.temporal_patch_size
210 + * embeddings.patch_size**2
211 + )
212 + total_patches = sum(t * h * w for t, h, w in grid_thw_list)
213 + # Match the tower dtype exactly: MiniMaxVLPatchEmbed.forward mutates
214 + # the module on dtype mismatch, which must not happen during capture.
215 + dummy_pixel_values = torch.zeros(
216 + total_patches,
217 + patch_dim,
218 + device=device,
219 + dtype=self.vision_tower.dtype,
220 + )
221 +
222 + # The padding sequence can contain nearly all captured rows when a
223 + # small image replays this graph. Leave one spare cu_seqlens slot.
224 + metadata = self.vision_tower.vision_model.prepare_encoder_metadata(
225 + grid_thw_list,
226 + device=device,
227 + max_batch_size=max_batch_size + 1,
228 + max_seqlen_override=total_patches,
229 + )
230 +
231 + values: dict[str, torch.Tensor] = {"pixel_values": dummy_pixel_values}
232 + values.update({k: v for k, v in metadata.items() if v is not None})
233 + self._encoder_cg_pad_totals[values["cu_seqlens"].data_ptr()] = total_patches
234 + return EncoderCudaGraphCaptureInputs(values=values)
235 +
236 + def prepare_encoder_cudagraph_replay_buffers(
237 + self,
238 + mm_kwargs: dict[str, Any],
239 + max_batch_size: int,
240 + max_frames_per_batch: int,
241 + path: str = "default",
242 + ) -> "EncoderCudaGraphReplayBuffers":
243 + from vllm.v1.worker.encoder_cudagraph_defs import (
244 + EncoderCudaGraphReplayBuffers,
245 + )
246 +
247 + pixel_values = self._get_pixel_values(mm_kwargs)
248 + # Unpadded: pad_cu_seqlens completes the tail with the padding sequence
249 + # so cu_seqlens[-1] matches the captured buffer's row count.
250 + metadata = self.vision_tower.vision_model.prepare_encoder_metadata(
251 + self._get_grid_thw_list(mm_kwargs),
252 + device=pixel_values.device,
253 + )
254 + # FlashAttention reads this CPU scalar during capture, so the captured
255 + # launch must retain the bound for the padded buffer.
256 + metadata.pop("max_seqlen")
257 +
258 + values: dict[str, torch.Tensor | None] = {"pixel_values": pixel_values}
259 + values.update(metadata)
260 + return EncoderCudaGraphReplayBuffers(values=values)
261 +
262 + def encoder_cudagraph_forward(
263 + self,
264 + inputs: dict[str, torch.Tensor],
265 + path: str = "default",
266 + ) -> torch.Tensor:
267 + pixel_values = inputs.pop("pixel_values")
268 + # Remaining keys are consumed as encoder_metadata.
269 + return self.vision_tower(pixel_values, grid_thw=None, encoder_metadata=inputs)
270 +
271 + def encoder_eager_forward(
272 + self,
273 + mm_kwargs: dict[str, Any],
274 + path: str = "default",
275 + ) -> torch.Tensor:
276 + return self.vision_tower(
277 + pixel_values=self._get_pixel_values(mm_kwargs).type(
278 + self.vision_tower.dtype
279 + ),
280 + grid_thw=self._get_grid_thw_list(mm_kwargs),
281 + )
vllm/models/minimax_m3/common/vision_tower.py
+82 -15
@@ -392,19 +392,33 @@ class MiniMaxVLVisionTransformer(nn.Module):
392 out.append([min(max_f, t - i), h, w])
393 return out
394
395 - # ── Forward ──────────────────────────────────────────────────────────
395 + # ── Encoder metadata (shared by eager forward and CUDA graph replay) ──
396
397 - def forward(
397 + def prepare_encoder_metadata(
398 self,
399 - pixel_values: torch.Tensor,
399 grid_thw: list[list[int]],
401 - ) -> torch.Tensor:
402 - # pixel_values: (total_N, C * temporal_patch_size * patch_size²)
403 - # Output: (total_N, hidden_size)
404 -
405 - hidden = self.embeddings(pixel_values) # (total_N, hidden_size)
406 - hidden = self.pre_layrnorm(hidden)
407 -
400 + *,
401 + device: torch.device,
402 + max_batch_size: int | None = None,
403 + max_seqlen_override: int | None = None,
404 + ) -> dict[str, torch.Tensor | None]:
405 + """Build all grid-dependent forward inputs as standalone tensors.
406 +
407 + The eager path calls this per forward; encoder CUDA graphs call it at
408 + capture (dummy grids, padded to fixed buffer sizes) and before each
409 + replay (real grids, unpadded — the manager's padding logic completes
410 + the captured buffers).
411 +
412 + Args:
413 + grid_thw: per-item [t, h, w] patch grids (host metadata).
414 + device: device for the attention metadata tensors.
415 + max_batch_size: pad cu_seqlens to this many segments with empty
416 + (zero-length) trailing segments; None leaves it unpadded.
417 + max_seqlen_override: bake this max_seqlen instead of deriving it
418 + from the grids; CUDA graph capture passes a worst-case value
419 + because max_seqlen is a CPU scalar baked into the graph.
420 +
421 + """
422 limited = self._apply_max_frames_limit(grid_thw)
423
424 # Token-level cumulative sequence lengths (one segment per limited grid).
@@ -412,15 +426,23 @@ class MiniMaxVLVisionTransformer(nn.Module):
426 cu_seqlens_np = np.zeros(len(lens) + 1, dtype=np.int32)
427 np.cumsum(np.array(lens, dtype=np.int32), out=cu_seqlens_np[1:])
428
429 + if max_batch_size is not None and len(lens) < max_batch_size:
430 + pad = np.full(max_batch_size - len(lens), cu_seqlens_np[-1], dtype=np.int32)
431 + cu_seqlens_np = np.concatenate([cu_seqlens_np, pad])
432 +
433 # Backend-specific encoder metadata. For FLASH_ATTN this returns the raw
434 # token cu_seqlens, the max segment length, and sequence_lengths=None;
435 # for FLASHINFER (cuDNN) it repacks cu_seqlens into element-offset
436 # indptrs, buckets max_seqlen, and builds padded per-sequence lengths.
437 sequence_lengths = MMEncoderAttention.maybe_compute_seq_lens(
420 - self.attn_backend, cu_seqlens_np, hidden.device
438 + self.attn_backend, cu_seqlens_np, device
439 )
440 max_seqlen = torch.tensor(
423 - MMEncoderAttention.compute_max_seqlen(self.attn_backend, cu_seqlens_np),
441 + max_seqlen_override
442 + if max_seqlen_override is not None
443 + else MMEncoderAttention.compute_max_seqlen(
444 + self.attn_backend, cu_seqlens_np
445 + ),
446 dtype=torch.int32,
447 )
448 cu_seqlens = MMEncoderAttention.maybe_recompute_cu_seqlens(
@@ -428,7 +450,7 @@ class MiniMaxVLVisionTransformer(nn.Module):
450 cu_seqlens_np,
451 self.hidden_size,
452 self.tp_size,
431 - hidden.device,
453 + device,
454 )
455
456 # 3D RoPE cos/sin: (3, total_N, half_rot_dim) fp32 t/h/w planes for
@@ -442,6 +464,45 @@ class MiniMaxVLVisionTransformer(nn.Module):
464 self.spatial_merge_size,
465 )
466
467 + return {
468 + "rotary_cos": rotary_cos,
469 + "rotary_sin": rotary_sin,
470 + "cu_seqlens": cu_seqlens,
471 + "max_seqlen": max_seqlen,
472 + "sequence_lengths": sequence_lengths,
473 + }
474 +
475 + # ── Forward ──────────────────────────────────────────────────────────
476 +
477 + def forward(
478 + self,
479 + pixel_values: torch.Tensor,
480 + grid_thw: list[list[int]] | None = None,
481 + *,
482 + encoder_metadata: dict[str, torch.Tensor | None] | None = None,
483 + ) -> torch.Tensor:
484 + # pixel_values: (total_N, C * temporal_patch_size * patch_size²)
485 + # Output: (total_N, hidden_size)
486 +
487 + hidden = self.embeddings(pixel_values) # (total_N, hidden_size)
488 + hidden = self.pre_layrnorm(hidden)
489 +
490 + if encoder_metadata is None:
491 + assert grid_thw is not None
492 + encoder_metadata = self.prepare_encoder_metadata(
493 + grid_thw, device=hidden.device
494 + )
495 +
496 + cu_seqlens = encoder_metadata["cu_seqlens"]
497 + rotary_cos = encoder_metadata["rotary_cos"]
498 + rotary_sin = encoder_metadata["rotary_sin"]
499 + max_seqlen = encoder_metadata["max_seqlen"]
500 + sequence_lengths = encoder_metadata.get("sequence_lengths")
501 + assert cu_seqlens is not None
502 + assert rotary_cos is not None
503 + assert rotary_sin is not None
504 + assert max_seqlen is not None
505 +
506 # Encoder expects (N, 1, hidden_size) — add batch dim
507 hidden = hidden.unsqueeze(1)
508
@@ -600,9 +661,15 @@ class MiniMaxVLVisionModel(nn.Module):
661 def forward(
662 self,
663 pixel_values: torch.Tensor,
603 - grid_thw: list[list[int]],
664 + grid_thw: list[list[int]] | None = None,
665 + *,
666 + encoder_metadata: dict[str, torch.Tensor | None] | None = None,
667 ) -> torch.Tensor:
605 - hidden = self.vision_model(pixel_values=pixel_values, grid_thw=grid_thw)
668 + hidden = self.vision_model(
669 + pixel_values=pixel_values,
670 + grid_thw=grid_thw,
671 + encoder_metadata=encoder_metadata,
672 + )
673 if hidden.dim() == 3:
674 hidden = hidden.squeeze(0)
675 hidden = self.multi_modal_projector(hidden)
vllm/models/minimax_m3/nvidia/model.py
+8 -1
@@ -70,6 +70,9 @@ from vllm.model_executor.models.utils import (
70 maybe_prefix,
71 )
72 from vllm.model_executor.models.vision import run_dp_sharded_mrope_vision_model
73 +from vllm.models.minimax_m3.common.encoder_cudagraph import (
74 + MiniMaxM3EncoderCudaGraphMixin,
75 +)
76 from vllm.models.minimax_m3.common.indexer import MiniMaxM3Indexer
77 from vllm.models.minimax_m3.common.mm_preprocess import (
78 MiniMaxM3VLDummyInputsBuilder,
@@ -1076,7 +1079,11 @@ class MiniMaxM3SparseForCausalLM(nn.Module, SupportsPP, SupportsEagle3):
1079 dummy_inputs=MiniMaxM3VLDummyInputsBuilder,
1080 )
1081 class MiniMaxM3SparseForConditionalGeneration(
1079 - nn.Module, SupportsMultiModal, SupportsPP, SupportsEagle3
1082 + nn.Module,
1083 + SupportsMultiModal,
1084 + MiniMaxM3EncoderCudaGraphMixin,
1085 + SupportsPP,
1086 + SupportsEagle3,
1087 ):
1088 """Top-level (VL) entry point for MiniMax M3.
1089