02 · CUDA from a Systems Engineer’s Eyes · 报告 04

两个融合算子:RMSNorm 与 online softmax

第 10 章练的是「把长数组归约成标量」,归约就是终点。 这里练归约之后的一步:拿归约结果回头缩放整行。 「回头」有两条路:再读一次显存,或者把数据留在片上。两条路的带宽差 1.5 到 2 倍。 SGEMM 那一章在 roofline 的左半边(算力密集); 这两个算子在右半边(访存密集),指标从「占峰值算力」换成 MBU。 online softmax 的三行递推,就是 FlashAttention 的全部数学核心。 在这里弄懂,能省下 04 阶段(FlashAttention 前向)的两天。

实测(RTX 4090 · 2026-10-01):寄存器驻留的 N3/N4 与 S3/S4 全部达到峰值带宽的 90–91%(MBU),比基线(N1 两趟 / S1 三趟)分别快 1.51× 和 1.70×。 开跑前写下的十四条预测成立 9 条。落空的 5 条错在同一处: 字节账算的是「指令发出的字节」,耗时却取决于「到显存的字节」,两者之间隔着一层 72 MiB 的 L2。 融合省带宽,靠的不是少发访存指令,而是把重用距离压进 cache 装得下的范围(见 §9)。

为什么是这两个算子↑

SGEMM 那一章练的是 roofline 的左半边:算力密集,看「占峰值算力」。 这两个算子在右半边:访存密集,看 MBU。 roofline 图的两端各练一次,这套方法才完整。 它们也不是玩具:RMSNorm 是 Qwen2.5 每层调两次的真实算子; online softmax 的三行递推就是 FlashAttention 的全部数学核心。

一、第 10 章练的是归约,这里练的是「归约之后呢」↑

第 10 章的 R1–R6 只解决一个问题:把一个长数组变成一个标量。 归约就是终点,用 atomicAdd 写出结果就结束。

在真实算子里,归约几乎从来不是终点。 RMSNorm 先求一行的平方和,然后立刻用这个和回头缩放这一行的每个元素; softmax 先求一行的 max 和 exp 和,然后回头对每个元素做除法。 归约只是中间步骤,「回头」才是花钱的地方。

这个差别要求改两处写法

① 归约结果要广播给全 block,不能只汇到 0 号 lane。 R6 用 __shfl_down_sync,因为只有 0 号 lane 需要结果。 这里 256 个线程都要用归一化因子缩放自己手里的元素, 所以要换成 __shfl_xor_sync(蝶形):归约完每个线程各有一份。

② 「回头」有两条路:再读一次显存,或者把数据留在片上。 两条路的带宽差 1.5 倍到 2 倍, 这就是本文四级阶梯里最值钱的一级。

二、它们在 Qwen2.5-7B 里的真实位置↑

模型参数取自 Qwen2.5-7B 结构拆解: hidden 3584 · 28 层 · 28 Q 头 / 4 KV 头 · head_dim 128 · ffn 18944 · vocab 152064。

两个算子在一次前向里被调用多少次
算子位置每次前向调用作用的形状
RMSNorm input_layernorm + post_attention_layernorm,每层两个;最后再加一个 norm 28×2 + 1 = 57 次 [tokens, 3584]
softmax 每层注意力的打分归一化;最后一层之后还有一次 logits 采样 28 + 1 = 29 次 [heads×tokens, ctx]
logits 是 [batch, 152064]

在 7B 的 decode 里,这两个算子都不是瓶颈。 decode 一个 token 时,57 次 RMSNorm 一共搬 1.6 MB, 按 1008 GB/s 算要 1.6 微秒。同一步要把 15 GB 权重读一遍,要 15 毫秒。 RMSNorm 只占万分之一。

prefill 的 softmax 不一样:这个数要记住

上下文 4096、28 个头时,打分矩阵有多大?

28 头 × 4096 × 4096 × 4 字节 = 1.88 GB   // 每层,fp32

// 朴素三趟 softmax 要搬 16N/行:
16 × 4096 × (28 × 4096) = 7.52 GB  → 按 1008 GB/s 算 7.46 ms  // 每层
× 28 层 = 209 ms

// 就算用本文最好的 s3(8N/行):
3.76 GB → 3.73 ms/层 × 28 = 104 ms

这就是 FlashAttention 存在的理由。 它不是把 softmax kernel 写得更快, 而是根本不把打分矩阵写进显存:在 shared memory 里分块算完,就地消掉。 「分块算 softmax」能成立, 唯一的前提是 §5 要推的三行递推式。 所以本文的 s3 会输给「没有 softmax kernel」。这不是失败,是指向终点的路牌。

先把 16N 的单位说清楚

16N 的单位是「字节 / 行」,不是「访问 16 次」。 N 是一行的元素个数,fp32 每个元素 4 字节。 朴素三趟让整行经过显存 4 遍(3 读 + 1 写),所以 16N = 4 趟 × 4 字节 × N。

这本账只数必须经过显存的字节。 每行 4 字节的 mx[] / sm[] 中间数组待在 L2 里,不计入。

① 一层的打分矩阵,和它的「行」 4096 × 4096 fp32 28 个头 1.879 GB / 层 取出一行 —— softmax 沿这一维做 N = 4096 个元素 一趟扫完 = 4N = 16 KB 这样的行有多少? M = 28 头 × 4096 = 114,688 行 每行独立做一次 softmax ② 从「每行 16N」到「一次 prefill 209 ms」 16N = 64 KB 每行经过显存 × 114,688 7.516 GB 每层 ÷ 1008 GB/s 7.46 ms 每层 × 28 层 209 ms 一次 prefill 的下界 另一种数法:= 1.879 GB × 4 趟,同一张矩阵整体过显存 4 遍

图 1 · 16N 怎么变成 209 ms。 上半:一层的打分矩阵是 28 个 4096×4096 的头(1.879 GB), softmax 沿 key 维做,所以行长 N = 4096、行数 M = 28 × 4096 = 114,688。 下半:每行 16N = 64 KB,乘行数得 7.516 GB/层,除 4090 的峰值带宽 1008 GB/s 得 7.46 ms/层,乘 28 层得 209 ms。 这是 MBU = 100% 时的理论下界:假设 exp 不花时间,现实只会更慢。

三、评价指标换成 MBU↑

MBU = 实测带宽 ÷ 理论峰值带宽 = (搬运字节数 ÷ 耗时) ÷ 1008 GB/s

// 4090 的峰值:内存时钟 10501 MHz × 2(DDR 双沿)× 384 bit ÷ 8 = 1008 GB/s
// 和 sgemm_all.cu 里算 roofline 用的是同一个数,口径一致

和 SGEMM 那边的对应关系

SGEMM(算力密集)这两个算子(访存密集)
主指标占 FP32 峰值 / 占 cuBLASMBU
优化目标减少每个 FMA 摊到的 load 条数减少每个元素经过显存的次数
哪本账管用条数账(字节账失效五次)字节账 —— 这次它是主角
天花板0.25 周期/warp圈MBU 100%(现实里 80% 就很好)

SGEMM 那一章里字节账七次失效五次;到了这里,它是唯一靠谱的账。 账本没有对错,只有适用区间。 这正是那一章最后的结论,在这里从反面得到了印证。

字节账这一次它是主角↑

两条阶梯里,每一级理论上快多少倍,就是纯字节数之比。 实测接近理论值,说明分析对了;差得远,就要查是哪一本别的账在起作用。

一、RMSNorm:12N 还是 8N↑

y = x · rsqrt(mean(x²) + eps) · w        // N = 3584

一句话:把一行(一个 token 的 3584 维向量)整体缩放到固定长度,再逐维乘一个学出来的增益。 公式里只有三个动作:求均方、取倒数平方根、逐维相乘。

用 12 维画(真实是 3584 维)· 横线是 0,柱子有正有负,三行共用同一把尺子 ① 输入 x 一个 token 整行归约:mean(x²) = 4.0217 → r = rsqrt(mean(x²) + eps) = 0.4987 ← 一个标量,3584 维共用同一个 ② × r 整行同一个数 ② 的形状和 ① 一模一样,只是整体矮了一半 —— 乘的是同一个数。此刻整行的模恒等于 √N,与输入无关 w = 1.0 1.4 0.6 1.2 0.8 1.5 1.0 0.7 1.3 0.9 1.1 1.0 ③ × w 逐维不同 ③ 才改变形状:w 是学出来的,每一维一个增益,和这一行的内容无关

图 2 · RMSNorm 全部内容就是这三步。 ①→② 整行只做一件事:乘同一个标量 r(这组数据 r ≈ 0.5),所以形状不变,只是整体矮了一半。 归一后整行的模恒等于 √N(N = 3584 时约 59.87)。不管输入多大多小,输出的长度都一样, 这就是「Norm」的含义。②→③ 乘 w 才改变形状;w 是训练学出来的, 对这一行是常量。

和 LayerNorm 的区别:RMSNorm 把「减均值」整个砍掉了
LayerNormRMSNorm
要哪些统计量均值 μ 和 方差 σ² 只要均方 mean(x²)
公式(x − μ) / √(σ² + ε) · γ + β x · rsqrt(mean(x²) + ε) · w
可学参数增益 γ 和 偏置 β(两个 N 维向量) 只有 w(一个 N 维向量)
几何上在干什么先平移到原点,再归一到 √N 的球面 不平移,直接归一到 √N 的球面(方向完全不变)

今天的主流模型(Llama、Qwen、Mistral)全部用 RMSNorm。 砍掉「减均值」来自 Zhang & Sennrich 2019: 效果几乎不降,还省掉一个统计量和一遍减法。

这个算子为什么会有 12N 和 8N 之争

关键在 r:它是整行归约出来的一个标量, 要看完整行才能算出;算出后又要用它缩放整行的每一个元素。 所以「回头」有两条路: 把 x 从显存再读一遍(12N),或者第一遍读时就把 x 留在寄存器里(8N)。 下表的四级台阶量的就是这件事。

每行必须经过显存的字节数(fp32)
级做法读 x写 y合计相对 N1
N1两个 kernel:先算 rstd,再归一化 2 次 8N4N12N基准
N2融成一个 kernel,但 x 仍从 global memory 读两次 2 次 8N4N12N 1.00×
N3x 留在寄存器,只读一次 4N4N8N 1.50×
N4N3 + float4 4N4N8N 1.50×

权重 w 只有 4N = 14 KB,所有行共用, 一直待在 L1/L2 里,不计入显存流量。 N1 还要把长度为 M 的 rstd 数组写一次、读一次, 相对 12NM 可以忽略。

二、softmax:16N / 12N / 8N↑

softmax(x)_i = exp(x_i − m) / Σ_j exp(x_j − m)      // m = max(x),N = 上下文长度

一句话:把一行任意实数变成一个概率分布:全为正、和为 1、大小顺序不变。 注意力里这一行是「这个 query 对 N 个 key 的打分」; softmax 后变成「分给每个 key 多少注意力」。

用 12 维画(真实是 4096)· ① 的横线是 0,② 从 0 往上长 ① 输入 x 一行打分,有正有负 整行归约两次:先 m = max(x) = 1.5,才能开始累 s = Σ exp(x − m) = 4.2665 ← 两个标量,全行共用 ② exp(x − m) 全变成正数 = 1 减 max 是为了防溢出:x − m ≤ 0 恒成立,所以每根柱子都落在 (0, 1] 里,最高那根恰好 = 1 ③ ÷ s 变成概率 11.6% 23.4% 6.4% 9.5% 17.4% 7.1% 10.5% ③ 是一条总宽 = 1 的带子,段宽就是概率。窄段没标数:最窄那段只有 1.2%

图 3 · softmax 也是「先归约、再回头缩放整行」,只是归约出两个标量。 ①→② exp 把所有数变成正数,并把差距按指数放大: 输入里 1.5 和 0.8 只差 0.7,输出是 1.00 和 0.50,差了一倍。 ②→③ 除以同一个标量 s:整行乘同一个数,形状不变, 和 图 2 的 ①→② 是同一件事。 ③ 画成一条总宽 = 1 的带子:12 段宽度就是 12 个概率,正好铺满,这就是「归一化」。 均匀分布每段 8.3%;这里最高 23.4%,最低 1.2%。

和 RMSNorm 并排看 —— 两个算子是同一个骨架
RMSNormsoftmax
归约出什么一个标量 r 两个标量 m 和 s
再逐元素做什么× r,再 × w exp(x − m),再 ÷ s
输出的性质整行的模恒等于 √N 全为正,加起来恒等于 1
朴素实现要几趟2 趟 3 趟(多一趟求 max)
起步字节数12N 16N

softmax 为什么比 RMSNorm 多一级台阶

RMSNorm 的归约量 mean(x²) 一趟就能累出来; softmax 的 s = Σ exp(x − m) 却要先知道 m 才能开始算。 所以朴素实现只能扫三趟:求 max → 求 exp 和 → 归一化, 加上写回一共 16N。

多出的这一趟可以省掉。 靠的不是代码技巧,而是一个数学改写:online 递推让 max 和 sum 在一趟里同时推进。 下面 S1 → S2 这一级(16N → 12N)全部来自这里,推导在 §5。

每行必须经过显存的字节数(fp32)
级做法趟数合计相对 S1
S1三个 kernel:求 max → 求 exp 和 → 归一化 读 3 次 + 写 1 次16N基准
S2online 递推:一趟同时得到 (max, sum),再一趟归一化 读 2 次 + 写 1 次12N 1.33×
S3一趟,x 留寄存器 读 1 次 + 写 1 次8N 2.00×
S4S3 + float4 同上8N 2.00×
每一格 = 整行过显存一趟 = 4N 字节 读一趟 4N 写一趟 4N 0 4N 8N 12N 16N S1 三个 kernel max → sum → div 16N · 64 KB/行 基准 S2 online 递推 两个 kernel 12N · 48 KB/行 1.33× S3 / S4 一趟 x 留寄存器 8N · 32 KB/行 2.00× 理论最优 FlashAttention 打分矩阵不落 HBM 0 字节 —— 没有这个 kernel,所以没有这笔账(见 §5·五) S1 → S2 省的是「求 max」那一趟,靠数学改写(§5·二);S2 → S3 省的是「回头读」那一趟,靠寄存器驻留。 S4 = S3 + float4:字节数和 S3 完全相同,动的只是访存指令条数(§6·四)。

图 4 · 四级台阶,条形长度就是字节数。 每格是一趟 4N,格数就是趟数,条形总长就是那一级的 16N / 12N / 8N。 两级收益的来源完全不同:S1→S2 用递推消掉「先扫一遍求 max」(数学); S2→S3 把 x 留在片上,不再回头读(硬件)。FlashAttention 不在这把尺子上:它把整张矩阵从显存里拿掉了。

S1 → S2 省掉的那一趟靠数学改写,不靠代码技巧。 朴素做法要看完整行才知道 max,才能开始算 exp 和; online 递推让两者同时推进。 推导在 §5。

三、融合 ≠ 省带宽↑

N2 这一级是故意留的陷阱

N2 的字节数和 N1 完全一样,都是 12N。 按通常的说法,N2 已是「融合版」:N1 是两个 kernel,N2 是一个 kernel。

N2 只发射一次 kernel, 但算完 rstd 后又从 global memory 把同一行 x 读了第二遍, 显存流量一个字节都没省。 它只省下一次 kernel launch(几微秒) 和 rstd 数组的一写一读(各 4M 字节)。

真正省带宽的是 N2 → N3:把 x 留在寄存器里。 工程讨论里「融合」一词用得很松: 有人指合并 kernel,有人指数据留在片上。 这两件事的收益差一个数量级。 N1/N2/N3 三级并排跑一次,就能把它们分清。

积木从第 10 章的 R6 到这里↑

归约代码可以直接搬第 10 章 R6 的那一套,但有一个字要改。

一、down 还是 xor↑

// 第 10 章 R6:结果只汇到 0 号 lane,其余 lane 的值是垃圾
for (int off = 16; off > 0; off >>= 1)
    sum += __shfl_down_sync(0xffffffffu, sum, off);
if (lane == 0) atomicAdd(output, sum);        // 只有它要用

// 这里:256 个线程每一个都要拿归一化因子去缩放自己手上的元素
for (int off = 16; off > 0; off >>= 1)
    v += __shfl_xor_sync(0xffffffffu, v, off);
// 五轮之后每个 lane 手里都是全和 —— 少一个线程知道都不行

shuffle 指令做什么

一个 warp 有 32 个 lane。每个 lane 手里的 v 是它自己的一份寄存器, 别的 lane 看不见。 lane 之间交换数据,常规办法是绕道 shared memory: 一方写入,__syncthreads(),另一方读出。 shuffle 指令是唯一的捷径: 一个 lane 直接读另一个 lane 的寄存器,不经过 shared memory 和显存,也不用同步。

__shfl_xor_sync 只做一件事: 把伙伴手里的 v 取回来交给我。 它不做加法,代码里的 += 是你自己写的。 这是这行代码最容易看错的地方,图 5 拆开的就是这一步。

一条 __shfl_xor_sync(0xffffffffu, v, 4) —— 这里画 8 个 lane,真实 warp 是 32 个 000 001 010 011 100 101 110 111 lane 0 lane 1 lane 2 lane 3 lane 4 lane 5 lane 6 lane 7 我手里的 v 每 lane 一份 3 1 4 1 5 9 2 6 指令返回值 = 伙伴的 v 5 9 2 6 3 1 4 1 配对规则:伙伴 = 我的 lane 号 ⊕ 4,于是 0↔4、1↔5、2↔6、3↔7 —— 箭头双向,同一条指令里两边互相读 第二行是指令的返回值,不是和;加法在第三行才发生 v += 之后 这一步是你写的 3+5=8 1+9=10 4+2=6 1+6=7 5+3=8 9+1=10 2+4=6 6+1=7

图 5 · 这条指令只负责「取」,不负责「算」。 第一行是每个 lane 寄存器里的 v。执行一条 __shfl_xor_sync(..., v, 4) 之后, 每个 lane 拿到伙伴那一份 v(第二行)。第三行的加法由 += 完成。 分开看这三步,就能读懂 v += __shfl_xor_sync(0xffffffffu, v, off)。

四个位置分别是什么
位置写的是含义
第 1 个参数0xffffffffu 参与掩码:32 个 1,表示这个 warp 的 32 个 lane 全部参与。 哪一位是 0,就表示那个 lane 不参与,读它是未定义行为
第 2 个参数v 我交出去的值:伙伴读到的就是这一份。 这个变量我自己也要用,但在这个位置上它的角色是「交出去」
第 3 个参数off 伙伴是谁:伙伴的 lane 号 = 我的 lane 号 ^ off。 xor 版和 down 版只差这一处(down 版是 + off)
返回值— 伙伴那一份 v。不是和, 也不是我自己的值。要得到和,必须自己写 +=

函数名为什么必须带 _sync

从 Volta(sm_70)开始,GPU 有独立线程调度,同一个 warp 的 32 个 lane 不再保证锁步执行。 分支之后,各 lane 可能停在不同的指令上。 所以读伙伴的寄存器之前,必须先说明哪些 lane 要在这条指令上会合。 掩码回答的就是这个问题,硬件按掩码把这些 lane 重新收拢。

所以不带 _sync 的老版本 __shfl_xor 已经废弃 :为 sm_70 及以上编译时,它已经不再提供,直接用会编译报错。 另外,掩码里列出的 lane 必须全部真的执行到这一句。 把它写进只有一半 lane 会进入的 if 里,同样是未定义行为。

为什么 xor 一定是「两两互读」

因为异或是对合运算:(lane ^ off) ^ off == lane。 我算出伙伴是 p,p 按同一规则算出的伙伴正好是我。 所以「我读你、你读我」天然成对,一条指令完成一次双向交换。

// xor:对合,成对
lane 2 的伙伴 = 2 ^ 4 = 6
lane 6 的伙伴 = 6 ^ 4 = 2   // 正好读回来,互为伙伴

// down:加法不对合,单向
lane 2 读 lane 2 + 4 = 6
lane 6 读 lane 6 + 4 = 10  // 不是 2,而是往更远处要 —— 所以是漏斗,不是交换
8 个 lane 走一遍蝶形(真实 warp 是 32 个 lane、5 轮) lane 0 lane 1 lane 2 lane 3 lane 4 lane 5 lane 6 lane 7 初始 各知 1 个 3 1 4 1 5 9 2 6 轮① off=4 各知 2 个 8 10 6 7 8 10 6 7 轮② off=2 各知 4 个 14 17 14 17 14 17 14 17 轮③ off=1 各知 8 个 ✓ 31 31 31 31 31 31 31 31 三轮之后 8 个 lane 手里全是 31 = 3+1+4+1+5+9+2+6 —— 每一个都拿到了全和,不用再广播

图 6 · 每轮「我知道的 lane 数」翻一倍。 初始时每个 lane 只知道自己(1 个)。off=4 之后知道 2 个,off=2 之后 4 个, off=1 之后 8 个,正好覆盖全部。 8 = 2³ 要 3 轮,32 = 2⁵ 就要 5 轮,所以 off 从 16 起步、循环 5 次。 连线是完全对称的蝶形,没有哪个 lane 处在特殊位置。

同一组数据 x = [3, 1, 4, 1, 5, 9, 2, 6](真和 = 31)两个版本逐轮对照,红色是不能用的值
版本轮次L0L1L2L3 L4L5L6L7
—初始 3141 5926
xoroff=4 81067 81067
xoroff=2 14171417 14171417
xoroff=1 31313131 31313131
downoff=4 81067 1018 412
downoff=2 14171625 1430 824
downoff=1 31334139 4438 3248

两个版本用的是同一组数、同样三轮、同样三条 shuffle 指令。 区别在于:down 版里 lane 4–7 从第一轮起就向「warp 外面」要数 (越界时硬件返回自己的值),所以它们累加出的是没有意义的部分和。 只有 lane 0 那一格两边都是 31。这就是 R6 只敢让 lane 0 做 atomicAdd 的原因。

五轮为什么正好够:off 就是在逐位翻 lane 号

off 取 16、8、4、2、1,二进制正好是 10000、01000、 00100、00010、00001,每轮翻 lane 号的一个 bit。 lane 号有 5 个 bit。翻完这 5 个 bit,就能从任意一个 lane 走到任意另一个 lane。 以 lane 5(00101)为例:

lane 5 = 00101 的五个伙伴:每轮翻掉一个 bit
轮次偏移量偏移量二进制伙伴 lane 号 伙伴二进制这轮之后我掌握几个 lane
①1610000 21101012
②801000 13011014
③400100 1000018
④200010 70011116
⑤100001 40010032 ✓ 全覆盖

看着像数据竞争,其实不是

v += __shfl_xor_sync(0xffffffffu, v, off); 这一句里,v 既是读出去的源,又是写回来的目标。看上去像是我改了 v, 伙伴却读到了新值。 实际不会。这是一条指令:硬件先给 32 个 lane 的 v 取一份快照,交换完成后,各 lane 才各自做 +=。 所有 lane 读到的都是本轮开始时的值。 注意:这个保证只在 warp 内部成立。 跨 warp 必须走 shared memory + __syncthreads(),见下一节。

__shfl_down_sync__shfl_xor_sync
数据流漏斗,往 0 号 lane 收蝶形,两两全交换
第 1 轮谁配谁0←16, 1←17, …0↔16, 1↔17, …
结束后谁有全和只有 lane 0全部 32 个 lane
指令条数5 条5 条,一样

xor 版不多花钱。 两版都是五轮、每轮一条 shuffle。 xor 版只把「漏斗」换成「全交换」,代价相同,还省掉了之后再广播一次。 所以凡是归约后所有线程都要用结果的场合,直接用 xor。

二、block 级两段式归约↑

BLOCK = 256 = 8 个 warp。 warp 内部用 shuffle 归约,跨 warp 只能走 shared memory。

__inline__ __device__ float blockReduceSum(float v, float *smem) {
    const int lane = threadIdx.x & 31;
    const int warp = threadIdx.x >> 5;

    v = warpReduceSum(v);                      // ① 每个 warp 各归各的
    if (lane == 0) smem[warp] = v;             // ② 8 个结果落进 shared
    __syncthreads();                           // ③ 等 8 个都写完

    v = (threadIdx.x < (BLOCK >> 5)) ? smem[lane] : 0.0f;
    if (warp == 0) v = warpReduceSum(v);       // ④ 0 号 warp 把 8 个归成 1 个

    if (threadIdx.x == 0) smem[0] = v;         // ⑤ 放回 shared
    __syncthreads();                           // ⑥ 等它写完
    return smem[0];                            // ⑦ 全 block 一起读走
}
BLOCK = 256 线程 = 8 个 warp ① warp 内 5 条 shuffle warp 0 warp 1 warp 2 warp 3 warp 4 warp 5 warp 6 warp 7 32 线程 32 线程 32 线程 32 线程 32 线程 32 线程 32 线程 32 线程 ② 只有 lane 0 各交 1 个数 smem[0] smem[1] smem[2] smem[3] smem[4] smem[5] smem[6] smem[7] ③ __syncthreads() —— 等 8 个都写完 ④ warp 0 把这 8 个数再归约一次 lane 8..31 喂 0(加法的幺元)· 外面套着 if (warp == 0) ★ 此刻只有 warp 0 的 32 个线程知道全和 warp 1–7 的 224 个线程还不知道 —— shuffle 跨不了 warp,只能再过一次 shared ⑤ 线程 0 写 smem[0] → ⑥ __syncthreads() → ⑦ 全部 256 个线程一起读走

图 7 · 两段的分界就在 shared memory 这一层。 256 个线程里,真正经过 shared memory 的只有 8 个数(每个 warp 交一个), smem 也只占 8 个 float,其余都在寄存器里完成。 注意中间那条红线:④ 跑完,结果还只在 warp 0 手里。

④ 跑完,另外 224 个线程还不知道结果

§3 · 一 说过「xor 版五轮之后每个 lane 都有全和」。 那为什么还要 ⑤⑥⑦ 再广播一次? 因为第 ④ 步外面套着 if (warp == 0)。 只有 0 号 warp 的 32 个 lane 参与了那次归约, 另外 224 个线程连这条指令都没执行。 xor 的「全员都有」只在一个 warp 之内成立。

blockReduceSum 和第 10 章 R6 的对照:骨架 ①②③④ 相同,连 : 0.0f 那个幺元技巧也相同
R6 · 第 10 章这里 · 融合算子
warp 内用哪条__shfl_down_sync 漏斗 __shfl_xor_sync 蝶形
归约完给谁用只有 lane 0,它去做 atomicAdd 全部 256 个线程都要拿去缩放自己手上的元素
尾巴到 ④ 就结束 多了 ⑤⑥⑦ 把结果广播回去
__syncthreads()1 次 2 次
再往上还有没有 有:多 block → atomicAdd 汇总成一个标量(grid 级第三段) 没有:blockIdx.x 就是行号,一个 block 包一整行,行与行互不相干

两个 __syncthreads() 一个都不能少

③ 保证「写 shared memory」发生在「读 shared memory」之前。 少了它,0 号 warp 可能在 7 号 warp 写完之前就读 smem[7]。

⑥ 保证「0 号 warp 写完最终值」发生在「其余 warp 读它」之前。 少了它,1–7 号 warp 读到的是 ② 留下的旧值(某个 warp 的部分和,不是全和)。

这类错误不会崩溃,也不报错, 只会让结果偏一点点。 所以 run_fused.sh 里专门留了 compute-sanitizer --tool racecheck 这一步(它查什么,见 §8 · 三)。

第 ④ 步的 : 0.0f 是加法的幺元,不是随便填的。 8 个 warp 只用到前 8 个 lane,其余 24 个 lane 必须填一个 「参与运算但不改变结果」的值:求和用 0,求最大值用 -FLT_MAX, 求乘积用 1。填错幺元是这类代码里最隐蔽的错误。

三、联合归约:把 (m, s) 当成一个整体↑

online softmax 要同时归约两个量,而且两者不能各归各的: s 的合并方式依赖 m。

__inline__ __device__ void warpReduceMaxSum(float &m, float &s) {
    for (int off = 16; off > 0; off >>= 1) {
        float m_o = __shfl_xor_sync(0xffffffffu, m, off);   // 伙伴的 max
        float s_o = __shfl_xor_sync(0xffffffffu, s, off);   // 伙伴的 sum
        float m_n = fmaxf(m, m_o);                          // 新的公共基准
        s = s * __expf(m - m_n) + s_o * __expf(m_o - m_n);
        m = m_n;
    }
}

两边都先换算到新基准 m_n,再相加。 max 较小的一边,exp 因子小于 1,会把它的和缩小; 两边 max 相等时,两个 exp 都是 1,退化成普通加法。

两个常见疑问

「这个循环只跑一次?」不,它跑 5 轮:off 依次是 16、8、4、2、1。 它和 §3 · 一 图 6 是同一个蝶形, 只是每个格子从「一个标量」变成「一对 (m, s)」, 每轮也从 1 条 shuffle 变成 2 条(m、s 各一条)。

「一轮只碰一个伙伴,怎么能覆盖 32 个 lane?」 因为伙伴递过来的不是它自己那一个元素, 而是它已经汇总好的一摞。 第 k 轮结束时,每个 lane 手里的 (m, s) 已经概括了 2k 个 lane;2⁵ = 32,正好覆盖全 warp。图 9 拆的就是这一点。

一、循环体那一行算什么

每个 lane 手里的 s 不是裸和,而是「以我自己的 m 为基准」的和: s = Σ exp(x_j − m)。 我和伙伴的 m 通常不同, 所以两个 s 的量纲不同,不能直接相加。 必须先把两边换算到同一个新基准 m_n = max(m, m_o),再相加。那一行做的就是这件事。

拆开循环体那一行 —— 以上面 8 lane 例子的第 3 轮、lane 0 为例 s = s * __expf(m - m_n) + s_o * __expf(m_o - m_n); 新的公共基准 m_n = fmaxf(m, m_o) = max(5, 9) = 9 我这一半 m = 5,s = 1.5530 概括了 lane 0、2、4、6,基准是 5 × exp(m − m_n) = exp(5−9) = 0.0183 = 0.0284 伙伴那一半 m_o = 9,s_o = 1.0505 概括了 lane 1、3、5、7,基准是 9 × exp(m_o − m_n) = exp(9−9) = 1.0000 = 1.0505 ← max 大的一边,因子恒为 1 s = 0.0284 + 1.0505 = 1.0789 两个因子里必有一个是 exp(0) = 1,另一个 ≤ 1 —— 换基准只会把小的那边缩下去,永远不会溢出

图 8 · 「换算到新基准再相加」,一行代码三件事。 s 和 s_o 的基准是不同的 m,量纲不同,不能直接加。 取 m_n = max 之后,两边各乘 exp(自己的 m − m_n) 对齐量纲,再相加。 基准大的一边,因子恰好是 exp(0) = 1,所以被缩小的总是基准小的一边。 两边 m 相等时,两个因子都是 1,整行退化成普通加法。

二、一轮只碰一个伙伴,为什么能覆盖全 warp

8 个 lane 走一遍(真实 warp 是 32 个、5 轮)· 初始每个 lane 手里是 (x, 1) lane 0 lane 1 lane 2 lane 3 lane 4 lane 5 lane 6 lane 7 初始 各覆盖 1 个 lane0: {0} m = 3 m = 1 m = 4 m = 1 m = 5 m = 9 m = 2 m = 6 s = 1.000 s = 1.000 s = 1.000 s = 1.000 s = 1.000 s = 1.000 s = 1.000 s = 1.000 off=4 后 各覆盖 2 个 lane0: {0,4} m = 5 m = 9 m = 4 m = 6 m = 5 m = 9 m = 4 m = 6 s = 1.135 s = 1.000 s = 1.135 s = 1.007 s = 1.135 s = 1.000 s = 1.135 s = 1.007 off=2 后 各覆盖 4 个 lane0: {0,2,4,6} m = 5 m = 9 m = 5 m = 9 m = 5 m = 9 m = 5 m = 9 s = 1.553 s = 1.050 s = 1.553 s = 1.050 s = 1.553 s = 1.050 s = 1.553 s = 1.050 off=1 后 各覆盖 8 个 ✓ lane0: 全部 m = 9 m = 9 m = 9 m = 9 m = 9 m = 9 m = 9 m = 9 s = 1.079 s = 1.079 s = 1.079 s = 1.079 s = 1.079 s = 1.079 s = 1.079 s = 1.079 直接验算:max = 9,Σ exp(x_j − 9) = 1.0789 —— 和三轮之后每个 lane 手里的数完全一致

图 9 · 伙伴递过来的是「一摞」,不是「一个」。 数据仍是 §3 · 一 那组 x = [3,1,4,1,5,9,2,6],每个 lane 初始持有 (x, 1)。 off=4 那一轮,lane 0 的伙伴 lane 4 递来的是它自己的 (5, 1.000)。 到 off=2 那一轮,lane 2 递来的 (4, 1.135) 已经是 lane 2 和 lane 6 合并后的结果。 信息每轮翻一倍:1 → 2 → 4 → 8。真实 warp 里是 1 → 2 → 4 → 8 → 16 → 32,正好 5 轮。

一个会让整行变成 NaN 的坑

幺元不能写 -INFINITY。

m = -INFINITY, s = 0        // 没分到元素的线程
m_n = max(-inf, -inf) = -inf
m - m_n = (-inf) - (-inf) = NaN
s = 0 × NaN + 0 × NaN = NaN   // 然后 NaN 顺着归约树污染整行

// 换成 -FLT_MAX:
m - m_n = (-FLT_MAX) - (-FLT_MAX) = 0
s = 0 × exp(0) + 0 × exp(0) = 0   // 安全退化

什么时候会有线程分不到元素? 两种情况:N 不是 BLOCK 的整数倍时,最后一轮有线程闲着 (比如 ./fused_ops s3 64 1000 3:1000 = 3×256 + 232,最后一轮有 24 个线程闲着); N < BLOCK 时,有的线程一个元素也分不到。 所以脚本里专门有一条 N=1000 的边界测试。

阶梯一RMSNorm · 四级↑

每一级只动一个变量。 N1→N2 动 kernel 个数,N2→N3 动数据驻留位置,N3→N4 动访存指令宽度。 只有 N2→N3 改变字节数,所以理论上只有这一步能变快。

一、N1:两趟,两个 kernel↑

先把变量对上号:RMSNorm 只沿最后一维做,所以前两维可以直接拉平。 每个 token 用自己的 3584 个数独立归一化,与它在哪个 batch、哪个位置无关。

① 三维张量拉平成 M × N —— 归一化沿最后一维做,所以行与行互不相干 [1, 4096, 3584] batch · seq · hidden 拉平 行 0 = token 0 的 3584 个数 行 1 = token 1 的 3584 个数 ⋮ 行 4095 M = 4096 行 = batch × seq_len N = 3584 列 = hidden_size 行与行完全独立 ② 一行交给一个 block;一个 block 256 线程 = 8 个 warp block 0 = 行 0 block 1 = 行 1 ··· block 4095 = 行 4095 grid = M = 4096 个 block 128 个 SM 轮着跑,每个 SM 排约 32 个 warp 0 32 lane warp 1 32 lane warp 2 32 lane warp 3 32 lane warp 4 32 lane warp 5 32 lane warp 6 32 lane warp 7 32 lane 每个线程负责 3584 ÷ 256 = 14 个元素(正好整除) 归约时:warp 内用 shuffle,warp 之间走 shared —— 就是 §3 那两块积木

图 10 · 从张量到线程,四层对应关系。 blockIdx.x 是行号,即第几个 token;threadIdx.x 决定这个线程取行内哪些元素。 行与行完全独立,所以 grid 这一层不需要通信。 这是它和第 10 章 R6 最大的结构差别:R6 还要多一层 atomicAdd,汇总 block 间的结果。

五个指针 / 两个标量,按默认 ./fused_ops n1 4096 3584 算
变量形状大小是什么
M标量4096 行数 = token 数 = batch × seq_len
N标量3584 行长 = hidden_size(Qwen2.5-7B)
dxM × N58.7 MB 输入:4096 个 token 的 hidden 向量
dyM × N58.7 MB 输出,同形状
dwN14 KB 可学权重,所有行共用,每个 hidden 维一个增益。 它很小,一直留在 L1/L2 里,不计入字节账
dmM16 KB 每行的 rstd,只有 N1 有。N2/N3/N4 把两个 kernel 并成一个, rstd 留在寄存器里,不需要这个数组

命令行 ./fused_ops n1 4096 3584 里的两个数字就是 M 和 N。 n1 1 3584 对应 decode batch=1: M=1,整张卡只有一个 block 在工作(本节末尾的 callout 讲这件事)。

跨步:i = threadIdx.x; i += 256 轮 0 里 warp 0 的 32 个 lane 取到: 0 1 2 3 4 5 6 7 …31 32 个连续 float = 128 字节 = 正好 1 个 cache line ✓ 轮 1 取 xr[256…],轮 2 取 xr[512…],共 14 轮 一人一段:t 负责 xr[14t … 14t+13] 同一轮里 warp 0 的 32 个 lane 取到: 0 14 28 42 56 70 84 98 … 地址之间隔着 14 个 float → 一次访存要碰 14 个 cache line ✗ 同样的数据量,访存事务多十几倍 格子里的数字是该 lane 取到的 xr 下标。两种写法算出来的结果一模一样,差的只是「谁取哪一个」。

图 11 · 索引怎么映射到线程,决定的不是代码好不好看。 跨步循环 i = threadIdx.x; i += BLOCK 让 warp 的 32 个线程在同一轮落在 32 个连续 float = 128 字节 = 正好一个 cache line 上,访存完全合并。 (NVIDIA 的 cache line 是 128 字节,由 4 个 32 字节的 sector 组成。「一人一段」的写法里,32 个地址散在 1792 字节里,横跨 14 条 cache line。) 这和第 3 章 K1/K2「索引映射写反」的教训是同一回事。

rms_n1_pass1 拿到一行之后只做三件事:累加 → 归约 → 写回。 其中「归约」是两层嵌套的调用: blockReduceSum 内部调两次 warpReduceSum。 第一次 8 个 warp 并行各做各的,第二次只有 0 号 warp 做。

一个 block 跑完 rms_n1_pass1 的全过程(以 block 12 = 行 12 为例) 做什么(代码) 此刻数据在哪、是什么 ① 跨步累加 for (i=tid; i<N; i+=256) acc += v*v; 每个线程自己跑 14 轮 22.4 19.8 25.1 21.7 26.2 24.1 … 256 个私有 acc,全在寄存器里,彼此看不见 ② acc = blockReduceSum(acc, smem); ├─ warpReduceSum(acc)   8 个 warp 同时各做各的,不是排队   5 条 shuffle,全在寄存器,不碰 shared 8 个 warp 各得一个和(warp 内 32 个 lane 都持有) 743.1 738.4 747.0 740.6 745.3 … ├─ lane 0 写 smem[warp];__syncthreads() smem[0…7] ← 跨 warp 唯一的通道,只 8 个 float ├─ warpReduceSum(acc)  ← 只有 warp 0 执行 Σx² = 5943.2 └─ smem[0];__syncthreads();return smem[0]   广播给全 block 256 个线程现在都拿到了 5943.2 但 pass1 里只有 1 个线程用得上 ↓ ③ if (threadIdx.x == 0)   rstd[row] = rsqrtf(Σx² / N + eps); rstd[12] = rsqrt(5943.2 / 3584) = 0.7766 写进 dm[12],一个 float —— pass1 的全部产出 整个过程只有 ② 里那一次 __syncthreads() 是全 block 必须对齐的点;① 和 ③ 各线程互不打扰。

图 12 · 三个阶段,两层归约。 warpReduceSum 调了两次,角色完全不同: 第一次是 8 个 warp 并行各归各的,得到 8 个部分和;第二次是 0 号 warp 把这 8 个数收成 1 个。 中间必须经过一次 shared memory,因为 shuffle 跨不了 warp。 图中的数只是示意,但内部自洽:8 个 warp 的和相加是 5943.2, rsqrt(5943.2/3584) 是 0.7766。

换个角度:同一份代码,不同的层面

CUDA 最别扭的一点:你写的是一个线程的代码,运行时却有 256 个线程同时跑这一份。 所以读这三个函数,不能只看「它算什么」,还要对每一句问 「这一句作用在哪个层面」:每个线程各算各的,一个 warp 的 32 个线程一起, 还是整个 block 的 256 个线程一起。

grid · 4096 个 block block 之间:kernel 内没有屏障 —— 只能 atomicAdd,或者另起一个 kernel block · 256 线程 = 8 个 warp warp 之间:__shared__ 存数据 + __syncthreads() 对齐时间 warp 0 · 32 个 lane lane 之间:__shfl_xor_sync 数据在寄存器,不碰任何内存 thread:每个 lane 一份私有寄存器 warp 1 · 32 个 lane 同上,和 warp 0 互不干扰 warp 7 · 32 个 lane 同上 … 越往里越便宜:warp 内是寄存器直通;block 内要过一次 shared 并全员等齐;grid 之间只能重启一个 kernel。

图 13 · 三层作用域,三种通信手段。 __shared__ 和 __syncthreads() 都在中间的框里, 它们是 block 级的:每个 block 有自己独立的一份 smem, __syncthreads() 也只同步本 block 的 256 个线程。 往里,warp 内部用不着它们(shuffle 直接读寄存器); 往外,block 之间没有这种机制。这就是 N1 必须拆成两个 kernel 的原因。

同一个变量名,在 256 个线程眼里是三种不同的东西
写法有几份值一样吗代码里的例子
普通局部变量每线程一份(256 份) 各不相同 acc、v、i、lane
由 blockIdx 推出来的每线程一份(256 份) 全都相同 row、xr、yr
__shared__ 整个 block 只有一份 就是同一块内存 smem[0…7]

第二行最容易看错:这不叫「共享」,它们只是恰好相等。 row 也是普通寄存器变量,也有 256 份。 只是 blockIdx.x 对同一个 block 的所有线程都相同,所以算出来的值相同。

同一份代码,256 个线程同时跑 —— 但每一句作用的层面不一样 THREAD WARP BLOCK GRID / 单线程 rms_n1_pass1 __global__ void rms_n1_pass1(…) { GRID 4096 个 block 各跑一份,互不通信 __shared__ float smem[BLOCK / 32]; BLOCK 整个 block 只有这一份,8 个 warp 共用 const long long row = blockIdx.x; BLOCK 每线程一份,但 256 个值全相同 const float *xr = x + row * N; BLOCK 同上 —— 都指向本 block 那一行 float acc = 0.0f; THREAD 每线程一份,值各不相同 for (int i = threadIdx.x; i < N; i += BLOCK) THREAD 每个线程走一串不同的 i acc += xr[i] * xr[i]; THREAD 各加各的,谁也看不见谁 acc = blockReduceSum(acc, smem); WARP→BLOCK 层面在这里切换 —— 见下半张 if (threadIdx.x == 0) THREAD 256 个里只有 1 个为真 rstd[row] = rsqrtf(acc / N + eps); 单线程 整个 block 只落笔 1 次 } blockReduceSum —— 层面就是在这 8 行里切换的 v = warpReduceSum(v); WARP 8 个 warp 同时各归各的(并行,不是排队) if (lane == 0) smem[warp] = v; WARP 每个 warp 选 1 个代表,把结果交出来 __syncthreads(); BLOCK 全 block 屏障 —— 256 个线程必须都到 v = (tid < 8) ? smem[lane] : 0.0f; BLOCK 读那 8 个格子;其余 lane 喂幺元 0 if (warp == 0) v = warpReduceSum(v); WARP 只有 0 号 warp 干活,另外 7 个空转 if (tid == 0) smem[0] = v; 单线程 选 1 个代表,把全和放回 shared __syncthreads(); BLOCK 全 block 屏障 return smem[0]; BLOCK 256 个线程一起读走同一个值

图 14 · 逐行看:这一句到底在哪个层面上。 上半是 rms_n1_pass1:开头几句 256 个线程算出相同的值(定位到本行), 中间的累加每个线程各不相同,最后用一句 if 选一个代表落笔。 下半是 blockReduceSum:八行里层面来回切了四次: warp 内(shuffle)→ block(shared memory + __syncthreads())→ warp 内 → block。 这就是「两段式」的含义。

if (threadIdx.x == 0) 不是「汇总」,是「选代表」

汇总在它之前已经做完:blockReduceSum 返回时, 256 个线程每一个手里都有完整的 Σx²。 加这个 if,是因为接下来要往显存写 rstd[row], 那是一个地址、一个 float。 256 个线程都写,就是 256 次冗余写:值相同,结果不会错,但全是浪费。

这是并行编程的一个固定套路: 归约负责「算出来」,选代表负责「落笔」,两件事分开。 这个套路在 blockReduceSum 里出现了两次: if (lane == 0) smem[warp] = v; 是每个 warp 选一个代表交结果, if (threadIdx.x == 0) smem[0] = v; 是全 block 选一个代表。

选几个代表?—— lane == 0 和 threadIdx.x == 0 不是一回事

这是这段代码最容易看错的地方。 threadIdx.x 是整个 block 内的编号(0…255); lane 是每个 warp 内的编号(0…31,每个 warp 都从 0 重新数)。 两者的拆法如下:

const int lane = threadIdx.x & 31;   // 低 5 位 → 在本 warp 里排第几  0…31
const int warp = threadIdx.x >> 5;   // 高位   → 是第几个 warp      0…7
一个 block 的 256 个线程,按 warp 分成 8 组(深色竖条 = 每组的 lane 0) warp 0 tid 0–31 warp 1 tid 32–63 warp 2 tid 64–95 warp 3 tid 96–127 warp 4 tid 128–159 warp 5 tid 160–191 warp 6 tid 192–223 warp 7 tid 224–255 lane == 0 8 个线程 smem[0] smem[1] smem[2] smem[3] smem[4] smem[5] smem[6] smem[7] warp == 0 32 个线程 只有 warp 0 干活,另外 7 个 warp 空转 threadIdx.x == 0 只有 1 个线程 smem[0] threadIdx.x == 0 等价于「lane == 0 而且 warp == 0」,比 lane == 0 严格得多。

图 15 · 同样长得像「选 0 号」,选出来的人数差 8 倍。 lane == 0 在每个 warp 里都有一个,共 8 个线程。它们写的是 smem[warp],8 个不同的下标,所以不冲突。 threadIdx.x == 0 在整个 block 里只有一个,所以 smem[0] = v 只由一个线程执行。

三个条件,各命中多少线程
条件满足的线程编号几个线程在代码里干什么
lane == 0 0, 32, 64, 96, 128, 160, 192, 224 8 个 每个 warp 选一个代表,把本 warp 的和写进 smem[warp]
warp == 0 0 … 31 32 个 只让 0 号 warp 收那 8 个数(只算,不写 shared memory)
threadIdx.x == 0 只有 0 1 个 全 block 选一个代表,把最终结果放回 smem[0]

④ 那行有一个隐含前提:warp 数 ≤ 32。这个前提永远成立。 有了它,⑤ 用一个 warp 就能收完。BLOCK = 256 只有 8 个 warp; CUDA 也限制一个 block 最多 1024 线程(= 32 个 warp)。

与之相对:__syncthreads() 绝不能放进分支

「选代表」可以写在 if 里, __syncthreads() 绝对不行。 它要求 block 里每一个线程都到达; 少一个就是未定义行为:可能挂死,也可能结果悄悄偏一点。 所以代码里两次 __syncthreads() 都写在 if 外面, if (lane == 0)、if (warp == 0) 里一个都没有。 如果把它「优化」进 if (warp == 0) { … } 里,就会触发这种未定义行为。

两个容易忽略的细节

① smem 为什么当参数传进去? __shared__ float smem[BLOCK/32] 在 kernel 里声明, 归约函数只是借用。这样 blockReduceSum 能被四级台阶反复调用, 由调用方决定这块 shared memory 何时分配、给谁用。

② pass1 里那次广播是多余的。 blockReduceSum 结尾的 smem[0] → __syncthreads() → 全员读走,是为了让 256 个线程都拿到结果; 但 pass1 只有 thread 0 用它。 这是共用同一个 helper 的代价。 真正需要全员拿到结果的是 N2/N3/N4,那里每个线程都要用 r 缩放自己负责的元素。 这点开销相对一行 14 KB 的显存流量可以忽略, 但它解释了为什么第 10 章 R6 只用一次 __syncthreads()。

两趟之间靠 dm 接力

pass1 里只有 thread 0 写 dm[row]; pass2 里 256 个线程读同一个地址 dm[row],硬件广播一次就够。 总账对得上:读 x 两次 + 写 y 一次 = 12N × M = 168 MB, 再加 dm 往返的 32 KB(占 0.02%,可忽略)。

下面把 §3 的两块归约积木、两个 kernel 和调用方式一次贴全,方便连续读完。 四级台阶共用同一份 blockReduceSum,后面 N2/N3/N4 不再重复贴。 完整文件见 §8 · 二。

// ═══ 积木:warp 内 + 跨 warp 的两段式求和(§3 · 一、二)══════════════════
static const int BLOCK = 256;                  // 8 个 warp

__inline__ __device__ float warpReduceSum(float v) {
    #pragma unroll
    for (int off = 16; off > 0; off >>= 1)
        v += __shfl_xor_sync(0xffffffffu, v, off);   // 蝶形,5 轮后全员持有全和
    return v;
}

__inline__ __device__ float blockReduceSum(float v, float *smem) {
    const int lane = threadIdx.x & 31;
    const int warp = threadIdx.x >> 5;
    v = warpReduceSum(v);                      // ① 每个 warp 各归各的
    if (lane == 0) smem[warp] = v;             // ② 8 个结果落进 shared
    __syncthreads();                           // ③ 等 8 个都写完
    v = (threadIdx.x < (BLOCK >> 5)) ? smem[lane] : 0.0f;  // 加法的幺元
    if (warp == 0) v = warpReduceSum(v);       // ④ 0 号 warp 把 8 个归成 1 个
    if (threadIdx.x == 0) smem[0] = v;         // ⑤ 放回 shared
    __syncthreads();                           // ⑥ 等它写完
    return smem[0];                            // ⑦ 全 block 一起读走
}

// ═══ N1 第一趟:只算每行的 rstd,写进一个长度 M 的小数组 ══════════════════
__global__ void rms_n1_pass1(const float *__restrict__ x, float *__restrict__ rstd,
                             int N, float eps) {
    __shared__ float smem[BLOCK / 32];
    const long long row = blockIdx.x;          // 一个 block 负责一行
    const float *xr = x + row * (long long)N;  // (long long) 防 M·N 溢出 int

    float acc = 0.0f;
    for (int i = threadIdx.x; i < N; i += BLOCK) {   // 跨步循环,天然合并
        float v = xr[i];
        acc += v * v;
    }
    acc = blockReduceSum(acc, smem);
    if (threadIdx.x == 0)
        rstd[row] = rsqrtf(acc / N + eps);     // rsqrtf 是硬件指令,一条搞定
}

// ═══ N1 第二趟:把 x 再读一遍,乘上 rstd 和权重,写出去 ═══════════════════
__global__ void rms_n1_pass2(const float *__restrict__ x, const float *__restrict__ w,
                             const float *__restrict__ rstd, float *__restrict__ y, int N) {
    const long long row = blockIdx.x;
    const float *xr = x + row * (long long)N;
    float       *yr = y + row * (long long)N;
    const float r = rstd[row];                 // 整个 block 读同一个地址 → 广播
    for (int i = threadIdx.x; i < N; i += BLOCK)
        yr[i] = xr[i] * r * w[i];               // ← 第二次读 x,12N 的根源
}

// ═══ 怎么调 ═════════════════════════════════════════════════════════════
// 显存四块:dx / dy 各 M×N 个 float;dw 长度 N(权重);
//          dm 长度 M —— 就是 rstd,两趟之间靠它传值
CUDA_CHECK(cudaMalloc(&dm, M * sizeof(float)));

// grid = M(行数,一个 block 一行),block = BLOCK = 256
// 全文里这两行在 launch 那个 lambda 的 switch (kind) 的 case K_N1 分支下
// 两个 kernel 都不需要动态 shared,所以 <<<>>> 里只写两个参数
rms_n1_pass1<<<M, BLOCK>>>(dx, dm, N, eps);           // 读 x        → 写 rstd
rms_n1_pass2<<<M, BLOCK>>>(dx, dw, dm, dy, N);        // 读 x+rstd+w → 写 y

// 注意:两次发射之间不用写任何同步。同一个 stream 上,后发的 kernel
// 一定在前一个完全结束之后才开始 —— pass2 读到的 rstd 必然是全的。

代码里那两个关键字

__inline__ 是 C++ inline 的 GNU / CUDA 写法,nvcc 预定义了它。 它不是 CUDA 特有的加速开关,这里写它主要是习惯。 有三处容易误解:

__inline__ 到底做了什么
常见误解实际
inline = 一定内联 inline 在 C++ 里是链接性关键字;要不要真内联,编译器自己决定
写它是为了消掉调用开销 __device__ 函数 nvcc 默认就几乎总是内联 (除非递归、通过函数指针调用、或标了 __noinline__),写不写一样
不写会出错 单文件编译不会。它真正必要的场合是放在头文件里被多个 .cu 包含 —— 那时需要 inline 链接性来避免 multiple definition

要明确指定,用 __forceinline__(强制) 或 __noinline__(禁止)。

但到了 N3,「真的被内联」至关重要

N3 让每个线程把自己负责的元素(最多 32 个 float)存进 xs[MAXS],这个数组必须留在寄存器里。 如果 blockReduceSum 真以函数调用的形式存在, 寄存器分配就要在调用点前后保住 xs[],很可能被迫 spill 进 local memory(也就是显存), 「数据留在片上」的前提就不成立了。 所以这里需要真内联。 但这要归功于 nvcc 对 __device__ 的默认行为,不是这个关键字;要确保内联,应写 __forceinline__。

__restrict__:一个你对编译器的承诺

含义:在这个指针的作用域内,它指向的内存只通过它访问,不和别的指针重叠。 它是 C99 restrict 的 C++ 写法 (C++ 标准没有 restrict,各编译器用 __restrict__ 扩展)。

没有 __restrict__ 编译器只知道「三个 float*」 重叠? x(读) y(写) w(读) 显存地址 → → 写 yr[i] 之后,xr / w 可能已经变了 → 每轮必须重新 load,不能提前、不能批量 → 不敢走只读缓存 有 __restrict__ 你承诺:三块互不重叠 x(读) y(写) w(读) 显存地址 → → load 可以提前、跨迭代批量取 → const + __restrict__ ⇒ 走只读缓存(LDG.E.CI) → w 只有 14 KB、全 block 反复读,最划算 编译器不检查这个承诺 哪天做 in-place、把 y 和 x 传成同一个指针 —— 未定义行为,不报错,结果悄悄错

图 16 · __restrict__ 让编译器敢做的事。 没有它,编译器必须假设 yr[i] = xr[i] * r * w[i] 里的写可能改到 xr 或 w (它们都是 float*),于是每轮都要重新 load,也不能走只读缓存。 这份代码里 dx / dy 来自两次独立的 cudaMalloc,承诺成立。

const 和 __restrict__ 要成对使用,缺一不可。 nvcc 只有在能证明「这块数据在整个 kernel 执行期间只读」时, 才生成走只读数据缓存的 load(LDG.E.CI,即 __ldg() 的路径,sm_35 起)。 const 说明「我不写它」,__restrict__ 说明「别人也不会通过别名写它」。 两条都有,编译器才用这条路径。

const float *__restrict__ x    // __restrict__ 在 * 右边 → 修饰指针本身
                               // const 在 * 左边       → 修饰指向的数据
float *const x                 // 这才是「指针本身不可改」,完全两码事

一个 block 一行,合适吗

N=3584,BLOCK=256 → 每个线程负责 14 个元素,循环 14 轮。 行数 M 就是 grid 大小。M=8192 时有 8192 个 block 排队, 128 个 SM 轮流执行,填得很满。 但 decode batch=1 时 M=1: 整张卡只有一个 block 在工作,另外 127 个 SM 空闲。 §7 的形状扫描测的就是这件事。

二、N2:融成一个 kernel,字节数一个都没省↑

__global__ void rms_n2(const float *__restrict__ x, const float *__restrict__ w,
                       float *__restrict__ y, int N, float eps) {
    __shared__ float smem[BLOCK / 32];
    const long long row = blockIdx.x;
    const float *xr = x + row * (long long)N;
    float       *yr = y + row * (long long)N;

    float acc = 0.0f;
    for (int i = threadIdx.x; i < N; i += BLOCK) {
        float v = xr[i];                       // ← 第一次读
        acc += v * v;
    }
    const float r = rsqrtf(blockReduceSum(acc, smem) / N + eps);
    for (int i = threadIdx.x; i < N; i += BLOCK)
        yr[i] = xr[i] * r * w[i];             // ← 第二次读,同样的地址
}

// ═══ 怎么调:和 N1 的唯一区别是只发一次 ═════════════════════════════════
rms_n2<<<M, BLOCK>>>(dx, dw, dy, N, eps);              // 读 x 两次 → 写 y

// 参数表里没有 dm 了 —— 不再需要那个长度 M 的中间数组,也省掉一次 kernel launch。
// 但 x 仍然从显存读了两遍,字节数一个都没省,还是 12N。
合并掉的是 kernel,不是访存 N1 两个 kernel 读 x(4N)→ 归约 → r thread 0 写 rstd(4 B) dm[] 显存 读 rstd → 读 x(4N) → 读 w 写 y(4N) 两次 launch,中间整个 grid 必须先全部结束 N2 一个 kernel 读 x(4N)→ 归约 → r 留在寄存器里 → 读 x(4N) → 读 w → 写 y(4N) dm[] 整个消失了 —— r 再也不经过显存 一次 launch 两行里那个红色的「读 x(4N)」都还在 —— 这就是为什么字节账纹丝不动。

图 17 · N2 拿掉的是中间那个小方块,不是两头的读写。 rstd 从「显存里长度为 M 的数组」变成「寄存器里的一个 float r」, 省掉一次 launch 和 dm[] 的往返。 但 xr[i] 仍然出现两次:第一趟算平方和,第二趟算输出, 同一个地址从显存读两次。字节账的大头没变。

每行的字节账,逐项对齐着看
级第一趟读第二趟读写回中间数组往返合计
N14N4N4N 8 字节12N + 8 B
N24N4N4N —12N

代码变短了,kernel 少了一个,但 xr[i] 仍出现两次。 显存流量还是 12N,与 N1 相同。

那 N2 到底省了什么

省了三样,都很小: ① 一次 kernel launch(几微秒;M=8192 时分摊到每行几乎为零); ② rstd 数组的一写一读(4M 字节,约为 12NM 的万分之一); ③ 第二趟不必等第一趟的整个 grid 结束(两个 kernel 之间有隐式同步)。

只有 ③ 可能有一点量, 而且只在 M 小、grid 填不满时才明显。 预测:M=8192 时 N2 比 N1 快 1.00×–1.05×。

跑完之后:这条预测错了,而且错得很有价值

实测 N2 比 N1 快 1.49×, 不是 1.00×–1.05×。 上面那张表没算错:xr[i] 确实出现两次, 访存指令确实发出了 12N 字节。 错在把「指令发出的字节」当成了「显存实际搬运的字节」。

两次读之间,这个 block 只读了自己这一行, 共 14 KB,数据还在 L2 里。反推下来,N2 实际搬的是 8.1N,和 N3 的 8.0N 几乎一样, 所以耗时也几乎一样(0.2591 ms vs 0.2560 ms)。 完整的追查过程见 §9 · 三。 先不要改上面的账: §7 的预测要保持原样,这是这套方法的规矩。

三、N3:把 x 留在寄存器 —— 这一级才动字节账↑

// 每线程最多存多少个 float。32 个 float = 32 个寄存器,离 255 的上限还远,不会 spill。
static const int MAXS = 32;                    // 单行最长 = BLOCK × MAXS = 8192

__global__ void rms_n3(const float *__restrict__ x, const float *__restrict__ w,
                       float *__restrict__ y, int N, float eps) {
    __shared__ float smem[BLOCK / 32];
    const long long row = blockIdx.x;
    const float *xr = x + row * (long long)N;
    float       *yr = y + row * (long long)N;

    float xs[MAXS];                            // 本线程负责的元素,全放寄存器
    float acc = 0.0f;
    #pragma unroll
    for (int c = 0; c < MAXS; ++c) {           // ← 固定次数,常量下标
        int i = threadIdx.x + c * BLOCK;
        float v = (i < N) ? xr[i] : 0.0f;      // 越界喂 0,不影响平方和
        xs[c] = v;
        acc += v * v;
    }
    const float r = rsqrtf(blockReduceSum(acc, smem) / N + eps);
    #pragma unroll
    for (int c = 0; c < MAXS; ++c) {
        int i = threadIdx.x + c * BLOCK;
        if (i < N) yr[i] = xs[c] * r * w[i];   // ← 直接用寄存器,不回显存
    }
}

// ═══ 怎么调:发射方式和 N2 一模一样 ═════════════════════════════════════
rms_n3<<<M, BLOCK>>>(dx, dw, dy, N, eps);              // 读 x 一次 → 写 y

// 但多了一个前提,必须在 host 侧先查 —— 整行得塞得进寄存器:
const bool needReg = (kind == K_N3 || kind == K_N4 || kind == K_S3 || kind == K_S4);
if (needReg && N > BLOCK * MAXS) {                 // 8192
    printf("✗ 这一级把整行存在寄存器里,单行最长 %d,而 N=%d。\n",
           BLOCK * MAXS, N);
    return 3;                                  // 不是实现偷懒,是硬件约束(§7·三)
}
① 同样一行数据,住在哪里 N2 读两遍 显存 xr[i] 读 ① v → acc,然后丢掉 读 ② xr[i] → y 同一个地址读了两遍 12N N3 读一遍 显存 xr[i] 读 ① xs[0…31] 32 个寄存器,一直留着 acc y 算完直接用,不回显存 12N → 8N,这一级才真的省 ② 但下标必须是编译期常量,否则这一级当场作废 ✗ xs[cnt++](下标运行期才算出来) → 编译器只好塞进 local memory(物理就在显存) → ptxas 报 spill stores   后果:x 还是读了两遍,只不过第二遍从 local 读。字节数一点没省,而你从耗时上完全看不出原因。 ✓ xs[c] 配 #pragma unroll(展开后 xs[0] xs[1] … 全是常量下标) → 真的分到 32 个寄存器 → 8N 达成

图 18 · N3 改的不是算法,是数据住在哪里。 N2 第一趟读进来的 v 用完就丢,所以第二趟只能回显存读; N3 把整行摊在 xs[MAXS] 上一直留着,归约完直接用寄存器里的值算输出。 这是四级台阶里唯一改变字节数的一步(12N → 8N,理论 1.5×)。 下半张是它的前提:只要下标不是编译期常量,xs 就进不了寄存器,这一级就白做了。

一个让整级失效、从耗时上看不出原因的坑

循环必须写成"固定 MAXS 次 + 边界判断",不能写成跨步循环配 xs[cnt++]。

// 错的写法(看起来更自然,但会毁掉这一级)
int cnt = 0;
for (int i = threadIdx.x; i < N; i += BLOCK) {
    xs[cnt++] = xr[i];                     // 下标是运行期算出来的
    ...
}

// 对的写法
#pragma unroll
for (int c = 0; c < MAXS; ++c) {              // 展开后 xs[0] xs[1] … 全是常量下标
    int i = threadIdx.x + c * BLOCK;
    ...
}

原因:GPU 的寄存器不能动态寻址。 下标只要不是编译期常量,编译器就不能把 xs 放进寄存器, 只能放进 local memory,而 local memory 物理上就在显存里。

后果:x 还是读了两次, 只是第二次从 local memory 读。字节数没省,1.5× 不会出现, 而且从耗时上看不出原因。 唯一的线索在 ptxas -v 的输出里:N bytes spill stores。 所以 run_fused.sh 第 0 步要检查 spill。

代价是单行长度有上限:N ≤ BLOCK × MAXS = 256 × 32 = 8192。 超过就放不下。程序会直接拒绝并说明原因,不会悄悄 spill。 这个限制不是实现偷懒, 它和 FlashAttention 必须分块是同一个物理限制,§5 · 五 细说。

四、N4:float4,字节不变,只动条数↑

// float4 版:8 个 float4 也是 32 个 float。两条路径的寄存器压力刻意做成一样,
// 这样 N3→N4 就是干净的单变量对照 —— 只动指令条数,不动寄存器。
static const int MAXV = 8;                     // MAXV × 4 = MAXS = 32

__global__ void rms_n4(const float *__restrict__ x, const float *__restrict__ w,
                       float *__restrict__ y, int N, float eps) {
    __shared__ float smem[BLOCK / 32];
    const long long row = blockIdx.x;
    const int n4 = N >> 2;                        // 一行有多少个 float4

    // reinterpret_cast 把 float* 重新解释成 float4*,地址必须 16 字节对齐 ——
    // cudaMalloc 返回的指针是 256 字节对齐的,而 row*N 只要 N%4==0 就仍然对齐。
    const float4 *xr = reinterpret_cast<const float4 *>(x + row * (long long)N);
    float4       *yr = reinterpret_cast<float4 *>(y + row * (long long)N);
    const float4 *wr = reinterpret_cast<const float4 *>(w);

    float4 xs[MAXV];                           // 8 个 float4 = 32 个寄存器,和 N3 一样
    float acc = 0.0f;
    #pragma unroll
    for (int c = 0; c < MAXV; ++c) {
        int i = threadIdx.x + c * BLOCK;
        float4 v = (i < n4) ? xr[i] : make_float4(0.f, 0.f, 0.f, 0.f);
        xs[c] = v;
        acc += v.x * v.x + v.y * v.y + v.z * v.z + v.w * v.w;
    }
    const float r = rsqrtf(blockReduceSum(acc, smem) / N + eps);
    #pragma unroll
    for (int c = 0; c < MAXV; ++c) {
        int i = threadIdx.x + c * BLOCK;
        if (i >= n4) continue;
        float4 v = xs[c], g = wr[i], o;        // 权重也按 float4 读
        o.x = v.x * r * g.x;  o.y = v.y * r * g.y;
        o.z = v.z * r * g.z;  o.w = v.w * r * g.w;
        yr[i] = o;                             // 一条 STG.128 写回 4 个 float
    }
}

// ═══ 怎么调:和 N3 一字不差 ═════════════════════════════════════════════
rms_n4<<<M, BLOCK>>>(dx, dw, dy, N, eps);              // 读 x 一次 → 写 y

// 除了 N3 那条寄存器长度的守卫,还多一条对齐守卫:
const bool needVec4 = (kind == K_N4 || kind == K_S4);
if (needVec4 && (N & 3)) {                          // N 不是 4 的倍数
    printf("✗ float4 版要求 N 是 4 的倍数(当前 N=%d)\n", N);
    return 3;
}
① 一个线程要搬的 32 个 float —— 两种装法(图里只画前 16 个) N3 float xs[32] 一格 = 1 个 float = 4 字节 32 条 LDG.E N4 float4 xs[8] .x .y .z .w .x .y .z .w .x .y .z .w .x .y .z .w 一框 = 1 个 float4 = 16 字节 8 条 LDG.E.128 同样 32 个 float、同样 32 个寄存器 —— 只是打包方式不同,MAXV × 4 = MAXS ② 一个 warp 发一条访存指令,搬多少 N3 32 lane × 4 字节 = 128 字节 = 1 条 cache line 搬完 32 个 float 要发 32 条 N4 32 lane × 16 字节 = 512 字节 = 4 条 cache line 搬完 32 个 float 只发 8 条

图 19 · 搬的字节一个不多不少,发的指令少了 3/4。 两边都是完全合并的访存,每字节的效率相同, 差别只在「同样多的字节,用几条指令搬完」。 所以字节账不变(还是 8N)。能不能变快要看另一本账: 访存指令的发射是否已成瓶颈。如果 N3 的 MBU 已经很高,说明瓶颈在带宽而不在指令, N4 就不会变快。这本身就是一个判据。

两个读入循环到底差在哪

两边的 int i = threadIdx.x + c * BLOCK; 一字不差 ,但 N3 里 i 是「第几个 float」, N4 里是「第几个 float4」。 这是全部差异的来源,其余几处差异都由它引出。

同一行 3584 个 float,两种扫法 —— 同一个 c,扫过的范围差 4 倍 N3 14 轮有效 c0 c1 c2 c3 c4 c5 c6 c7 c8 c9 c10 c11 c12 c13 每轮:256 个线程各取 1 个 float → 256 个连续 float = 1024 字节 (循环写了 32 次,后 18 次越界喂 0) N4 4 轮 c = 0 c = 1 c = 2 c = 3 每轮:256 个线程各取 1 个 float4 → 1024 个连续 float = 4096 字节 (写了 8 次,后 4 次越界) 同一个 i = threadIdx.x + c * BLOCK,两边数的东西不一样(以线程 0 为例) c = 0 c = 1 c = 2 c = 3 N3 i = 第几个 float float #0 #256 #512 #768 N4 i = 第几个 float4 float #0 #1024 #2048 #3072 同一个 i,落点差 4 倍

图 20 · 同一个 c,N4 扫过的范围是 N3 的 4 倍。 所以轮数从 14 降到 4。两边都是完全连续的访存,合并程度相同, 区别只在一条指令覆盖多宽。 下半张最能说明问题:c = 1 时两边的 i = 0 + 1×256 相同,N3 落在 float #256,N4 落在 float #1024。

逐行对照 —— 只有第二行是因,其余都是果
位置N3N4为什么
循环次数MAXS = 32MAXV = 8 一次取 4 个,轮数 ÷ 4
i 的含义 第几个 float第几个 float4 ← 根源,表达式却一字不差
上界i < N(3584)i < n4(896) 上界也得换成 float4 的个数
指针类型const float *const float4 * reinterpret_cast 来的,要求 16 字节对齐
一次取多少4 字节 · 一条 LDG.E 16 字节 · 一条 LDG.E.128 字节总数不变,指令条数 ÷ 4
越界喂什么0.0f make_float4(0,0,0,0) 4 个分量都要喂加法的幺元
怎么累加acc += v * v acc += v.x*v.x + … + v.w*v.w 一条乘加拆成四条
xs[c] 占几个寄存器 1 个4 个 所以 float4 xs[8] 和 float xs[32] 都是 32 个寄存器

一个容易忽略的不对称

N3 里 3584 ÷ 256 = 14 正好整除,每个线程都取 14 个 float。 N4 里 n4 = 896,而 896 ÷ 256 = 3.5: 线程 0–127 取 4 个 float4(16 个 float), 线程 128–255 只取 3 个(12 个 float)。 128×16 + 128×12 = 3584 ✓ 总数对得上,只是负载不再均匀。 差半轮,对这个受访存限制的 kernel 可以忽略。 但它解释了 MAXV = 8 为什么要留一倍余量:3.5 轮向上取整是 4, 余下的空间留给更长的行。

N3 → N4 只动了中间那一列
级每行字节每线程访存指令每线程寄存器单行上限
N38N 96 条32 读 x + 32 读 w + 32 写 y 328192
N48N 24 条8 + 8 + 8,每条宽 4 倍 328192

MAXV = 8 是故意选的:8 个 float4 = 32 个 float,与 N3 的 MAXS = 32 相同。 两条路径的寄存器压力相同, N3→N4 才是干净的单变量对照:只动指令宽度,不动寄存器数。

对齐要求,以及为什么这里是安全的

float4 访存要求地址 16 字节对齐。 cudaMalloc 返回的指针 256 字节对齐; 行首地址是 base + row·N·4,只要 N % 4 == 0,行首仍 16 字节对齐。 程序启动前检查 N 是否为 4 的倍数,不是就拒绝运行。 3584 % 4 == 0 ✓,2048 % 4 == 0 ✓。

预测:N4 比 N3 快约 1.0×–1.15×。 字节数没变,理论上不该变快。如果快一点, 原因是访存指令更少、内存请求更少(4 条 32 位并成 1 条 128 位), 即 SGEMM 那边的「条数账」和「请求账」。 但这两本账只在带宽没跑满时才有空间: 如果 N3 的 MBU 已经很高,N4 应该几乎不变快。这一条本身就是判据。

推导online softmax 的三行递推↑

这一节没有 CUDA,只有数学。 但它是整份报告最该花时间的一节:FlashAttention 的全部核心就是这三行。 在这里花两小时弄懂,能省下 04 阶段(FlashAttention 前向)的两天。

一、为什么要减 max↑

softmax(x)_i = exp(x_i) / Σ_j exp(x_j)       // 教科书定义

直接照着算会溢出。 fp32 能表示的最大值约 3.4×10³⁸,而 exp(89) 就超了。 注意力打分经过 1/√d 缩放后通常在 ±20 以内,但训练不稳或上下文很长时可能超过 90。 一旦溢出,结果就是 inf / inf = NaN。

softmax(x)_i = exp(x_i − m) / Σ_j exp(x_j − m)    // m = max(x),恒等变形

// 为什么相等:分子分母同乘 exp(−m),约掉了
// 为什么安全:x_i − m ≤ 0,所以 exp(·) ∈ (0, 1],永远不会溢出

减 max 的代价是多扫一趟。 要减 max,就得先知道 max; 要知道 max,就得把整行看完 —— 于是朴素实现必须扫三趟。

朴素三趟 · 每一趟都要把整行从显存读一遍
趟做什么为什么不能和上一趟合并
① 求 maxm = max(x)—
② 求和s = Σ exp(x_j − m)要用 ① 的 m
③ 归一化y_i = exp(x_i − m) / s要用 ② 的 s

二、递推式怎么来的↑

换个做法:不等看完整行,边扫边维护一对 (m, s)。 约定 m_k 是前 k 个元素的最大值, s_k = Σ_{j≤k} exp(x_j − m_k) 是以当前最大值为基准的部分和。

现在来了第 k+1 个元素 x。新的最大值是 m' = max(m_k, x)。 但 s_k 的基准是旧的 m_k。基准变了,s_k 就不能直接加。

先把两个记号分清楚 —— 同一批数 x_1 … x_k,用不同基准记账,得到的是不同的数:

s_k      = Σ_{j≤k} exp(x_j − m_k)      // 手里已经有的,旧基准 m_k
s_k(m')  = Σ_{j≤k} exp(x_j − m')       // 想要的,新基准 m'

两者只差一个常数倍 —— 下面五步就是把这个倍数找出来。

// ① 在指数里加个零(−m' + m'),什么都没改变
    x_j − m_k  =  x_j − m' + m' − m_k

// ② 重新分组:一半随 j 变,一半是常数
               =  (x_j − m')  +  (m' − m_k)
                  └ 随 j 变 ┘     └ 与 j 无关 ┘

// ③ 用 exp(a + b) = exp(a) · exp(b) 拆开
    exp(x_j − m_k)  =  exp(x_j − m') · exp(m' − m_k)

// ④ 对 j 求和 —— 常数可以提到求和号外面
    s_k  =  Σ_{j≤k} exp(x_j − m_k)
         =  Σ_{j≤k} [ exp(x_j − m') · exp(m' − m_k) ]
         =  exp(m' − m_k) · Σ_{j≤k} exp(x_j − m')
            └ 提出来的公因子 ┘     └──── 这就是 s_k(m') ────┘
         =  exp(m' − m_k) · s_k(m')

// ⑤ 反解出我们真正想要的 s_k(m'):两边同除 exp(m' − m_k)
    s_k(m')  =  s_k / exp(m' − m_k)
             =  s_k · exp(m_k − m')       // ← 指数反号了!
                      ↑ 这才是代码里那个缩放因子

第 ⑤ 步是最容易卡住的地方

④ 里提出来的公因子是 exp(m′ − m_k), 而代码里用的是 exp(m_k − m′) —— 两者指数正好相反。 不矛盾:④ 是「把新账乘成旧账」,⑤ 是「把旧账除成新账」, 除以 exp(m′ − m_k) 就等于乘以 exp(m_k − m′)。 我们手里有旧账 s_k,要的是新账,所以代码里出现的一定是 exp(m_k − m′)。因为 m′ ≥ m_k,它恒 ≤ 1。

拿 x = [1, 2]、旧基准 m_k = 2、新基准 m′ = 5 验一遍
量怎么算值
旧账 s_kexp(1−2) + exp(2−2) 1.367879
缩放因子exp(m_k − m′) = exp(−3) 0.049787
换算结果1.367879 × 0.049787 0.068103
直接按新基准算exp(1−5) + exp(2−5) 0.068103 ✓
为什么一定差个常数倍 —— 换个角度看 T = exp(1) + exp(2) = 10.107 不依赖任何基准的「真实和」 代码里绝不能真算它 exp(x) 正是要避免的那个溢出 × exp(−m_k) = exp(−2) × exp(−m′) = exp(−5) s_k = 1.3679 以 m_k = 2 记账 s_k(m′) = 0.0681 以 m′ = 5 记账 × exp(m_k − m′) = exp(−3) = 0.0498 两者之比 exp(−5) ÷ exp(−2) = exp(2 − 5) = exp(m_k − m′) —— 那个缩放因子就是这么来的。 s 永远是「真实和 T」乘上 exp(−基准);换基准,换的就是这个乘数。

图 21 · 基准只是记账单位,量本身没变。 同一批数的「真实和」T 是固定的;s 不过是把它乘上 exp(−基准) 缩到安全范围里。 换基准 = 换这个乘数,比例自然是两个 exp 之商。 T 只是帮助思考的中间量,代码里绝不能真去算。 §5 · 三 证结合律时还会再用一次这个技巧。

把换算好的旧账,和本来就以 m′ 记账的新项加起来,就是那两行递推:

m' = max(m_k, x)
s' = s_k · exp(m_k − m') + exp(x − m')
     └─── 旧账换算到新基准 ───┘   └─ 新项,本来就以 m' 记账 ─┘

递推只有这两行。 直观含义:遇到更大的 max,就把已经累计的和按比例"缩"回去。 缩放因子 exp(m_k − m') 一定 ≤ 1,因为 m' ≥ m_k。 没遇到更大的 max 时,m' = m_k,因子是 exp(0) = 1,退化成普通累加。

1.0 0 ① 基准 m = 2 已扫过 x = [1, 2] 0.368 1.000 exp(1−2) exp(2−2) s = 1.368 × exp(2−5) = 0.0498 ② 换基准到 m′ = 5 旧项被整体缩小 0.018 0.050 exp(1−5) exp(2−5) s = 0.068 + exp(5−5) = 1.000 ③ 加上新元素 5 s′ = 0.068 + 1 0.018 0.050 1.000 exp(1−5) exp(2−5) exp(5−5) s = 1.068 ② 里那两根条已经几乎归零 —— 在新基准下,比 max 小很多的项自动退出竞争。这既是「缩」的含义,也是减 max 几乎不损精度的原因。

图 22 · 「缩回去」是什么意思。 纵轴是每一项 exp(x_j − 基准) 的值,三块共用一把尺子(虚线是 1.0)。 扫到更大的元素 5 时,基准从 2 换成 5,所有旧项乘上同一个因子 exp(2−5) = 0.0498(②), 然后新项 exp(5−5) = 1 才能安全地加进去(③)。 因子恒 ≤ 1,因为 m′ ≥ m;没遇到更大的 max 时因子是 exp(0) = 1,退化成普通累加。

把 x = [1, 2, 5, 3] 用递推走一遍 —— 最后一列是「直接按最终基准算」的校验值,两边必须逐行相等
步骤新的最大值缩放因子新的部分和独立校验
初始−∞—00
加 11—exp(0) = 1.00001.0000
加 22exp(1−2) = 0.36791·0.3679 + 1 = 1.36791.3679
加 55exp(2−5) = 0.04981.3679·0.0498 + 1 = 1.06811.0681
加 35exp(5−5) = 11.0681 + exp(3−5) = 1.20341.2034

递推一路只维护两个标量,却和「先扫完整行拿到 max、再从头累加」得到同一个数。 最后一行的校验:exp(1−5) + exp(2−5) + exp(5−5) + exp(3−5) = 0.0183 + 0.0498 + 1 + 0.1353 = 1.2034。 「加 3」这一步 max 没变,因子退化成 1,就是普通累加。

三、结合律:为什么能做树形归约↑

这是最容易被跳过、但绝不能跳过的一步

上面的递推是串行推出来的:一个一个往里加。 但 GPU 上用的是蝶形归约:256 个线程两两合并,合并顺序和串行扫描完全不同。 为什么结果还一样? 因为这个合并运算满足结合律。不证明这一点,整个 kernel 的正确性就是悬空的。

先把合并运算写成一般形式(不再限定第二个操作数是单个元素):

(m₁, s₁) ⊕ (m₂, s₂) = ( max(m₁, m₂),
                         s₁·exp(m₁ − max) + s₂·exp(m₂ − max) )

先看式子在干什么

(m, s) 不是「两个数」,它是一整段数据的摘要。 一段数据 A = {x₁ … x_k} 可能有几千个数, 压成摘要就只剩两个 float:m = max(A),s = Σ exp(x_j − m)。

而 ⊕ 回答的问题是: 已知 A 的摘要、B 的摘要,能不能直接算出 A∪B 的摘要 —— 不回头翻原始数据? 能,公式就是它。

(m, s) 不是两个数 —— 是一整段数据的摘要 原始数据(可能几千个数) 摘要(就 2 个 float) A = {1, 2} 压缩 (2, 1.3679) B = {5, 3} 压缩 (5, 1.1353) ⊕ A ∪ B = {1, 2, 5, 3} 压缩 (5, 1.2034) 左边那条路:把 4 个数从头看一遍。右边那条路:只碰两份摘要,一共 4 个 float。 结果一模一样 —— 这就是 ⊕ 存在的全部意义。

图 23 · 摘要能合并,所以原始数据可以扔掉。 这正是 warp 归约和 FlashAttention 共同依赖的性质: 32 个 lane 各自摘要自己那一摞,两两 ⊕ 就能得到全 warp 的摘要, lane 之间传的是 2 个 float 而不是几千个; FlashAttention 的每个分块算完摘要就把分块丢掉,块与块之间也靠 ⊕。

式子的两个分量,各用一句话就能说清。 式子里 max 出现三次,指的是同一个数,先给它起个名:

令 m = max(m₁, m₂)

(m₁,s₁) ⊕ (m₂,s₂) = (  m  ,  s₁·exp(m₁−m) + s₂·exp(m₂−m)  )
                       ↑              ↑                  ↑
                  合并后的最大值   A 的账换到新基准    B 的账换到新基准

// 第一个分量:两堆数合起来的最大值,就是两个最大值里更大的那个 —— 显然
// 第二个分量:s₁ 以 m₁ 记账、s₂ 以 m₂ 记账,量纲不同不能直接加,
//             所以两边都先换算到新基准 m 再加(§5·二 那个换基准公式,这次两边都要换)

// 代进上面的例子:
exp(m₁ − m) = exp(2−5) = 0.0498      // m 小的那边,被缩下去
exp(m₂ − m) = exp(5−5) = 1.0000      // m 大的那边,因子恒为 1

两个因子里必有一个是 exp(0) = 1,另一个 ≤ 1 —— 所以这一步永远不会溢出。

同一个运算,两种用法
第二个操作数用在哪
§5 · 二 的递推一个元素 (x, 1) 串行扫描:一个一个往里加
一般形式 ⊕任意一段的摘要 (m₂, s₂) 蝶形归约、跨 warp 合并、FlashAttention 分块合并
串行扫描 · 一个一个往里加,顺序固定 ⊕ 1 ⊕ 2 ⊕ 5 ⊕ 3 (−∞, 0) 幺元 · 空集 (1, 1.000) 前 1 项 (2, 1.368) 前 2 项 (5, 1.068) 前 3 项 (5, 1.203) 全部 4 项 蝶形归约 · 两两合并,顺序和串行完全不同 —— 每个元素自成一段 (x, 1) (1, 1) (2, 1) (5, 1) (3, 1) ⊕ ⊕ (2, 1.368) (5, 1.135) ⊕ (5, 1.203) = 和串行扫描同一个答案 ⊕ 在 (max, T) 表示下就是「取 max」和「求和」的并排组合 —— 两者都可结合可交换,所以任意合并顺序结果相同。

图 24 · 两条完全不同的合并路径,同一个答案。 数据是 x = [1, 2, 5, 3]。上排是串行扫描(代码里 sm_s2_ms 那个 for 循环, 每个线程在自己的 strided 片段上干的事);下排是蝶形归约(blockReduceMaxSum 干的事, 256 个线程两两合并)。两条路径的中间状态并不对应—— 蝶形的右子树 (5, 1.135) 是「后两个元素单独合并」的结果, 串行扫描里从来没有出现过这个状态。可根节点一样,靠的就是 ⊕ 的结合律。 这半页证明不做,kernel 的正确性就是悬空的。

证法一:合出来的,就是并集的摘要

图 23 已经证完了结合律的一大半。 图 23 对应一条引理: 两段各自的摘要合并起来,正好等于「这两段并在一起」那一整段的摘要。 把引理当作已知,剩下的证明只有三行:

// 引理(就是图 23,直接代定义验算即可)
    摘要(A) ⊕ 摘要(B)  =  摘要(A ∪ B)

// 于是两种括号法分别是
    ( 摘要A ⊕ 摘要B ) ⊕ 摘要C  =  摘要(A∪B) ⊕ 摘要C  =  摘要( (A∪B) ∪ C )
    摘要A ⊕ ( 摘要B ⊕ 摘要C )  =  摘要A ⊕ 摘要(B∪C)  =  摘要( A ∪ (B∪C) )

// 而集合的并当然可结合
    (A∪B) ∪ C  =  A ∪ (B∪C)

// 两边摘要的输入是同一个集合,输出自然相同
    (摘要A ⊕ 摘要B) ⊕ 摘要C  =  摘要A ⊕ (摘要B ⊕ 摘要C)        // ∎

整个证明归结到「集合的并可结合」,这一点无可怀疑。 不管你怎么加括号,合出来的都是「这几段并在一起」 那一整段的摘要;而并在一起,跟你怎么分组无关。

这个证法的适用范围 —— 以及为什么还有另一个

它只覆盖「确实是某个集合的摘要」的那些 (m, s)。 对 kernel 来说完全够用:每个 lane 一开始拿的就是自己那摞的摘要, 幺元 (−∞, 0) 是空集的摘要,合并出来的还是摘要 —— 永远跑不出这个范围。

下面折叠里那个证法对任意 (m, s) 都成立,代价是要多绕一圈。 它的价值在别处:以后要并行归约别的东西 (top-k、方差、logsumexp 的变体)时,需要一套可靠办法判断「这个算子能不能乱序合并」。 「找一个到已知可结合结构的同态」就是那个通用办法。

证法二:把结合律从 (max, +) 搬过来 —— 更严密,也更通用选读

证明思路:给 (m, s) 换一套坐标 —— 换到一个不依赖基准的表示上去。 对任意一段数据 A,无论基准 m 取什么,下面这个量

T(A) = s · exp(m) = Σ_{j∈A} exp(x_j)      // 真正的"无偏指数和"

都是同一个数——基准 m 只是为了防溢出而人为引入的,乘回去就消掉了。

先把要证的东西摆清楚

// 换坐标的映射
φ(m, s) = ( m , s·exp(m) ) = ( M , T )

// 新坐标里的运算:两个分量各管各的
(M₁, T₁) ⊞ (M₂, T₂) = ( max(M₁, M₂) , T₁ + T₂ )

// 要证:⊕ 可结合。路线四步 ——
①  φ 是单射                     // 能原路还回来
②  φ(A ⊕ B) = φ(A) ⊞ φ(B)       // φ 把 ⊕ 翻译成 ⊞
③  ⊞ 可结合                      // 显然,但要说
④  ①+②+③  ⟹  ⊕ 可结合

① φ 是单射 —— 这一步不能省

给定新坐标 (M, T),可以唯一还原回去:
    m = M
    s = T · exp(−M)

所以   φ(m₁,s₁) = φ(m₂,s₂)  ⟹  (m₁,s₁) = (m₂,s₂)

为什么非要这一步

② 只能推出「两种括号法算出来的 T 相同」。 可我们真正要的是「两种括号法算出来的 (m, s) 相同」—— 这是代码里实际存的东西。 从「T 相同」跳到「(m,s) 相同」, 靠的正是 φ 可逆。 少了这句,证明链是断的。

② φ 把 ⊕ 翻译成 ⊞

令 m = max(m₁, m₂)。按 ⊕ 的定义
    A ⊕ B = ( m , s₁·exp(m₁−m) + s₂·exp(m₂−m) )

// 第一个分量
    M(A ⊕ B) = m = max(m₁, m₂) = max( M(A), M(B) )        // ⊞ 的第一分量 ✓

// 第二个分量 —— 关键是 m 会被完全消掉
    T(A ⊕ B) = [ s₁·exp(m₁−m) + s₂·exp(m₂−m) ] · exp(m)
             = s₁·exp(m₁−m)·exp(m) + s₂·exp(m₂−m)·exp(m)   // 乘进括号
             = s₁·exp(m₁−m+m)      + s₂·exp(m₂−m+m)        // exp(a)exp(b)=exp(a+b)
             = s₁·exp(m₁)          + s₂·exp(m₂)
             = T(A) + T(B)                                // ⊞ 的第二分量 ✓

⟹  φ(A ⊕ B) = φ(A) ⊞ φ(B)

第二个分量里的 m 一进一出,被彻底消掉。 这就是「T 不依赖基准」的全部含义。 ⊕ 看着复杂,是因为它在「带基准」的坐标里干活;换到 T 坐标,它就是一个普通加法。

③ ⊞ 可结合、可交换

⊞ 的两个分量互不干涉:第一个分量是 max,第二个是 +。 而 max 和 + 都既可结合又可交换,所以逐分量组合出来的 ⊞ 也是。 可交换这一条对蝶形同样要紧 —— 一对 lane 谁读谁、谁在左谁在右,结果不能变。

④ 拼起来

φ( (A⊕B) ⊕ C )  =  φ(A⊕B) ⊞ φ(C)          // 由 ②
                 =  ( φA ⊞ φB ) ⊞ φC        // 由 ②
                 =  φA ⊞ ( φB ⊞ φC )        // 由 ③ ← 唯一用到结合律的地方
                 =  φA ⊞ φ(B⊕C)             // 由 ②
                 =  φ( A ⊕ (B⊕C) )          // 由 ②

两端 φ 的值相同,而 φ 是单射(①),所以

    (A⊕B) ⊕ C  =  A ⊕ (B⊕C)                        // ∎
② 说的就是这个方块「怎么绕都一样」 (m₁, s₁) 和 (m₂, s₂) 带基准的坐标 —— 代码里存的就是它 ⊕ (m, s) φ φ φ(m, s) = ( m , s·exp(m) ) 可逆:s = T·exp(−M) (M₁, T₁) 和 (M₂, T₂) 不带基准的坐标 —— 只用来想,不用来算 ⊞ max 和 + 各管各的 (M, T) 两条路 —— 先 ⊕ 再 φ,还是先 φ 再 ⊞ —— 落到同一个格子。这就是「φ 是同态」。 再加上 φ 可逆,就能把下面那层现成的结合律,原封不动搬回上面那层。

图 25 · 换坐标,是为了把难证的搬到好证的地方。 下面那层的结合律是白送的(max 和 + 谁都知道可结合); 上面那层才是我们真正要的,但直接证很别扭。 φ 的作用就是在两层之间架桥 —— 横着走(先合并再换坐标)和绕着走(先换坐标再合并)结果一致, 桥又是双向的(可逆),于是下层的性质原样抬到上层。

幺元:为什么是 (−∞, 0)

取 e = (−∞, 0),对应 T(e) = 0 —— 也就是空集。

验证 e ⊕ (m, s):令 M = max(−∞, m) = m
    s′ = 0·exp(−∞ − m) + s·exp(m − m)
       = 0·0            + s·1
       = s
⟹  e ⊕ (m, s) = (m, s)   // 另一侧同理,交换律已证

数学上 −∞ 没有问题,但在浮点里 (−∞) − (−∞) = NaN。 所以代码里用 -FLT_MAX 顶替 —— 那个坑和修法见 §3 · 三。

拿三个单元素段验一遍:A=(1,1)、B=(2,1)、C=(5,1)
算法中间结果最终结果
先左后右(A⊕B)⊕C A⊕B = (2, 1.367879) (5, 1.068102707)
先右后左A⊕(B⊕C) B⊕C = (5, 1.049787) (5, 1.068102707)
两者之差 中间结果完全不同 0.0 逐位相同 ✓

再核对 T:exp(1)+exp(2)+exp(5) = 158.520497, 而 1.068102707 × exp(5) = 158.520497 —— 两种括号法算出来的 (m, s) 还原出同一个 T,正是 ② 说的那件事。

(T 只是证明用的中间量,代码里绝不能真的去算它 —— exp(m) 恰恰就是我们一开始要避免的那个溢出。 保持 (m, s) 的形式,是为了让每一步的中间值都留在安全区间里。)

四、数值上到底安不安全↑

表达式取值范围会不会出事
exp(x − m')(0, 1] 不会。x ≤ m' 恒成立
exp(m_k − m')(0, 1] 不会。m' ≥ m_k 恒成立
s[0, N] 不会。最多 N 个不超过 1 的数相加,N=152064 也远没到 fp32 上限
下溢exp(−100) ≈ 4e-44 会,但无害 变成 0,而它本来就该被忽略

唯一真正的风险是 s = 0 导致除零。 但 s 至少包含 exp(m − m) = 1 这一项 (最大的那个元素),所以 s ≥ 1,永远不会是 0。 前提是这一行至少有一个元素 —— 空行是调用方的错,kernel 不负责。

五、和 FlashAttention 的关系↑

把 §5 · 三 证明的结合律和 §4 · 三 那个"单行最长 8192"的约束放在一起看, FlashAttention 就是必然结果。

推理链说明
① 打分矩阵太大 上下文 4096、28 个头,一层就是 1.88 GB。朴素三趟要在显存里搬它 4 遍(7.52 GB),每层 7.46 ms;最优的 S3 也要写一次、读一次(3.76 GB),每层 3.73 ms
② 所以不能落显存 必须在 shared memory 里算完就消掉。而 shared memory 每个 SM 只有 100 KB
③ 所以必须分块 一次只能处理一小块打分(比如 128×128),算完丢掉,再算下一块
④ 分块就要能合并 softmax 而 softmax 的分母是全行的和 —— 看不到全行怎么归一化?
⑤ online 递推给出了答案 每块算出自己的 (m, s), 用 ⊕ 合并;结合律保证合并结果和"看完整行再算"完全一致

所以这份报告的 S3 注定要输,而这正是重点

S3 已经是"每个元素只过一次显存"的理论最优了(8N),但它仍然要把整个打分矩阵写出来再读回。

朴素三趟 S1     7.52 GB/层  →  7.46 ms  →  ×28 层 = 209 ms
本文最优 S3     3.76 GB/层  →  3.73 ms  →  ×28 层 = 104 ms
FlashAttention  0 GB        →  打分矩阵根本不存在

把 kernel 写快两倍,不如根本不写这个 kernel。 这是整份报告最该带走的一句话, 也是"算子级优化"和"算法级优化"的分水岭。 而你能跨过这条分水岭,靠的就是 §5 · 三 那半页证明。

阶梯二online softmax · 四级↑

这条阶梯和 RMSNorm 那条对称,但多一级:朴素版要三趟。

一、S1:三个 kernel,16N↑

S1 和 N1 是同一个套路:趟与趟之间只能靠 kernel 边界同步, 中间结果存在长度 M 的小数组里。 softmax 要传两个中间量(max 和 sum), 所以 S1 有三个 kernel、两个小数组。

// ═══ 积木一:两个 warp 级归约,只差一个运算符 ═════════════════
__inline__ __device__ float warpReduceMax(float v) {
    #pragma unroll
    for (int off = 16; off > 0; off >>= 1)
        v = fmaxf(v, __shfl_xor_sync(0xffffffffu, v, off));   // ← 取大
    return v;
}
__inline__ __device__ float warpReduceSum(float v) {
    #pragma unroll
    for (int off = 16; off > 0; off >>= 1)
        v += __shfl_xor_sync(0xffffffffu, v, off);            // ← 求和,其余一模一样
    return v;
}

// ═══ 积木二:block 级求和 —— 结尾那三行是广播 ══════════════
__inline__ __device__ float blockReduceSum(float v, float *smem) {
    const int lane = threadIdx.x & 31;
    const int warp = threadIdx.x >> 5;
    v = warpReduceSum(v);                      // ① 8 个 warp 并行各归各的
    if (lane == 0) smem[warp] = v;             // ② 每 warp 交一个
    __syncthreads();                           // ③ 等 8 个都写完
    v = (threadIdx.x < (BLOCK >> 5)) ? smem[lane] : 0.0f;  // 加法的幺元
    if (warp == 0) v = warpReduceSum(v);       // ④ warp 0 收尾
    if (threadIdx.x == 0) smem[0] = v;         // ⑤ ┐
    __syncthreads();                           // ⑥ ├ 广播给全 block
    return smem[0];                            // ⑦ ┘
}

// 注意:这份代码里没有 blockReduceMax —— 求 max 那边是直接写在 sm_s1_max 里的,
//       而且故意省掉了 ⑤⑥⑦,因为只有 thread 0 要用。下面对照着看。

// ═══ ① 求 max:整行扫一遍,每行吐出一个 float ═════════════════════════
__global__ void sm_s1_max(const float *__restrict__ x, float *__restrict__ mx, int N) {
    __shared__ float smem[BLOCK / 32];
    const long long row = blockIdx.x;
    const float *xr = x + row * (long long)N;

    float m = -FLT_MAX;                            // 求 max 的幺元(不能用 -INFINITY,§3·三)
    for (int i = threadIdx.x; i < N; i += BLOCK) m = fmaxf(m, xr[i]);

    // 两段式归约,但这里手写、没有广播尾巴 —— 只有 thread 0 要用结果
    const int lane = threadIdx.x & 31, warp = threadIdx.x >> 5;
    m = warpReduceMax(m);                      // warp 内,8 个 warp 并行
    if (lane == 0) smem[warp] = m;             // 每 warp 交一个
    __syncthreads();
    m = (threadIdx.x < BLOCK / 32) ? smem[lane] : -FLT_MAX;   // 其余 lane 喂幺元
    if (warp == 0) m = warpReduceMax(m);       // warp 0 收尾
    if (threadIdx.x == 0) mx[row] = m;         // 全 block 只落笔一次
}

// ═══ ② 求 exp 和:要用 ① 的 m,所以必须等 ① 整个 grid 跑完 ═══════
__global__ void sm_s1_sum(const float *__restrict__ x, const float *__restrict__ mx,
                          float *__restrict__ sm, int N) {
    __shared__ float smem[BLOCK / 32];
    const long long row = blockIdx.x;
    const float *xr = x + row * (long long)N;
    const float m = mx[row];                   // 256 个线程读同一个地址 → 广播

    float s = 0.0f;
    for (int i = threadIdx.x; i < N; i += BLOCK)
        s += __expf(xr[i] - m);                  // ← 第二遍读 x
    s = blockReduceSum(s, smem);               // 复用 §3 的 helper(带广播尾巴)
    if (threadIdx.x == 0) sm[row] = s;
}

// ═══ ③ 归一化:要用 ② 的 s ═══════════════════════════════════════════
__global__ void sm_s1_div(const float *__restrict__ x, const float *__restrict__ mx,
                          const float *__restrict__ sm, float *__restrict__ y, int N) {
    const long long row = blockIdx.x;
    const float *xr = x + row * (long long)N;
    float       *yr = y + row * (long long)N;
    const float m = mx[row], inv = 1.0f / sm[row];   // 先取倒数,循环里就不用除法

    for (int i = threadIdx.x; i < N; i += BLOCK)
        yr[i] = __expf(xr[i] - m) * inv;           // ← 第三遍读 x;exp 又算了一遍
}

// ═══ 怎么调 ═════════════════════════════════════════════════════════════
// 两个长度 M 的小数组接力:dm 存每行的 max,ds 存每行的 Σexp
CUDA_CHECK(cudaMalloc(&dm, M * sizeof(float)));
CUDA_CHECK(cudaMalloc(&ds, M * sizeof(float)));

sm_s1_max<<<M, BLOCK>>>(dx, dm, N);                     // 读 x        → 写 dm
sm_s1_sum<<<M, BLOCK>>>(dx, dm, ds, N);                  // 读 x + dm   → 写 ds
sm_s1_div<<<M, BLOCK>>>(dx, dm, ds, dy, N);              // 读 x + dm+ds→ 写 y

// 三次发射之间不用写同步 —— 同一个 stream 上后发的 kernel
// 一定在前一个整个 grid 结束之后才开始。这正是趟与趟之间唯一的同步手段。
三趟,三个 kernel,靠两个长度 M 的小数组接力 ① sm_s1_max for i: m = fmaxf(m, xr[i]) → 两段式归约求 max 读 x 4N 写 dm 4 B dm[row]:这一行的 max kernel 边界 —— 跨 block 唯一的同步手段 ② sm_s1_sum for i: s += __expf(xr[i] − m) → 归约求和 读 x 4N 读 dm、写 ds 各 4 B ds[row]:这一行的 Σ exp kernel 边界 ③ sm_s1_div for i: yr[i] = __expf(xr[i] − m) × (1/s) 读 x 4N 写 y 4N 合计:读 x 三遍 + 写 y 一遍 = 16N / 行 (两个小数组一行共往返 8 B,相对 16N 可忽略)

图 26 · 三个 kernel,两个小数组,三趟读 x。 dm 和 ds 的作用和 N1 里的 dm 相同: 把中间结果写到显存,只为了跨过 kernel 边界。 必须跨的原因:② 要用 ① 算出的 m, 而 m 由别的 block 算出;grid 这一层没有同步原语(图 13), 只能把「下一次 kernel 发射」当同步点。

代码里的两处细节

① sm_s1_max 手写了归约,没有广播尾巴。 sm_s1_sum 直接调用 blockReduceSum,它结尾有 smem[0] → __syncthreads() → 全员读走。 但这两个 kernel 都只有 thread 0 要用结果。 所以 ① 省得对,② 为了复用 helper 多付了一点, 这正是 §4 · 一 那个观察的实例。

② __expf(xr[i] − m) 在 ② 和 ③ 各算了一遍。 同一个元素的 exp 算两次,在条数账上是真浪费。 但这个 kernel 的瓶颈是访存,字节账才是主角,所以这笔浪费在这里影响不大。 而且 online 递推的 exp 只会更多: S1 每元素 2 次,S2 / S3 是 3 次。 online 递推是拿算力换访存:16N → 8N,代价是多算一次 exp。详见 §6 · 三。

__expf 是硬件近似指令(对应 SASS 的 MUFU.EX2), 比 expf 快一个量级。误差随 |x| 增大(NVIDIA 给的上界是 2 + floor(|1.16x|) ulp),在打分常见的 ±20 区间内约 1e-5 量级。 推理里普遍用它:softmax 的结果马上要做加权平均, 这点误差远小于 fp16 本身的表示误差。校验时容差要放宽到 2e-3。

二、S2:online 递推,省掉一趟↑

S2 省掉的是 S1 的第 ① 趟,即求 max 那一趟。 靠的是 §5 · 二 的数学改写,不是代码技巧: online 递推让 max 和 sum 在同一趟里同时推进。 S1 的第三个 kernel 原样复用,所以这一级只新写了一个 kernel。

// ═══ 积木一:联合归约 —— warp 内把 (m, s) 当一个整体 ═════════════════
__inline__ __device__ void warpReduceMaxSum(float &m, float &s) {
    #pragma unroll
    for (int off = 16; off > 0; off >>= 1) {
        float m_o = __shfl_xor_sync(0xffffffffu, m, off);   // 伙伴的 max
        float s_o = __shfl_xor_sync(0xffffffffu, s, off);   // 伙伴的 sum(每轮 2 条 shuffle)
        float m_n = fmaxf(m, m_o);                          // 新的公共基准
        s = s * __expf(m - m_n) + s_o * __expf(m_o - m_n);  // 两边都换算到 m_n 再加
        m = m_n;
    }
}

// ═══ 积木二:block 级联合归约 —— 两个分量一起收 ═══════════════════════
__inline__ __device__ void blockReduceMaxSum(float &m, float &s, float *smem) {
    const int lane = threadIdx.x & 31;
    const int warp = threadIdx.x >> 5;
    const int nwarp = BLOCK >> 5;                           // = 8
    warpReduceMaxSum(m, s);                                 // ① 8 个 warp 并行各归各的
    if (lane == 0) { smem[warp] = m; smem[nwarp + warp] = s; }  // ② m 放前 8 格,s 放后 8 格
    __syncthreads();                                        // ③
    if (warp == 0) {
        // 幺元是 (-inf, 0):最大值取 -inf 不影响 max,和取 0 不影响求和
        m = (lane < nwarp) ? smem[lane]         : -FLT_MAX;
        s = (lane < nwarp) ? smem[nwarp + lane] : 0.0f;
        warpReduceMaxSum(m, s);                             // ④ warp 0 收尾
        if (lane == 0) { smem[2 * nwarp] = m; smem[2 * nwarp + 1] = s; }   // ⑤ 存进最后 2 格
    }
    __syncthreads();                                        // ⑥ 注意:屏障在 if 外面
    m = smem[2 * nwarp];                                      // ⑦ ┐ 广播给全 block
    s = smem[2 * nwarp + 1];                                  //   ┘
}

// ═══ 一趟同时拿到 m 和 s ═════════════════════════════════════════════
__global__ void sm_s2_ms(const float *__restrict__ x, float *__restrict__ mx,
                         float *__restrict__ sm, int N) {
    __shared__ float smem[BLOCK / 32 * 2 + 2];              // 18 个 float
    const long long row = blockIdx.x;
    const float *xr = x + row * (long long)N;

    float m = -FLT_MAX, s = 0.0f;              // 幺元:空集的摘要
    for (int i = threadIdx.x; i < N; i += BLOCK) {
        float v = xr[i];                       // ← 整个 S2 只在这里读 x
        // 递推三行:见到更大的 max,就把已经累好的 s 按比例缩回去
        float m_n = fmaxf(m, v);
        s = s * __expf(m - m_n) + __expf(v - m_n);
        m = m_n;
    }
    blockReduceMaxSum(m, s, smem);             // 256 对 (m,s) 用 ⊕ 归成一对
    if (threadIdx.x == 0) { mx[row] = m; sm[row] = s; }     // 一次写两个小数组
}

// ═══ 第二个 kernel:原样复用 S1 的 sm_s1_div(再贴一遍,省得翻回去)═══
__global__ void sm_s1_div(const float *__restrict__ x, const float *__restrict__ mx,
                          const float *__restrict__ sm, float *__restrict__ y, int N) {
    const long long row = blockIdx.x;
    const float *xr = x + row * (long long)N;
    float       *yr = y + row * (long long)N;
    const float m = mx[row], inv = 1.0f / sm[row];   // 先取倒数,循环里不做除法

    for (int i = threadIdx.x; i < N; i += BLOCK)
        yr[i] = __expf(xr[i] - m) * inv;           // ← 第二遍读 x
}

// ═══ 怎么调:只发两次 ═══════════════════════════════════════════════════
sm_s2_ms <<<M, BLOCK>>>(dx, dm, ds, N);                  // 读 x        → 写 dm + ds
sm_s1_div<<<M, BLOCK>>>(dx, dm, ds, dy, N);              // ← S1 的第三个 kernel,原样复用

// dm / ds 的分配和 S1 一模一样;少的只是一次 kernel 发射和一整趟读 x。
S1 · 三趟 · 16N S2 · 两趟 · 12N ① sm_s1_max 读 x 4N → 写 dm ② sm_s1_sum 读 x 4N → 写 ds ③ sm_s1_div 读 x 4N → 写 y 4N ① sm_s2_ms 一趟同时得到 m 和 s 读 x 4N → 写 dm + ds = S1 的 ① + ② 合成一趟 ② sm_s1_div 读 x 4N → 写 y 4N 直接复用 S1 的,一个字没改 16N 省掉一整趟读 x 12N blockReduceMaxSum 要的 shared:BLOCK/32*2 + 2 = 18 个 float m0 m1 m2 m3 m4 m5 m6 m7 s0 s1 s2 s3 s4 s5 s6 s7 m s 各 warp 的 m(8 个) 各 warp 的 s(8 个) 广播 (m,s) blockReduceSum 只要 8 个格子 —— 这里归约的是一对数,还得多留 2 个做广播。

图 27 · 合并掉的是前两趟,第三趟原样保留。 S1 的 ① 和 ② 都要把整行读一趟,而它们做的两件事(求 max、求 exp 和)可以在一趟里同时推进。 这就是 online 递推的收益:16N → 12N,少一整趟读 x。 下半是联合归约的 shared memory 布局:前 8 格放各 warp 的 m, 接着 8 格放各 warp 的 s,最后 2 格存最终的 (m, s) 供全 block 读走。 它比 blockReduceSum 多一倍多一点,因为要归约的是一对数。

三处细节

① 线程内是串行递推,线程间是树形归约,两种顺序混用,结果仍然正确。 每个线程沿自己的跨步序列逐个累加, 然后 256 对 (m, s) 两两合并。这一步的正确性完全依赖 §5 · 三 证明的结合律:⊕ 不在乎顺序。

② blockReduceMaxSum 里的 warp 归约每轮要 2 条 shuffle。 m 一条、s 一条,5 轮共 10 条, 是 blockReduceSum 的两倍。但两条互不依赖,可以背靠背发射, 所以关键路径仍是 5 步(§3 · 三)。

③ 第 ⑥ 行的 __syncthreads() 在 if (warp == 0) 外面。 规矩和 blockReduceSum 一样: 「选代表」可以写在 if 里,__syncthreads() 绝对不行。 挪进 if 就是未定义行为。

三、S3:一趟,8N↑

S3 把最后一趟也省掉了:整行只经过显存一次,(m, s) 全程留在片上。 代价是整行要放进寄存器。这和 N3 是同一个套路, 连「固定次数 + 常量下标」那条规矩也一样。 于是 dm / ds 两个中间数组消失,只剩一个 kernel。

// 每线程最多存多少个 float —— 和 N3 共用同一个常量
static const int MAXS = 32;                    // 单行最长 = BLOCK × MAXS = 8192

// 积木仍是 §6·二 那两个:warpReduceMaxSum / blockReduceMaxSum,原样复用
// 但这一级真的需要它结尾那几行广播 —— 256 个线程每人都要拿 (m,s) 去归一化自己手上的元素

// ═══ 一个 kernel 走完全程 ═══════════════════════════════════════════════
__global__ void sm_s3(const float *__restrict__ x, float *__restrict__ y, int N) {
    __shared__ float smem[BLOCK / 32 * 2 + 2];
    const long long row = blockIdx.x;
    const float *xr = x + row * (long long)N;
    float       *yr = y + row * (long long)N;

    // 常量下标 + #pragma unroll,理由同 rms_n3(否则 xs 会被塞进 local memory)
    float xs[MAXS];                            // 整行摊在寄存器上
    float m = -FLT_MAX, s = 0.0f;
    #pragma unroll
    for (int c = 0; c < MAXS; ++c) {
        int i = threadIdx.x + c * BLOCK;
        if (i >= N) { xs[c] = -FLT_MAX; continue; }  // 越界塞幺元,见下面的 callout
        float v = xr[i];                       // ← 整个 S3 只在这里读一次 x
        xs[c] = v;                             //   读完就留着,不再回显存
        float m_n = fmaxf(m, v);               // ┐
        s = s * __expf(m - m_n) + __expf(v - m_n);  // ├ 递推三行,同 S2
        m = m_n;                               // ┘
    }
    blockReduceMaxSum(m, s, smem);             // 256 对 (m,s) 归成一对,并广播给全员

    const float inv = 1.0f / s;                // 先取倒数,循环里不做除法
    #pragma unroll
    for (int c = 0; c < MAXS; ++c) {
        int i = threadIdx.x + c * BLOCK;
        if (i < N) yr[i] = __expf(xs[c] - m) * inv;   // ← 直接用寄存器,不回显存
    }
}

// ═══ 怎么调:只发一次,而且参数表里没有中间数组 ═════════════════════════
sm_s3<<<M, BLOCK>>>(dx, dy, N);                          // 读 x 一次 → 写 y

// 和 N3 共用同一条守卫 —— 整行得塞得进寄存器:
const bool needReg = (kind == K_N3 || kind == K_N4 || kind == K_S3 || kind == K_S4);
if (needReg && N > BLOCK * MAXS) {                 // 8192
    printf("✗ 这一级把整行存在寄存器里,单行最长 %d,而 N=%d。\n",
           BLOCK * MAXS, N);
    return 3;
}
一路砍下来,砍掉了什么 kernel 中间数组 x 过显存 写 y 每行字节 S1 dm ds 3 次 1 次 16N S2 dm ds 2 次 1 次 12N S3 — — 1 次 1 次 8N 一个 kernel,零中间数组 —— dm 和 ds 彻底不存在了 换来的代价:exp 反而算得更多 S1 每元素 2 次 exp 趟② 求和 1 次 + 趟③ 写回 1 次 S2 / S3 每元素 3 次 exp 递推里 2 次(缩放因子 + 新项) + 写回 1 次 访存瓶颈的 kernel 里这笔换算划算 —— 字节账是主角,条数账在这儿让位。

图 28 · 从 S1 到 S3:kernel 从 3 个减到 1 个,中间数组从 2 个减到 0 个。 dm / ds 能消失,是因为它们只为「跨 kernel 边界」而存在; 只剩一个 kernel 时,中间结果放在寄存器和 shared memory 里就够了。 下半是代价:exp 从每元素 2 次涨到 3 次, online 递推是拿算力换访存。在这个访存瓶颈的 kernel 里划算,换个场景未必。

越界位置为什么塞 -FLT_MAX 而不是 0

如果塞 0,而这一行的真实数据全是负数,这个假的 0 就会成为 max,整行结果全错。 -FLT_MAX 不可能成为 max, exp(-FLT_MAX − m) 也是 0,对和没有贡献。它就是 ⊕ 的幺元。

写回时还有 if (i < N) 兜底,这些位置不会被写出去。两道保险。 注意幺元跟着运算走: 求和用 0,求 max 用 -FLT_MAX;这里求的是 (max, sum) 这一对, 所以幺元是 (−∞, 0)。

四、S4:float4,顺便演示了分块合并↑

S4 = S3 + float4:字节数不变(仍是 8N),减少的是访存指令条数。 它还多做了一件事:一次读进 4 个元素, 所以必须先把这 4 个合成一个小块摘要,再并进累计值。 这用的是 §5 · 三 那个一般形式的 ⊕, 而不是 S3 那个「并进一个元素」的特例。

// float4 版:8 个 float4 也是 32 个 float,寄存器压力和 S3 刻意做成一样
static const int MAXV = 8;                     // MAXV × 4 = MAXS = 32

// 积木仍是 §6·二 那两个,原样复用;广播这一级同样真的需要

// ═══ 一个 kernel,一次搬 4 个 ═══════════════════════════════════════════
__global__ void sm_s4(const float *__restrict__ x, float *__restrict__ y, int N) {
    __shared__ float smem[BLOCK / 32 * 2 + 2];
    const long long row = blockIdx.x;
    const int n4 = N >> 2;                        // 一行有多少个 float4
    const float4 *xr = reinterpret_cast<const float4 *>(x + row * (long long)N);
    float4       *yr = reinterpret_cast<float4 *>(y + row * (long long)N);

    float4 xs[MAXV];                           // 8 个 float4 = 32 个寄存器
    float m = -FLT_MAX, s = 0.0f;
    #pragma unroll
    for (int c = 0; c < MAXV; ++c) {
        int i = threadIdx.x + c * BLOCK;
        if (i >= n4) { xs[c] = make_float4(-FLT_MAX, -FLT_MAX, -FLT_MAX, -FLT_MAX); continue; }
        float4 v = xr[i];                      // ← 一条 LDG.E.128,16 字节
        xs[c] = v;

        // 第 ① 层:先在寄存器里把这 4 个合成一个小块摘要 (mv, sv)
        float mv = fmaxf(fmaxf(v.x, v.y), fmaxf(v.z, v.w));
        float sv = __expf(v.x - mv) + __expf(v.y - mv) + __expf(v.z - mv) + __expf(v.w - mv);

        // 第 ② 层:再把这个小块用 ⊕ 并进累计值 —— 第二个操作数不再是单个元素
        float m_n = fmaxf(m, mv);
        s = s * __expf(m - m_n) + sv * __expf(mv - m_n);   // ← 注意 sv 前面这个系数
        m = m_n;
    }
    blockReduceMaxSum(m, s, smem);

    const float inv = 1.0f / s;
    #pragma unroll
    for (int c = 0; c < MAXV; ++c) {
        int i = threadIdx.x + c * BLOCK;
        if (i >= n4) continue;
        float4 v = xs[c], o;                   // 直接用寄存器里的 xs
        o.x = __expf(v.x - m) * inv;  o.y = __expf(v.y - m) * inv;
        o.z = __expf(v.z - m) * inv;  o.w = __expf(v.w - m) * inv;
        yr[i] = o;                             // 一条 STG.128 写回 4 个 float
    }
}

// ═══ 怎么调:和 S3 一字不差 ═════════════════════════════════════════════
sm_s4<<<M, BLOCK>>>(dx, dy, N);                          // 读 x 一次 → 写 y

// 除了 S3 那条寄存器长度守卫,还多一条对齐守卫:
const bool needVec4 = (kind == K_N4 || kind == K_S4);
if (needVec4 && (N & 3)) {                          // N 不是 4 的倍数
    printf("✗ float4 版要求 N 是 4 的倍数(当前 N=%d)\n", N);
    return 3;
}

sv = … 这一行算的是什么

这一行直接照抄「摘要」的定义,一个字都没多。

// 摘要的定义(§5·三)
摘要(A) = ( max(A) , Σ_{j∈A} exp(x_j − max(A)) )

// 代进 A = { v.x, v.y, v.z, v.w }
mv = max(A)                                                // ← 第一个分量
sv = exp(v.x−mv) + exp(v.y−mv) + exp(v.z−mv) + exp(v.w−mv)   // ← 第二个分量

// 所以这两行合起来 = 摘要({v.x, v.y, v.z, v.w}),(mv, sv) 是一对,拆开都没意义
拿 v = (1, 2, 5, 3) 走一遍
分量减去基准取指数
v.x = 11 − 5 = −40.018316
v.y = 22 − 5 = −30.049787
v.z = 55 − 5 = 01.000000 最大的那个,恰好是 1
v.w = 33 − 5 = −20.135335
合计 svmv = 51.203438 和 §5 · 三 那个 x=[1,2,5,3] 的例子是同一个数

三个「为什么」

为什么要减 mv? 为了防溢出。v_i − mv ≤ 0 恒成立,所以每项都落在 (0, 1]。打分冲到 90 时 exp(90) = 1.22e39, 已经超过 fp32 上限 3.4e38;减掉 max 后就永远不会溢出。

为什么 sv 一定 ≥ 1? 因为最大的分量贡献 exp(mv − mv) = 1。 所以 sv ∈ [1, 4]:四个数全相等时取 4,差距极大时趋近 1。 这也保证后面的 1.0f / s 不会除零。

为什么平铺写四项,不写循环? float4 的四个分量是四个独立的寄存器,没有下标可以循环, 所以展开写是它的自然写法。

块内用的是「朴素两趟」,不是递推

这两行的结构是先求 max,再求和,正是 S1 那套朴素做法, 不是 online 递推。

// 块内(4 个元素,全在寄存器里):朴素两趟
mv = max(...)                    // 第 1 趟:求 max,0 次 exp
sv = Σ exp(v_i − mv)             // 第 2 趟:求和,4 次 exp        → 共 4 次

// 如果改成递推、逐个并进来:
for v_i in {x, y, z, w}:
    m_n = fmaxf(m, v_i);
    s = s * exp(m − m_n) + exp(v_i − m_n);   // 每元素 2 次 exp   → 共 8 次

4 次对 8 次:在块内,朴素法反而少算一半 exp。 S1 的问题不在算得多, 而在「两趟」意味着两趟读显存。 而这四个数就在寄存器里,两趟不花钱, 于是朴素法「少算一半 exp」的优势就显出来了。

sm_s4 里同时住着两种策略,各管各的尺度
尺度数据在哪用什么exp 次数为什么这么选
块内4 个元素 寄存器 朴素两趟先 max 再求和 4 四个数同时在手,「两趟」不花钱
块与块之间8 轮循环 累计值在寄存器 online 递推 ⊕ 每块 2 块一个一个来,拿不到全局 max
① 读入一个 float4,做的是两层合并 ① 4 次 exp ② 2 次 exp v.x v.y v.z v.w 一个 float4 (mv, sv) 这 4 个的摘要 (m, s) ⊕ (mv, sv) 用 §5·三 的一般式 新 (m, s) ② 同一个 ⊕,变的只是第二个操作数有多大 S3 (m,s) ⊕ (v, 1) 第二个操作数是「一个元素」 块大小 1 S4 (m,s) ⊕ (mv, sv) 是「4 个元素的摘要」 块大小 4 FlashAttn (m,s) ⊕ (m_tile, s_tile) 是「一整块打分的摘要」 块大小 128×128 合并公式一个字都不用改 —— 变的只是块多大、摘要住在哪(寄存器 → shared memory)。

图 29 · 这段代码已经是 FlashAttention 的最小形态。 S3 往累计值里并的是一个元素 (v, 1);S4 并的是一个小块的摘要 (mv, sv)。 块大小从 1 变成 4。FlashAttention 做的事,就是把块从 4 放大到 128×128, 把摘要从寄存器挪到 shared memory。 合并公式一个字都不用改,因为 §5 · 三 证明了任意两段摘要都能这样合并。

B + 2 怎么数出来

先定义「块」:块 = 一次循环迭代里拿到的那几个元素。 两个 kernel 覆盖的元素总数相同,差别只在每轮拿几个:

行号对应 fused_ops.cu
循环每轮拿到几个所以 B =
sm_s3372 行 for (c < MAXS) float v = xr[i] → 1 个1
sm_s4401 行 for (c < MAXV) float4 v = xr[i] → 4 个4

S3:每元素 2 次(fused_ops.cu:378)

float m_n = fmaxf(m, v);                            // 0 次
s = s * __expf(m - m_n) + __expf(v - m_n);          // ← 2 次
        └ 缩放因子:把已累计的 s ┘   └ 新项 ┘
          从旧基准 m 换到 m_n

关键:每处理一个元素,都要重新换一次基准, 因为 m_n 每轮都可能变。所以 B 个元素要 2B 次。

S4:B + 2 次(fused_ops.cu:409 与 411)

float mv = fmaxf(fmaxf(v.x, v.y), fmaxf(v.z, v.w));                     // 0 次
float sv = __expf(v.x-mv)+__expf(v.y-mv)+__expf(v.z-mv)+__expf(v.w-mv);   // ← B = 4 次
           ↑ 基准 mv 是这 4 个自己的 max,一次定好、中途不变 → 不需要任何缩放因子

float m_n = fmaxf(m, mv);                                               // 0 次
s = s * __expf(m - m_n) + sv * __expf(mv - m_n);                     // ← 2 次
        └ 换累计值的基准 ┘   └ 换整块 sv 的基准 ┘
                             ↑ 和 S3 的唯一结构差别:sv 这个系数

// 整个块只换一次基准 → B + 2 = 6
S3 · 逐元素(B = 1) S4 · 分块(B = 4) v.x 换基准 1 次 + 新项 1 次 2 v.y 换基准 1 次 + 新项 1 次 2 v.z 换基准 1 次 + 新项 1 次 2 v.w 换基准 1 次 + 新项 1 次 2 合计 4 × 2 = 8 次 v.x v.y v.z v.w 块内定一个 mv 4 次 exp(无需换基准) ⊕ 换一次基准 2 次 exp 合计 4 + 2 = 6 次 省下的正是那 3 次多余的换基准 —— 元素项(每个元素总得算一次 exp)是省不掉的。

图 30 · 省的不是元素项,是换基准。 每个元素总要算一次 exp,这部分省不掉; 能省的只有换基准:逐元素做要换 B 次,分块做只换 1 次。 于是 逐元素 2B = B(元素项)+ B(换基准), 分块 B+2 = B(元素项)+ 2(只换一次)。

B = 1 时分块反而更亏,所以 S3 选逐元素不是疏忽

一个元素也要付 2 次换基准的固定开销, B+2 = 3 比 2B = 2 多。 B = 2 时打平,B ≥ 3 时分块才开始省。 每元素成本是 (B+2)/B = 1 + 2/B,B 越大越接近 1。

写回阶段没有这个差别

sm_s3:386 是 __expf(xs[c] - m) * inv, sm_s4:421-422 是四个分量各一次,都是每元素 1 次。 因为写回时 m 已经是全行最终的 max,不用换基准,也就没什么可省。 所以每元素总计:S3 = 2 + 1 = 3;S4 = 1.5 + 1 = 2.5。

读入阶段的 exp 次数(写回阶段固定每元素 1 次)
块大小逐元素做分块做分块摊到每元素出现在哪
1233.00 S3(所以 S3 用的是逐元素那条路)
2442.00 打平的分界点
3651.67 分块开始占便宜
4861.50 S4(每元素总计 1.5 + 1 = 2.5,比 S3 的 3 还少)
B2BB + 21 + 2/B 一般情形
128×128——≈ 1.00 FlashAttention —— 几乎只剩不可省的那一次

五、长行:s3/s4 会拒绝跑,这是设计↑

if (needReg && N > BLOCK * MAXS) {
    printf("✗ 这一级把整行存在寄存器里,单行最长 %d,而 N=%d。\n", BLOCK * MAXS, N);
    printf("  这不是实现偷懒,是硬件约束 —— 也正是 FlashAttention 必须分块的原因。\n");
    return 3;
}

Qwen2.5-7B 的 vocab 是 152064。 采样时要对这么长的一行做 softmax,而 S3 的上限是 8192。 差 18 倍,无论如何都放不进寄存器。

宁可拒绝,也不要偷偷 spill

如果不做这个检查,编译器会把 xs 放进 local memory。 程序照样跑,结果照样对,速度也"还行"。 你会因此得出一个完全错误的结论: "S3 在长行上也有效"。实际上它退化成了 S2,而且更慢。 让它明确失败,是为了让约束显形。

预测跑之前先写死↑

和前面三份报告一样:先写预测,再看结果。 如果看着数字再编解释,什么账都能"对得上"。

一、主阶梯:八条↑

M=8192 行 · RMSNorm N=3584 · softmax N=2048 · 跑完逐条打勾
#预测判决标准理由实测判决
①N2 几乎不比 N1 快 1.00× – 1.05× 字节数相同,都是 12N。省的只有 launch 和 rstd 往返 1.49×落空
②N3 相对 N1 接近 1.5× 1.35× – 1.50× 12N → 8N,纯按字节比算。达不到就说明有别的瓶颈 1.51×成立
③N4 相对 N3 提升很小 1.00× – 1.15× 字节数不变,只减指令条数。如果带宽已经吃紧,就没有提升空间 1.00×成立
④S2 相对 S1 接近 1.33× 1.20× – 1.33× 16N → 12N。这一步的收益全部来自 online 递推 1.14×落空
⑤S3 相对 S1 接近 2.0× 1.80× – 2.00× 16N → 8N 1.70×落空
⑥最好的那一级 MBU 能到 70% 以上 MBU > 70% 计划书里的验收线。纯流式读写没有复杂访存模式,应该能接近峰值 91.0%成立
⑦十一个 kernel 全部 0 spill ptxas 全 0 n3/n4/s3/s4 一旦 spill,②⑤ 必然落空, 而且从耗时上看不出原因 11/11 全 0成立
⑧N=1000 的边界用例也全通过 ✓ 校验通过 最后一轮只有部分线程有活,幺元和越界判断都在这里受考验 n3 ✓ s3 ✓成立
第 ① 条是这组实验的眼睛。 如果 N2 也明显变快,说明我对"字节数"的计算漏了什么, 后面所有推导都要回头查。一条符合预期的"没有变快",比一条变快更能验证模型。

二、形状扫描:四条↑

行数 M 变化时 · N 固定
#形状预测理由实测判决
⑨M=1
decode b1
MBU 掉到个位数 grid 只有 1 个 block, 128 个 SM 里 127 个空转。这是 decode 的常态,不是特例 0.3–0.7%成立
⑩M=32
decode b32
MBU 仍然很低 32 个 block 对 128 个 SM 还是填不满 10–22%成立
⑪M=2048
prefill
MBU 接近大批的水平 2048 个 block 足够把卡填满了 155–328%口径失效
⑫M 小的时候 N3 相对 N1 的优势会缩小 卡没填满时瓶颈不是带宽, 是并行度 —— 省字节救不了空转的 SM 1.00→1.50×成立

第 ⑫ 条和 SGEMM 形状扫描的结论相同: 在 decode 一侧,辛苦优化的那本账会失效, 因为瓶颈变了。 SGEMM 那边是"砍指令条数救不了带宽墙", 这边是"砍字节救不了空转的 SM"。

三、长行那一条↑

#预测判决标准实测判决
⑬ s3 32 152064 被程序拒绝,返回码 3 打印 ✗ 并退出 ✗ 已打印日志未记录退出码成立
⑭ logits 形状(M=32, N=152064)的 MBU 明显低于主阶梯 比 M=8192 低 147.9%(反而更高)落空
第 ⑭ 条检验"长行 + 少行"这种形状有多难喂饱卡。 32 行意味着 32 个 block;每行 152064 个元素意味着每个 block 要循环 594 轮。 并行度全压在行内,而行内只有 256 个线程。

上机代码全文与验收↑

本节有两个文件,用一条命令跑完。 两个文件原样收录,磁盘路径是 debug/07/fused_ops.cu 与 debug/07/run_fused.sh。

一、验收清单↑

项验收标准对应正文估时
编译 十一个 kernel 全部 0 bytes spill stores。 尤其 rms_n3 / rms_n4 / sm_s3 / sm_s4 §4 · 三1 min
寄存器数 n1/n2 应该只有十几个;n3/n4/s3/s4 应该明显更多(32 个存数据 + 其余)。 两组相近,说明寄存器驻留没生效 §4 · 三—
正确性八个 kernel 各看到一次 ✓ 抽样校验通过 §8 · 二2 min
边界 N=1000(不是 BLOCK 整数倍)也要通过。 幺元写错、漏掉越界判断,都会在这里报错 §3 · 三1 min
主阶梯三轮读数差 < 2%;八条预测逐条打勾 §7 · 一5 min
形状扫描四组 M;重点看 MBU 随 M 的变化 §7 · 二5 min
长行s3 32 152064 应当被拒绝并返回 3,这是预期行为 §6 · 五2 min
竞态三段 racecheck 都要 0 hazards §3 · 二3 min
合计约 20 分钟。 比 SGEMM 那套短,原因有两个:访存密集算子本身跑得快;不需要 ncu。 MBU 用字节数和耗时就能算出,不依赖硬件计数器, 所以在没有 profiling 权限的机器上也能验收。
# 测试机
nvidia-smi                          # 确认卡上没有别人
bash run_fused.sh                   # 约 20 分钟

# 想快速过一遍
ROUNDS=1 bash run_fused.sh

二、fused_ops.cu 全文↑

十一个 kernel + 完整脚手架(设备查询、occupancy、字节账、CPU 参照校验、中位数计时)。 计时和校验的写法和 sgemm_all.cu 相同: 预热次数按 ITERS 分档;样本数为偶数时,取中间两个的平均。 这两处写法来自 SGEMM 那一轮的教训,来历见本阶段 README「怎么复现」的四条规矩。

测试数据怎么造:536~539 这四行为什么这样写选读
// fused_ops.cu:533-539
const size_t nElem = (size_t)M * N;
const size_t bytes = nElem * sizeof(float);
std::vector<float> hx(nElem), hw(N), hy(nElem);
std::mt19937 rng(12345);
std::uniform_real_distribution<float> dist(-3.0f, 3.0f);
for (size_t i = 0; i < nElem; ++i) hx[i] = dist(rng);
for (int i = 0; i < N; ++i)        hw[i] = 0.5f + 0.5f * dist(rng) / 3.0f;

这段代码在 runOne() 里、cudaMalloc 之前,只在 CPU 侧运行,不进计时窗口。

种子写死是有意的

重点是种子 12345 写死在代码里, 而不是用 std::random_device{}()。 std::mt19937 是 Mersenne Twister 伪随机引擎 (名字里的 19937 是它的周期 219937−1)。

固定种子换来的三件事
场合如果输入每次都变会怎样
对照 CPU 参照 某次 verifyRows 报错,你没法复现它
三轮取中位数 八级之间比的不再是 kernel,是输入
换机器重跑 数字失去可比性
另外,verifyRows 里还有一个 std::mt19937 rng(20260925)(fused_ops.cu:437)。 它只用来抽行号,和这里是两条独立的流,所以种子特意取得不同。

引擎和分布是两件事

<random> 把「产生随机位」和「映射成某种分布」分成两步: 引擎输出 32 位整数流,uniform_real_distribution 负责映射。 按标准,区间是左闭右开的 [-3, 3)。dist 是函数对象, 每调用一次,就从 rng 取走一段位。

为什么偏偏是 ±3

三个条件共同把 L 定在 3 附近:两条阶梯各提一个要求,再加一个让公式成立的前提。

① RMSNorm 一侧:要避开一个具体的退化点

// U(−L, L) 的密度是 1/(2L)
E[x²] = ∫_{−L}^{L} x² · 1/(2L) dx = (1/(2L))·[x³/3]_{−L}^{L}
      = (1/(2L))·(L³/3 + L³/3)
      = L²/3

// 于是缩放因子
r = 1/√(E[x²]) = √3 / L
缩放因子随区间宽度怎么走
半宽 L均方值缩放因子
10.3331.7320
√3 ≈ 1.73211.000 1.000000 退化点 —— 这里漏写 * r 也照样通过校验
21.3330.8660
33.000 0.5774 离退化点有 1.73 倍余量
1033.330.1732
50833.30.0346
要避开的只有 L = √3 这一个点,不是一片区域。 上界没有硬约束: 要让 y 掉进 fp32 非正规数,L 得大到 1038 量级, 那时 v*v 已经先溢出。所以这一侧只有「别取 √3」这一条。

② 区间必须跨零,否则上面的公式不成立

只有区间对称、E[x] = 0 时,方差才等于 E[x²]。 常见写法 (b−a)²/12 算的是方差,不是 E[x²]。两者的关系是 E[x²] = Var(x) + (E[x])²。

同样宽 6 的两个区间,均方值差 4 倍
区间均值方差均方值
[−3, 3]03 3
[0, 6]33 12

跨零还有一个好处:「漏写平方」的后果从细微偏差变成数量级的错误。 下表是 20 万样本的实测:

把 acc += v * v 误写成 acc += v 的后果
区间正确的均方值与因子漏平方后能不能看出来
[−3, 3] 3.012 → 0.576 −0.0026 → 19.6趋向 1/√eps = 1000 实测差 34 倍(极限可达三个数量级),很明显
[0, 6] 11.99 → 0.289 3.000 → 0.577 只差 2 倍, 容易误判成 eps 位置写错之类的问题
对称区间上 mean(x) ≈ 0, 再被 eps = 1e-6 一除,因子就升到几百上千。

③ softmax 一侧:上界与 L 无关,L 只管下界

// 对任意 j:x_j ≥ −L 而 max < L
x_j − max ∈ (−2L, 0]        →        exp(x_j − max) ∈ (e^(−2L), 1]

上界 1 不是 ±3 的功劳

最大元素的指数项恰好是 exp(0) = 1。 这是「减去 max」本身的性质,和区间宽度无关。 即使 L = 1000,减去 max 后的最大项还是 1。 所以算法本身已经排除了溢出,不需要靠选数据范围来保证。 L 只影响下界。

下界随 L 下降的速度
半宽 L最小的指数项落在哪
32.479e-03 离 fp32 下限很远
66.144e-06
102.061e-09
503.720e-44 已经是非正规数
两条临界线: e^(−2L) < 1.18e-38(跌出正规数)⟺ L > 43.67; e^(−2L) < 1.40e-45(彻底变 0)⟺ L > 51.64。 所以 ±50 正好卡在这条线上,不是随意挑的反例。

④ 行和的量级

// 把 max 近似当常数 L(N 大时 E[max] = L − 2L/(N+1),近似极好)
E[exp(x − L)] = ∫_{−L}^{L} e^(x−L) · 1/(2L) dx = (1/(2L))·[e^(x−L)]_{−L}^{L}
              = (1 − e^(−2L)) / (2L)

// L = 3 时
E[s] = N · (1 − e^(−6)) / 6 = 0.166254 · N
各行长下的行和期望
行长行和期望最大值期望出现在哪
N=2048340.52.9971 主阶梯的 softmax
N=3584595.92.9983 RMSNorm 的行长,softmax 不跑
N=15206425281.23.0000 logits 形状
行和在几百到几万之间,fp32 的有效位足够。 抽一行实测 s = 349.6。要判断它是否吻合,先算波动幅度: 单项方差 (1−e^(−12))/12 − 0.166254² = 0.055693(SD 0.2360), 和的 SD = 0.2360·√2048 = 10.68。 (349.6 − 340.5)/10.68 = 0.85σ,属于正常的采样波动。

真正的设计点:容差里那个 1e-7 兜底项

±3 的主要作用在校验的容差里。上面四条只保证数值不出问题。

// fused_ops.cu:461 —— verifyRows 的判定
if (d > 2e-3 * fabs(ref[i]) + 1e-7) { ... }

因为有这个绝对兜底项,参照值小于 1e-7 的元素,GPU 算出什么都会通过,等于没查。 输出是 y_j = exp(x_j − max)/s, 所以「自动通过」的条件是 exp(x_j − max) < 1e-7 · s。 s 又随 L 变小(≈ N/(2L))。所以 L 一大,兜底区很快就覆盖整行。

N=2048 时,每行有多少元素实际没被校验
半宽 L行和自动通过的门槛占比
3340.5 x−max < −10.29 0.0%最低只到 −6,够不着
5204.8x−max < −10.80 0.0%
6170.7x−max < −10.98 8.5%
10102.4x−max < −11.49 42.6%
5020.5x−max < −13.10 86.9%
L=3 时没有元素自动通过,整行每个数都被真正校验。 临界点在 L ≈ 5.5 附近,±3 留了约 1.8 倍余量。

实测:rescale 写错,两种区间下各能抓到多少

方法:用 fp32 模拟 N=2048,植入两种 bug,再跑同一套容差判定。

同一个 bug,换个数据范围,就从「全部报警」降为「零星报警」
区间版本算出的行和超出容差的元素
±3正确339.04 0 / 2048
漏掉整个 rescale347.76 2048 / 2048(100%)
rescale 乘错位置344.45 2048 / 2048(100%)
±50正确23.27 0 / 2048
漏掉整个 rescale25.09 198 / 2048(9.7%)
rescale 乘错位置22.43 190 / 2048(9.3%)
实测落进兜底区的元素:±3 是 0 / 2048, ±50 是 1791 / 2048 = 87.5%,和上一张表预测的 86.9% 吻合。

差别不在「能不能抓」,在余量

±50 下这个 bug 仍会被抓到(9.7%),但报警范围从「每个元素」 缩小到「只有最大的约一成元素」。 ±3 下,连舍入级别的偏差都能查出; ±50 下,再叠加一点别的扰动,整行就可能漏查。 online 递推里 s * __expf(m - m_n)(fused_ops.cu:378)这一步, 只有每一项对行和都有可见的贡献时才查得出来。这才是 ±3 的主要用处。

为什么不能用常数输入

对照实验:往 kernel 里植入九个真实的 bug,用三种输入各跑一遍,看同一套容差判定各抓到多少。 判定与 fused_ops.cu:461 完全相同(|ref−got| > 2e-3·|ref| + 1e-7)。 参照用 double,被测用 fp32,规模是 M=8 行 × N=1000 列 = 8000 个元素。 取 N=1000,是为了让「漏掉越界判断」这个 bug 真正生效。

RMSNorm 四级(权重 w 用随机值)
植入的 bug全 0全常数 1.5U(−3,3)
漏写平方 acc += v✗ 漏掉抓到 8000抓到 3000
漏乘缩放因子 * r✗ 漏掉抓到 8000抓到 8000
权重下标写成 w[c]✗ 漏掉抓到 7976抓到 7976
写回整体错位一列✗ 漏掉抓到 8000抓到 7993
漏掉 i < N 越界判断✗ 漏掉抓到 7000抓到 7000
对 RMSNorm,非零常数输入也够用,五个 bug 全部抓到。 但「全 0」一个都抓不到。

常数输入下 RMSNorm 还能抓住「写回错位」,因为它有两个变化源: 输入 x 和权重 w。 把 w 也换成全 1,这两个 bug 在常数输入下就都抓不到了:

同样的 bug,但 w 改成全 1
植入的 bug全 0全常数 1.5U(−3,3)
权重下标写成 w[c]✗ 漏掉✗ 漏掉✗ 漏掉
写回整体错位一列✗ 漏掉✗ 漏掉抓到 7990
让 RMSNorm 抓到 bug 的是 w,不是输入。 两者都是常数时, y_i = c · r · w 在每个位置都一样,位置信息完全消失, 下标怎么错都看不出来。这就是 本节后面坚持让 w 取随机值的原因。
softmax 四级:没有 w 可以依靠
植入的 bug全 0全常数 1.5U(−3,3)
漏掉 rescale✗ 漏掉✗ 漏掉抓到 8000
rescale 乘错位置✗ 漏掉✗ 漏掉抓到 7879
m 初值写成 0.0f✗ 漏掉✗ 漏掉✗ 漏掉
写回整体错位一列✗ 漏掉✗ 漏掉抓到 7991
两种常数输入下,四个 bug 一个都抓不到。 softmax 只有 x 这一个变化源。 输入恒定时,正确答案是均匀的 1/N,每个位置的值都相同。

两条阶梯的差别在于变化源的个数

RMSNorm:y_i = x_i · r · w_i 有两个带下标的量。 只要有一个在变,位置就可区分。

softmax:y_i = exp(x_i − m)/s 只有一个。 x 恒定 ⟹ 全部 y_i 相等 ⟹ 任何与位置有关的 bug 都查不出。 所以在这份测试里, softmax 四级必须用随机输入。这是硬约束,不是可选项。

常数输入下,「漏掉 rescale」版与正确版的输出逐位相同

// 常数输入 x ≡ c 时,走一遍递推:
// 第 1 个元素:m = −FLT_MAX → m_n = c
正确:  s = s * exp(m − m_n) + exp(c − c) = 0 * exp(−FLT_MAX − c) + 1 = 1
漏掉:  s = s               + exp(c − c) = 0                        + 1 = 1   ← 一样

// 第 2 个元素起:m 再也不变,m_n == m
正确:  s = s * exp(0) + 1 = s + 1
漏掉:  s =              s + 1                                            ← 还是一样

// 结论:rescale 这一步【一次都没被真正触发过】

常数输入的问题不是「检验力度弱」,而是这条代码路径根本没被执行。 实测:全 0 和全常数 1.5 下,正确版与漏 rescale 版的输出 逐位相同(array_equal 为真);换成 U(−3,3),输出立刻不同。

实验还发现两个盲点,换随机输入也补不上

① m 初值写成 0.0f(而不是 -FLT_MAX):三种输入都抓不到。 只有一整行元素全为负时,这个 bug 才会触发。 U(−3,3) 抽 1000 个数,最大值为负的概率是 2−1000。 要测出它,必须专门构造「全负行」。 现在这套测试覆盖不到。

② 容差判定会放过 NaN。 要修复,判定里得加一句 !std::isfinite(yr[i])。 IEEE754 规定,任何与 NaN 的比较都返回 false。 所以 d 为 NaN 时,d > 2e-3·|ref| + 1e-7 恒为假,结果被当成通过。 (±Inf 的比较正常,没有这个问题。) 上面「漏写平方」在 U(−3,3) 下只抓到 3000 而不是 8000,原因就在这里: 对称区间上 acc 可能为负,rsqrtf(负数) 返回 NaN,那 5 行就无报错地通过了。

总结:该用哪种输入
输入够不够用为什么
全 0 九个 bug 全漏 平方和恒为 0,r 被 eps 定死; 越界多读几个 0 也不改变和
非零常数 只够 RMSNorm 而且得靠 w 是随机的;softmax 那边四个 bug 全漏
U(−3,3) 九个里抓到八个 每个元素各不相同 → 位置可区分;max 会反复更新 → 递推被真正执行

最后那行:权重 w

// dist(rng)              ∈ [-3, 3)      ← 复用上一行那个 ±3 的分布
// 0.5f * dist(rng)/3.0f  ∈ [-0.5, 0.5)  ← 实际就是 d/6
// 0.5f + ...             ∈ [0, 1)       ← 均匀,均值 0.5
hw[i] = 0.5f + 0.5f * dist(rng) / 3.0f;

这一行没有另外声明 uniform_real_distribution<float>(0.0f, 1.0f), 而是对现有的 dist 做仿射变换。 两者在数学上严格等价:均匀分布对仿射变换封闭,重映射不引入偏差。 这样写只是为了少写一行。行尾的多余空格是为了和上一行的 hx[i] 对齐。

循环长度是 N,不是 nElem。这是两条阶梯最大的结构差异。 w 是可学参数,每列一个,所有 M 行共用; N=3584 时整份只有 14 KB。循环变量的类型也不同:上一行用 size_t i(nElem 可能很大), 这一行用 int i(N 本身就是 int)。

真实模型的初始化是全 1,为什么不直接写 hw[i] = 1.0f
用 w = 1 会漏掉的错误为什么漏掉随机 w 怎么抓住
kernel 里漏写 * w[i] 乘 1 等于没乘,结果照样对 沿列方向变化 → 少乘一下立刻对不上
w 的下标误写成 w[row*N+i] w 全是 1 时,读哪儿都对 所有行共用同一份 → 用展平偏移就会读到别处
区间取 [0, 1),输出的量级就保持稳定: y = x · 0.577 · w,w 均值 0.5,所以 y ≈ 0.29x,既不溢出,也不会小到看不出差别。

softmax 四级完全不用 w。 签名可以证明:sm_s1_max/sum/div、sm_s2_ms、 sm_s3、sm_s4 都只收 (x, …, y, N),softmax 本来就没有可学权重。 但代码没为此加分支,跑 s1–s4 时照样填充并上传 hw。代价只是 14 KB 和一次 cudaMemcpy,不值得写一个 if。

为什么字节账里没有 w

§2 · 一 的账是 8N(读 x + 写 y),不是 12N,因为 w 由 M 行共用。 每行都要读一遍 w,但全份只有 14 KB,摊到每行只有 4N/M = 1.75 字节,占 8N(28672 字节)的 0.006%。

这不只是估算,§9 · 一 的实测直接证实了它
假设搬运量按 917.5 GB/s 需要
w 每个 block 都回显存取一遍12N = 336.0 MiB 0.3840 ms
w 由缓存供给8N = 224.0 MiB 0.2560 ms
N3 实测— 0.2560 ms
实测正好等于 8N 的预测。 14 KB 的 w 被 8192 个 block 反复读, 但它小到一直留在片上(const + __restrict__ 还让它走只读缓存通路), 只有第一次读要访问显存。
跑之前的三道前置检查:4/5/6 这几个数怎么来选读

这段代码在计时之前打印三行诊断。目的是:如果结果不对,能立刻知道哪个前提不成立。

// fused_ops.cu:570-593
// ---- occupancy:这一级理论上每个 SM 能同时跑几个 block ----
int blocksPerSM = 0;
const void *rep = (kind == K_N1) ? (const void *)rms_n1_pass1
                : (kind == K_N2) ? (const void *)rms_n2
                : …(八级各挑一个代表)
                                 : (const void *)sm_s4;
CUDA_CHECK(cudaOccupancyMaxActiveBlocksPerMultiprocessor(&blocksPerSM, rep, BLOCK, 0));

const double smFill = (double)M / (prop.multiProcessorCount * blocksPerSM);

printf("footprint = x + y = %.1f MB(L2 %.1f MB)%s\n",
       2.0 * bytes / 1048576.0, prop.l2CacheSize / 1048576.0,
       2.0 * bytes > prop.l2CacheSize ? "  → 装不下,必须走显存"
                                      : "  ⚠ 装得下 → 数字会虚高");

一、rep:为什么要挑一个「代表 kernel」

cudaOccupancyMaxActiveBlocksPerMultiprocessor 一次只接受一个 kernel 函数指针。 但 N1 要发射两个 kernel,S1 要发射三个,所以只能挑一个代表。 嵌套三元写成单个表达式,是为了让 rep 能声明成 const。 强转成 (const void *),是因为这个 API 是 C 风格接口。

挑了谁,以及没挑的那些用多少寄存器
级实际发射挑的代表其余 kernel
N1pass1 + pass2 rms_n1_pass140 个寄存器 pass2 只用 26
S1max + sum + div sm_s1_max39 个寄存器 sum 用 40、div 用 24
S2ms + div sm_s2_ms22 个寄存器 div 用 24
这里有个隐含假设:同一级里各 kernel 的占用率相同。 这份代码里它恰好成立(三级的所有 kernel 都落在同一档), 但不是普遍成立:sm_s1_sum 比 sm_s1_max 多用 1 个寄存器, 只是没跨过下面那道悬崖。 如果某个 kernel 多用几个寄存器, 这行打印的数字就不能代表整级了。

二、这个 API 算什么:三条限额取最小

// RTX 4090(sm_89),BLOCK = 256
线程限    = 1536 / 256                       = 6
寄存器限  = 65536 / 每 block 占用的寄存器数
shared 限 = 102400 / 每 block 的 shared 字节

// 关键:寄存器按 warp 分配,而且要向上取整到 256 的倍数
每 warp 占 = 256 × ceil(寄存器数 × 32 / 256) = 256 × ceil(寄存器数 / 8)
每 block 占 = 8 个 warp × 每 warp 占

公式的两个输入「寄存器数」和「shared memory 字节」,都来自 -Xptxas -v 打印的同一行。 日志 fused_20261001_005225.txt 第 14–67 行:

// 行号      kernel          ptxas info : Used …
 17   sm_s4          Used 48 registers, used 1 barriers, 72 bytes smem, 372 bytes cmem[0]
 22   sm_s3          Used 56 registers, used 1 barriers, 72 bytes smem, 372 bytes cmem[0]
 27   sm_s2_ms       Used 22 registers, used 1 barriers, 72 bytes smem, 380 bytes cmem[0]
 32   sm_s1_div      Used 24 registers, used 0 barriers,                388 bytes cmem[0]   ← 没有 smem
 37   sm_s1_sum      Used 40 registers, used 1 barriers, 32 bytes smem, 380 bytes cmem[0]
 42   sm_s1_max      Used 39 registers, used 1 barriers, 32 bytes smem, 372 bytes cmem[0]
 47   rms_n4         Used 55 registers, used 1 barriers, 32 bytes smem, 384 bytes cmem[0]
 52   rms_n3         Used 52 registers, used 1 barriers, 32 bytes smem, 384 bytes cmem[0]
 57   rms_n2         Used 39 registers, used 1 barriers, 32 bytes smem, 384 bytes cmem[0]
 62   rms_n1_pass2   Used 26 registers, used 0 barriers,                388 bytes cmem[0]   ← 没有 smem
 67   rms_n1_pass1   Used 40 registers, used 1 barriers, 32 bytes smem, 376 bytes cmem[0]
smem 的大小可以追溯到源码
源码里的声明算出来PTXAS 报的哪些 KERNEL
__shared__ float smem[BLOCK / 32] 8 个 float × 432 bytes smem rms_n1_pass1 · rms_n2 · rms_n3rms_n4 · sm_s1_max · sm_s1_sum
__shared__ float smem[BLOCK / 32 * 2 + 2] 18 个 float × 472 bytes smem sm_s2_ms · sm_s3 · sm_s4要同时传 m 和 s,再加 2 个广播位
日志里没有 smem 这一项的两个 kernel(sm_s1_div、rms_n1_pass2),源码里本来就没有 __shared__。 它们不做归约,只读已经算好的 m/s/rstd。 该项为 0 时,ptxas 直接不打印。 同一行的 0 barriers 与此一致: 这两个 kernel 都不调 __syncthreads()。 barrier 也是每 SM 的资源(16 个), 但 1 个/block × 6 block = 6,从不构成约束,所以不在上面那三条限额里。

cmem[0] 是常量内存 bank 0,CUDA 用它传 kernel 参数。 代码里看不到它的声明,但它的内容就是函数签名里的参数,编译器把参数放进了常量内存。 按「指针 8 字节、int/float 各 4 字节」数一遍:

cmem[0] = 固定前缀 + 参数表
KERNEL形参参数共CMEM[0]差值
sm_s48+8+420 372352
sm_s38+8+420 372352
sm_s2_ms8+8+8+428 380352
sm_s1_div8+8+8+8+436 388352
sm_s1_sum8+8+8+428 380352
sm_s1_max8+8+420 372352
rms_n48+8+8+4+432 384352
rms_n38+8+8+4+432 384352
rms_n28+8+8+4+432 384352
rms_n1_pass28+8+8+8+436 388352
rms_n1_pass18+8+4+424 376352
十一个 kernel 的差值全是 352(= 0x160)。 352 是参数区在常量 bank 0 里的起始偏移。 sm_70 以后,这段前缀固定留给驱动/ABI,和你的代码无关。

为什么 cmem 不在那三条限额里

常量内存是一块独立的 64 KB 空间,不按 SM 切分,也不随 block 数复制。 所以它不限制每个 SM 能驻留几个 block。 只有参数表超过 4 KB、编译不通过时,它才成为问题。 这里是 372–388 字节,离上限很远。

照这个模型反推八个 kernel,和日志里的实测逐个对账
KERNEL寄存器每 WARP每 BLOCK 寄存器限线程限SHARED 限取最小API 返回
rms_n1_pass140128010240 663200 66 ✓
rms_n239128010240 663200 66 ✓
rms_n352179214336 463200 44 ✓
rms_n455179214336 463200 44 ✓
sm_s1_max39128010240 663200 66 ✓
sm_s2_ms227686144 1061422 66 ✓
sm_s356179214336 461422 44 ✓
sm_s448153612288 561422 55 ✓
八个全部对上。 要留意三点:① shared memory 从来不是约束: 这些 kernel 只用 32 或 72 字节,算下来能放 1400~3200 个 block,是 6 的两三百倍; ② sm_s2_ms 是唯一被线程数限制的(寄存器允许 10 个,线程只允许 6 个); ③ 1536 这个线程上限不常见:Ada 和 GA10x 都是 1536,A100(sm_80)是 2048。 换到 A100,线程限变成 8 个 block,这张表要重算。 注意:「API 返回」这一列不是硬件测量值。 它是 cudaOccupancyMaxActiveBlocksPerMultiprocessor 读编译产物算出的解析值 (日志 fused_20261001_005225.txt 第 198/208/218/228/238/248/258/268 行的 occupancy:)。所以这张表比的是手算模型 vs NVIDIA 的模型。 两边吻合只说明上面几个常数取对了,不证明运行时真有 4/5/6 个 block 同时驻留。 要测实际驻留,得用 ncu 的 achieved occupancy; §10 · 二说明了这台实例没有 profiling 权限。

第四个参数那个 0

它是 dynamicSMemSize,传 0 表示「没有额外的动态 shared memory」。 不要把静态大小也填进去,否则会被重复计算一遍。 这些 kernel 用的是 kernel 内部声明的静态 __shared__,已经编进 kernel 元数据,API 会自己读出来。

用 API 而不自己算,是因为它直接读编译产物里的实际用量。 换架构、换 nvcc 版本,都不用改这段代码。 上面手算模型里的「256 字节粒度」和架构绑定,自己写死,迟早算错。

三、寄存器悬崖,以及 sm_s4 正好踩在边上

// BLOCK = 256 固定,把上面的式子化简
blocks_reg = floor( 32 / ceil(寄存器数 / 8) )
寄存器数的影响是阶梯式的,不是连续的
寄存器数每 SM 的 BLOCK 数占用率
≤ 406100.0%
41 – 48583.3%
49 – 64466.7%
65 – 72350.0%
两道悬崖在 40→41 和 48→49。 sm_s4 用 48 个寄存器, 正好在第二道悬崖边上:再多 1 个,就掉到 4 个 block。

两者都要在寄存器里存 32 个 float,但 s4 比 s3 少用 8 个寄存器(48 vs 56)。 原因是 float4 版把它们装进 8 个 float4, 循环只跑 8 轮,省掉了大量地址计算和下标寄存器。所以 s4 的占用率(83.3%)高于 s3(66.7%)。

占用率不是目标,是手段

s4 的占用率比 s3 高 25%,但实测耗时完全相同, 都是 0.1475 ms。 访存密集算子只要 4 个 block 就能用满带宽。 多放的 block 只是多了几个排队等显存的线程。 所以这行打印的百分比用来「排除嫌疑」,不用来「追求高」。 它低于 25% 才值得担心;66.7% 和 83.3% 的差别没有意义。

四、smFill:填充度就是「几个整波」

smFill 表示这个 grid 要分几波跑完。 分母是整张卡能同时驻留的 block 总数(128 SM × 每 SM 几个)。 分子是 grid 大小;这份代码里 grid = M,一行一个 block。

各形状的波数与尾波浪费
形状M每 SM卡的容量填充度尾波浪费
decode-b1 · n116 7680.0099.9%
decode-b32 · n1326 7680.0495.8%
prefill · n120486 7682.6711.1%
prefill · n320484 5124.000.0%
主阶梯 · n181926 76810.673.0%
主阶梯 · n381924 51216.000.0%
主阶梯 · s481925 64012.801.5%
大批 · n3163844 51232.000.0%
一个巧合:主阶梯上 n3 正好是 16.00 波(零尾波),n1 是 10.67 波(3% 尾波)。 n3 占用率更低,grid 反而分得更整齐。 这是 n3 那 1.51× 里一个次要的小收益。

两点保留

① 「整波推进」是粗模型,上面那列百分比只能当上界看。 真实的调度器每退休一个 block,就立刻补一个新的, 不等整波结束,所以尾部浪费比模型算的小。

② smFill < 1.0 这个告警阈值偏松。 prefill 的 2.67 已经有 11% 的尾波浪费,但不触发告警。

五、footprint 这一行是 §9 · 三那个误导信号的来源

bytes = nElem * sizeof(float) 是 x 的大小, 2.0 * bytes 是 x + y。L2 是 75497472 字节 = 72 MiB。

程序的判定 vs 实际该看的量
形状xx + y程序判定X 单独装得下吗
RMSNorm 主阶梯112.0 MiB224.0 装不下否
softmax 主阶梯64.0 MiB 128.0装不下 是 ←
softmax 大批128.0 MiB256.0 装不下否
prefill · n128.0 MiB56.0 装得下,虚高是
logits18.6 MiB37.1 装得下,虚高是
softmax 主阶梯这一行,就是让预测 ④⑤落空的信号。 程序打印「装不下,必须走显存」,让人以为 16N 的账会如实兑现。 但 x 单独只有 64 MiB,放得进 72 MiB 的 L2。 S1 的 max→sum 两趟之间,x 还在片上,所以实际只搬了 13.7N。
这一行的三个口径问题
问题后果
只看 x+y 的总和不看单个数组 跨 kernel 重用能不能命中,取决于被重用的那个数组自己的大小。 预测 ④⑤ 就错在这里
完全不看重用距离 N2 的 footprint 是 224 MiB,判定为「装不下」;但它的重用距离只有一行 14 KB, 第二次读一定命中。所以实测快 1.49×,预测 ① 落空
单位标的是 MB除的是 1048576 实际是 MiB。程序内部一致,但和日志里的 GB/s(十进制)一起看时容易算错
要让这一行给出正确信号,至少要同时打印单个数组的大小,以及每行的字节数(重用距离的下界)。
// fused_ops.cu —— 两个访存密集算子的融合阶梯:RMSNorm 与 online softmax
//
//   这份文件是第 10 章(Reduction)的实际应用。第 10 章练的是"把一个长数组归约成
//   一个标量",这里练的是"把每一行归约成一个标量,然后立刻拿它去缩放这一行" ——
//   归约不再是目的,而是中间步骤,这才是它在真实推理栈里的样子。
//
//   ---- RMSNorm 阶梯(Qwen2.5 用的就是它,作用在 hidden=3584 上)----
//   n1   两趟两个 kernel:先算 sum(x²),再归一化          —— 基线   12N 字节
//   n2   融成一个 kernel,但 x 仍然从 global 读两次        —— 只省 launch,不省字节  12N
//   n3   x 留在寄存器,读一次                              —— 这一步才动字节账   8N
//   n4   n3 + float4 向量化                                —— 只动条数账,字节不变  8N
//
//   ---- online softmax 阶梯(attention 打分和 logits 采样都要用)----
//   s1   三趟三个 kernel:max → sum(exp) → 归一化          —— 基线   16N 字节
//   s2   online 递推,两趟两个 kernel:(m,s) → 归一化      —— 省一趟  12N
//   s3   一趟,x 留寄存器                                  —— 8N
//   s4   s3 + float4                                       —— 8N
//
//   ⚠ n2 和 n1 的字节数【完全一样】。这是故意安排的一级台阶:
//     它用来证明"融合"本身不等于省带宽 —— 省带宽的是"让数据留在片上",
//     而不是"把两个 kernel 写成一个"。这两件事经常被混为一谈。
//
//   评价指标是 MBU(Memory Bandwidth Utilization)= 实测 GB/s ÷ 理论峰值 GB/s。
//   访存密集算子的唯一目标就是把 MBU 顶上去 —— 和 SGEMM 看"占峰值算力"正好是
//   roofline 的两端。
//
//   编译:   nvcc -O3 -arch=sm_89 -lineinfo -Xptxas -v fused_ops.cu -o fused_ops
//   运行:   ./fused_ops <kernel> [M] [N] [ITERS]
//             M = 行数(token 数 / 打分矩阵的行数),N = 每行长度
//             RMSNorm 默认 M=4096  N=3584   (Qwen2.5-7B 的 hidden)
//             softmax 默认 M=4096  N=2048   (一段 2048 上下文的打分行)
//   查竞态: compute-sanitizer --tool racecheck ./fused_ops n3 8 3584 1
//
//   语法速查见 sgemm_all.cu 开头那一段,这里只标注新出现的写法。

#include <cuda_runtime.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cmath>
#include <vector>
#include <algorithm>
#include <random>
#include <cfloat>

// ---------------------------------------------------------------------------
// 出错就地报,不要等到后面某个莫名其妙的地方才崩
// ---------------------------------------------------------------------------
#define CUDA_CHECK(call) do {                                                  \
    cudaError_t e_ = (call);                                                   \
    if (e_ != cudaSuccess) {                                                   \
        printf("[CUDA 错误] %s:%d  %s\n  ↳ %s\n",                              \
               __FILE__, __LINE__, cudaGetErrorString(e_), #call);             \
        exit(1);                                                               \
    }                                                                          \
} while (0)

// 每个 block 固定 256 线程。一个 block 负责一行。
static const int BLOCK = 256;
// 寄存器驻留版每线程最多存多少个 float。32 个 float = 32 个寄存器,
// 加上其余开销还远没到 255 的上限,不会 spill。
// 于是单行最长 = BLOCK × MAXS = 256 × 32 = 8192。
static const int MAXS = 32;
// float4 版:8 个 float4 也是 32 个 float,两条路径的寄存器压力刻意做成一样,
// 这样 n3→n4 / s3→s4 就是干净的单变量对照(只动指令条数,不动寄存器)。
static const int MAXV = 8;

// ===========================================================================
//                            归约积木
// ===========================================================================
//
// 和第 10 章 R6 的区别,就一处,但很关键:
//
//   R6 用的是 __shfl_down_sync —— 归约结果只汇聚到 0 号 lane,因为那里只需要
//   一个线程把结果 atomicAdd 出去,其余线程的值是垃圾无所谓。
//
//   这里用 __shfl_xor_sync(蝶形/butterfly)—— 归约完【每个 lane 手里都是全和】。
//   因为接下来 32 个线程都要拿这个和去缩放自己负责的那几个元素,
//   少一个线程知道都不行。
//
//   代价是一样的:同样 5 轮,同样每轮一条 shuffle 指令。xor 版不多花钱,
//   只是把"漏斗"换成了"全交换"。
// ---------------------------------------------------------------------------
__inline__ __device__ float warpReduceSum(float v) {
    // off 依次取 16 8 4 2 1;lane ^ off 是"异或伙伴",
    // 第 1 轮 0↔16、1↔17……第 5 轮 0↔1、2↔3……五轮之后所有 lane 都持有全和。
    #pragma unroll
    for (int off = 16; off > 0; off >>= 1)
        v += __shfl_xor_sync(0xffffffffu, v, off);
    return v;
}

__inline__ __device__ float warpReduceMax(float v) {
    #pragma unroll
    for (int off = 16; off > 0; off >>= 1)
        v = fmaxf(v, __shfl_xor_sync(0xffffffffu, v, off));
    return v;
}

// ---------------------------------------------------------------------------
// online softmax 的联合归约:把两个 (m, s) 合成一个
//
//   合并规则:
//       m = max(m1, m2)
//       s = s1·exp(m1 − m) + s2·exp(m2 − m)
//
//   为什么可以拿它做树形归约?因为这个运算【满足结合律】。
//   不满足的话,warp 内的蝶形归约就是错的 —— 它的合并顺序和串行扫描完全不同。
//   结合律的证明见导学 §5 · 三,一句话版本:
//   两个 (m,s) 合并后等价于"把两段数据拼起来重新算一遍 max 和 sum",
//   而"拼起来"这个操作本身显然是可结合的。
// ---------------------------------------------------------------------------
// 注意这里【不能】用 -INFINITY 当初值。
//   没分到元素的线程 m 保持初值、s=0;两个这样的线程合并时
//   m - m_n = (-inf) - (-inf) = NaN,s = 0×NaN + 0×NaN = NaN,然后污染整行。
//   换成 -FLT_MAX:相减得 0,exp(0)=1,s = 0×1 + 0×1 = 0,安全退化。
__inline__ __device__ void warpReduceMaxSum(float &m, float &s) {
    #pragma unroll
    for (int off = 16; off > 0; off >>= 1) {
        float m_o = __shfl_xor_sync(0xffffffffu, m, off);
        float s_o = __shfl_xor_sync(0xffffffffu, s, off);
        float m_n = fmaxf(m, m_o);
        // 两边都先缩放到新的 m_n 再相加。谁的 m 小,谁的 exp 就是个小于 1 的数,
        // 不会溢出;相等时两个 exp 都是 1,退化成普通求和。
        s = s * __expf(m - m_n) + s_o * __expf(m_o - m_n);
        m = m_n;
    }
}

// ---------------------------------------------------------------------------
// block 级归约:先 warp 内,再跨 warp。
//   BLOCK=256 → 8 个 warp。第一轮每个 warp 归出一个值放进 shared,
//   第二轮让 0 号 warp 把这 8 个值再归一次,最后广播给全 block。
// ---------------------------------------------------------------------------
__inline__ __device__ float blockReduceSum(float v, float *smem) {
    const int lane = threadIdx.x & 31;
    const int warp = threadIdx.x >> 5;
    v = warpReduceSum(v);
    if (lane == 0) smem[warp] = v;
    __syncthreads();
    // 只有前 8 个 lane 有真值,其余喂 0(加法的幺元)
    v = (threadIdx.x < (BLOCK >> 5)) ? smem[lane] : 0.0f;
    if (warp == 0) v = warpReduceSum(v);
    // 0 号 warp 算完了,但全 block 都要用,所以再过一次 shared 广播
    if (threadIdx.x == 0) smem[0] = v;
    __syncthreads();
    return smem[0];
}

__inline__ __device__ void blockReduceMaxSum(float &m, float &s, float *smem) {
    const int lane = threadIdx.x & 31;
    const int warp = threadIdx.x >> 5;
    const int nwarp = BLOCK >> 5;
    warpReduceMaxSum(m, s);
    if (lane == 0) { smem[warp] = m; smem[nwarp + warp] = s; }
    __syncthreads();
    if (warp == 0) {
        // 幺元是 (-inf, 0):最大值取 -inf 不影响 max,和取 0 不影响求和
        m = (lane < nwarp) ? smem[lane]         : -FLT_MAX;
        s = (lane < nwarp) ? smem[nwarp + lane] : 0.0f;
        warpReduceMaxSum(m, s);
        if (lane == 0) { smem[2 * nwarp] = m; smem[2 * nwarp + 1] = s; }
    }
    __syncthreads();
    m = smem[2 * nwarp];
    s = smem[2 * nwarp + 1];
}

// ===========================================================================
//                            RMSNorm
//                y = x · rsqrt(mean(x²) + eps) · w
// ===========================================================================

// ---- N1:两趟,两个 kernel ------------------------------------------------
// 第一趟只算每行的 rstd,写进一个 M 长的小数组。
// 字节:读 x 一次 = 4N/行
__global__ void rms_n1_pass1(const float *__restrict__ x, float *__restrict__ rstd,
                             int N, float eps) {
    __shared__ float smem[BLOCK / 32];
    const long long row = blockIdx.x;
    const float *xr = x + row * (long long)N;

    float acc = 0.0f;
    for (int i = threadIdx.x; i < N; i += BLOCK) {
        float v = xr[i];
        acc += v * v;
    }
    acc = blockReduceSum(acc, smem);
    if (threadIdx.x == 0)
        rstd[row] = rsqrtf(acc / N + eps);
}

// 第二趟再把 x 读一遍,乘上第一趟算好的 rstd 和权重,写出去。
// 字节:读 x 一次 + 写 y 一次 = 8N/行   (合计 12N)
__global__ void rms_n1_pass2(const float *__restrict__ x, const float *__restrict__ w,
                             const float *__restrict__ rstd, float *__restrict__ y, int N) {
    const long long row = blockIdx.x;
    const float *xr = x + row * (long long)N;
    float       *yr = y + row * (long long)N;
    const float r = rstd[row];
    for (int i = threadIdx.x; i < N; i += BLOCK)
        yr[i] = xr[i] * r * w[i];
}

// ---- N2:融成一个 kernel,但 x 还是读两次 ---------------------------------
// 和 N1 的字节数【一模一样】。省掉的只有:一次 kernel launch、
// rstd 数组的一写一读(4M 字节,相对 12NM 可以忽略)。
// 留着这一级,就是为了让"融合 ≠ 省带宽"这件事有个可量化的对照。
__global__ void rms_n2(const float *__restrict__ x, const float *__restrict__ w,
                       float *__restrict__ y, int N, float eps) {
    __shared__ float smem[BLOCK / 32];
    const long long row = blockIdx.x;
    const float *xr = x + row * (long long)N;
    float       *yr = y + row * (long long)N;

    float acc = 0.0f;
    for (int i = threadIdx.x; i < N; i += BLOCK) {
        float v = xr[i];                       // ← 第一次读
        acc += v * v;
    }
    const float r = rsqrtf(blockReduceSum(acc, smem) / N + eps);
    for (int i = threadIdx.x; i < N; i += BLOCK)
        yr[i] = xr[i] * r * w[i];              // ← 第二次读,同样的地址
}

// ---- N3:x 留在寄存器,只读一次 -------------------------------------------
// 字节:读 x 一次 + 写 y 一次 = 8N/行。相对 N1/N2 的 12N,理论加速 1.5×。
// 代价是每线程多占 MAXS 个寄存器,且单行长度被 BLOCK×MAXS 卡死。
__global__ void rms_n3(const float *__restrict__ x, const float *__restrict__ w,
                       float *__restrict__ y, int N, float eps) {
    __shared__ float smem[BLOCK / 32];
    const long long row = blockIdx.x;
    const float *xr = x + row * (long long)N;
    float       *yr = y + row * (long long)N;

    // 循环必须写成"固定 MAXS 次 + 边界判断",不能写成 for(i=tid; i<N; i+=BLOCK)
    // 配一个 xs[cnt++]。后者的下标是运行期算出来的,而【寄存器不能动态寻址】——
    // 编译器只好把 xs 放进 local memory(也就是显存),ptxas 会报 spill stores,
    // 这一级"数据留在片上"的前提当场作废。
    // 写成常量下标 xs[c] 并 #pragma unroll,编译器才会把它真正分配到寄存器里。
    float xs[MAXS];
    float acc = 0.0f;
    #pragma unroll
    for (int c = 0; c < MAXS; ++c) {
        int i = threadIdx.x + c * BLOCK;
        float v = (i < N) ? xr[i] : 0.0f;      // 越界的线程喂 0,不影响平方和
        xs[c] = v;
        acc += v * v;
    }
    const float r = rsqrtf(blockReduceSum(acc, smem) / N + eps);
    #pragma unroll
    for (int c = 0; c < MAXS; ++c) {
        int i = threadIdx.x + c * BLOCK;
        if (i < N) yr[i] = xs[c] * r * w[i];
    }
}

// ---- N4:N3 + float4 -------------------------------------------------------
// 字节数和 N3 完全相同,变的只有指令条数和内存请求数:
// 4 个 32 位 load 并成 1 个 128 位 load。
__global__ void rms_n4(const float *__restrict__ x, const float *__restrict__ w,
                       float *__restrict__ y, int N, float eps) {
    __shared__ float smem[BLOCK / 32];
    const long long row = blockIdx.x;
    const int n4 = N >> 2;                     // 一行有多少个 float4

    // reinterpret_cast 把 float* 重新解释成 float4*,地址必须 16 字节对齐 ——
    // cudaMalloc 返回的指针是 256 字节对齐的,而 row*N 只要 N%4==0 就仍然对齐。
    const float4 *xr = reinterpret_cast<const float4 *>(x + row * (long long)N);
    float4       *yr = reinterpret_cast<float4 *>(y + row * (long long)N);
    const float4 *wr = reinterpret_cast<const float4 *>(w);

    float4 xs[MAXV];
    float acc = 0.0f;
    #pragma unroll
    for (int c = 0; c < MAXV; ++c) {
        int i = threadIdx.x + c * BLOCK;
        float4 v = (i < n4) ? xr[i] : make_float4(0.f, 0.f, 0.f, 0.f);
        xs[c] = v;
        acc += v.x * v.x + v.y * v.y + v.z * v.z + v.w * v.w;
    }
    const float r = rsqrtf(blockReduceSum(acc, smem) / N + eps);
    #pragma unroll
    for (int c = 0; c < MAXV; ++c) {
        int i = threadIdx.x + c * BLOCK;
        if (i >= n4) continue;
        float4 v = xs[c], g = wr[i], o;
        o.x = v.x * r * g.x;  o.y = v.y * r * g.y;
        o.z = v.z * r * g.z;  o.w = v.w * r * g.w;
        yr[i] = o;
    }
}

// ===========================================================================
//                          online softmax
//                  y_i = exp(x_i − max) / Σ exp(x_j − max)
// ===========================================================================

// ---- S1:三趟,三个 kernel -------------------------------------------------
// 字节:4N + 4N + (4N+4N) = 16N/行
__global__ void sm_s1_max(const float *__restrict__ x, float *__restrict__ mx, int N) {
    __shared__ float smem[BLOCK / 32];
    const long long row = blockIdx.x;
    const float *xr = x + row * (long long)N;
    float m = -FLT_MAX;
    for (int i = threadIdx.x; i < N; i += BLOCK) m = fmaxf(m, xr[i]);

    const int lane = threadIdx.x & 31, warp = threadIdx.x >> 5;
    m = warpReduceMax(m);
    if (lane == 0) smem[warp] = m;
    __syncthreads();
    m = (threadIdx.x < BLOCK / 32) ? smem[lane] : -FLT_MAX;
    if (warp == 0) m = warpReduceMax(m);
    if (threadIdx.x == 0) mx[row] = m;
}

__global__ void sm_s1_sum(const float *__restrict__ x, const float *__restrict__ mx,
                          float *__restrict__ sm, int N) {
    __shared__ float smem[BLOCK / 32];
    const long long row = blockIdx.x;
    const float *xr = x + row * (long long)N;
    const float m = mx[row];
    float s = 0.0f;
    for (int i = threadIdx.x; i < N; i += BLOCK) s += __expf(xr[i] - m);
    s = blockReduceSum(s, smem);
    if (threadIdx.x == 0) sm[row] = s;
}

__global__ void sm_s1_div(const float *__restrict__ x, const float *__restrict__ mx,
                          const float *__restrict__ sm, float *__restrict__ y, int N) {
    const long long row = blockIdx.x;
    const float *xr = x + row * (long long)N;
    float       *yr = y + row * (long long)N;
    const float m = mx[row], inv = 1.0f / sm[row];
    for (int i = threadIdx.x; i < N; i += BLOCK)
        yr[i] = __expf(xr[i] - m) * inv;
}

// ---- S2:online 递推,两趟 -------------------------------------------------
// 第一趟【一次扫描同时得到 max 和 sum】—— 这就是 online softmax 的全部内容。
// 字节:4N + (4N+4N) = 12N/行
__global__ void sm_s2_ms(const float *__restrict__ x, float *__restrict__ mx,
                         float *__restrict__ sm, int N) {
    __shared__ float smem[BLOCK / 32 * 2 + 2];
    const long long row = blockIdx.x;
    const float *xr = x + row * (long long)N;

    float m = -FLT_MAX, s = 0.0f;
    for (int i = threadIdx.x; i < N; i += BLOCK) {
        float v = xr[i];
        // 递推三行:见到更大的 max,就把已经累好的 s 按比例缩回去
        float m_n = fmaxf(m, v);
        s = s * __expf(m - m_n) + __expf(v - m_n);
        m = m_n;
    }
    blockReduceMaxSum(m, s, smem);
    if (threadIdx.x == 0) { mx[row] = m; sm[row] = s; }
}

// ---- S3:一趟,x 留寄存器 ---------------------------------------------------
// 字节:4N + 4N = 8N/行。相对 S1 的 16N,理论加速 2.0×。
__global__ void sm_s3(const float *__restrict__ x, float *__restrict__ y, int N) {
    __shared__ float smem[BLOCK / 32 * 2 + 2];
    const long long row = blockIdx.x;
    const float *xr = x + row * (long long)N;
    float       *yr = y + row * (long long)N;

    // 常量下标 + #pragma unroll,理由同 rms_n3
    float xs[MAXS];
    float m = -FLT_MAX, s = 0.0f;
    #pragma unroll
    for (int c = 0; c < MAXS; ++c) {
        int i = threadIdx.x + c * BLOCK;
        if (i >= N) { xs[c] = -FLT_MAX; continue; }  // 越界位置塞 -FLT_MAX:
        float v = xr[i];                             // 既不会成为 max,
        xs[c] = v;                                   // exp 出来也是 0
        float m_n = fmaxf(m, v);
        s = s * __expf(m - m_n) + __expf(v - m_n);
        m = m_n;
    }
    blockReduceMaxSum(m, s, smem);
    const float inv = 1.0f / s;
    #pragma unroll
    for (int c = 0; c < MAXS; ++c) {
        int i = threadIdx.x + c * BLOCK;
        if (i < N) yr[i] = __expf(xs[c] - m) * inv;
    }
}

// ---- S4:S3 + float4 -------------------------------------------------------
__global__ void sm_s4(const float *__restrict__ x, float *__restrict__ y, int N) {
    __shared__ float smem[BLOCK / 32 * 2 + 2];
    const long long row = blockIdx.x;
    const int n4 = N >> 2;
    const float4 *xr = reinterpret_cast<const float4 *>(x + row * (long long)N);
    float4       *yr = reinterpret_cast<float4 *>(y + row * (long long)N);

    float4 xs[MAXV];
    float m = -FLT_MAX, s = 0.0f;
    #pragma unroll
    for (int c = 0; c < MAXV; ++c) {
        int i = threadIdx.x + c * BLOCK;
        if (i >= n4) { xs[c] = make_float4(-FLT_MAX, -FLT_MAX, -FLT_MAX, -FLT_MAX); continue; }
        float4 v = xr[i];
        xs[c] = v;
        // 一次进来 4 个元素,先在寄存器里把这 4 个合成一个 (m,s),再并进累计值。
        // 这已经是 FlashAttention 分块合并的最小形态了 —— 只不过块大小是 4。
        float mv = fmaxf(fmaxf(v.x, v.y), fmaxf(v.z, v.w));
        float sv = __expf(v.x - mv) + __expf(v.y - mv) + __expf(v.z - mv) + __expf(v.w - mv);
        float m_n = fmaxf(m, mv);
        s = s * __expf(m - m_n) + sv * __expf(mv - m_n);
        m = m_n;
    }
    blockReduceMaxSum(m, s, smem);
    const float inv = 1.0f / s;
    #pragma unroll
    for (int c = 0; c < MAXV; ++c) {
        int i = threadIdx.x + c * BLOCK;
        if (i >= n4) continue;
        float4 v = xs[c], o;
        o.x = __expf(v.x - m) * inv;  o.y = __expf(v.y - m) * inv;
        o.z = __expf(v.z - m) * inv;  o.w = __expf(v.w - m) * inv;
        yr[i] = o;
    }
}

// ===========================================================================
//                         CPU 参照与抽样校验
// ===========================================================================
enum Kind { K_N1, K_N2, K_N3, K_N4, K_S1, K_S2, K_S3, K_S4 };
static bool isNorm(Kind k) { return k <= K_N4; }

// 只抽查若干行。全查的话 M=4096、N=3584 要在 CPU 上算 1400 万次 exp,太慢。
static int verifyRows(Kind kind, int M, int N, const std::vector<float> &hx,
                      const std::vector<float> &hw, const std::vector<float> &hy,
                      int nrows) {
    std::mt19937 rng(20260925);
    int bad = 0;
    for (int t = 0; t < nrows; ++t) {
        long long row = rng() % (unsigned)M;
        const float *xr = hx.data() + row * (long long)N;
        const float *yr = hy.data() + row * (long long)N;

        std::vector<double> ref(N);
        if (isNorm(kind)) {
            double acc = 0.0;
            for (int i = 0; i < N; ++i) acc += (double)xr[i] * xr[i];
            double r = 1.0 / std::sqrt(acc / N + 1e-6);
            for (int i = 0; i < N; ++i) ref[i] = xr[i] * r * hw[i];
        } else {
            double m = -1e300;
            for (int i = 0; i < N; ++i) m = std::max(m, (double)xr[i]);
            double s = 0.0;
            for (int i = 0; i < N; ++i) s += std::exp(xr[i] - m);
            for (int i = 0; i < N; ++i) ref[i] = std::exp(xr[i] - m) / s;
        }
        // __expf / rsqrtf 是快速近似指令,容差要比普通浮点宽一点。
        // 这里用相对误差 2e-3,softmax 的绝对值很小,再加一个 1e-7 的绝对兜底。
        for (int i = 0; i < N; ++i) {
            double d = std::fabs(ref[i] - yr[i]);
            if (d > 2e-3 * std::fabs(ref[i]) + 1e-7) {
                if (bad < 3)
                    printf("  行 %lld 列 %d: GPU %.6g  CPU %.6g\n", row, i, yr[i], ref[i]);
                ++bad;
            }
        }
    }
    return bad;
}

// ===========================================================================
//                                main
// ===========================================================================
int main(int argc, char **argv) {
    if (argc < 2) {
        printf("用法: %s <kernel> [M] [N] [ITERS]\n"
               "  kernel = n1|n2|n3|n4   RMSNorm     默认 M=4096 N=3584\n"
               "           s1|s2|s3|s4   softmax     默认 M=4096 N=2048\n"
               "  M = 行数(token 数 / 打分矩阵的行数),N = 每行长度\n"
               "  例: %s n4 4096 3584 11      Qwen2.5-7B 的一次 RMSNorm\n"
               "       %s n4 1 3584 11         decode batch=1(只有 1 个 block!)\n"
               "       %s s4 114688 2048 11    28 头 × 4096 个 query 的打分\n",
               argv[0], argv[0], argv[0], argv[0]);
        return 2;
    }
    const char *name = argv[1];
    Kind kind;
    if      (!strcmp(name, "n1")) kind = K_N1;
    else if (!strcmp(name, "n2")) kind = K_N2;
    else if (!strcmp(name, "n3")) kind = K_N3;
    else if (!strcmp(name, "n4")) kind = K_N4;
    else if (!strcmp(name, "s1")) kind = K_S1;
    else if (!strcmp(name, "s2")) kind = K_S2;
    else if (!strcmp(name, "s3")) kind = K_S3;
    else if (!strcmp(name, "s4")) kind = K_S4;
    else { printf("不认识的 kernel: %s\n", name); return 2; }

    int M     = (argc > 2) ? atoi(argv[2]) : 4096;
    int N     = (argc > 3) ? atoi(argv[3]) : (isNorm(kind) ? 3584 : 2048);
    int ITERS = (argc > 4) ? atoi(argv[4]) : 11;
    if (M <= 0 || N <= 0) { printf("M/N 必须为正\n"); return 2; }

    cudaDeviceProp prop;
    CUDA_CHECK(cudaGetDeviceProperties(&prop, 0));
    int memClkKHz = 0, gpuClkKHz = 0;
    cudaDeviceGetAttribute(&memClkKHz, cudaDevAttrMemoryClockRate, 0);
    cudaDeviceGetAttribute(&gpuClkKHz, cudaDevAttrClockRate, 0);
    // 峰值带宽 = 内存时钟 × 2(DDR 双沿)× 位宽/8。和 sgemm_all.cu 用同一个口径。
    const double peakBW = memClkKHz * 2.0 * (prop.memoryBusWidth / 8) / 1.0e6;

    printf("GPU: %s  sm_%d%d  %d SM  L2 %.1f MB   显存带宽峰值 %.0f GB/s\n",
           prop.name, prop.major, prop.minor, prop.multiProcessorCount,
           prop.l2CacheSize / 1048576.0, peakBW);
    printf("[%s] %s   M=%d 行 × N=%d 列   block %d 线程   grid %d 个 block\n",
           name, isNorm(kind) ? "RMSNorm" : "softmax", M, N, BLOCK, M);

    // ---- 两个会让结果作废的前提,先查 ----
    const bool needReg = (kind == K_N3 || kind == K_N4 || kind == K_S3 || kind == K_S4);
    if (needReg && N > BLOCK * MAXS) {
        printf("✗ 这一级把整行存在寄存器里,单行最长 %d,而 N=%d。\n",
               BLOCK * MAXS, N);
        printf("  这不是实现偷懒,是硬件约束 —— 也正是 FlashAttention 必须分块的原因,\n"
               "  详见导学 §7 · 三。想跑长行请用 n1/n2 或 s1/s2。\n");
        return 3;
    }
    const bool needVec4 = (kind == K_N4 || kind == K_S4);
    if (needVec4 && (N & 3)) {
        printf("✗ float4 版要求 N 是 4 的倍数(当前 N=%d)\n", N);
        return 3;
    }

    // ---- 数据 ----
    const size_t nElem = (size_t)M * N;
    const size_t bytes = nElem * sizeof(float);
    std::vector<float> hx(nElem), hw(N), hy(nElem);
    std::mt19937 rng(12345);
    std::uniform_real_distribution<float> dist(-3.0f, 3.0f);
    for (size_t i = 0; i < nElem; ++i) hx[i] = dist(rng);
    for (int i = 0; i < N; ++i)        hw[i] = 0.5f + 0.5f * dist(rng) / 3.0f;

    float *dx = nullptr, *dw = nullptr, *dy = nullptr, *dm = nullptr, *ds = nullptr;
    CUDA_CHECK(cudaMalloc(&dx, bytes));
    CUDA_CHECK(cudaMalloc(&dy, bytes));
    CUDA_CHECK(cudaMalloc(&dw, N * sizeof(float)));
    CUDA_CHECK(cudaMalloc(&dm, M * sizeof(float)));
    CUDA_CHECK(cudaMalloc(&ds, M * sizeof(float)));
    CUDA_CHECK(cudaMemcpy(dx, hx.data(), bytes, cudaMemcpyHostToDevice));
    CUDA_CHECK(cudaMemcpy(dw, hw.data(), N * sizeof(float), cudaMemcpyHostToDevice));

    const float eps = 1e-6f;
    // lambda:把"发射这一级的全部 kernel"打包成一个可重复调用的东西。
    // [&] 表示按引用捕获外面所有用到的变量。
    auto launch = [&]() {
        switch (kind) {
            case K_N1: rms_n1_pass1<<<M, BLOCK>>>(dx, dm, N, eps);
                       rms_n1_pass2<<<M, BLOCK>>>(dx, dw, dm, dy, N);          break;
            case K_N2: rms_n2<<<M, BLOCK>>>(dx, dw, dy, N, eps);               break;
            case K_N3: rms_n3<<<M, BLOCK>>>(dx, dw, dy, N, eps);               break;
            case K_N4: rms_n4<<<M, BLOCK>>>(dx, dw, dy, N, eps);               break;
            case K_S1: sm_s1_max<<<M, BLOCK>>>(dx, dm, N);
                       sm_s1_sum<<<M, BLOCK>>>(dx, dm, ds, N);
                       sm_s1_div<<<M, BLOCK>>>(dx, dm, ds, dy, N);             break;
            case K_S2: sm_s2_ms <<<M, BLOCK>>>(dx, dm, ds, N);
                       sm_s1_div<<<M, BLOCK>>>(dx, dm, ds, dy, N);             break;
            case K_S3: sm_s3<<<M, BLOCK>>>(dx, dy, N);                         break;
            case K_S4: sm_s4<<<M, BLOCK>>>(dx, dy, N);                         break;
        }
    };

    // ---- occupancy:这一级理论上每个 SM 能同时跑几个 block ----
    int blocksPerSM = 0;
    const void *rep = (kind == K_N1) ? (const void *)rms_n1_pass1
                    : (kind == K_N2) ? (const void *)rms_n2
                    : (kind == K_N3) ? (const void *)rms_n3
                    : (kind == K_N4) ? (const void *)rms_n4
                    : (kind == K_S1) ? (const void *)sm_s1_max
                    : (kind == K_S2) ? (const void *)sm_s2_ms
                    : (kind == K_S3) ? (const void *)sm_s3
                                     : (const void *)sm_s4;
    CUDA_CHECK(cudaOccupancyMaxActiveBlocksPerMultiprocessor(&blocksPerSM, rep, BLOCK, 0));
    printf("occupancy: %d block × %d 线程 = %d / %d = %.1f%%",
           blocksPerSM, BLOCK, blocksPerSM * BLOCK, prop.maxThreadsPerMultiProcessor,
           100.0 * blocksPerSM * BLOCK / prop.maxThreadsPerMultiProcessor);
    // grid 太小的话,占用率再高也没用 —— 卡根本没被填满。这是 decode 的常态。
    const double smFill = (double)M / (prop.multiProcessorCount * blocksPerSM);
    printf("   grid 填充度 %d / (%d SM × %d) = %.2f%s\n",
           M, prop.multiProcessorCount, blocksPerSM, smFill,
           smFill < 1.0 ? "   ⚠ 填不满,卡在空转" : "");

    printf("footprint = x + y = %.1f MB(L2 %.1f MB)%s\n",
           2.0 * bytes / 1048576.0, prop.l2CacheSize / 1048576.0,
           2.0 * bytes > prop.l2CacheSize ? "  → 装不下,必须走显存"
                                          : "  ⚠ 装得下 → 数字会虚高");

    // ---- 计时:和 sgemm_all.cu 同一套(预热 + 中位数)----
    const int WARMUP = (ITERS <= 1) ? 0 : (ITERS <= 2 ? 1 : 3);
    for (int i = 0; i < WARMUP; ++i) launch();
    CUDA_CHECK(cudaGetLastError());
    CUDA_CHECK(cudaDeviceSynchronize());

    cudaEvent_t ev0, ev1;
    CUDA_CHECK(cudaEventCreate(&ev0));
    CUDA_CHECK(cudaEventCreate(&ev1));
    std::vector<float> samples;
    for (int it = 0; it < ITERS; ++it) {
        CUDA_CHECK(cudaEventRecord(ev0));
        launch();
        CUDA_CHECK(cudaEventRecord(ev1));
        CUDA_CHECK(cudaEventSynchronize(ev1));
        float t; CUDA_CHECK(cudaEventElapsedTime(&t, ev0, ev1));
        samples.push_back(t);
    }
    std::sort(samples.begin(), samples.end());
    const size_t ns = samples.size();
    const double ms = (ns % 2) ? samples[ns / 2]
                               : 0.5 * (samples[ns / 2 - 1] + samples[ns / 2]);

    CUDA_CHECK(cudaMemcpy(hy.data(), dy, bytes, cudaMemcpyDeviceToHost));
    const int bad = verifyRows(kind, M, N, hx, hw, hy, 8);
    printf("%s(抽查 8 行)\n", bad == 0 ? "✓ 抽样校验通过" : "✗ 抽样校验失败");

    // ---- 字节账:这是这两个算子唯一重要的一本账 ----
    // 每行搬多少字节,按"必须经过显存的量"算。权重 w 只有 4N 字节,
    // 所有行共用、稳稳待在 L2 里,不计入。
    double bytesPerRow;
    switch (kind) {
        case K_N1: case K_N2: bytesPerRow = 12.0 * N; break;   // 读两次 + 写一次
        case K_N3: case K_N4: bytesPerRow =  8.0 * N; break;   // 读一次 + 写一次
        case K_S1:            bytesPerRow = 16.0 * N; break;   // 读三次 + 写一次
        case K_S2:            bytesPerRow = 12.0 * N; break;
        default:              bytesPerRow =  8.0 * N; break;
    }
    const double moved = bytesPerRow * M;
    const double gbps  = moved / (ms / 1000.0) / 1.0e9;
    const double mbu   = 100.0 * gbps / peakBW;

    printf("中位耗时 %.4f ms   搬运 %.1f MB   %.1f GB/s   MBU %.1f%%\n",
           ms, moved / 1048576.0, gbps, mbu);
    printf("字节账: %.0fN 字节/行 × %d 行 = %.1f MB;"
           "理论下限 %.4f ms(按 %.0f GB/s 跑满)\n",
           bytesPerRow / N, M, moved / 1048576.0,
           moved / (peakBW * 1e9) * 1000.0, peakBW);

    CUDA_CHECK(cudaFree(dx)); CUDA_CHECK(cudaFree(dy)); CUDA_CHECK(cudaFree(dw));
    CUDA_CHECK(cudaFree(dm)); CUDA_CHECK(cudaFree(ds));
    return bad == 0 ? 0 : 1;
}

三、compute-sanitizer 在查什么:读懂 racecheck 日志↑

compute-sanitizer 是 CUDA 自带的运行时检查工具。 它照常运行你的程序,同时监控每一条访存指令。

这里用的是它的 racecheck 工具,只查一类错误:shared memory 上的竞态。 同时满足下面三个条件,就是一处竞态:

  • 同一个 block 里的两个线程,访问同一个 shared memory 地址;
  • 两次访问中,至少有一次是写;
  • 两次访问之间,没有 __syncthreads()。

一、为什么需要它:抽样校验抓不住竞态

漏写 __syncthreads() 不会让程序崩溃,也不会报错。 它只会让结果偏一点点。偏多少取决于 warp 的执行顺序。 换一个形状或换一张卡,结果可能就不同。 所以「✓ 抽样校验通过」不能证明没有竞态。 这一次结果对,可能只是执行顺序碰巧合适。

racecheck 不看结果对不对,只看访问顺序。 只要访问顺序满足上面三个条件,它就报告一处竞态,哪怕这次读到的值碰巧正确。 racecheck 把每一处竞态记为一个 hazard,按读写的先后分三类:

racecheck 报告的三类 hazard
类型含义在 blockReduceSum 里的例子
RAWRead-After-Write 线程 B 要读线程 A 写的新值,但 A 可能还没写 删掉同步点 ⑥:1–7 号 warp 读 smem[0] 时,0 号线程可能还没写入
WARWrite-After-Read 线程 B 还没读完旧值,线程 A 就把它覆盖了 同一个 kernel 连续做两次归约:第二次的 ② 可能覆盖第一次还没读完的 smem[0]
WAWWrite-After-Write 两个线程写同一个地址,最后留下哪个值不确定 把 smem[warp] 错写成 smem[lane]:8 个 warp 的 lane 0 都写 smem[0]

二、对照源码:racecheck 检查哪几行

下面是 fused_ops.cu 里的 blockReduceSum,n3 用的就是它。 注释说明 racecheck 怎样看每一行。 蓝色是 shared memory 访问; 红色是 __syncthreads(),删掉任何一个,racecheck 都会报告竞态。

__inline__ __device__ float blockReduceSum(float v, float *smem) {
    const int lane = threadIdx.x & 31;
    const int warp = threadIdx.x >> 5;
    v = warpReduceSum(v);                // warp 内用 shuffle 交换,数据在寄存器里,racecheck 不检查
    if (lane == 0) smem[warp] = v;       // ② 写:8 个线程各写一个槽 smem[0..7],地址不重叠,没有 WAW
    __syncthreads();                     // ③ 同步:删掉它,smem[1..7] 报 RAW
                                         //    (0 号 warp 读的时候,7 号 warp 可能还没写)
    v = (threadIdx.x < (BLOCK >> 5)) ? smem[lane] : 0.0f;
                                         // ④ 读:只有 0 号 warp 的前 8 个线程读
    if (warp == 0) v = warpReduceSum(v);
    if (threadIdx.x == 0) smem[0] = v;   // ⑤ 写回 smem[0]:④ 里读 smem[0] 的也是 0 号线程
                                         //    同一个线程先读后写,不算竞态,所以这里不需要同步
    __syncthreads();                     // ⑥ 同步:删掉它,smem[0] 报 RAW
                                         //    (其余 255 个线程可能读到 ② 写入的部分和)
    return smem[0];                      // ⑦ 读:256 个线程读同一个地址,只读不写,没有竞态
}

三、逐行读 fused_20261001_005225.txt 的 racecheck 段

注意:只看最后一行 RACECHECK SUMMARY。 racecheck 运行时,程序打印的耗时、GB/s 和 MBU 都没有意义。

--- racecheck · n3 ---
$ compute-sanitizer --tool racecheck ./fused_ops n3 8 1000 1
========= COMPUTE-SANITIZER                // 以 ========= 开头的行由 sanitizer 打印;其余行是程序自己的输出
GPU: NVIDIA GeForce RTX 4090  sm_89  128 SM  L2 72.0 MB   显存带宽峰值 1008 GB/s
[n3] RMSNorm   M=8 行 × N=1000 列   block 256 线程   grid 8 个 block
occupancy: 4 block × 256 线程 = 1024 / 1536 = 66.7%   grid 填充度 8 / (128 SM × 4) = 0.02   ⚠ 填不满,卡在空转
                                           // 这里的 ⚠ 是预期的:这一步故意用很小的形状,不测性能
footprint = x + y = 0.1 MB(L2 72.0 MB)  ⚠ 装得下 → 数字会虚高
✓ 抽样校验通过(抽查 8 行)                // 程序自己的正确性校验,和 racecheck 无关
中位耗时 2.3409 ms   搬运 0.1 MB   0.0 GB/s   MBU 0.0%
                                           // 正常应是微秒级;插桩让程序慢了几百倍,这个数字没有意义
字节账: 8N 字节/行 × 8 行 = 0.1 MB;理论下限 0.0001 ms(按 1008 GB/s 跑满)
========= RACECHECK SUMMARY: 0 hazards displayed (0 errors, 0 warnings)
                                           // 只看这一行:0 hazards 表示没有发现竞态

s3 和 s4 两段的格式相同,结尾也都是 0 hazards。 这满足验收清单里「三段 racecheck 都要 0 hazards」的要求。

如果有人删掉一个 __syncthreads(),SUMMARY 前面会多出几条报告。 每条报告写明竞态类型、shared memory 地址,以及读写两侧的源码位置。 编译时要加 -lineinfo,报告才能定位到行号。SUMMARY 里的 hazard 数也不再是 0。

四、为什么只测三个 kernel,为什么形状这么小

run_fused.sh 第 5 段的四个取舍
取舍原因
只测 n3 s3 s4 竞态只可能出现在 block 级归约里。n3 代表 blockReduceSum(n4 用同一个函数); s3、s4 代表 blockReduceMaxSum(s4 是它的 float4 版)。
M=8 N=1000,只跑 1 次 racecheck 让程序慢几百倍,用主基准的 8192 行跑不完。 竞态发生在 block 内部,每个 block 执行的代码相同。 所以 8 个 block 和 8192 个 block 检查到的代码路径相同。
N=1000 1000 不是 256 的整数倍,所以每行末尾的边界分支也会被执行到。
放在计时之后 racecheck 很慢,结果只有「0」和「非 0」两种。 放在最后,机器上没装 compute-sanitizer 时只跳过这一段,前面的性能数据不受影响。

racecheck 不检查的错误

racecheck 只检查 shared memory。 其他错误要换用同一个程序的其他工具:

错误工具
全局内存越界--tool memcheck(默认)
读到未初始化的数据--tool initcheck
__syncthreads() 放在了不是所有线程都会执行的分支里--tool synccheck

warp 内的 __shfl_xor_sync 只在寄存器之间交换数据, 不经过 shared memory,所以也不在检查范围内。

小结:三项检查各回答一个问题。

  • 计时:kernel 快不快。
  • 抽样校验:这一次结果对不对。
  • racecheck:结果对,是不是只因为执行顺序碰巧合适。

三段都是 0 hazards,说明 blockReduceSum 和 blockReduceMaxSum 里的 __syncthreads() 位置正确。

四、run_fused.sh 全文↑

一条命令跑完八个 kernel × 五种形状 × 三轮,外加边界、长行拒绝和 racecheck。 这次实跑的产物就是 §9 那三张表。

  • 脚本开头有一段 shell 语法速查。 它讲脚本用到的写法:${VAR:-默认}、$( )、"$@" 与 "$*"、 2>&1、set -u、set --、command -v 等。 正文每段开头也标了该段做什么。不熟悉 shell 的话,先读那段,再读正文。
  • 全脚本只有一处故意不加引号: 形状扫描那段的 set -- $shp。目的是让 "prefill 2048" 按空格拆成两个词。注释里也标了。
#!/usr/bin/env bash
# =============================================================================
# run_fused.sh —— RMSNorm / online softmax 两条融合阶梯的完整测试
#
#   八个 kernel:n1 n2 n3 n4(RMSNorm)+ s1 s2 s3 s4(softmax)
#
#   和 SGEMM 那套的区别:这两个算子是【访存密集】的,
#   所以看的不是"占峰值算力",是 MBU = 实测 GB/s ÷ 1008。
#
#   阶段:
#     0 编译 + spill 检查   ← 这一步比 SGEMM 那边更要命,见下面
#     1 正确性(含边界)
#     2 主阶梯(固定形状,看 1.5× 和 2.0× 兑现了多少)
#     3 形状扫描(decode b1 / b32 / prefill / 大批)
#     4 长行:寄存器放不下会怎样
#     5 竞态检查
#
#   ⚠ 第 0 步的 spill 检查是这份测试的命门:
#     n3/n4/s3/s4 的全部立论就是"整行留在寄存器里,只读一次 global"。
#     一旦 ptxas 报 spill stores,那行数据其实被挪到了 local memory(也就是显存),
#     于是它们退化成"读两次",1.5× 和 2.0× 都不会出现,而且你从耗时上看不出原因。
#
#   用法:bash run_fused.sh
#         ROUNDS=1 bash run_fused.sh      # 快速过一遍
# =============================================================================
#
# ---- 给不熟悉 shell 的读者:这份脚本用到的语法,一次讲清 --------------------
#
#   #!/usr/bin/env bash  第一行的 #! 叫 shebang,告诉系统拿哪个解释器跑这个文件。
#                      写 env bash 而不是写死 /bin/bash,是为了用 PATH 里找到的那个。
#   #                  行内从 # 到行尾都是注释(shebang 那一行除外)。
#   $VAR / ${VAR}      取变量的值。${} 是为了和紧跟的字符分开,
#                      比如 "fused_${STAMP}.txt" —— 不加花括号会被当成变量名 STAMP.txt。
#   "$VAR"             几乎永远要加双引号 —— 不加的话,值里有空格就会被拆成多个参数。
#                      下面只有一处故意不加(set -- $shp),那里就是要它拆开。
#   ${VAR:-默认值}      VAR 没设置或为空时取「默认值」。ROUNDS=${ROUNDS:-3} 的意思是:
#                      允许调用者用 ROUNDS=1 bash run_fused.sh 从环境覆盖它。
#   $(命令)             命令替换:先跑命令,把它打印的内容替换到这里。
#   名字() { ...; }     定义函数。调用时直接写名字,第 1、2 个参数是 $1 $2 …
#                      函数体写在一行时,最后一条命令后面也要有分号,} 前要有空格。
#   "$@"  与  "$*"     都表示"全部参数",但用途相反:
#                      "$@" 保持每个参数各自独立 → 拿来【执行】;
#                      "$*" 拼成一整串           → 拿来【打印】。下面 run() 里两个都用到了。
#   cmd1 | cmd2        管道:把 cmd1 的输出喂给 cmd2 的输入。
#   >  文件            把输出写进文件(覆盖);  >>  是追加。
#   2>/dev/null        把「错误输出」丢掉(1 是正常输出,2 是错误输出)。
#   2>&1               把错误输出并进正常输出,这样管道和重定向才收得到它。
#   \$                 反斜杠转义,输出一个字面的 $ 符号,而不是去取变量。
#   for x in 一串词     按空格逐个取出。词可以来自字面量、变量展开或 $( )。
#   do … done          for / while 的循环体;if 用 then … fi 收尾。
#   A || { B; }        A 失败(返回非 0)时才执行 B。A && B 则相反。
#                      { } 把多条命令打包成一组(注意最后一条也要有分号)。
#   set -u             用到没定义过的变量就立刻报错退出,防手滑打错变量名。
#   set -- 词 词       重设位置参数:执行后 $1 $2 就变成这些词。
#                      下面拿它把 "prefill 2048" 这种「名字+数值」的配对拆成两个变量。
#   command -v xxx     查「系统里有没有 xxx 这个命令」,输出丢掉只看成败。
#   exit 1             中止脚本并返回非 0(约定:0 成功,非 0 失败)。
#
#   ---- 被测程序的调用约定 ----
#   ./fused_ops <kind> <M> <N> <iters>
#       kind   n1|n2|n3|n4|s1|s2|s3|s4
#       M      行数(= batch × seq,每行一个 block)
#       N      列数(RMSNorm 用 hidden=3584,softmax 用 seq=2048)
#       iters  计时重复次数,程序取中位数
#
# =============================================================================
set -u

# ---- 可调参数:都用 ${VAR:-默认} 写,于是能从命令行环境覆盖 ----
ROUNDS=${ROUNDS:-3}
HID=${HID:-3584}                          # Qwen2.5-7B 的 hidden_size
SEQ=${SEQ:-2048}                          # 打分行长度(上下文长度)
# date +格式 生成时间戳,$( ) 把它取出来拼进文件名 → 每次运行各自一份输出
STAMP=$(date +%Y%m%d_%H%M%S)
OUT="fused_${STAMP}.txt"                  # ${STAMP} 的花括号是为了和后面的 .txt 分开

# ---- 三个小函数 ----
# log:  "$@" = 调用时传进来的全部参数(保留原有的分词);
#        tee 同时写屏幕和文件,-a 表示追加(没有 -a 会每次清空)
# rule: 固定打一条横线,纯粹为了日志好读
# run:  先把命令行原样记进日志(方便复现),再真正执行它。函数体是两条命令,用 ; 隔开:
#          echo "\$ $*" >>"$OUT"     ← 记日志的是这半句。\$ 输出字面的 $ 符号(模仿终端提示符),
#                                      $* 把全部参数拼成一行;>> 只写文件、不上屏幕
#          "$@" 2>&1 | tee -a "$OUT" ← 这半句才是真执行。"$@" 保持每个参数独立
#                                      ($* 会拼成一整串,不能拿来执行);2>&1 让报错也进日志
log()  { echo "$@" | tee -a "$OUT"; }
rule() { log "-------------------------------------------------------------"; }
run()  { echo "\$ $*" >>"$OUT"; "$@" 2>&1 | tee -a "$OUT"; }

# ---------------------------------------------------------------- 环境快照
# date -Is 打 ISO 格式时间;hostname 机器名;uname -sr 内核名和版本
log "============================================================="
log " 融合算子基准 · RMSNorm + online softmax   $(date -Is)"
log " 主机 $(hostname)   $(uname -sr)"
log " hidden=$HID  seq=$SEQ  (Qwen2.5-7B 的真实维度)"
log "============================================================="
# 记下卡型号和功耗墙 —— 换机器重跑时,这几行决定数字能不能对比
run nvidia-smi --query-gpu=name,driver_version,clocks.max.sm,clocks.max.mem,power.limit --format=csv

# ---------------------------------------------------------------- 0 编译
rule
log "编译 + spill 检查"
rule
# -Xptxas -v 让 ptxas 把每个 kernel 的寄存器数 / 栈帧 / spill 打出来 —— 这一步的全部目的
# ||  前面失败才执行后面的 { };{ } 是把多条命令打包成一组(注意最后一条也要有分号)
run nvcc -O3 -arch=sm_89 -lineinfo -Xptxas -v fused_ops.cu -o fused_ops || {
  log "!! 编译失败,停在这里"; exit 1; }
log ""
# log "" 打一个空行;下面这几行 >>> 是写给读日志的人看的验收提示,不参与计算
log ">>> 十一个 kernel 全都要是 0 bytes stack frame / 0 bytes spill stores。"
log ">>> 尤其盯住 rms_n3 / rms_n4 / sm_s3 / sm_s4 —— 它们每线程要拿 32 个寄存器"
log ">>> 存整行数据。一旦 spill,那行数据就跑到显存里去了,"
log ">>> '只读一次' 的前提作废,1.5× / 2.0× 一个都不会出现。"
log ">>> 顺带记下寄存器数:n1/n2 应该很少(十几个),n3/n4/s3/s4 应该明显更多。"

# ---------------------------------------------------------------- 1 正确性
rule
log "正确性:八个 kernel 各跑一次小规模"
rule
# for k in n1 n2 n3 n4 —— 直接给字面量列表,$k 依次取到每一个
# M 只取 64:这一步看的是校验行,不是速度,跑快点好
for k in n1 n2 n3 n4; do
  log ""; log "--- $k · M=64 N=$HID ---"
  run ./fused_ops "$k" 64 "$HID" 3
done
# softmax 这四个换成 N=$SEQ —— 行长不同,两条阶梯不能混在同一个 N 上比
for k in s1 s2 s3 s4; do
  log ""; log "--- $k · M=64 N=$SEQ ---"
  run ./fused_ops "$k" 64 "$SEQ" 3
done
log ""
log ">>> 八行都要看到 ✓ 抽样校验通过。"
log ">>> softmax 那四个尤其要看 —— online 递推写错的典型症状不是崩溃,"
log ">>> 是结果偏一点点,只有对照 CPU 才看得出来。"

log ""
log "--- 边界:N 不是 BLOCK 的整数倍(最后一轮只有部分线程有活)---"
# 1000 不是 256 的整数倍:1000 = 3×256 + 232,最后一轮有 24 个线程闲着
# 只测 n3/s3 —— 寄存器驻留那两级才有"幺元 + 越界判断"的组合风险
for k in n3 s3; do
  log ""; log "--- $k · N=1000 ---"
  run ./fused_ops "$k" 64 1000 3
done
log ">>> 这两个必须也通过。写越界判断时最容易在这里翻车。"

# ---------------------------------------------------------------- 2 主阶梯
log ""
log "============================================================="
log " 主阶梯:${ROUNDS} 轮"
log " RMSNorm  M=8192 行 × N=$HID    理论 n1→n3 = 12N/8N = 1.50×"
log " softmax  M=8192 行 × N=$SEQ    理论 s1→s3 = 16N/8N = 2.00×"
log "============================================================="
# seq 1 3 打印 1 2 3,$( ) 接住当成 for 的列表 —— 跑 ROUNDS 轮取中位数,防偶发抖动
for r in $(seq 1 "$ROUNDS"); do
  log ""
  log "##################### 第 $r 轮 #####################"
  # M=8192 是主基准形状;iters 给到 11,程序取中位数
  for k in n1 n2 n3 n4; do
    log ""; log "--- round $r · rmsnorm · $k ---"
    run ./fused_ops "$k" 8192 "$HID" 11
  done
  for k in s1 s2 s3 s4; do
    log ""; log "--- round $r · softmax · $k ---"
    run ./fused_ops "$k" 8192 "$SEQ" 11
  done
done

# ---------------------------------------------------------------- 3 形状扫描
log ""
log "============================================================="
log " 形状扫描 —— 行数变了,结论会不会变"
log "   decode b1   M=1      一个 block,128 个 SM 里 127 个在看戏"
log "   decode b32  M=32     还是填不满"
log "   prefill     M=2048   一次前向 2048 个 token"
log "   大批        M=16384  把卡喂饱,MBU 的上限在这里"
log "============================================================="
# 这里要同时带「名字」和「M 值」两个信息。shell 没有字典,常见做法是把一对
# 塞进一个字符串,循环里再拆开:
#   set -- $shp   把 $shp 按空格拆成词,重设位置参数 → $1 是名字,$2 是 M
#   注意 $shp 这里【故意不加引号】—— 加了就成一个词,拆不开了。
#   这是本脚本唯一一处不加引号的变量展开。
for shp in "decode-b1 1" "decode-b32 32" "prefill 2048" "大批 16384"; do
  set -- $shp
  nm=$1; m=$2
  log ""
  log "========== $nm : M=$m =========="
  # 每种形状只跑 n1/n3/n4 和 s1/s3/s4 —— n2/s2 是中间台阶,形状扫描里不需要
  for k in n1 n3 n4; do
    log ""; log "--- $nm · $k ---"
    run ./fused_ops "$k" "$m" "$HID" 11
  done
  for k in s1 s3 s4; do
    log ""; log "--- $nm · $k ---"
    run ./fused_ops "$k" "$m" "$SEQ" 11
  done
done

# ---------------------------------------------------------------- 4 长行
log ""
log "============================================================="
log " 长行:寄存器放不下的时候"
log "   Qwen2.5-7B 的 vocab 是 152064 —— 采样时要对这么长的一行做 softmax。"
log "   而 s3/s4 的单行上限是 BLOCK × MAXS = 256 × 32 = 8192。"
log "   下面三条命令里,前两条能跑,第三条会被程序拒绝并说明原因。"
log ""
log "   这不是实现偷懒,而是【FlashAttention 为什么必须分块】的同一个约束:"
log "   行放不进片上存储 → 必须分块 → 分块就要能增量合并 softmax"
log "   → 于是 online 递推从'优化技巧'变成'必需品'。"
log "============================================================="
# 这三条不进循环,因为每条要配一句不同的说明 —— 日志是给人读的
log ""; log "--- logits softmax · s1(三趟,不吃寄存器)---"
run ./fused_ops s1 32 152064 11
log ""; log "--- logits softmax · s2(online 两趟,也不吃寄存器)---"
run ./fused_ops s2 32 152064 11
log ""; log "--- logits softmax · s3(寄存器驻留,预期被拒绝)---"
run ./fused_ops s3 32 152064 11
log ""
# 注意:这一条【预期失败】。脚本没开 set -e,加上 run() 用了管道(退出码取自末尾的 tee),
# 所以这次非 0 退出不会打断后面的步骤 —— 失败与否要靠读日志里的 ✗ 判断
log ">>> s3 应该打印 '✗ 这一级把整行存在寄存器里' 并退出(返回码 3),这是预期行为。"
log ">>> 同时注意 s1 和 s2 在这个形状上的 MBU —— 行长 152064、只有 32 行,"
log ">>> 两个数字放在一起看,能说明'长行 + 少行'是最难喂饱卡的一种形状。"

# ---------------------------------------------------------------- 5 竞态
# command -v 查这台机器上装没装 compute-sanitizer;没装就整段跳过,不报错
# >/dev/null 2>&1 把它的正常输出和错误输出都丢掉 —— 只要成败,不要内容
if command -v compute-sanitizer >/dev/null 2>&1; then
  log ""
  log "============================================================="
  log " 竞态检查 —— 放在计时之后"
  log " 这两个算子的 shared 用法比第 10 章复杂:blockReduceMaxSum 里"
  log " 同一块 shared 先写 warp 部分和、再被 0 号 warp 读、再广播,"
  log " 三个阶段之间少一个 __syncthreads 就是竞态。"
  log "============================================================="
  # 规模必须小(M=8 N=1000 跑 1 次):racecheck 会逐条指令检查 shared 访问,
  # 慢几百倍,拿主基准的形状去跑等于死机
  for k in n3 s3 s4; do
    log ""; log "--- racecheck · $k ---"
    run compute-sanitizer --tool racecheck ./fused_ops "$k" 8 1000 1
  done
  log ""
  log ">>> 每段结尾都要看到 RACECHECK SUMMARY: 0 hazards displayed"
fi

# ---------------------------------------------------------------- 收尾
log ""
log "============================================================="
log " 完成 → $OUT"
log ""
log " 本地取回后:"
log "   python parse_fused.py $OUT      # 解析成 CSV"
log "============================================================="

结果RTX 4090 实测↑

RTX 4090 · 2026-10-01 · 三轮中位数。 原始日志 Results/Fused/fused_20261001_005225.txt。驱动 595.91.07,nvcc -O3 -arch=sm_89, 显存带宽峰值 1008 GB/s。 八级全部通过 ✓ 抽样校验;十一个 kernel 全部 0 spill; 三个 racecheck 全部 0 hazards。 十四条预测里成立 9 条、落空 4 条、口径失效 1 条。 没成立的 5 条都栽在同一件事上,见 §9 · 三。

一、主阶梯↑

M=8192 行 · RMSNorm N=3584 · softmax N=2048 · 三轮中位数(轮间离散度最大 0.7%)· 寄存器列是每个 kernel 的 ptxas 用量
kernel字节/行耗时 msGB/sMBU 相对基线理论值寄存器
N1 两趟12N0.3859912.9 90.6%基准—40 / 26
N2 融合但读两次12N0.2591 1359.9超过峰值 → 虚高 134.9%1.49×1.00×39
N3 寄存器驻留8N0.2560917.5 91.0%1.51×1.50×52
N4 +float48N0.2560917.4 91.0%1.51×1.50×55
S1 三趟16N0.2509 1070.0超过峰值 → 虚高 106.1%基准—39 / 40 / 24
S2 online 两趟12N0.2202914.5 90.7%1.14×1.33×22 / 24
S3 一趟8N0.1475910.2 90.3%1.70×2.00×56
S4 +float48N0.1475910.2 90.3%1.70×2.00×48

二、形状扫描↑

MBU 随行数 M 的变化。M=2048 一列全部超过 100%。这不是比显存还快,而是整个数据集装进了 L2,MBU 的分子已经不是显存流量。
kernelM=1
decode b1
M=32
decode b32
M=2048
prefill
M=16384
大批
N10.7%22.2%328.2%90.9%
N30.5%15.3%289.4%91.0%
N40.7%22.2%316.0%91.2%
S10.4%13.0%295.5%91.7%
S30.3%10.2%154.8%90.3%
S40.4%12.7%232.2%90.7%

三、十四条预测的判决↑

§7 三张表的「实测」「判决」两列已经填好。 成立 9 条、落空 4 条、口径失效 1 条。 没成立的 5 条都栽在同一件事上:L2。

十四条逐条判决
判决条目一句话
成立 ② ③ ⑥ ⑦ ⑧ ⑨ ⑩ ⑫ ⑬ 只要喂饱卡、且数据装不进 L2,字节账完全兑现; 11 个 kernel 零 spill;边界用例和长行拒绝都符合预期
落空① N2 快 1.49×,预测只有 1.00×–1.05×
落空④ ⑤ S2 只快 1.14×(预测 1.33×),S3 只快 1.70×(预测 2.00×)
落空⑭ logits 形状的 MBU 不降反升,到了 147.9%
口径失效⑪ M=2048 的 MBU 到了 155–328%,这个指标在那里已经量不了显存
§7 把第 ① 条称作「这组实验的眼睛」,还写了一句 「如果 N2 也快了一大截,说明我对字节数的计算漏了什么,后面所有推导都要回头查」。 N2 确实快了一大截。下面是回头查的结果。

五条的共同原因:重用距离

字节账算的是 kernel 发出了多少访存请求;耗时取决于显存实际读写了多少。 两者之间隔着 72 MiB 的 L2。 同一行数据被读两次,两次读之间隔了多少数据,叫重用距离。 重用距离小于 L2 容量,第二次读就不花钱。

两次读同一行之间,隔了多少数据 L2 = 72 MiB N2 同 kernel 同 block 14 KB → 第二次读命中 L2,DRAM 只看到 8.1N S1 max → sum 跨 kernel 64 MiB → 挤在容量边缘,只兑现了一部分,DRAM 看到 13.7N N1 pass1 → pass2 跨 kernel 112 MiB → 整个 grid 扫完才回头,L2 早被挤干净,DRAM 看到 12.1N 左边:还在片上 右边:装不下,回 DRAM

图 31 · 条的长度只是示意,不按比例。要看的是条落在虚线哪一侧。 N1 和 N2 的代码字节账相同(都读 x 两次), 差别只在两次读之间隔多远。

① N2 为什么快 1.49×

把 N3 实测的 917.5 GB/s 当作这张卡显存带宽的实际上限 (峰值 1008 GB/s 的 91.0%,八级里六级都贴着它跑)。 用「耗时 × 917.5 GB/s」反推每一级实际搬了多少,再和账面比:

账面兑现率 = 反推的实际搬运量 ÷ 账面搬运量
级账面反推实际兑现率折算
N1336.0 MiB337.7 MiB 100.5%12.1N
N2336.0 MiB226.7 MiB 67.5%8.1N
N3 / N4224.0 MiB224.0 MiB 100.0%8.0N
S1256.0 MiB219.5 MiB 85.8%13.7N
S2192.0 MiB192.7 MiB 100.4%12.0N
S3 / S4128.0 MiB129.1 MiB 100.8%8.1N
八级里六级落在 100.0%–100.8%,只有 N2(67.5%)和 S1(85.8%)掉了出来。 六级这么整齐,说明字节账本身是准的;掉出来的两级另有原因。

N2 账面 12N,实际只搬了 8.1N:第二次读 xr[i] 没有走到显存。 原因见代码:

// rms_n2:两次读之间,这个 block 只处理了自己这一行
for (int i = threadIdx.x; i < N; i += BLOCK) {
    float v = xr[i];                       // 第一次读,3584 个 float = 14 KB
    acc += v * v;
}
const float r = rsqrtf(blockReduceSum(acc, smem) / N + eps);
for (int i = threadIdx.x; i < N; i += BLOCK)
    yr[i] = xr[i] * r * w[i];              // 第二次读,几微秒后,同样 14 KB

// rms_n1:pass1 读完【全部 8192 行 = 112 MiB】,kernel 结束,
//         pass2 才从头再读一遍 —— 那时 72 MiB 的 L2 早被后面的行挤干净了

预测 ① 错在默认「访存指令发出的字节」等于「显存搬运的字节」。 实测 N2(0.2591 ms)和 N3(0.2560 ms)只差 1.2% ,因为对显存来说两者都是 8N。 N2 只是多发了一遍 L2 读请求。

这条落空比成立值钱

融合确实省带宽,但机制不是「少发访存指令」, 而是「把重用距离压到 cache 装得下」。 N1→N2 没改任何一条 load 指令的数量,只把两次 load 挪进同一个 block, 重用距离就从 112 MiB 降到 14 KB。 这正是 FlashAttention 的全部赌注: 它也没有减少对 Q/K/V 的读次数,减少的是两次读之间的距离。

④⑤ S2 / S3 为什么没达到理论值

问题不在 S2 和 S3,而是基线 S1 被 L2 抬快了。 softmax 在 M=8192 × N=2048 时, x 整张是 64 MiB,L2 是 72 MiB。 刚好装得下。 S1 的 max kernel 扫完 x 后, sum kernel 再读同一份 x,其中相当一部分还在 L2 里。 所以 S1 账面 16N,实际只付了 13.7N。基线虚快,比值就缩水。

同一组 kernel,只把 M 从 8192 加到 16384(x 从 64 MiB 涨到 128 MiB,L2 装不下)
形状x 大小S1 兑现率S3 对 S1S4 对 S1理论
M=819264 MiB装得进 72 MiB L2 85.8%1.70×1.70× 2.00×
M=16384128 MiB装不下 99.2%1.97×1.98× 2.00×
换个形状,预测 ⑤ 就从 1.70× 回到 1.97×,几乎正中 2.00×。 RMSNorm 一侧没出过这个问题:N=3584 时 x 在 M=8192 时是 112 MiB,M=16384 时是 224 MiB,都装不进 L2。所以 ② 一直成立 (M=8192 是 1.51×,M=16384 是 1.50×)。

更糟的是,程序当时给出了相反的保证

日志里 softmax 主阶梯那一行印的是 footprint = x + y = 128.0 MB(L2 72.0 MB)→ 装不下,必须走显存。 这个信号本身是误导的。 它只比较 x + y 的总和;决定跨 kernel 重用能否命中的, 是被重用的那个数组自己的大小。x 单独只有 64 MiB,装得进 72 MiB 的 L2。 检查代码的完整拆解见 §8 · 二的「跑之前的三道前置检查」。

⑪⑭ MBU 什么时候失效

MBU = 账面字节 ÷ 耗时 ÷ 1008 GB/s。只要数据整个装进 L2,分子就不再是显存流量,这个数也就失去意义。

四个 MBU 超过 100% 的场合,根因都一样
场合实测 MBU整份数据为什么虚高
N2主阶梯134.9% 224 MiB 数据装不下,但第二次读的重用距离只有 14 KB
S1主阶梯106.1% x 单独 64 MiB x 装得进 L2,跨 kernel 的第二次读部分命中
⑪ M=2048prefill155–328% 32–56 MiB x 和 y 全部装进 L2,整个基准没有碰显存
⑭ M=32 N=152064logits147.9% 37 MiB 同上。预测说会更低,结果更高

⑪ 的预测本意(2048 个 block 够把卡填满)是对的 :grid 填充度从 2.67(N1)到 4.00(N3),确实填满了。 错在用 MBU 去验证它: 数据进了 L2,这把尺子就不再量显存。

⑭ 还暴露了一个反转

logits 形状下 S2(0.0602 ms)比 S1(0.0522 ms)还慢, 尽管 S2 的账面字节少 25%。 数据全在 L2 时,省字节没有收益; online 递推多出的 exp 和更长的依赖链照样要花时间。 阶梯倒过来了。 这反证了一点:整套优化的前提是「瓶颈在显存」,前提不成立,结论也不成立。

⑫ 唯一一条在五种形状下都成立的

N3 比 N1 快的倍数随 M 上升,M=8192 起稳定在约 1.5×
行数M=1M=32M=2048M=8192M=16384
N3 对 N11.00×1.02× 1.32×1.51×1.50×
grid 填充度按 N3 的 4 block/SM 算0.0020.06 4.0016.032.0
M=1 时省掉三分之一的字节没有任何用:128 个 SM 里 127 个在空转, 瓶颈不是带宽。 注意:M=1 / M=32 的绝对耗时都在 4–6 μs, 已经贴着 kernel 启动和同步的下限,这两列的细微差别不值得解读。

一个例外:M=1 和 M=32 时,N4 比 N3 快 1.49× (0.0041 ms 对 0.0061 ms)。原因不是省字节(字节一个没省), 而是 float4 把访存指令条数减到四分之一,缩短了延迟受限时的关键路径。 喂饱卡时它没有用(1.00×);喂不饱时,它是唯一还起作用的一级。

这一轮实验真正留下的三句话

① 字节账要算「到显存的字节」,不是「指令发出的字节」。 两者之间隔着一层 cache,差距可达 1.5 倍。

② 决定第二次访问花不花钱的是重用距离,不是访问次数。 融合、分块、寄存器驻留,三种手法压缩的都是这个量。

③ 选基准形状时,先看数据装不装得进 L2。 M=8192 的 softmax 刚好卡在 64 MiB / 72 MiB 这条线上, 预测 ④⑤ 就是被它绊倒的。 换成 M=16384,理论值立刻兑现。

延伸这两个算子往后通到哪里↑

一、三条线索↑

这里学的往后用在哪
online softmax 递推
+ 结合律证明
FlashAttention 前向(路线图 04 阶段)。这是分块算 attention 的唯一数学前提。 FA-2 前向伪代码里的 rescale 步骤就是 s · exp(m_old − m_new)。
寄存器驻留
+ 单行长度上限
为什么必须分块。shared memory 和寄存器的容量是硬约束, 所有「把数据留在片上」的优化最终都会撞上它。
MBU 视角 01 阶段的两堵墙。decode 撞带宽墙, 在 vLLM 的吞吐曲线上是宏观现象,在这里是微观机制,两头能对上。

二、这份报告没做、但值得做的↑

方向为什么没做做了会怎样
fp16 / bf16 版本 和 SGEMM 那一章的 fp32 口径保持一致,便于横向比较 字节数减半,MBU 的分母不变 → 理论上耗时减半。 但归约必须在 fp32 里做,否则 3584 个数累加会丢精度。 PyTorch 的 RMSNorm 先把输入转成 .float() 再算,就是这个原因。
RMSNorm + 残差加法融合 会引入第二个输入张量,破坏「每级只动一个变量」 这是生产里最常见的融合, 因为残差加法本来要单独读写一遍,融进来能再省 8N
一个 warp 一行 N=3584 需要每线程 112 个元素,寄存器不够 短行(例如 head_dim=128)时这种排布更好: 省掉跨 warp 归约和两次 __syncthreads
ncu 验证 MBU 手头的实例没有 profiling 权限 gpu__dram_throughput 能直接验证我们算的 MBU。 不过这两个算子的访存是纯顺序流,手算字节数比 SGEMM 那边可信得多。

三、读完能不能回答这 6 个问题↑

  • ① 为什么这里用 __shfl_xor_sync,而第 10 章 R6 用 __shfl_down_sync? 换过来会怎样?
  • ② N1 和 N2 发出的访存指令都是 12N,为什么 N2 实测快 1.49×?「指令发出的字节」和「显存搬运的字节」差在哪一层、差多少?
  • ③ 为什么 float xs[MAXS] 必须配 #pragma unroll 和常量下标? 写成 xs[cnt++] 会发生什么?从哪里能看出来?
  • ④ 不加证明就把 online softmax 的递推用在蝶形归约上,错在哪? 那半页结合律证明证的是什么?
  • ⑤ 幺元为什么不能写 -INFINITY?什么输入会触发这个 bug?
  • ⑥ S3 已经做到「每个元素只过一次显存」, 为什么 FlashAttention 还能比它快一个数量级?