Kernel Dev 训练营/ 提示与讲解
M2 提示与讲解 · Track M · MoE 与低精度 · 对照 solutions/m2_fused_moe.py 逐行读

M2 讲解:fused MoE grouped GEMM

一次 kernel launch 算完所有专家的矩阵乘:这正是 vLLM fused_moe_kernel 的结构。这一页接着 M1 的例子,讲清楚每个 program 怎么从 expert_ids 知道该读哪个专家的权重、A 为什么要 gather(以及 GEMM1 和 GEMM2 的 gather 为什么不一样)、路由权重为什么在 GEMM2 的 epilogue 里乘,最后把整层 SwiGLU MoE 串起来,并和 vLLM 的 kernel 逐行对照。

怎么读这一页

对照 drillsm2_fused_moe.py 读。前置:M1 讲解 的三个输出,T4 讲解 的 Triton GEMM(这里的主循环和 T4 几乎一样),F3 讲解 的 silu_and_mul。

① 先看全貌:一层 MoE 的五步

drillsmoe_layer(79–91 行)

SwiGLU MoE 层(T 个 token,top-K)悬停节点查看连接
GEMM1 的输入是 T 行的 x,输出是 T·K 行(每个 (token, slot) 一行);GEMM2 的输入、输出都是 T·K 行。两个 GEMM 用的是同一套 sorted_ids 和 expert_ids,只是读 A 的方式不同。

W13 是每个专家的 gate 和 up 两个投影拼在一起([E, 2I, H],前 I 行是 gate,后 I 行是 up),和 F3 里 gate_up_proj 的布局一样。

② grid:每个 M 块只属于一个专家

drills28–34 行 · host 端 drillsgrid(71 行)

grid = (sorted_ids.numel() // block_m, cdiv(N, block_n))

pid_m, pid_n = tl.program_id(0), tl.program_id(1)
offs_m   = pid_m * BLOCK_M + tl.arange(0, BLOCK_M)
flat_ids = tl.load(sorted_ids_ptr + offs_m)            # 这一块的 BLOCK_M 个 (token, slot) 编号
row_mask = flat_ids < num_valid                        # padding 行的值是 T·K,被 mask 掉
expert   = tl.load(expert_ids_ptr + pid_m)             # 这一块属于哪个专家

M1 的例子(T = 4、K = 2、block_m = 2)里,sorted_ids 长 10,有 5 个 M 块:

pid_m
0
1
2
3
4
flat_ids
0, 4
3, 8
1, 2
5, 7
6, 8
expert
0
1
2
2
3
GEMM1 读 x 的行
t0, t2
t1, —
t0, t1
t2, t3
t3, —

每个 program 只读一个专家的权重 W[expert]。划掉的 8 是 padding,row_mask 为 False。GEMM1 读 x 的行号是 flat_id // K。

和 T4 的普通 GEMM 相比,只有两处不同:A 的行不连续(要通过 flat_ids 去 gather),B 的起点由 expert 决定(w_ptr + expert * stride_we)。

类比 · 流水线上的工位卡

GEMM 的每个 program 是流水线上的一个工位。工位开工前先看两张卡:一张写着“今天加工哪几件”(flat_ids,最多 BLOCK_M 件,空位写着“无”),一张写着“用哪套模具”(expert)。拿到模具后,从仓库的不同货架上把这几件原料取过来(gather),加工方法和普通工位完全一样(T4 的主循环)。所有工位同时开工(一次 launch),各用各的模具。

③ A 的 gather:GEMM1 和 GEMM2 不一样

drills36–46 行

if A_ROWS_ARE_TOKENS:          # GEMM1:A 是 x [T, H]
    a_rows = flat_ids // top_k
else:                          # GEMM2:A 是 h [T·K, I]
    a_rows = flat_ids
a_ptrs = a_ptr + a_rows[:, None] * stride_am + offs_k[None, :]
w_ptrs = w_ptr + expert * stride_we + offs_n[None, :] * stride_wn + offs_k[:, None]
A A 有几行 flat_id f 对应 A 的哪一行
GEMM1 x T(每个 token 一行) f // K:同一个 token 的 K 个 slot 读同一行 x
GEMM2 h T·K(每个 (token, slot) 一行) f:每个 slot 有自己的中间结果

A_ROWS_ARE_TOKENS 是 constexpr,所以两种 gather 是同一个 kernel 编译出来的两个版本。

W 的转置:W[e] 存成 [N, K] 的行优先(PyTorch Linear 的权重格式),GEMM 需要的是 Wᵀ。和 T4、T5 一样,用 stride 直接读成 [BLOCK_K, BLOCK_N] 的转置块:offs_n[None, :] * stride_wn + offs_k[:, None]。

为什么 gather 比连续读慢:同一个 M 块里的行在 x 里是分散的(t0、t2),每一行是连续的 H 个数,所以每行内部仍然是合并访存,但不同行之间无法连成更大的连续块,硬件预取和 TMA 之类的块拷贝也用不上。题目页提到这是和稠密 GEMM 差距的原因之一。

④ 主循环和 epilogue

drills48–61 行

主循环和 T4 一样:沿 K 方向每次取 BLOCK_K,tl.dot 累加到 fp32 的 acc。mask 多了一项 row_mask[:, None],padding 行读 0。

if MUL_WEIGHT:                                  # 只在 GEMM2 打开
    tw = tl.load(topk_w_ptr + flat_ids, mask=row_mask, other=0.0)
    acc = acc * tw[:, None]
out_ptrs = out_ptr + flat_ids[:, None] * stride_om + offs_n[None, :]
tl.store(out_ptrs, acc.to(...), mask=row_mask[:, None] & (offs_n[None, :] < N))

⑤ 性能:为什么只有稠密 GEMM 的一半

题目页的实测:T = 4096、E = 64、top-8、H = 2048、I = 768(接近 Qwen3-30B-A3B),bf16。

FLOPs 时间 TFLOP/s
GEMM1:2 · T·K · H · 2I 2 × 32768 × 2048 × 1536 = 206 G
GEMM2:2 · T·K · I · H 2 × 32768 × 768 × 2048 = 103 G
参考解整层 309 G 3245 us 95
同 FLOPs 的两个稠密 cuBLAS GEMM 309 G 1461 us 212

差距的来源(题目页列出的几条):

decode 时为什么更糟悬停节点查看连接
decode 时 16 × 8 = 128 个 (token, slot) 分散到 64 个专家上,每个专家只有一两行,Tensor Core 的大部分算力花在 padding 上;而这一步又要把几乎所有专家的权重都读一遍。vLLM 对小 batch 会选更小的 BLOCK_SIZE_M。
类比 · 包车

prefill 像一个旅行团 4096 人去 64 个景点,每个景点平均 500 多人,每辆大巴(BLOCK_M = 64 座)都能坐满。decode 像只有 16 个散客,每人要去 8 个景点,平摊到每个景点只有一两个人,却还是给每个景点派一辆 64 座的大巴:车费(读权重)一分不少,座位几乎全空着。

⑥ 对照 vLLM 的 fused_moe_kernel

参考解的结构就是从 vLLM 抄来的:vllmfused_moe_kernel

参考解 vLLM
flat_ids = tl.load(sorted_ids_ptr + offs_m) vllmoffs_token = tl.load(sorted_token_ids_ptr + offs_token_id)
row_mask = flat_ids < num_valid vllmtoken_mask = offs_token < num_valid_tokens
expert = tl.load(expert_ids_ptr + pid_m) vllmoff_experts = …;if off_experts == -1(专家并行时不在本卡的专家直接写 0 跳过)
a_rows = flat_ids // top_k vllmoffs_token // top_k * stride_am
acc *= tw[:, None] vllmmoe_weight = tl.load(topk_weights_ptr + …)(MUL_ROUTED_WEIGHT)

vLLM 的版本还处理了 FP8 / INT8 的 per-tensor 和 block-wise scale(M3 会讲 block scaling)、专家并行,以及按 GROUP_SIZE_M 分组的 program 顺序(T4 讲过的 L2 优化)。

⑦ 常见坑与自测

自测 1:M1 的例子里 pid_m = 3 这个 program(GEMM1),读 x 的哪两行?写 out 的哪两行?用哪个专家的权重?

flat_ids = [5, 7],读 x 的 5 // 2 = 2 和 7 // 2 = 3 两行(t2、t3),写 out 的第 5、7 行,用专家 2 的 W13。

自测 2:top-K 从 8 改成 2,其余不变,GEMM1 和 GEMM2 的 FLOPs 怎么变?

都和 T·K 成正比,变成原来的 1/4:GEMM1 51.5 G,GEMM2 25.8 G。但读权重的量不一定变少:只要 token 足够多,每个专家都会被激活,每个专家的权重都要读一遍。

源码与资料索引

本仓库:drillssolutions/m2_fused_moe.py · drillsmine/m2_fused_moe.py · drillstests/test_m2_fused_moe.py · drillscontent/m2.md · drillsdrill.py(load() 选择你的 M1 还是参考解)

vLLM:vllmfused_moe_kernel · vllmtry_get_optimal_moe_config · vllmmoe_align_block_size

Triton:tritontutorial 08 grouped GEMM(另一种做法:每个 group 一个独立的 GEMM 描述)

论文:Mixtral · MegaBlocks(block-sparse 的 MoE 计算)