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}