M3 是一个 VL 外壳套一个稀疏 MoE 文本骨架:config.json 顶层只有
text_config 与 vision_config 两段,权重也干净地分成
language_model.*(22893 个张量)与 vision_tower.* / projector(523 个)。
真正的新东西是文本侧的 MSA:注意力仍是普通 GQA(64 头 : 4 KV 头,head_dim 128),
但每个 query token 先由一个 4 头的轻量 indexer 给每 128 个 token 一块打分,
只取 16 块 = 2048 个 KV token 进真正的注意力。
索引的 K 只有一个头、单独存一份 page-128 cache,
整套东西在 ATOM 里落在 atom/models/minimax_m3.py 与
atom/model_ops/minimax_m3/ 两处。
定位
把 M3 和 GLM-5.2 / 5.3 那一系放在一起最容易读懂它:两边都在做「先索引、再稀疏注意力」, 但三个关键选择完全不同。
M3 的三个选择
sparse_block_size = 128 被硬绑在 KV page size 上,
一个稀疏块正好是一个 page,选中的块号可以直接当块表用。sparse_disable_index_value
对所有稀疏层置 1,indexer 没有 v 投影也没有 per-head 权重,
score 就是 max over 块内 128 个位置的 q·kᵀ/√128。落到 ATOM 的位置
atom/models/minimax_m3.py — 925 行,独立模型文件(不像 GLM 挂在
deepseek_v2.py 上)。atom/model_ops/minimax_m3/index_topk.py — 1155 行 Triton:打分 + bitonic top-k
+ 顺手吐出 page-16 块表。atom/model_ops/minimax_m3/sparse_attn.py — 1423 行:block-sparse GQA 前向。SparseMHAPagedAttentionImpl(attention_mha.py)只覆写了
rope_cache 与 dispatch_backend 两个钩子,
KV cache 的分配 / 绑定完全走标准 MHA 路径。视觉塔目前不在 ATOM 里
文件末尾一行 MiniMaxM3SparseForConditionalGeneration =
MiniMaxM3SparseForConditionalGenerationTextOnly:架构名存在,但走的是文本视图,
vision_tower. / multi_modal_projector. / patch_merge_mlp. 三个前缀在
skip_weight_prefixes 里被直接跳过。所以 recipe 里的
--language-model-only 不是可选项。
规模
下面每一行都是从 59 个 safetensors 分片的头部读出来的真实形状累加,不是从 config 推的。 专家权重吃掉 96.74 %,剩下所有东西加起来不到 14 B。
激活量是怎么凑出来的
一个对不上的数
model.safetensors.index.json 的 metadata.total_size 写的是
869.16 GB,而 59 个分片 du --apparent-size 求和是
854.18 GB,与把所有 header 里的形状按 dtype 累加得到的
854.17 GB 一致。多出来的 15 GB 不对应任何一个实际张量,算显存时不要用 metadata 那个数。
结构全图
右侧展开的是 layer 3–59 中任意一层。值得单独看一眼的是那次
[6144 → 9856] 的打包 GEMM:q(8192) + k(512) + v(512) + index_q(512) + index_k(128)
五路一次算完,磁盘上它们是 5 个独立张量,靠
packed_modules_mapping 在加载时拼进 qkv_proj。
即使是复用上一层 top-k 的那 42 层,这次 GEMM 也照算不误——省掉的只是 indexer 的
norm / rope / 打分 / top-k。
图 1 · 结构全图
fused_allreduce_gemma_rms_norm[_quant],
所以图中每个 layernorm 方框实际上都同时承担了 TP 归约与残差累加。
核心机制 · MSA
索引器的形状很值得单独念一遍:index_q_proj [512, 6144] 是
4 个头 × 128 维,而 index_k_proj [128, 6144] 只有
1 个头。打分内核里 pid_h 遍历 4 个索引头,
但每次都从同一个 page 载入同一份 K——索引是 MQA,主注意力是 GQA。
4 个索引头正好对上 4 个 KV 头,于是每个 KV 头组(16 个 q 头)拿到属于自己的那 16 个块。
图 2 · MSA 三步
sparse_init_block = 0、sparse_local_block = 1:M3 不强制保留开头的块,
只强制保留当前 token 所在的那一块。强制的实现方式是在 top-k 之前把该块的 score 直接改写成
1e29(init 用 1e30),而不是单独留出名额——所以 16 是含强制块的总数。
打分内核的两个细节
sm_scale = 128-1/2,并且预乘了
log2(e)——整条链路是 base-2 softmax。-inf,不会污染 max。为什么 block-size 必须是 128
index cache 布局是 [num_blocks, 128, 128],第二维就是 page 内的 token 位置。
打分内核用 block_table[blk] 直接换算 page 号,
top-k 出来的块号也直接当块表用。--block-size 128 一改,这三处同时失配。
图 3 · 层调度
*_freq 数组在 config 里是同一个形状:moe_layer_freq、
sparse_attention_freq、sparse_disable_index_value 都是
[0,0,0,1,1,…,1]——前 3 层稠密且全注意力,后 57 层既是 MoE 也是稀疏层,
三者边界完全重合。第三行不是 checkpoint 的性质而是 ATOM 的运行期调度:
use_index_cache=true 且 index_topk_freq=4 时,
按稀疏层序号(不是绝对层号)每 4 层只有第 1 层跑 indexer 的 norm/rope/打分/top-k,
其余 3 层直接复用缓存下来的块下标。57 个稀疏层因此只剩 15 层真正算 top-k。
MoE
路由是 sigmoid 打分 + 修正偏置那一套:gate 权重在 checkpoint 里是
fp32([128, 6144]),e_score_correction_bias
也是 fp32。选出 top-4 后 renormalize,再整体乘
routed_scaling_factor = 2.0,最后加上 shared expert 的输出。
| 字段 | 值 | 形状 / 说明 |
|---|---|---|
| num_local_experts | 128 | w1 / w3 [3072, 6144],w2 [6144, 3072] |
| num_experts_per_tok | 4 | 每 token 激活 4 × 56.62 M = 226.49 M |
| n_shared_experts | 1 | shared_intermediate_size 3072,与单个路由专家等宽 |
| intermediate_size | 3072 | 路由专家;dense_intermediate_size 12288 只用于 layer 0–2 |
| scoring_func | sigmoid | 配 use_routing_bias = true |
| routed_scaling_factor | 2.0 | 只乘路由输出,shared 不乘 |
| hidden_act | swigluoai | gate·σ(1.702·gate)·(up+1),gate 与 up 各自 clamp 到 ±7.0 |
ATOM 里一处刻意的反优化
MiniMaxM3MoE.__init__ 把后端的 intermediate_pad 强行置 0,
注释写得很直白:专家权重在加载时已经补齐,运行期再走「跳过 pad」的快路径会掉精度,
所以宁可整块算完 padded intermediate。看 MoE 性能时这一行值得记住。
归一化与位置编码
| 项 | 配置 | 含义 |
|---|---|---|
| 归一化 | use_gemma_norm = true | ATOM 用 GemmaRMSNorm:x·(1+w) 而不是 x·w,且乘完再回 bf16 |
| QK-norm | use_qk_norm · per_head | q_norm / k_norm 权重只有 [128],对每个头独立归一化,60 层全有 |
| RoPE | partial_rotary_factor = 0.5 | rotary_dim = 64,head_dim 的后 64 维不旋转;rope_theta = 5e6 |
| 索引路 RoPE | 共享主路 | index_rotary_emb = rotary_emb,连 cos/sin 缓存都是同一份拼好的张量 |
| attention 门控 | attention_output_gate = false | M2 系有的输出门在 M3 上关掉了 |
| max_position | 524 288 | config 里是 512 K;对外宣称的 1 M 上下文靠推理侧外推 |
这一整套(qk-norm → 部分 RoPE → 写 KV → 写 index K)在 ATOM 里是一个 aiter 算子:
aiter.fused_qknorm_idxrqknorm,直接吃打包好的 qkv 张量,
一次写完 SHUFFLE KV cache、index cache 和 fp8 的 per-token dequant scale。
只有复用 top-k 的那 42 层会退回到 Triton 版 triton_fused_norm_rope_cache(不带索引路)。
显存
图 4 · 两套 KV 布局
view 成 page-16——这样绑定路径上没有任何 M3 专用代码,
代价是读代码时要记住同一个指针有两种形状。
| 缓存 | 每 token 每层 | 层数 | 每 token | 1 M 上下文 |
|---|---|---|---|---|
| 主 KV(bf16) | 2 × 4 × 128 × 2 B = 2048 B | 60 | 120.00 KiB | 120 GiB |
| index K(bf16) | 1 × 128 × 2 B = 256 B | 57 | 14.25 KiB | 14 GiB |
| 合计 | 134.25 KiB | 134 GiB | ||
| 主 KV 转 fp8 后 | 1024 B | 60 | 74.25 KiB | 74 GiB |
index cache 的 dtype 由 atom_config.index_cache_dtype 单独控制,默认跟随 KV;
打分内核里有 k.dtype.is_fp8() 分支,索引路可以独立降到 fp8。
多模态
图 5 · 视觉前端
patch_merge_mlp.linear_1.weight 是
[6144, 24576],而 24576 = 4 × 6144,不是 4 × 1280。
patch_embedding 是 Conv3d [1280, 3, 2, 14, 14],
那个 2 是 temporal_patch_size:静态图按重复两帧处理,视频天然按 2 帧一组。
ATOM 后端
不能改的参数
--block-size 128:一个稀疏块 ⇔ 一个 page,改了直接失配。--language-model-only:视觉塔未移植。--no-trust-remote-code:ATOM 自己注册模型类,
用 HF 的 auto_map 反而会走错实现。use_triton_attn = False)。可以调的开关
--hf-overrides '{"use_index_cache": true, "index_topk_freq": 4}':
top-k 复用的开关与步长,见图 3。index_topk_pattern:给一串 "S" / 非 S 逐层指定,
优先级高于 index_topk_freq。--kv-cache-dtype fp8:主 KV 减半,见缓存账。--compilation-config '{"cudagraph_mode": "FULL_AND_PIECEWISE"}'。| checkpoint 变体 | 量化 | 要点 |
|---|---|---|
| MiniMax-M3 | bf16 | 854.18 GB,59 分片 |
| MiniMax-M3-MXFP4 | fp4 per_group 32 · e8m0 scale | 权重与激活都 MXFP4,观测器 PerBlockMXObserver |
| MiniMax-M3-MXFP8 | mxfp8 · weight_block [1, 32] | 动态激活量化;ignored_layers 里排除了 lm_head、embed、视觉塔与部分层的 block_sparse_moe.gate |
| MiniMax-M3-MXFP4-AttnFP8 | MXFP4 + 注意力 fp8 | MXFP4 基础上把注意力线性层单独提到 fp8 |
| MiniMax-M3-EAGLE3 | bf16 | 草稿模型,见下节 |
MXFP8 走的是运行期在线量化:recipe 里那串
--additional-config '{"online_quant_config": {"global_quant_config": "ptpc_fp8", …}}',
排除表和 checkpoint 里的 ignored_layers 是同一批名字。
| 配置 | GSM8K flexible-extract | strict-match |
|---|---|---|
| MXFP8 | 0.9503 | 0.9510 |
| MXFP4 | 0.9399 | 0.9407 |
| MXFP8 + kv fp8 | 0.9480 | 0.9487 |
| MXFP4 + kv fp8 | 0.9439 | 0.9445 |
5-shot、5 次本地取平均,来自 recipes/atom_vllm/MiniMax-M3.md。
投机解码
text_config 里明明白白有 num_mtp_modules = 7 与
num_nextn_predict_layers = 1,但 23416 个张量里没有一个属于 MTP——
ATOM 的模型文件里也没有 MTP 分支。M3 在 ATOM 上的投机解码走的是 Eagle3:
aux hidden state 的取法
get_eagle3_aux_hidden_state_layers() 返回
(2, 30, 57)(early / mid / late)。取的是进入该层、过完 input_layernorm 之后的
residual,不是常见的 hidden_states + residual——
因为 M3 的融合 all-reduce RMSNorm 会让那个和在 CUDA graph 下处于 TP 部分和状态甚至出 NaN。
草稿模型
LlamaForCausalLMEagle3,1 层。rope_theta 同为 5e6,
max_position_embeddings 直接写 1 048 576。读码路线
| # | 文件 | 看什么 |
|---|---|---|
| 1 | config.json | 三条 *_freq 数组的边界是否重合(M3 是重合的) |
| 2 | atom/models/minimax_m3.py | _sparse_attention_layer_ordinals 与 _should_skip_minimax_m3_index_topk:调度按稀疏层序号而非绝对层号 |
| 3 | atom/models/minimax_m3.py | MiniMaxM3SparseAttention.forward:5 路 split 的宽度就是那次打包 GEMM 的全部内容 |
| 4 | atom/model_ops/attention_mha.py | SparseMHAPagedAttentionImpl.rope_cache / _to_page16_shuffle:两套布局在这里对上 |
| 5 | minimax_m3/index_topk.py | _index_block_score_kernel:4 个索引头共读一份 K;_topk_index_kernel:强制块靠改写 score 实现 |
| 6 | minimax_m3/sparse_attn.py | page-16 块表如何喂给 gluon PA |
recipes/atom_vllm/MiniMax-M3.md # 起服务的完整命令 + GSM8K 基线 recipes/atom_sglang/MiniMax-M3.md # SGLang 后端 recipes/mesh/MiniMax-M3.md # mesh 部署 /shared/data/amd_int/models/MiniMax-M3* # bf16 与 4 个量化变体