spark_model/weight_loader/
minimax.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! MiniMax M2 weight loader.
4//!
5//! Architecturally MiniMax M2 (both M2.1 and M2.7 — same `minimax_m2`
6//! model_type, weights differ only) is a cousin of Qwen3.5-122B-A10B:
7//!   * 62 full-attention layers (no SSM/Mamba)
8//!   * GQA 48 Q heads / 8 KV heads, head_dim 128
9//!   * Partial RoPE (rotary_dim=64 on head_dim=128)
10//!   * Full-hidden qk_norm (RMSNorm over the concatenated Q/K projections
11//!     before RoPE — wired via `AttentionWeights::q_norm_full` /
12//!     `k_norm_full`)
13//!   * 256 experts top-8 with **sigmoid** routing + correction bias
14//!   * 3 MTP draft modules (vs 1 in Qwen3.5) — M5 follow-up
15//!   * Native FP8 E4M3 with `weight_block_size=[128,128]` — M4 follow-up
16//!
17//! Layer construction here produces a `Vec<Qwen3AttentionLayer>` with:
18//!   * Attention: runtime-quantize Q/K/V/O from BF16 to NVFP4 (same
19//!     code path as qwen35_dense `Standard` variant); `AttentionWeights`
20//!     carries both the full-hidden qk_norm weights (MiniMax-active) and
21//!     dummy per-head `q_norm/k_norm` slots (unused).
22//!   * MoE: 256 experts, no shared expert, via the new
23//!     `load_moe_minimax` helper. The `correction_bias` tensor is
24//!     populated on `MoeWeights`; the MoE layer itself still dispatches
25//!     through `moe_topk_softmax` in this commit — the sigmoid+bias
26//!     dispatch lands in a follow-up when `MoeLayer::new_sigmoid` is
27//!     introduced. Until that follows, runtime output is structurally
28//!     correct but quantitatively wrong (softmax routing on a
29//!     sigmoid-trained model) and should only be used to smoke-test
30//!     the full load path on tiny-random weights.
31//!
32//! See `docs/MINIMAX-M2-IMPL-PLAN.md` for the full wire-up map.
33
34use anyhow::Result;
35use atlas_core::config::ModelConfig;
36use spark_runtime::gpu::GpuBackend;
37use spark_runtime::kv_cache::KvCacheDtype;
38use spark_runtime::weights::WeightStore;
39
40use super::ModelWeightLoader;
41use crate::layer::TransformerLayer;
42use crate::layers::{FfnComponent, MoeLayer, Qwen3AttentionLayer};
43use crate::tp_shard::{
44    TpShardKind, load_qk_norms_tp, load_qkvo_tp, shard_dense_1d_bf16, shard_dense_bf16,
45};
46use crate::weight_map::{
47    AttentionWeights, DenseWeight, MtpWeights, QuantizedWeight, dense, dense_auto,
48    detect_nvfp4_variant, load_kv_scales, load_moe_minimax, quantize_to_nvfp4,
49};
50
51pub struct MinimaxM2WeightLoader;
52
53impl ModelWeightLoader for MinimaxM2WeightLoader {
54    fn supports_tp(&self) -> bool {
55        // MiniMax M2 was the reference implementation: Q/K/V col-parallel,
56        // O row-parallel, q_norm/k_norm 1D-sharded, attention head counts
57        // pre-divided by tp_size at construction. See `load_layers` below.
58        true
59    }
60
61    fn load_layers(
62        &self,
63        store: &WeightStore,
64        config: &ModelConfig,
65        gpu: &dyn GpuBackend,
66        layer_kv_dtypes: &[KvCacheDtype],
67    ) -> Result<Vec<Box<dyn TransformerLayer>>> {
68        let absmax_k = gpu.kernel("quantize_nvfp4", "nvfp4_global_absmax")?;
69        let quantize_k = gpu.kernel("quantize_nvfp4", "quantize_bf16_to_nvfp4")?;
70        let stream = gpu.default_stream();
71        let h = config.hidden_size;
72        let variant = detect_nvfp4_variant(store, config);
73        tracing::info!(
74            "minimax_m2: loading {} layers, variant={:?}, hidden_size={h}",
75            config.num_hidden_layers,
76            variant,
77        );
78
79        // Note: MoE prefill-transpose is deferred to a post-load pass in
80        // `factory::build` after LM-head NVFP4 quantization frees ~22 GB of
81        // BF16 headroom — doing it here at layer 0 would see only 46 GB
82        // free vs a 58.9 GB transpose cost and skip it entirely. See
83        // `crates/spark-model/src/factory.rs` step between LM-head quant
84        // and buffer-arena allocation for the actual transpose call site.
85
86        let mut layers: Vec<Box<dyn TransformerLayer>> =
87            Vec::with_capacity(config.num_hidden_layers);
88
89        // Dummy weight for the unused per-head q_norm/k_norm slots. MiniMax
90        // normalizes over the full projected hidden (q_norm_full), not per
91        // head — so the existing Qwen3-convention slot is intentionally
92        // left NULL. The attention forward checks q_norm_full first; if
93        // `Some`, it takes precedence and the NULL slot is ignored.
94        let dummy_norm = DenseWeight {
95            weight: spark_runtime::gpu::DevicePtr::NULL,
96        };
97
98        for i in 0..config.num_hidden_layers {
99            let lp = format!("model.layers.{i}");
100            tracing::debug!("minimax_m2: layer {i}");
101            let input_norm = dense(store, &format!("{lp}.input_layernorm.weight"))?;
102            let post_attn_norm = dense(store, &format!("{lp}.post_attention_layernorm.weight"))?;
103
104            // ── MoE ────────────────────────────────────────────────────
105            // 256 experts, no shared expert, sigmoid-routable bias loaded
106            // into MoeWeights.correction_bias for M3 dispatch.
107            let moe_weights = load_moe_minimax(
108                store,
109                &lp,
110                config.num_experts,
111                gpu,
112                config,
113                variant,
114                absmax_k,
115                quantize_k,
116                stream,
117            )?;
118            let gate_nvfp4 = quantize_to_nvfp4(
119                &moe_weights.gate,
120                config.num_experts,
121                h,
122                gpu,
123                absmax_k,
124                quantize_k,
125                stream,
126            )?;
127            let mut moe_layer = MoeLayer::new(
128                moe_weights,
129                config.num_experts,
130                Some(gate_nvfp4),
131                gpu,
132                config,
133            )?;
134            // Wire up MoE prefill acceleration. `predequant_for_prefill` is
135            // cheap (~50 MB total: gate only, no shared expert) and always
136            // runs here. `transpose_for_prefill` is deferred to the post-load
137            // pass in `factory::build` so it sees the ~65 GB free memory
138            // window after LM-head NVFP4 quantization (vs the ~46 GB window
139            // available at layer 0 here).
140            moe_layer.predequant_for_prefill(gpu, config, stream)?;
141            let ffn = FfnComponent::Moe(moe_layer);
142
143            // ── Attention (ungated Q, full-hidden qk_norm) ─────────────
144            let p = format!("{lp}.self_attn");
145            // dense_auto dequants FP8→BF16 (new GPU alloc) for each
146            // projection; we runtime-quantize immediately to NVFP4 then
147            // free BOTH the transient BF16 dequant buffer AND the
148            // original FP8 source on GPU. Without freeing the FP8 source,
149            // MiniMax M2's 230 GB checkpoint (109 GB/rank under EP=2)
150            // stays resident and the runtime NVFP4 allocations push the
151            // rank OOM. Attention forward uses NVFP4 via q/k/v_nvfp4 —
152            // the DenseWeight slots in AttentionWeights are only
153            // consulted by the BF16 fallback that MiniMax doesn't take.
154            let tp_rank = config.tp_rank;
155            let tp_size = config.tp_world_size;
156            let load_and_quant = |name: &str,
157                                  full_n: usize,
158                                  full_k: usize,
159                                  kind: TpShardKind|
160             -> Result<(DenseWeight, QuantizedWeight)> {
161                let wkey = format!("{p}.{name}.weight");
162                let scale_key = format!("{p}.{name}.weight_scale_inv");
163                let (src_ptr, src_dtype) = {
164                    let t = store.get(&wkey)?;
165                    (t.ptr, t.dtype)
166                };
167                let src_is_fp8 = src_dtype == spark_runtime::weights::WeightDtype::FP8E4M3;
168                let src_is_f32 = src_dtype == spark_runtime::weights::WeightDtype::FP32;
169                let scale_ptr = if src_is_fp8 && store.contains(&scale_key) {
170                    Some(store.get(&scale_key)?.ptr)
171                } else {
172                    None
173                };
174                let dense_w = dense_auto(store, &wkey, gpu)?;
175                // TP shard the BF16 weight before NVFP4 quantization. When
176                // tp_size == 1 the helper returns the input pointer untouched
177                // so the existing single-rank path is unchanged.
178                let (sharded_ptr, local_n, local_k) =
179                    shard_dense_bf16(dense_w.weight, full_n, full_k, kind, tp_rank, tp_size, gpu)?;
180                let sharded = DenseWeight {
181                    weight: sharded_ptr,
182                };
183                let q = quantize_to_nvfp4(
184                    &sharded, local_n, local_k, gpu, absmax_k, quantize_k, stream,
185                )?;
186                if sharded_ptr != dense_w.weight {
187                    gpu.free(sharded_ptr)?;
188                }
189                if src_is_fp8 {
190                    // M2 path: dense_auto allocated a fresh BF16 dequant
191                    // buffer separate from the FP8 source on GPU. Free
192                    // both: BF16 dequant is no longer needed once NVFP4
193                    // is built, and the FP8 source can be released
194                    // because the attention forward only reads the
195                    // NVFP4 path. The WeightStore retains stale
196                    // pointers; nothing reads them again.
197                    gpu.free(dense_w.weight)?;
198                    gpu.free(src_ptr)?;
199                    if let Some(sp) = scale_ptr {
200                        gpu.free(sp)?;
201                    }
202                } else {
203                    // M2.7-NVFP4 path: source is BF16 and dense_auto
204                    // or FP32. For FP32, dense_auto allocated a BF16
205                    // conversion buffer that is no longer needed once
206                    // NVFP4 is built. The original dense source can also
207                    // be released because attention forward only reads
208                    // q/k/v/o_nvfp4 after load_layers returns.
209                    if src_is_f32 {
210                        gpu.free(dense_w.weight)?;
211                    }
212                    gpu.free(src_ptr)?;
213                }
214                Ok((
215                    DenseWeight {
216                        weight: spark_runtime::gpu::DevicePtr::NULL,
217                    },
218                    q,
219                ))
220            };
221            // Q/K/V column-parallel + O row-parallel via the standard TP
222            // helper. `load_and_quant` is the loader-specific format closure;
223            // it reads a BF16 weight from the store, TP-shards before
224            // quantization, and converts to NVFP4. Helper handles the
225            // dimension math for all four projections.
226            let [
227                (q_dense, q_nvfp4),
228                (k_dense, k_nvfp4),
229                (v_dense, v_nvfp4),
230                (_o_dense, o_nvfp4),
231            ] = load_qkvo_tp(config, |name, full_n, full_k, kind| {
232                load_and_quant(name, full_n, full_k, kind)
233            })?;
234
235            let (k_scale, v_scale) = load_kv_scales(store, &p, gpu);
236
237            // MiniMax's q_norm is RMSNorm over the full projected Q:
238            // weight shape `[num_heads * head_dim]`. k_norm is likewise
239            // `[num_kv_heads * head_dim]`. Both applied BEFORE RoPE.
240            //
241            // Under TP, both vectors shard column-parallel matching the
242            // local Q/K projection output. shard_dense_1d_bf16 returns the
243            // input ptr untouched when tp_size == 1.
244            let (q_norm_full, k_norm_full) = load_qk_norms_tp(config, |name, full_dim| {
245                let src = dense(store, &format!("{p}.{name}.weight"))?;
246                let (ptr, _) = shard_dense_1d_bf16(src.weight, full_dim, tp_rank, tp_size, gpu)?;
247                Ok::<_, anyhow::Error>(DenseWeight { weight: ptr })
248            })?;
249
250            let attn = AttentionWeights {
251                q_proj: q_dense,
252                k_proj: k_dense,
253                v_proj: v_dense,
254                o_proj: o_nvfp4,
255                // Per-head slots intentionally NULL — the full-hidden
256                // norm below takes precedence in the attention forward.
257                q_norm: dummy_norm,
258                k_norm: dummy_norm,
259                q_norm_full: Some(q_norm_full),
260                k_norm_full: Some(k_norm_full),
261                k_scale,
262                v_scale,
263            };
264
265            let mut layer = Qwen3AttentionLayer::new_ungated(
266                input_norm,
267                attn,
268                post_attn_norm,
269                ffn,
270                i, // attn_layer_idx — every layer is an attention layer for MiniMax
271                Some(q_nvfp4),
272                Some(k_nvfp4),
273                Some(v_nvfp4),
274                gpu,
275                layer_kv_dtypes[i],
276                config.fp8_kv_calibration_tokens,
277                config,
278            )?;
279
280            // Transpose attention NVFP4 weights for prefill coalesced reads.
281            // Without this, prefill.rs falls through to the tiny-tile
282            // `w4a16_gemm` kernel (64×64×16, sync loads) instead of the
283            // efficient `w4a16_gemm_n128_m128` path. Per-layer cost ≈
284            // 24.6 MB (Q+K+V+O); 62 layers × 24.6 MB ≈ 1.5 GB per rank —
285            // fits comfortably in post-MoE-transpose headroom.
286            let q_proj_n = config.num_attention_heads * config.head_dim;
287            let kv_proj_n = config.num_key_value_heads * config.head_dim;
288            let qt = q_nvfp4.transpose_for_gemm(gpu, q_proj_n, h)?;
289            let kt = k_nvfp4.transpose_for_gemm(gpu, kv_proj_n, h)?;
290            let vt = v_nvfp4.transpose_for_gemm(gpu, kv_proj_n, h)?;
291            let ot = layer.attn.o_proj.transpose_for_gemm(gpu, h, q_proj_n)?;
292            layer.set_prefill_weights(Some(qt), Some(kt), Some(vt), Some(ot));
293
294            layers.push(Box::new(layer));
295        }
296
297        tracing::info!("minimax_m2: built {} layers", layers.len());
298        Ok(layers)
299    }
300
301    fn load_embedding(
302        &self,
303        store: &WeightStore,
304        _config: &ModelConfig,
305        _gpu: &dyn GpuBackend,
306    ) -> Result<DenseWeight> {
307        dense(store, "model.embed_tokens.weight")
308    }
309
310    fn load_final_norm(
311        &self,
312        store: &WeightStore,
313        _config: &ModelConfig,
314        _gpu: &dyn GpuBackend,
315    ) -> Result<DenseWeight> {
316        dense(store, "model.norm.weight")
317    }
318
319    fn load_lm_head(
320        &self,
321        store: &WeightStore,
322        _config: &ModelConfig,
323        _gpu: &dyn GpuBackend,
324    ) -> Result<DenseWeight> {
325        dense(store, "lm_head.weight")
326    }
327
328    fn load_mtp_weights(
329        &self,
330        _store: &WeightStore,
331        _config: &ModelConfig,
332        _gpu: &dyn GpuBackend,
333    ) -> Result<Option<MtpWeights>> {
334        // MiniMax uses multi-module MTP (3 heads). The single-module
335        // trait method returns None so any loader that calls the
336        // old entry point gets a clean "no MTP" signal; the real
337        // work happens in `load_mtp_weights_multi` below.
338        Ok(None)
339    }
340
341    fn load_mtp_weights_multi(
342        &self,
343        store: &WeightStore,
344        config: &ModelConfig,
345        _gpu: &dyn GpuBackend,
346    ) -> Result<Vec<MtpWeights>> {
347        // MiniMax M2 MTP module layout in the checkpoint (verified against
348        //   modeling_minimax_m2.py, MiniMaxM2ForCausalLM):
349        //   model.layers.{N + i}.{...}   for i in 0..num_mtp_modules
350        // where N = config.num_hidden_layers.
351        //
352        // Each module is a full transformer layer (attention + MoE) plus
353        // the usual MTP concat block (embed-norm, hidden-norm, fc,
354        // output-norm). Weight shapes match the main layers.
355        //
356        // The tiny-random test variant ships none of these tensors (see
357        // docs/MINIMAX-M5-DESIGN.md §"Open questions"). Detect that
358        // absence by probing for the first expected key — if the
359        // checkpoint has no MTP modules, return an empty Vec so the
360        // engine disables speculative decoding cleanly instead of
361        // erroring out mid-load.
362        let first_mtp_idx = config.num_hidden_layers;
363        let probe = format!("model.layers.{first_mtp_idx}.input_layernorm.weight");
364        if !store.contains(&probe) {
365            tracing::info!(
366                "minimax_m2: no MTP module weights found in checkpoint \
367                 (expected starting at layer {first_mtp_idx}); MTP disabled"
368            );
369            return Ok(Vec::new());
370        }
371
372        // Real 229B checkpoint staging is a separate effort; once those
373        // weights are present, populate one `MtpWeights` per module
374        // following the same shape as `load_layers` (attention + MoE
375        // sharing the existing helpers). Until then fail fast with a
376        // clear message so nobody thinks MTP is silently working.
377        anyhow::bail!(
378            "minimax_m2: MTP module weights detected at layer {first_mtp_idx} \
379             but the MiniMax loader hasn't implemented per-module extraction \
380             yet. Run with --speculative 0 for non-MTP decode, or await \
381             MiniMax M5 phase-3 (populate load_mtp_weights_multi with the \
382             concrete Mixtral-convention weight keys)."
383        )
384    }
385}