Kernel Dev 训练营/ 提示与讲解
X2 提示与讲解 · Track X · MLX / Metal · 对照 solutions/x2_metal_softmax.py 逐行读

X2 讲解:Metal 里的行归约(simdgroup + threadgroup 内存)

把 C2 的 reduction 和 T2 的 softmax 搬到 Apple GPU 上。这一页逐行读参考解:一个 threadgroup 一行、线程怎么跨步扫一行、simd_sum / simd_max 对应 CUDA 的什么、tg_reduce 为什么让每个 simdgroup 都做第二轮归约(于是所有线程都拿到结果)、为什么要两次 barrier,以及三遍读的 softmax 为什么在短行上比 MLX 慢一倍。

怎么读这一页

对照 drillsx2_metal_softmax.py 读。前置:C2 讲解(两层归约)、T2 讲解(减 max 的 softmax)、X1 讲解(metal_kernel 和 grid 的语义)。这一页主要讲 Metal 版和 CUDA 版的不同。

① 先看全貌:一个 threadgroup 负责一行

一行 softmax 的三遍(每遍都读整行)悬停节点查看连接
和 T2 不同,这里的整行不在寄存器里,而是每一遍都从内存重新读。好处是行多长都能处理;代价是读三次。T2 的 softmax 一次把整行读进寄存器,只读一次。
CUDA(C2) Metal(X2)
block threadgroup:一行一个
warp simdgroup(32 个线程)
__shfl_down_sync 循环 5 轮 simd_sum(v) / simd_max(v):一个函数调用,硬件做完
__shared__ float warp_sums[8] threadgroup float scratch[32]
__syncthreads() threadgroup_barrier(mem_flags::mem_threadgroup)
lane = threadIdx.x & 31 thread_index_in_simdgroup(内置属性)
warp = threadIdx.x >> 5 simdgroup_index_in_threadgroup(内置属性)

② kernel 开头:内置属性和 auto

drills_SOFTMAX 开头(28–38 行)

uint row  = threadgroup_position_in_grid.x;       // 第几个 threadgroup = 第几行
uint tid  = thread_position_in_threadgroup.x;     // 组内第几个线程
uint nthr = threads_per_threadgroup.x;
uint lane = thread_index_in_simdgroup;            // 0..31
uint sg   = simdgroup_index_in_threadgroup;       // 第几个 simdgroup
uint nsg  = (nthr + 31) / 32;
threadgroup float scratch[32];
auto xr = x + row * n;

host 端(drills82–87 行):grid=(rows * t, 1, 1), threadgroup=(t, 1, 1)。别忘了 X1 讲的:grid 是线程总数,所以 rows 行就是 rows × t 个线程。

每行几个线程

drills_threads(77–79 行):ncols < 1024 时取不小于 ncols 的 2 的幂(至少 32),否则 1024。

ncols 线程数 t 每个线程扫几个
1 32 1(31 个线程闲着)
100 128 1(28 个闲着)
1000 1024 1
4096 1024 4
32000 1024 31–32

③ tg_reduce:每个线程都拿到结果

drillstg_reduce(14–25 行)

template <bool IS_MAX>
inline float tg_reduce(float v, threadgroup float* scratch, uint lane, uint sg, uint nsg) {
    v = IS_MAX ? simd_max(v) : simd_sum(v);          // ① simdgroup 内归约
    if (lane == 0) scratch[sg] = v;                  // ② 每个 simdgroup 交一份
    threadgroup_barrier(mem_flags::mem_threadgroup); // ③ 等大家都交完
    float init = IS_MAX ? -INFINITY : 0.0f;
    v = lane < nsg ? scratch[lane] : init;           // ④ 读回部分结果
    v = IS_MAX ? simd_max(v) : simd_sum(v);          //    每个 simdgroup 都做一遍
    threadgroup_barrier(mem_flags::mem_threadgroup); // ⑤ 让调用方能马上复用 scratch
    return v;
}

和 C2 的 block 归约结构一样,有两个不同:

不同 1:simd_sum 一次调用就完成 simdgroup 内的归约。 而且它的结果每个 lane 都有,不像 C2 的 __shfl_down_sync 只有 lane 0 是对的。

不同 2:第二轮归约每个 simdgroup 都做,而不是只有第 0 个。 C2 只需要一个数去做 atomicAdd,所以只让 warp 0 归约。softmax 不同:每个线程后面都要用 max 和 sum 去算自己负责的那些元素。如果只有 simdgroup 0 有结果,还得再写一次 scratch、再 barrier 一次广播给大家。让每个 simdgroup 都读一遍 scratch 并自己归约,算了重复的活,但省掉了一次广播,所有线程都直接拿到结果。

tg_reduce 里的数据流(4 个 simdgroup)悬停节点查看连接
第 ④ 步里 lane ≥ nsg 的线程填 init(max 填 −inf,sum 填 0),和 C2 里 lane ≥ WARPS 填 0 是同一个道理。

为什么最后还要一次 barrier(⑤):tg_reduce 会被调用两次(先 max 后 sum),两次用的是同一个 scratch。没有第 ⑤ 步的话,一个跑得快的 simdgroup 可能已经进入第二次 tg_reduce,在第 ② 步把自己的部分和写进 scratch,而另一个慢的 simdgroup 还在第 ④ 步读第一次的 max,就会读到被覆盖的值。这叫“读后写”冲突。

类比 · 开会传达结果

C2 的做法是:各组组长把数字交给秘书(scratch),只有第一组的组长汇总,汇总结果交给总部(atomicAdd),其他人不需要知道。X2 不一样,每个人后面都要用这个总数干活。如果还是只让第一组汇总,就得再开一次会把结果告诉大家。参考解的做法是:所有组长交完数字后,每个组都自己抄一份白板上的数字、自己加一遍。大家重复做了一点加法,但不用再开第二次会。散会前再等一下(第二次 barrier),确保所有人都抄完了,才允许有人擦白板写下一个议题。

④ 三遍扫描和 RMSNorm

drills40–49 行

for (uint c = tid; c < n; c += nthr) m = max(m, float(xr[c]));        // 第 1 遍
m = tg_reduce<true>(m, ...);
for (uint c = tid; c < n; c += nthr) s += metal::exp(float(xr[c]) - m);   // 第 2 遍
s = tg_reduce<false>(s, ...);
for (uint c = tid; c < n; c += nthr) out[row * n + c] = T(metal::exp(float(xr[c]) - m) * inv);  // 第 3 遍

for (c = tid; c < n; c += nthr) 是一行之内的 grid-stride loop(C1、C2 讲过):同一轮里相邻线程读相邻元素,合并访存。

RMSNorm(drills52–68 行)是同样的结构,只需要一次归约(Σx²),所以只扫两遍。

为什么短行比 MLX 慢

题目页的实测(M2,fp16,2048 行):

N mx.softmax 参考解
1024 310 us 640 us
4096 716 us 1152 us
32000 4432 us 4032 us

MLX 的 softmax 按行长选两种实现(mlxaxis_size > looped_limit ? "looped_" : "block_",mlxSOFTMAX_LOOPED_LIMIT = 4096):

参考解不管多长都读三遍,所以 N ≤ 4096 时慢了将近一倍;N = 32000 时两边都是多遍读,参考解反而略快。

类比 · 抄书

三遍法像抄一本书的索引:第一遍从头翻到尾找最大的页码,第二遍再翻一遍算总数,第三遍再翻一遍写出来。书很薄时(短行),完全可以把整本书放在桌上一眼看完(读进寄存器),只翻一遍。书太厚放不下(长行),才只好一遍遍地翻。MLX 根据书的厚度选办法,参考解永远翻三遍。

⑤ 对照 MLX 源码

⑥ 常见坑与自测

自测 1:ncols = 100 时,t = 128,nsg 是多少?第二轮归约里每个 simdgroup 读 scratch 的哪几个槽?

nsg = (128 + 31) / 32 = 4。每个 simdgroup 的 lane 0–3 读 scratch[0..3],lane 4–31 填 init。4 个 simdgroup 各自做同样的归约,得到同样的结果。

自测 2:把 softmax 改成 online 的“两遍法”,第一遍同时维护什么?tg_reduce 要怎么改?

第一遍每个线程维护自己的 (m, s):遇到更大的值时 s = s·exp(m − m_new) + exp(x − m_new)(T2 第 ⑤ 节的公式)。归约时不能分别对 m 和 s 归约,要把两个 (m, s) 按 m = max(m₁, m₂)、s = s₁·exp(m₁ − m) + s₂·exp(m₂ − m) 合并,所以 tg_reduce 要改成归约一个二元组,simd_max / simd_sum 不能直接用,得用 simd_shuffle_xor 自己写。

源码与资料索引

本仓库:drillssolutions/x2_metal_softmax.py · drillsmine/x2_metal_softmax.py · drillstests/test_x2_metal_softmax.py · drillscontent/x2.md

MLX:mlxsoftmax.h · mlxrms_norm.metal · mlxsoftmax.cpp · mlxlayer_norm.metal

文档:Metal Shading Language Specification(SIMD-group functions、threadgroup_barrier 两节) · MLX · Custom Metal Kernels