1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Inference-only MiniMax M3 (text backbone) model — AMD ROCm implementation.
4
+
5
+Self-contained per-platform impl (mirrors ``deepseek_v4/amd``). It is identical
6
+to ``../nvidia/model.py`` except for RMS normalization: FlashInfer's Gemma
7
+RMSNorm kernels are CUDA-only, so ``MiniMAXGemmaRMSNorm`` here uses a native
8
+(FlashInfer-free) implementation.
9
+
10
+The MiniMax-M3-preview config selects a single set of branches:
11
+ * qk_norm_type == "per_head"
12
+ * hidden_act == "swigluoai"
13
+ * use_gemma_norm == True -> Gemma-style RMSNorm everywhere
14
+ * attention_output_gate == False
15
+ * scoring_func == "sigmoid" with a routing-bias correction term
16
+ * sparse_attention_config present -> a subset of layers run the extra
17
+ "index" attention branch.
18
+"""
19
+
20
+from collections.abc import Iterable
21
+
22
+import torch
23
+from torch import nn
24
+from transformers import PretrainedConfig
25
+
26
+from vllm import _custom_ops as ops
27
+from vllm.compilation.breakable_cudagraph import eager_break_during_capture
28
+from vllm.config import (
29
+ CacheConfig,
30
+ VllmConfig,
31
+ get_current_vllm_config,
32
+)
33
+from vllm.distributed import get_tensor_model_parallel_world_size
34
+from vllm.forward_context import get_forward_context
35
+from vllm.model_executor.layers.attention import Attention
36
+from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase
37
+from vllm.model_executor.layers.fused_allreduce_gemma_rms_norm import (
38
+ fused_allreduce_gemma_rms_norm,
39
+)
40
+from vllm.model_executor.layers.fused_moe import (
41
+ FusedMoE,
42
+ GateLinear,
43
+ fused_moe_make_expert_params_mapping,
44
+)
45
+from vllm.model_executor.layers.linear import (
46
+ MergedColumnParallelLinear,
47
+ MinimaxM3QKVParallelLinearWithIndexer,
48
+ QKVParallelLinear,
49
+ RowParallelLinear,
50
+)
51
+from vllm.model_executor.layers.logits_processor import LogitsProcessor
52
+from vllm.model_executor.layers.quantization import QuantizationConfig
53
+from vllm.model_executor.layers.rotary_embedding import get_rope
54
+from vllm.model_executor.layers.vocab_parallel_embedding import (
55
+ ParallelLMHead,
56
+ VocabParallelEmbedding,
57
+)
58
+from vllm.model_executor.model_loader.weight_utils import (
59
+ default_weight_loader,
60
+ maybe_remap_kv_scale_name,
61
+)
62
+from vllm.model_executor.models.interfaces import (
63
+ EagleModelMixin,
64
+ MultiModalEmbeddings,
65
+ SupportsEagle3,
66
+ SupportsMultiModal,
67
+)
68
+from vllm.model_executor.models.utils import (
69
+ AutoWeightsLoader,
70
+ WeightsMapper,
71
+ init_vllm_registered_model,
72
+ is_pp_missing_parameter,
73
+ make_layers,
74
+ maybe_prefix,
75
+)
76
+from vllm.model_executor.models.vision import run_dp_sharded_mrope_vision_model
77
+from vllm.models.minimax_m3.amd.ops import (
78
+ gemma_fused_add_rmsnorm,
79
+ gemma_rmsnorm,
80
+ swiglu_oai_split,
81
+)
82
+from vllm.models.minimax_m3.common.indexer import MiniMaxM3Indexer
83
+from vllm.models.minimax_m3.common.mm_preprocess import (
84
+ MiniMaxM3VLDummyInputsBuilder,
85
+ MiniMaxM3VLMultiModalProcessor,
86
+ MiniMaxM3VLProcessingInfo,
87
+)
88
+from vllm.models.minimax_m3.common.sparse_attention import (
89
+ MiniMaxM3SparseBackend,
90
+ MiniMaxM3SparseImpl,
91
+ select_main_impl_cls,
92
+)
93
+from vllm.models.minimax_m3.common.vision_tower import MiniMaxVLVisionModel
94
+from vllm.multimodal import MULTIMODAL_REGISTRY
95
+from vllm.utils.torch_utils import kv_cache_dtype_str_to_dtype
96
+from vllm.v1.kv_cache_interface import (
97
+ FullAttentionSpec,
98
+ KVCacheSpec,
99
+ get_kv_quant_mode,
100
+)
101
+
102
+
103
+def _sparse_attention_layer_ids(config: PretrainedConfig) -> set[int]:
104
+ """Layer ids whose attention runs the extra sparse "index" branch."""
105
+ cfg = getattr(config, "sparse_attention_config", None)
106
+ if not cfg:
107
+ return set()
108
+ freq = cfg.get("sparse_attention_freq")
109
+ if freq is None:
110
+ return set()
111
+ return {i for i, f in enumerate(freq) if f != 0}
112
+
113
+
114
+def _is_moe_layer(config: PretrainedConfig, layer_id: int) -> bool:
115
+ """Whether this layer's MLP is a sparse MoE block (vs a dense MLP)."""
116
+ moe_layer_freq = getattr(config, "moe_layer_freq", None)
117
+ if moe_layer_freq is None:
118
+ return True
119
+ return moe_layer_freq[layer_id] != 0
120
+
121
+
122
+def _build_rotary_emb(config: PretrainedConfig, head_dim: int):
123
+ """Build the (partial NeoX) RoPE, honoring an optional ``rope_scaling`` config.
124
+
125
+ Without scaling the cos/sin cache is sized to ``max_position_embeddings``
126
+ (524288 native); a request whose positions exceed that reads the cache out of
127
+ bounds and the worker hard-crashes (no Python traceback). When ``rope_scaling``
128
+ is set (e.g. YaRN ``factor: 2`` to reach 1M), thread it into ``get_rope`` so the
129
+ proper scaled embedding is built and its cache covers
130
+ ``original_max_position_embeddings * factor`` positions. Default behavior
131
+ (no scaling) is unchanged. Shared by the dense and sparse attention layers, and
132
+ the index branch reuses the returned module.
133
+
134
+ Note: for the VL checkpoint, set ``rope_scaling`` on the *text* config
135
+ (``--hf-overrides '{"text_config":{"rope_scaling":{...}}}'``) -- that is the
136
+ config the decoder reads here; a top-level override does not reach it.
137
+ """
138
+ rope_parameters = {
139
+ "rope_theta": config.rope_theta,
140
+ "partial_rotary_factor": config.partial_rotary_factor,
141
+ }
142
+ max_position = config.max_position_embeddings
143
+ rope_scaling = getattr(config, "rope_scaling", None)
144
+ if rope_scaling:
145
+ rope_parameters.update(rope_scaling)
146
+ # HF uses "rope_type" (older configs: "type"); get_rope reads "rope_type".
147
+ if "rope_type" not in rope_parameters and "type" in rope_scaling:
148
+ rope_parameters["rope_type"] = rope_scaling["type"]
149
+ rope_parameters.setdefault(
150
+ "original_max_position_embeddings", config.max_position_embeddings
151
+ )
152
+ factor = float(rope_scaling.get("factor", 1.0))
153
+ # Cover the extended range (informational for get_rope's default branch;
154
+ # the YaRN embedding sizes its own cache from original * factor).
155
+ max_position = int(rope_parameters["original_max_position_embeddings"] * factor)
156
+ return get_rope(
157
+ head_dim,
158
+ max_position=max_position,
159
+ rope_parameters=rope_parameters,
160
+ )
161
+
162
+
163
+class MiniMAXGemmaRMSNorm(nn.Module):
164
+ """Gemma-style RMS normalization (native ROCm implementation).
165
+
166
+ Normalizes in fp32 and scales by ``(1 + weight)`` — numerically equivalent
167
+ to the FlashInfer ``gemma_rmsnorm`` / ``gemma_fused_add_rmsnorm`` kernels
168
+ used in the NVIDIA path, which are unavailable on ROCm. When ``residual`` is
169
+ given, the fused add + norm returns the updated ``(normed, residual)`` pair.
170
+
171
+ The fp32 normalize + scale + (optional) residual-add run in a single fused
172
+ Triton pass (``amd.ops.gemma_rmsnorm`` / ``gemma_fused_add_rmsnorm``) instead
173
+ of a chain of elementwise PyTorch kernels.
174
+ """
175
+
176
+ def __init__(
177
+ self,
178
+ hidden_size: int,
179
+ eps: float = 1e-6,
180
+ ) -> None:
181
+ super().__init__()
182
+ self.weight = nn.Parameter(torch.zeros(hidden_size))
183
+ self.variance_epsilon = eps
184
+
185
+ def forward(
186
+ self,
187
+ x: torch.Tensor,
188
+ residual: torch.Tensor | None = None,
189
+ ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
190
+ if residual is None:
191
+ return gemma_rmsnorm(x, self.weight, self.variance_epsilon)
192
+ return gemma_fused_add_rmsnorm(x, residual, self.weight, self.variance_epsilon)
193
+
194
+
195
+class MiniMaxM3MLP(nn.Module):
196
+ """Dense SwiGLU-OAI MLP (used by the leading dense layers)."""
197
+
198
+ def __init__(
199
+ self,
200
+ config: PretrainedConfig,
201
+ intermediate_size: int,
202
+ quant_config: QuantizationConfig | None = None,
203
+ reduce_results: bool = True,
204
+ prefix: str = "",
205
+ ) -> None:
206
+ super().__init__()
207
+ self.gate_up_proj = MergedColumnParallelLinear(
208
+ config.hidden_size,
209
+ [intermediate_size] * 2,
210
+ bias=False,
211
+ quant_config=quant_config,
212
+ prefix=f"{prefix}.gate_up_proj",
213
+ )
214
+ self.down_proj = RowParallelLinear(
215
+ intermediate_size,
216
+ config.hidden_size,
217
+ bias=False,
218
+ quant_config=quant_config,
219
+ reduce_results=reduce_results,
220
+ prefix=f"{prefix}.down_proj",
221
+ )
222
+ if config.hidden_act != "swigluoai":
223
+ raise ValueError(
224
+ f"Unsupported activation: {config.hidden_act}. "
225
+ "Only swigluoai is supported."
226
+ )
227
+ # gate * sigmoid(alpha * gate) * (up + beta), with both halves clamped.
228
+ # Kept as our fp32 Triton kernel (not the #22 SWIGLUOAI_UNINTERLEAVE op
229
+ # ``silu_and_mul_with_clamp``): that op IS built on ROCm but rounds
230
+ # intermediates to bf16 (rel ~3e-3 vs our fp32 ~1e-6), which costs gsm8k
231
+ # accuracy since this activation feeds the MXFP8 quant + MoE.
232
+ self.swiglu_alpha = config.swiglu_alpha
233
+ self.swiglu_beta = config.swiglu_beta
234
+ self.swiglu_limit = config.swiglu_limit
235
+
236
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
237
+ gate_up, _ = self.gate_up_proj(x)
238
+ x = swiglu_oai_split(
239
+ gate_up,
240
+ alpha=self.swiglu_alpha,
241
+ beta=self.swiglu_beta,
242
+ limit=self.swiglu_limit,
243
+ )
244
+ x, _ = self.down_proj(x)
245
+ return x
246
+
247
+
248
+class MiniMaxM3MoE(nn.Module):
249
+ """Sigmoid-routed MoE block with a routing-bias correction and a shared
250
+ expert."""
251
+
252
+ def __init__(
253
+ self,
254
+ config: PretrainedConfig,
255
+ layer_id: int,
256
+ quant_config: QuantizationConfig | None = None,
257
+ prefix: str = "",
258
+ ) -> None:
259
+ super().__init__()
260
+ self.tp_size = get_tensor_model_parallel_world_size()
261
+ if self.tp_size > config.num_local_experts:
262
+ raise ValueError(
263
+ f"Tensor parallel size {self.tp_size} is greater than "
264
+ f"the number of experts {config.num_local_experts}."
265
+ )
266
+
267
+ self.routed_scaling_factor = getattr(config, "routed_scaling_factor", 1.0)
268
+ self.n_shared_experts = getattr(config, "n_shared_experts", None)
269
+
270
+ # Sigmoid routing uses a per-expert score-correction bias for selection.
271
+ self.use_routing_bias = getattr(config, "use_routing_bias", False)
272
+ if self.use_routing_bias:
273
+ self.e_score_correction_bias = nn.Parameter(
274
+ torch.empty(config.num_local_experts, dtype=torch.float32)
275
+ )
276
+ self.e_score_correction_bias.weight_loader = (
277
+ MiniMaxM3MoE.ebias_weight_loader
278
+ )
279
+ else:
280
+ self.e_score_correction_bias = None
281
+
282
+ # Router weights are stored in fp32; GateLinear upcasts the bf16
283
+ # activations and computes the gate in fp32 (fp32 router logits).
284
+ self.gate = GateLinear(
285
+ config.hidden_size,
286
+ config.num_local_experts,
287
+ bias=False,
288
+ params_dtype=torch.float32,
289
+ out_dtype=torch.float32,
290
+ prefix=f"{prefix}.gate",
291
+ )
292
+
293
+ self.shared_experts: MiniMaxM3MLP | None = None
294
+ if self.n_shared_experts:
295
+ self.shared_experts = MiniMaxM3MLP(
296
+ config=config,
297
+ intermediate_size=config.intermediate_size * self.n_shared_experts,
298
+ quant_config=quant_config,
299
+ reduce_results=False,
300
+ prefix=f"{prefix}.shared_experts",
301
+ )
302
+
303
+ self.experts = FusedMoE(
304
+ num_experts=config.num_local_experts,
305
+ top_k=config.num_experts_per_tok,
306
+ hidden_size=config.hidden_size,
307
+ intermediate_size=config.intermediate_size,
308
+ scoring_func=config.scoring_func,
309
+ e_score_correction_bias=self.e_score_correction_bias,
310
+ renormalize=True,
311
+ activation="swigluoai_uninterleave",
312
+ swiglu_limit=config.swiglu_limit,
313
+ swiglu_alpha=config.swiglu_alpha,
314
+ swiglu_beta=config.swiglu_beta,
315
+ routed_scaling_factor=self.routed_scaling_factor,
316
+ apply_routed_scale_to_output=True,
317
+ router_logits_dtype=self.gate.out_dtype,
318
+ shared_experts=self.shared_experts,
319
+ quant_config=quant_config,
320
+ prefix=f"{prefix}.experts",
321
+ )
322
+
323
+ @staticmethod
324
+ def ebias_weight_loader(param: nn.Parameter, loaded_weight: torch.Tensor) -> None:
325
+ assert param.size() == loaded_weight.size()
326
+ param.data.copy_(loaded_weight.to(torch.float32))
327
+
328
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
329
+ num_tokens, hidden_dim = hidden_states.shape
330
+ hidden_states = hidden_states.view(-1, hidden_dim)
331
+
332
+ # router_logits: (num_tokens, n_experts); GateLinear casts to fp32.
333
+ router_logits, _ = self.gate(hidden_states)
334
+ final_hidden_states = self.experts(
335
+ hidden_states=hidden_states, router_logits=router_logits
336
+ )
337
+
338
+ return final_hidden_states.view(num_tokens, hidden_dim)
339
+
340
+
341
+class MiniMaxM3Attention(nn.Module):
342
+ """Dense attention with per-head QK norm and partial RoPE."""
343
+
344
+ def __init__(
345
+ self,
346
+ config: PretrainedConfig,
347
+ layer_id: int,
348
+ quant_config: QuantizationConfig | None = None,
349
+ prefix: str = "",
350
+ cache_config: CacheConfig | None = None,
351
+ ) -> None:
352
+ super().__init__()
353
+ self.hidden_size = config.hidden_size
354
+ tp_size = get_tensor_model_parallel_world_size()
355
+
356
+ self.total_num_heads = config.num_attention_heads
357
+ assert self.total_num_heads % tp_size == 0
358
+ self.num_heads = self.total_num_heads // tp_size
359
+ self.total_num_kv_heads = config.num_key_value_heads
360
+ if self.total_num_kv_heads >= tp_size:
361
+ assert self.total_num_kv_heads % tp_size == 0
362
+ else:
363
+ assert tp_size % self.total_num_kv_heads == 0
364
+ self.num_kv_heads = max(1, self.total_num_kv_heads // tp_size)
365
+ self.head_dim = config.head_dim
366
+ self.q_size = self.num_heads * self.head_dim
367
+ self.kv_size = self.num_kv_heads * self.head_dim
368
+ self.scaling = self.head_dim**-0.5
369
+
370
+ self.qkv_proj = QKVParallelLinear(
371
+ self.hidden_size,
372
+ self.head_dim,
373
+ self.total_num_heads,
374
+ self.total_num_kv_heads,
375
+ bias=False,
376
+ quant_config=quant_config,
377
+ prefix=f"{prefix}.qkv_proj",
378
+ )
379
+ # reduce_results=False: the attention all-reduce is fused with the
380
+ # following post_attention_layernorm (GemmaRMSNorm) in the decoder layer
381
+ # via fused_allreduce_gemma_rms_norm.
382
+ self.o_proj = RowParallelLinear(
383
+ self.total_num_heads * self.head_dim,
384
+ self.hidden_size,
385
+ bias=False,
386
+ reduce_results=False,
387
+ quant_config=quant_config,
388
+ prefix=f"{prefix}.o_proj",
389
+ )
390
+
391
+ # Per-head QK norm (qk_norm_type == "per_head", use_gemma_norm == True).
392
+ self.q_norm = MiniMAXGemmaRMSNorm(self.head_dim, eps=config.rms_norm_eps)
393
+ self.k_norm = MiniMAXGemmaRMSNorm(self.head_dim, eps=config.rms_norm_eps)
394
+
395
+ # Partial RoPE: rotary_dim == head_dim * partial_rotary_factor. Honors
396
+ # config.rope_scaling (e.g. YaRN) so long-context positions are covered.
397
+ self.rotary_emb = _build_rotary_emb(config, self.head_dim)
398
+
399
+ self.attn = Attention(
400
+ self.num_heads,
401
+ self.head_dim,
402
+ self.scaling,
403
+ num_kv_heads=self.num_kv_heads,
404
+ cache_config=cache_config,
405
+ quant_config=quant_config,
406
+ prefix=f"{prefix}.attn",
407
+ )
408
+
409
+ def forward(
410
+ self,
411
+ positions: torch.Tensor,
412
+ hidden_states: torch.Tensor,
413
+ ) -> torch.Tensor:
414
+ qkv, _ = self.qkv_proj(hidden_states)
415
+ # Fused per-head Gemma QK-norm + partial NeoX RoPE on q/k, in place (dense
416
+ # mode: no index branch, no KV-cache insert). Matches nvidia/model.py and
417
+ # replaces the unfused split -> q_norm/k_norm -> rotary_emb chain; verified
418
+ # bit-equivalent on ROCm (q/k rel ~2e-3 bf16 noise, v untouched).
419
+ ops.fused_minimax_m3_qknorm_rope_kv_insert(
420
+ qkv,
421
+ self.q_norm.weight,
422
+ self.k_norm.weight,
423
+ self.rotary_emb.cos_sin_cache,
424
+ positions,
425
+ self.num_heads,
426
+ self.num_kv_heads,
427
+ self.rotary_emb.rotary_dim,
428
+ self.q_norm.variance_epsilon,
429
+ )
430
+ q, k, v = qkv.split([self.q_size, self.kv_size, self.kv_size], dim=-1)
431
+ attn_output = self.attn(q, k, v)
432
+ output, _ = self.o_proj(attn_output)
433
+ return output
434
+
435
+
436
+class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
437
+ """Block-sparse attention layer with the lightning-indexer branch.
438
+
439
+ This is a merged attention layer: it owns the projections (qkv + index
440
+ q/k), per-head QK norms and RoPE, *and* the attention-backend wiring that a
441
+ generic ``Attention`` layer would normally provide — it binds the
442
+ ``MiniMaxM3SparseBackend`` + main impl, registers the main paged K/V cache,
443
+ and owns the lightning indexer (``MiniMaxM3Indexer``), which holds the
444
+ index-key side cache.
445
+
446
+ The index branch (index_{q,k}_proj + index_{q,k}_norm) feeds the sparse
447
+ top-k block selection. M3 always disables the index value/output
448
+ projections (``sparse_disable_index_value`` set for every sparse layer), so
449
+ ``index_{v,o}_proj`` are never created.
450
+ """
451
+
452
+ def __init__(
453
+ self,
454
+ config: PretrainedConfig,
455
+ layer_id: int,
456
+ quant_config: QuantizationConfig | None = None,
457
+ prefix: str = "",
458
+ cache_config: CacheConfig | None = None,
459
+ ) -> None:
460
+ super().__init__()
461
+ self.hidden_size = config.hidden_size
462
+ tp_size = get_tensor_model_parallel_world_size()
463
+
464
+ self.total_num_heads = config.num_attention_heads
465
+ assert self.total_num_heads % tp_size == 0
466
+ self.num_heads = self.total_num_heads // tp_size
467
+ self.total_num_kv_heads = config.num_key_value_heads
468
+ if self.total_num_kv_heads >= tp_size:
469
+ assert self.total_num_kv_heads % tp_size == 0
470
+ else:
471
+ assert tp_size % self.total_num_kv_heads == 0
472
+ self.num_kv_heads = max(1, self.total_num_kv_heads // tp_size)
473
+ self.head_dim = config.head_dim
474
+ self.q_size = self.num_heads * self.head_dim
475
+ self.kv_size = self.num_kv_heads * self.head_dim
476
+ self.scaling = self.head_dim**-0.5
477
+
478
+ # Sparse "index" branch dims. index_q has the same head count as the KV
479
+ # heads (sparse_num_index_heads == num_key_value_heads), so it shards
480
+ # identically -- including replication when tp_size > num_key_value_heads.
481
+ sparse_cfg = config.sparse_attention_config
482
+ self.total_idx_heads = sparse_cfg["sparse_num_index_heads"]
483
+ self.num_idx_heads = self.num_kv_heads
484
+ self.idx_head_dim = sparse_cfg["sparse_index_dim"]
485
+ self.index_q_size = self.num_idx_heads * self.idx_head_dim
486
+
487
+ # Single fused projection: q, k, v, index_q, index_k in one GEMM.
488
+ self.qkv_proj = MinimaxM3QKVParallelLinearWithIndexer(
489
+ self.hidden_size,
490
+ self.head_dim,
491
+ self.total_num_heads,
492
+ self.total_num_kv_heads,
493
+ self.total_idx_heads,
494
+ self.idx_head_dim,
495
+ bias=False,
496
+ quant_config=quant_config,
497
+ prefix=f"{prefix}.qkv_proj",
498
+ )
499
+ # reduce_results=False: the attention all-reduce is fused with the
500
+ # following post_attention_layernorm (GemmaRMSNorm) in the decoder layer
501
+ # via fused_allreduce_gemma_rms_norm.
502
+ self.o_proj = RowParallelLinear(
503
+ self.total_num_heads * self.head_dim,
504
+ self.hidden_size,
505
+ bias=False,
506
+ reduce_results=False,
507
+ quant_config=quant_config,
508
+ prefix=f"{prefix}.o_proj",
509
+ )
510
+
511
+ # Per-head QK norm (qk_norm_type == "per_head", use_gemma_norm == True).
512
+ self.q_norm = MiniMAXGemmaRMSNorm(self.head_dim, eps=config.rms_norm_eps)
513
+ self.k_norm = MiniMAXGemmaRMSNorm(self.head_dim, eps=config.rms_norm_eps)
514
+
515
+ # Partial RoPE: rotary_dim == head_dim * partial_rotary_factor. Honors
516
+ # config.rope_scaling (e.g. YaRN) so long-context positions are covered.
517
+ self.rotary_emb = _build_rotary_emb(config, self.head_dim)
518
+
519
+ self.index_q_norm = MiniMAXGemmaRMSNorm(
520
+ self.idx_head_dim, eps=config.rms_norm_eps
521
+ )
522
+ self.index_k_norm = MiniMAXGemmaRMSNorm(
523
+ self.idx_head_dim, eps=config.rms_norm_eps
524
+ )
525
+ self.index_rotary_emb = self.rotary_emb
526
+
527
+ # Attention-backend wiring.
528
+ vllm_config = get_current_vllm_config()
529
+ self.layer_name = f"{prefix}.attn"
530
+ self.kv_cache_dtype = (
531
+ cache_config.cache_dtype if cache_config is not None else "auto"
532
+ )
533
+ self.kv_cache_torch_dtype = kv_cache_dtype_str_to_dtype(
534
+ self.kv_cache_dtype, vllm_config.model_config
535
+ )
536
+ # fp8 main-K/V cache: the fused qknorm+rope+kv-insert op is bf16-cache-only
537
+ # (asserts kv_cache dtype == qkv), so on the fp8 path we run it in
538
+ # norm+rope-only mode and write the cache via the fp8-capable
539
+ # reshape_and_cache_flash in _insert_kv. (index cache stays bf16.)
540
+ self._fp8_kv = "fp8" in self.kv_cache_dtype
541
+
542
+ self.attn_backend = MiniMaxM3SparseBackend
543
+ # Indexer and main attention are separate impls. On ROCm the SM100 gate
544
+ # is always False, so both pick Triton and the index cache stays bf16.
545
+ # impl is AttentionImplBase (broader than AttentionLayerBase's annotation).
546
+ self.impl: MiniMaxM3SparseImpl = select_main_impl_cls( # type: ignore[assignment]
547
+ topk_blocks=sparse_cfg["sparse_topk_blocks"],
548
+ kv_cache_dtype=self.kv_cache_dtype,
549
+ )(
550
+ self.num_heads,
551
+ self.head_dim,
552
+ self.scaling,
553
+ self.num_kv_heads,
554
+ kv_cache_dtype=self.kv_cache_dtype,
555
+ topk_blocks=sparse_cfg["sparse_topk_blocks"],
556
+ sparse_block_size=sparse_cfg["sparse_block_size"],
557
+ )
558
+ # Self-contained nn.Module: owns its side cache, selects its impl in init
559
+ # (Triton on ROCm, where the SM100 gate is always False).
560
+ self.indexer = MiniMaxM3Indexer(
561
+ num_kv_heads=self.num_kv_heads,
562
+ scale=self.scaling,
563
+ topk_blocks=sparse_cfg["sparse_topk_blocks"],
564
+ sparse_block_size=sparse_cfg["sparse_block_size"],
565
+ num_index_heads=self.num_idx_heads,
566
+ index_head_dim=self.idx_head_dim,
567
+ prefix=self.layer_name,
568
+ init_blocks=sparse_cfg.get("sparse_init_block", 0),
569
+ local_blocks=sparse_cfg.get("sparse_local_block", 0),
570
+ score_type=sparse_cfg.get("sparse_score_type", "max"),
571
+ cache_config=cache_config,
572
+ )
573
+
574
+ # Register the main K/V cache so the KV-cache manager allocates it.
575
+ compilation_config = vllm_config.compilation_config
576
+ if self.layer_name in compilation_config.static_forward_context:
577
+ raise ValueError(f"Duplicate layer name: {self.layer_name}")
578
+ compilation_config.static_forward_context[self.layer_name] = self
579
+ self.kv_cache = torch.tensor([]) # replaced by bind_kv_cache
580
+
581
+ def get_attn_backend(self) -> type[MiniMaxM3SparseBackend]:
582
+ return self.attn_backend
583
+
584
+ def get_kv_cache_spec(self, vllm_config: VllmConfig) -> KVCacheSpec | None:
585
+ # Main GQA K/V cache. Block size may change after load, refresh it.
586
+ return FullAttentionSpec(
587
+ block_size=vllm_config.cache_config.block_size,
588
+ num_kv_heads=self.num_kv_heads,
589
+ head_size=self.head_dim,
590
+ head_size_v=self.head_dim,
591
+ dtype=self.kv_cache_torch_dtype,
592
+ kv_quant_mode=get_kv_quant_mode(self.kv_cache_dtype),
593
+ )
594
+
595
+ def _insert_kv(
596
+ self,
597
+ key: torch.Tensor,
598
+ value: torch.Tensor,
599
+ index_key: torch.Tensor,
600
+ main_slot_mapping: torch.Tensor,
601
+ index_slot_mapping: torch.Tensor,
602
+ ) -> None:
603
+ """Write main K/V (fp8-quantizing) and index-K into their paged caches.
604
+
605
+ Used only on the fp8-KV path: the fused #20 op is bf16-cache-only, so it
606
+ runs in norm+rope-only mode and the (already normed/roped) k/v/index_k are
607
+ written here via ``reshape_and_cache_flash`` (which honors kv_cache_dtype,
608
+ unit scale -- matching the fp8 read path added in #33). Mirrors the
609
+ pre-#20 unfused insert. The index cache stays bf16 (no quant).
610
+ """
611
+ key_cache, value_cache = self.kv_cache.unbind(1)
612
+ scale = torch.ones((), device=key.device)
613
+ ops.reshape_and_cache_flash(
614
+ key.view(-1, self.num_kv_heads, self.head_dim),
615
+ value.view(-1, self.num_kv_heads, self.head_dim),
616
+ key_cache,
617
+ value_cache,
618
+ main_slot_mapping,
619
+ self.kv_cache_dtype,
620
+ scale,
621
+ scale,
622
+ )
623
+ idx_cache = self.indexer.index_cache.kv_cache.view(-1, self.idx_head_dim)
624
+ idx_cache[index_slot_mapping] = index_key.to(idx_cache.dtype)
625
+
626
+ def forward(
627
+ self,
628
+ positions: torch.Tensor,
629
+ hidden_states: torch.Tensor,
630
+ ) -> torch.Tensor:
631
+ # Single fused projection emitting [q | k | v | index_q | index_k].
632
+ qkv, _ = self.qkv_proj(hidden_states)
633
+
634
+ # Horizontally-fused per-head Gemma QK-norm + partial NeoX RoPE on the
635
+ # main (q/k) and index (index_q/index_k) branches, all read straight out
636
+ # of the single fused ``qkv`` tensor. Once the paged caches are bound the
637
+ # kernel also inserts k/v and the index key into them (each with its own
638
+ # slot_mapping); the memory-profiling run (caches unbound, no slot_mapping)
639
+ # short-circuits to zeros below. Replaces the
640
+ # q_norm/k_norm/rotary_emb/index_*_norm/index_rotary_emb/_insert_kv chain.
641
+ # (#20 fused_minimax_m3_qknorm_rope_kv_insert; HIP/CDNA path. The main and
642
+ # index slot mappings are read from the forward context's slot_mapping
643
+ # dict, matching the breakable-cudagraph path -- see nvidia/model.py.)
644
+ cos_sin_cache = self.rotary_emb.cos_sin_cache
645
+ rotary_dim = self.rotary_emb.rotary_dim
646
+ eps = self.q_norm.variance_epsilon
647
+ num_tokens = qkv.shape[0]
648
+
649
+ fwd_slot_mapping = get_forward_context().slot_mapping
650
+ if (
651
+ not isinstance(fwd_slot_mapping, dict)
652
+ or self.layer_name not in fwd_slot_mapping
653
+ ):
654
+ # Memory-profiling run: caches not yet bound, slot_mapping is empty.
655
+ return qkv.new_zeros((num_tokens, self.hidden_size))
656
+
657
+ main_slot_mapping = fwd_slot_mapping[self.layer_name]
658
+ index_slot_mapping = fwd_slot_mapping[self.indexer.index_cache.prefix]
659
+ q = qkv.new_empty((num_tokens, self.q_size))
660
+ index_q = qkv.new_empty((num_tokens, self.index_q_size))
661
+ # On the fp8-KV path the fused op cannot write the (fp8) cache, so pass
662
+ # kv_cache/index_cache = None -> insert_kv=False (norm+rope only): it still
663
+ # de-interleaves q/index_q and rewrites the normed/roped k & index_k in
664
+ # place in qkv, leaving v raw (correct -- v is never normed/roped). We then
665
+ # write the cache via _insert_kv below.
666
+ insert_via_fused = not self._fp8_kv
667
+ ops.fused_minimax_m3_qknorm_rope_kv_insert(
668
+ qkv,
669
+ self.q_norm.weight,
670
+ self.k_norm.weight,
671
+ cos_sin_cache,
672
+ positions,
673
+ self.num_heads,
674
+ self.num_kv_heads,
675
+ rotary_dim,
676
+ eps,
677
+ self.index_q_norm.weight,
678
+ self.index_k_norm.weight,
679
+ self.num_idx_heads,
680
+ main_slot_mapping,
681
+ index_slot_mapping,
682
+ self.kv_cache if insert_via_fused else None,
683
+ self.indexer.index_cache.kv_cache if insert_via_fused else None,
684
+ self.kv_cache.size(2), # paged-cache block size
685
+ q,
686
+ index_q,
687
+ )
688
+ if not insert_via_fused:
689
+ # Extract the normed/roped k, raw v, normed/roped index_k from qkv
690
+ # ([q | k | v | index_q | index_k], all head_dim=128) and fp8-insert.
691
+ kv = self.num_kv_heads * self.head_dim
692
+ # These are strided views into qkv (row stride = full qkv width), but
693
+ # their last dim is contiguous, so `_insert_kv`'s `.view(-1, nkv,
694
+ # head_dim)` works on them and `reshape_and_cache_flash` honors the
695
+ # input stride -- no `.contiguous()` needed (verified bit-identical;
696
+ # avoids a [N, kv] copy per step on the fp8-KV path).
697
+ k = qkv[:, self.q_size : self.q_size + kv]
698
+ v = qkv[:, self.q_size + kv : self.q_size + 2 * kv]
699
+ ik0 = self.q_size + 2 * kv + self.index_q_size
700
+ index_k = qkv[:, ik0 : ik0 + self.num_idx_heads * self.idx_head_dim]
701
+ self._insert_kv(k, v, index_k, main_slot_mapping, index_slot_mapping)
702
+
703
+ output = torch.empty_like(q)
704
+ attn_output = self._run_attention(q, index_q, output)
705
+ output, _ = self.o_proj(attn_output)
706
+ return output
707
+
708
+ @eager_break_during_capture
709
+ def _run_attention(
710
+ self,
711
+ query: torch.Tensor,
712
+ index_query: torch.Tensor,
713
+ output: torch.Tensor,
714
+ ) -> torch.Tensor:
715
+ # Single eager break around both: their split-K kernels read per-request
716
+ # metadata and can't be captured into a cudagraph.
717
+ topk_idx = self.indexer(index_query)
718
+ return self.impl.forward(self, query, self.kv_cache, topk_idx, output)
719
+
720
+
721
+class MiniMaxM3DecoderLayer(nn.Module):
722
+ def __init__(
723
+ self,
724
+ config: PretrainedConfig,
725
+ prefix: str,
726
+ cache_config: CacheConfig | None = None,
727
+ quant_config: QuantizationConfig | None = None,
728
+ force_sparse_attn: bool = False,
729
+ force_moe: bool = False,
730
+ ) -> None:
731
+ super().__init__()
732
+ self.hidden_size = config.hidden_size
733
+ # DecoderLayers are created with `make_layers` which passes the prefix
734
+ # with the layer's index.
735
+ layer_id = int(prefix.split(sep=".")[-1])
736
+ self.layer_id = layer_id
737
+
738
+ is_sparse_attention_layer = (
739
+ force_sparse_attn or layer_id in _sparse_attention_layer_ids(config)
740
+ )
741
+
742
+ if is_sparse_attention_layer:
743
+ self.self_attn = MiniMaxM3SparseAttention(
744
+ config=config,
745
+ layer_id=layer_id,
746
+ quant_config=quant_config,
747
+ prefix=f"{prefix}.self_attn",
748
+ cache_config=cache_config,
749
+ )
750
+ else:
751
+ self.self_attn = MiniMaxM3Attention(
752
+ config=config,
753
+ layer_id=layer_id,
754
+ quant_config=quant_config,
755
+ prefix=f"{prefix}.self_attn",
756
+ cache_config=cache_config,
757
+ )
758
+
759
+ # Dense layers store the FFN under `mlp`; MoE layers under
760
+ # `block_sparse_moe` -- matching the checkpoint's naming.
761
+ self.is_moe_layer = force_moe or _is_moe_layer(config, layer_id)
762
+ if self.is_moe_layer:
763
+ self.block_sparse_moe = MiniMaxM3MoE(
764
+ config=config,
765
+ layer_id=layer_id,
766
+ quant_config=quant_config,
767
+ prefix=f"{prefix}.block_sparse_moe",
768
+ )
769
+ else:
770
+ self.mlp = MiniMaxM3MLP(
771
+ config=config,
772
+ intermediate_size=config.dense_intermediate_size,
773
+ quant_config=quant_config,
774
+ prefix=f"{prefix}.mlp",
775
+ )
776
+
777
+ # config.use_gemma_norm is True for M3 -> Gemma-style RMSNorm.
778
+ self.input_layernorm = MiniMAXGemmaRMSNorm(
779
+ config.hidden_size, eps=config.rms_norm_eps
780
+ )
781
+ self.post_attention_layernorm = MiniMAXGemmaRMSNorm(
782
+ config.hidden_size, eps=config.rms_norm_eps
783
+ )
784
+
785
+ def forward(
786
+ self,
787
+ positions: torch.Tensor,
788
+ hidden_states: torch.Tensor,
789
+ residual: torch.Tensor | None,
790
+ ) -> tuple[torch.Tensor, torch.Tensor]:
791
+ # Self Attention
792
+ if residual is None:
793
+ residual = hidden_states
794
+ hidden_states = self.input_layernorm(hidden_states)
795
+ else:
796
+ hidden_states, residual = self.input_layernorm(hidden_states, residual)
797
+ hidden_states = self.self_attn(
798
+ positions=positions,
799
+ hidden_states=hidden_states,
800
+ )
801
+
802
+ hidden_states, residual = fused_allreduce_gemma_rms_norm(
803
+ hidden_states, residual, self.post_attention_layernorm
804
+ )
805
+ ffn = self.block_sparse_moe if self.is_moe_layer else self.mlp
806
+ hidden_states = ffn(hidden_states)
807
+ return hidden_states, residual
808
+
809
+
810
+class MiniMaxM3Model(nn.Module, EagleModelMixin):
811
+ fall_back_to_pt_during_load = False
812
+
813
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
814
+ super().__init__()
815
+
816
+ config = vllm_config.model_config.hf_text_config
817
+ cache_config = vllm_config.cache_config
818
+ quant_config = vllm_config.quant_config
819
+ self.config = config
820
+
821
+ self.vocab_size = config.vocab_size
822
+
823
+ self.embed_tokens = VocabParallelEmbedding(
824
+ config.vocab_size,
825
+ config.hidden_size,
826
+ quant_config=quant_config,
827
+ prefix=f"{prefix}.embed_tokens",
828
+ )
829
+
830
+ self.start_layer, self.end_layer, self.layers = make_layers(
831
+ config.num_hidden_layers,
832
+ lambda prefix: MiniMaxM3DecoderLayer(
833
+ config,
834
+ prefix,
835
+ cache_config=cache_config,
836
+ quant_config=quant_config,
837
+ ),
838
+ prefix=f"{prefix}.layers",
839
+ )
840
+
841
+ self.norm = MiniMAXGemmaRMSNorm(config.hidden_size, eps=config.rms_norm_eps)
842
+
843
+ def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
844
+ return self.embed_tokens(input_ids)
845
+
846
+ def forward(
847
+ self,
848
+ input_ids: torch.Tensor | None,
849
+ positions: torch.Tensor,
850
+ inputs_embeds: torch.Tensor | None = None,
851
+ ) -> torch.Tensor | tuple[torch.Tensor, list[torch.Tensor]]:
852
+ if inputs_embeds is not None:
853
+ hidden_states = inputs_embeds
854
+ else:
855
+ hidden_states = self.embed_input_ids(input_ids)
856
+ residual = None
857
+
858
+ # EAGLE3 is not yet compatible with pipeline parallel
859
+ aux_hidden_states = self._maybe_add_hidden_state([], 0, hidden_states, residual)
860
+ for idx, layer in enumerate(self.layers[self.start_layer : self.end_layer]):
861
+ hidden_states, residual = layer(positions, hidden_states, residual)
862
+ self._maybe_add_hidden_state(
863
+ aux_hidden_states, idx + 1, hidden_states, residual
864
+ )
865
+
866
+ hidden_states, _ = self.norm(hidden_states, residual)
867
+
868
+ if len(aux_hidden_states) > 0:
869
+ return hidden_states, aux_hidden_states
870
+ return hidden_states
871
+
872
+ def get_expert_mapping(self) -> list[tuple[str, str, int, str]]:
873
+ # Checkpoint experts use w1=gate, w2=down, w3=up.
874
+ return fused_moe_make_expert_params_mapping(
875
+ self,
876
+ ckpt_gate_proj_name="w1",
877
+ ckpt_down_proj_name="w2",
878
+ ckpt_up_proj_name="w3",
879
+ num_experts=self.config.num_local_experts,
880
+ )
881
+
882
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
883
+ # q/k/v_proj -> fused qkv_proj; gate_proj/up_proj -> fused gate_up_proj
884
+ # (dense MLP and shared expert). On sparse layers the indexer
885
+ # index_q/index_k_proj fold into the same fused qkv_proj
886
+ # (MinimaxM3QKVParallelLinearWithIndexer); these entries simply never match on
887
+ # dense layers, whose checkpoints have no index_*_proj weights. Leading
888
+ # dots keep `q_proj`/`k_proj` from matching `index_q_proj`/`index_k_proj`
889
+ # (preceded by `_`, not `.`).
890
+ stacked_params_mapping: list[tuple[str, str, int | str]] = [
891
+ # (param_name, shard_name, shard_id)
892
+ (".qkv_proj", ".q_proj", "q"),
893
+ (".qkv_proj", ".k_proj", "k"),
894
+ (".qkv_proj", ".v_proj", "v"),
895
+ (".qkv_proj", ".index_q_proj", "index_q"),
896
+ (".qkv_proj", ".index_k_proj", "index_k"),
897
+ (".gate_up_proj", ".gate_proj", 0),
898
+ (".gate_up_proj", ".up_proj", 1),
899
+ ]
900
+
901
+ # (param_name, weight_name, expert_id, shard_id)
902
+ expert_params_mapping = self.get_expert_mapping()
903
+
904
+ params_dict = dict(self.named_parameters())
905
+ loaded_params: set[str] = set()
906
+ for name, loaded_weight in weights:
907
+ # The MTP module is not modeled yet.
908
+ if "mtp." in name:
909
+ continue
910
+
911
+ # The checkpoint stores block scales as ``weight_scale_inv``; the
912
+ # ModelOpt MXFP8 layers expose them as ``weight_scale``.
913
+ if "weight_scale_inv" in name:
914
+ name = name.replace("weight_scale_inv", "weight_scale")
915
+
916
+ for param_name, weight_name, shard_id in stacked_params_mapping:
917
+ if weight_name not in name:
918
+ continue
919
+ # Routed experts (w1/w2/w3) are handled below; don't let the
920
+ # stacked mapping rewrite them.
921
+ if ("block_sparse_moe.experts." in name) and name not in params_dict:
922
+ continue
923
+ name = name.replace(weight_name, param_name)
924
+ if name.endswith(".bias") and name not in params_dict:
925
+ continue
926
+ if is_pp_missing_parameter(name, self):
927
+ continue
928
+ if name not in params_dict:
929
+ continue
930
+ param = params_dict[name]
931
+ weight_loader = param.weight_loader
932
+ weight_loader(param, loaded_weight, shard_id)
933
+ break
934
+ else:
935
+ for (
936
+ param_name,
937
+ weight_name,
938
+ expert_id,
939
+ expert_shard_id,
940
+ ) in expert_params_mapping:
941
+ if weight_name not in name:
942
+ continue
943
+ name = name.replace(weight_name, param_name)
944
+ if is_pp_missing_parameter(name, self):
945
+ continue
946
+ if name not in params_dict:
947
+ continue
948
+ param = params_dict[name]
949
+ weight_loader = param.weight_loader
950
+ weight_loader(
951
+ param,
952
+ loaded_weight,
953
+ name,
954
+ shard_id=expert_shard_id,
955
+ expert_id=expert_id,
956
+ )
957
+ break
958
+ else:
959
+ if name.endswith(".bias") and name not in params_dict:
960
+ continue
961
+ remapped = maybe_remap_kv_scale_name(name, params_dict)
962
+ if remapped is None:
963
+ continue
964
+ name = remapped
965
+ if is_pp_missing_parameter(name, self):
966
+ continue
967
+ # Modules not modeled yet (e.g. attention) are skipped until
968
+ # they are ported.
969
+ if name not in params_dict:
970
+ continue
971
+ param = params_dict[name]
972
+ weight_loader = getattr(
973
+ param, "weight_loader", default_weight_loader
974
+ )
975
+ weight_loader(param, loaded_weight)
976
+ loaded_params.add(name)
977
+ return loaded_params
978
+
979
+
980
+class MiniMaxM3SparseForCausalLM(nn.Module, SupportsEagle3):
981
+ """MiniMax M3 (sparse/dense backbone) for causal language modeling."""
982
+
983
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
984
+ super().__init__()
985
+ config = vllm_config.model_config.hf_text_config
986
+ quant_config = vllm_config.quant_config
987
+ self.config = config
988
+ self.quant_config = quant_config
989
+ self.model = MiniMaxM3Model(
990
+ vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model")
991
+ )
992
+ self.lm_head = ParallelLMHead(
993
+ config.vocab_size,
994
+ config.hidden_size,
995
+ quant_config=quant_config,
996
+ prefix=maybe_prefix(prefix, "lm_head"),
997
+ )
998
+ self.logits_processor = LogitsProcessor(config.vocab_size)
999
+
1000
+ def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
1001
+ return self.model.embed_input_ids(input_ids)
1002
+
1003
+ def forward(
1004
+ self,
1005
+ input_ids: torch.Tensor | None,
1006
+ positions: torch.Tensor,
1007
+ inputs_embeds: torch.Tensor | None = None,
1008
+ **kwargs,
1009
+ ) -> torch.Tensor:
1010
+ return self.model(input_ids, positions, inputs_embeds)
1011
+
1012
+ def compute_logits(self, hidden_states: torch.Tensor) -> torch.Tensor | None:
1013
+ return self.logits_processor(self.lm_head, hidden_states)
1014
+
1015
+ def get_expert_mapping(self) -> list[tuple[str, str, int, str]]:
1016
+ return self.model.get_expert_mapping()
1017
+
1018
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
1019
+ loader = AutoWeightsLoader(self)
1020
+ return loader.load_weights(weights)
1021
+
1022
+
1023
+# TODO(refactor): this VL wrapper is platform-agnostic and byte-identical to the
1024
+# NVIDIA copy — it only orchestrates the shared vision tower + the per-platform
1025
+# language model (resolved via ``init_vllm_registered_model``). Hoist it into
1026
+# ``common/`` to drop the amd/nvidia duplication once the split stabilizes.
1027
+@MULTIMODAL_REGISTRY.register_processor(
1028
+ MiniMaxM3VLMultiModalProcessor,
1029
+ info=MiniMaxM3VLProcessingInfo,
1030
+ dummy_inputs=MiniMaxM3VLDummyInputsBuilder,
1031
+)
1032
+class MiniMaxM3SparseForConditionalGeneration(
1033
+ nn.Module, SupportsMultiModal, SupportsEagle3
1034
+):
1035
+ """Top-level (VL) entry point for MiniMax M3.
1036
+
1037
+ Owns the shared MiniMax-M3 vision tower on ROCm and delegates text
1038
+ generation to the AMD language-model path.
1039
+ """
1040
+
1041
+ # The vision tower runs replicated per rank under ``--mm-encoder-tp-mode
1042
+ # data``; ``run_dp_sharded_mrope_vision_model`` shards the work across
1043
+ # ranks (see ``_process_image_input`` / ``_process_video_input``).
1044
+ supports_encoder_tp_data = True
1045
+
1046
+ hf_to_vllm_mapper = WeightsMapper(
1047
+ orig_to_new_prefix={
1048
+ "multi_modal_projector.": "vision_tower.multi_modal_projector.",
1049
+ "patch_merge_mlp.": "vision_tower.patch_merge_mlp.",
1050
+ },
1051
+ orig_to_new_substr={
1052
+ ".mlp.fc1.": ".fc1.",
1053
+ ".mlp.fc2.": ".fc2.",
1054
+ },
1055
+ )
1056
+
1057
+ @classmethod
1058
+ def get_placeholder_str(cls, modality: str, i: int) -> str | None:
1059
+ if modality == "image":
1060
+ return MiniMaxM3VLProcessingInfo.IMAGE_TOKEN
1061
+ if modality == "video":
1062
+ return MiniMaxM3VLProcessingInfo.VIDEO_TOKEN
1063
+ raise ValueError(f"Unsupported modality: {modality!r}")
1064
+
1065
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
1066
+ super().__init__()
1067
+ config = vllm_config.model_config.hf_config
1068
+ self.config = config
1069
+ self.quant_config = vllm_config.quant_config
1070
+ self.multimodal_config = vllm_config.model_config.multimodal_config
1071
+ assert self.multimodal_config is not None
1072
+ self.use_data_parallel = self.multimodal_config.mm_encoder_tp_mode == "data"
1073
+
1074
+ text_hidden_size = getattr(config.text_config, "hidden_size", None)
1075
+ assert text_hidden_size is not None, "text_config.hidden_size is required"
1076
+ projector_hidden_size = getattr(config, "projector_hidden_size", None)
1077
+
1078
+ with self._mark_tower_model(vllm_config, {"image", "video"}):
1079
+ vision_config = config.vision_config
1080
+ self.vision_tower = MiniMaxVLVisionModel(
1081
+ config=PretrainedConfig.from_dict(vision_config),
1082
+ text_hidden_size=text_hidden_size,
1083
+ projector_hidden_size=projector_hidden_size,
1084
+ quant_config=self.quant_config,
1085
+ prefix=maybe_prefix(prefix, "vision_tower"),
1086
+ )
1087
+
1088
+ self.language_model = init_vllm_registered_model(
1089
+ vllm_config=vllm_config,
1090
+ hf_config=config.text_config,
1091
+ prefix=maybe_prefix(prefix, "language_model"),
1092
+ architectures=["MiniMaxM3SparseForCausalLM"],
1093
+ )
1094
+
1095
+ def _parse_and_validate_image_input(self, **kwargs: object) -> dict | None:
1096
+ pixel_values = kwargs.pop("pixel_values", None)
1097
+ image_grid_thw = kwargs.pop("image_grid_thw", None)
1098
+ if pixel_values is None:
1099
+ return None
1100
+ return {"pixel_values": pixel_values, "image_grid_thw": image_grid_thw}
1101
+
1102
+ def _parse_and_validate_video_input(self, **kwargs: object) -> dict | None:
1103
+ pixel_values_videos = kwargs.pop("pixel_values_videos", None)
1104
+ video_grid_thw = kwargs.pop("video_grid_thw", None)
1105
+ if pixel_values_videos is None:
1106
+ return None
1107
+ return {
1108
+ "pixel_values_videos": pixel_values_videos,
1109
+ "video_grid_thw": video_grid_thw,
1110
+ }
1111
+
1112
+ def _process_image_input(self, image_input: dict) -> tuple[torch.Tensor, ...]:
1113
+ pixel_values: torch.Tensor = image_input["pixel_values"].type(
1114
+ self.vision_tower.dtype
1115
+ )
1116
+ grid_thw: torch.Tensor = image_input["image_grid_thw"]
1117
+ assert grid_thw.ndim == 2
1118
+
1119
+ if self.use_data_parallel:
1120
+ # Already returns a per-item tuple of embeddings.
1121
+ return run_dp_sharded_mrope_vision_model(
1122
+ self.vision_tower,
1123
+ pixel_values,
1124
+ grid_thw.tolist(),
1125
+ rope_type="rope_3d",
1126
+ )
1127
+
1128
+ image_embeds = self.vision_tower(
1129
+ pixel_values=pixel_values,
1130
+ grid_thw=grid_thw.tolist(),
1131
+ )
1132
+
1133
+ # Split the concatenated output into one tensor per image item.
1134
+ merge_size = self.vision_tower.spatial_merge_size
1135
+ sizes = (grid_thw.prod(-1) // (merge_size * merge_size)).tolist()
1136
+ return image_embeds.split(sizes)
1137
+
1138
+ def _process_video_input(self, video_input: dict) -> tuple[torch.Tensor, ...]:
1139
+ pixel_values: torch.Tensor = video_input["pixel_values_videos"].type(
1140
+ self.vision_tower.dtype
1141
+ )
1142
+ grid_thw: torch.Tensor = video_input["video_grid_thw"]
1143
+ assert grid_thw.ndim == 2
1144
+
1145
+ if self.use_data_parallel:
1146
+ # Already returns a per-item tuple of embeddings.
1147
+ return run_dp_sharded_mrope_vision_model(
1148
+ self.vision_tower,
1149
+ pixel_values,
1150
+ grid_thw.tolist(),
1151
+ rope_type="rope_3d",
1152
+ )
1153
+
1154
+ video_embeds = self.vision_tower(
1155
+ pixel_values=pixel_values,
1156
+ grid_thw=grid_thw.tolist(),
1157
+ )
1158
+
1159
+ # Split the concatenated output into one tensor per video item.
1160
+ merge_size = self.vision_tower.spatial_merge_size
1161
+ sizes = (grid_thw.prod(-1) // (merge_size * merge_size)).tolist()
1162
+ return video_embeds.split(sizes)
1163
+
1164
+ def _parse_and_validate_multimodal_inputs(
1165
+ self, **kwargs: object
1166
+ ) -> dict[str, dict]:
1167
+ mm_input_by_modality: dict[str, dict] = {}
1168
+ for input_key in kwargs:
1169
+ if input_key == "pixel_values" and "image" not in mm_input_by_modality:
1170
+ image_input = self._parse_and_validate_image_input(**kwargs)
1171
+ if image_input is not None:
1172
+ mm_input_by_modality["image"] = image_input
1173
+ if (
1174
+ input_key == "pixel_values_videos"
1175
+ and "video" not in mm_input_by_modality
1176
+ ):
1177
+ video_input = self._parse_and_validate_video_input(**kwargs)
1178
+ if video_input is not None:
1179
+ mm_input_by_modality["video"] = video_input
1180
+ return mm_input_by_modality
1181
+
1182
+ def embed_multimodal(self, **kwargs: object) -> MultiModalEmbeddings:
1183
+ mm_input_by_modality = self._parse_and_validate_multimodal_inputs(**kwargs)
1184
+ if not mm_input_by_modality:
1185
+ return []
1186
+
1187
+ multimodal_embeddings: list[torch.Tensor] = []
1188
+ for modality in mm_input_by_modality:
1189
+ multimodal_input = mm_input_by_modality[modality]
1190
+ if modality == "image":
1191
+ image_embeddings = self._process_image_input(multimodal_input)
1192
+ multimodal_embeddings.extend(image_embeddings)
1193
+ if modality == "video":
1194
+ video_embeddings = self._process_video_input(multimodal_input)
1195
+ multimodal_embeddings.extend(video_embeddings)
1196
+
1197
+ return tuple(multimodal_embeddings)
1198
+
1199
+ def forward(
1200
+ self,
1201
+ input_ids: torch.Tensor | None,
1202
+ positions: torch.Tensor,
1203
+ inputs_embeds: torch.Tensor | None = None,
1204
+ **kwargs,
1205
+ ) -> torch.Tensor:
1206
+ return self.language_model(input_ids, positions, inputs_embeds)
1207
+
1208
+ def compute_logits(self, hidden_states: torch.Tensor) -> torch.Tensor | None:
1209
+ return self.language_model.compute_logits(hidden_states)
1210
+
1211
+ def get_expert_mapping(self) -> list[tuple[str, str, int, str]]:
1212
+ return self.language_model.get_expert_mapping()
1213
+
1214
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
1215
+ loader = AutoWeightsLoader(self)
1216
+ return loader.load_weights(weights, mapper=self.hf_to_vllm_mapper)