02 · CUDA from a Systems Engineer’s Eyes · 报告 04
第 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 章的 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 结构拆解:
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 里,不计入。
图 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 = 实测带宽 ÷ 理论峰值带宽 = (搬运字节数 ÷ 耗时) ÷ 1008 GB/s // 4090 的峰值:内存时钟 10501 MHz × 2(DDR 双沿)× 384 bit ÷ 8 = 1008 GB/s // 和 sgemm_all.cu 里算 roofline 用的是同一个数,口径一致
和 SGEMM 那边的对应关系
| SGEMM(算力密集) | 这两个算子(访存密集) | |
|---|---|---|
| 主指标 | 占 FP32 峰值 / 占 cuBLAS | MBU |
| 优化目标 | 减少每个 FMA 摊到的 load 条数 | 减少每个元素经过显存的次数 |
| 哪本账管用 | 条数账(字节账失效五次) | 字节账 —— 这次它是主角 |
| 天花板 | 0.25 周期/warp圈 | MBU 100%(现实里 80% 就很好) |
SGEMM 那一章里字节账七次失效五次;到了这里,它是唯一靠谱的账。 账本没有对错,只有适用区间。 这正是那一章最后的结论,在这里从反面得到了印证。
两条阶梯里,每一级理论上快多少倍,就是纯字节数之比。 实测接近理论值,说明分析对了;差得远,就要查是哪一本别的账在起作用。
y = x · rsqrt(mean(x²) + eps) · w // N = 3584
一句话:把一行(一个 token 的 3584 维向量)整体缩放到固定长度,再逐维乘一个学出来的增益。 公式里只有三个动作:求均方、取倒数平方根、逐维相乘。
图 2 · RMSNorm 全部内容就是这三步。
①→② 整行只做一件事:乘同一个标量 r(这组数据 r ≈ 0.5),所以形状不变,只是整体矮了一半。
归一后整行的模恒等于 √N(N = 3584 时约 59.87)。不管输入多大多小,输出的长度都一样,
这就是「Norm」的含义。②→③ 乘 w 才改变形状;w 是训练学出来的,
对这一行是常量。
| LayerNorm | RMSNorm | |
|---|---|---|
| 要哪些统计量 | 均值 μ 和 方差 σ² | 只要均方 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)。
下表的四级台阶量的就是这件事。
| 级 | 做法 | 读 x | 写 y | 合计 | 相对 N1 |
|---|---|---|---|---|---|
| N1 | 两个 kernel:先算 rstd,再归一化 | 2 次 8N | 4N | 12N | 基准 |
| N2 | 融成一个 kernel,但 x 仍从 global memory 读两次 | 2 次 8N | 4N | 12N | 1.00× |
| N3 | x 留在寄存器,只读一次 | 4N | 4N | 8N | 1.50× |
| N4 | N3 + float4 | 4N | 4N | 8N | 1.50× |
权重 w 只有 4N = 14 KB,所有行共用,
一直待在 L1/L2 里,不计入显存流量。
N1 还要把长度为 M 的 rstd 数组写一次、读一次,
相对 12NM 可以忽略。
softmax(x)_i = exp(x_i − m) / Σ_j exp(x_j − m) // m = max(x),N = 上下文长度
一句话:把一行任意实数变成一个概率分布:全为正、和为 1、大小顺序不变。 注意力里这一行是「这个 query 对 N 个 key 的打分」; softmax 后变成「分给每个 key 多少注意力」。
图 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 | softmax | |
|---|---|---|
| 归约出什么 | 一个标量 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。
| 级 | 做法 | 趟数 | 合计 | 相对 S1 |
|---|---|---|---|---|
| S1 | 三个 kernel:求 max → 求 exp 和 → 归一化 | 读 3 次 + 写 1 次 | 16N | 基准 |
| S2 | online 递推:一趟同时得到 (max, sum),再一趟归一化 | 读 2 次 + 写 1 次 | 12N | 1.33× |
| S3 | 一趟,x 留寄存器 | 读 1 次 + 写 1 次 | 8N | 2.00× |
| S4 | S3 + float4 | 同上 | 8N | 2.00× |
图 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 三级并排跑一次,就能把它们分清。
这正是 SGEMM 那一章的方法论
每一级只动一个变量。 N1→N2 只动 kernel 个数(字节不变); N2→N3 只动数据驻留位置(字节减 1/3);N3→N4 只动访存指令宽度(字节不变)。 每一份收益归谁都很明确。
归约代码可以直接搬第 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 拆开的就是这一步。
图 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,而是往更远处要 —— 所以是漏斗,不是交换
图 6 · 每轮「我知道的 lane 数」翻一倍。
初始时每个 lane 只知道自己(1 个)。off=4 之后知道 2 个,off=2 之后 4 个,
off=1 之后 8 个,正好覆盖全部。
8 = 2³ 要 3 轮,32 = 2⁵ 就要 5 轮,所以 off 从 16 起步、循环 5 次。
连线是完全对称的蝶形,没有哪个 lane 处在特殊位置。
| 版本 | 轮次 | L0 | L1 | L2 | L3 | L4 | L5 | L6 | L7 |
|---|---|---|---|---|---|---|---|---|---|
| — | 初始 | 3 | 1 | 4 | 1 | 5 | 9 | 2 | 6 |
| xor | off=4 | 8 | 10 | 6 | 7 | 8 | 10 | 6 | 7 |
| xor | off=2 | 14 | 17 | 14 | 17 | 14 | 17 | 14 | 17 |
| xor | off=1 | 31 | 31 | 31 | 31 | 31 | 31 | 31 | 31 |
| down | off=4 | 8 | 10 | 6 | 7 | 10 | 18 | 4 | 12 |
| down | off=2 | 14 | 17 | 16 | 25 | 14 | 30 | 8 | 24 |
| down | off=1 | 31 | 33 | 41 | 39 | 44 | 38 | 32 | 48 |
两个版本用的是同一组数、同样三轮、同样三条 shuffle 指令。
区别在于:down 版里 lane 4–7 从第一轮起就向「warp 外面」要数
(越界时硬件返回自己的值),所以它们累加出的是没有意义的部分和。
只有 lane 0 那一格两边都是 31。这就是 R6 只敢让 lane 0
做 atomicAdd 的原因。
off 取 16、8、4、2、1,二进制正好是 10000、01000、
00100、00010、00001,每轮翻 lane 号的一个 bit。
lane 号有 5 个 bit。翻完这 5 个 bit,就能从任意一个 lane 走到任意另一个 lane。
以 lane 5(00101)为例:
| 轮次 | 偏移量 | 偏移量二进制 | 伙伴 lane 号 | 伙伴二进制 | 这轮之后我掌握几个 lane |
|---|---|---|---|---|---|
| ① | 16 | 10000 | 21 | 10101 | 2 |
| ② | 8 | 01000 | 13 | 01101 | 4 |
| ③ | 4 | 00100 | 1 | 00001 | 8 |
| ④ | 2 | 00010 | 7 | 00111 | 16 |
| ⑤ | 1 | 00001 | 4 | 00100 | 32 ✓ 全覆盖 |
xor 版还白送一个性质:轮次顺序无关
五个 bit 先翻哪个、后翻哪个,结果都一样。
每翻一个 bit,我掌握的 lane 集合就按这个 bit 对称地扩大一倍;
五个 bit 翻完就是全集,和顺序无关。
down 版则必须严格从大到小
,因为它靠「把数据一段段推向低位 lane」,顺序错了数据就会丢。
这也是为什么 warpReduceMaxSum 里同样可以放心写 off = 16 … 1。
看着像数据竞争,其实不是
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 = 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 一起读走
}
图 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 之内成立。
| 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。填错幺元是这类代码里最隐蔽的错误。
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 · 「换算到新基准再相加」,一行代码三件事。
s 和 s_o 的基准是不同的 m,量纲不同,不能直接加。
取 m_n = max 之后,两边各乘 exp(自己的 m − m_n) 对齐量纲,再相加。
基准大的一边,因子恰好是 exp(0) = 1,所以被缩小的总是基准小的一边。
两边 m 相等时,两个因子都是 1,整行退化成普通加法。
图 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 轮。
两个附带问题
为什么不先用 warpReduceMax 求出全局 max,再 warpReduceSum?
那样也对,而且 __expf 只算 1 次(融合版算 10 次)。
但那是两个前后依赖的归约,关键路径是 5 + 5 = 10 步。
融合版每轮的两条 shuffle 互不依赖,可以背靠背发射,关键路径只有 5 步。
而且这是访存瓶颈的 kernel,exp 不是成本所在。
更重要的理由:这个合并运算必须支持「两两合并」。
FlashAttention 分块算 softmax 时,
块是逐个到来的,拿不到全局 max
,只能每块算出自己的 (m, s),再两两合并。
这里写成融合形式,就是把它和 §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 的边界测试。
每一级只动一个变量。 N1→N2 动 kernel 个数,N2→N3 动数据驻留位置,N3→N4 动访存指令宽度。 只有 N2→N3 改变字节数,所以理论上只有这一步能变快。
先把变量对上号:RMSNorm 只沿最后一维做,所以前两维可以直接拉平。 每个 token 用自己的 3584 个数独立归一化,与它在哪个 batch、哪个位置无关。
图 10 · 从张量到线程,四层对应关系。
blockIdx.x 是行号,即第几个 token;threadIdx.x 决定这个线程取行内哪些元素。
行与行完全独立,所以 grid 这一层不需要通信。
这是它和第 10 章 R6 最大的结构差别:R6 还要多一层 atomicAdd,汇总 block 间的结果。
| 变量 | 形状 | 大小 | 是什么 |
|---|---|---|---|
M | 标量 | 4096 | 行数 = token 数 = batch × seq_len |
N | 标量 | 3584 | 行长 = hidden_size(Qwen2.5-7B) |
dx | M × N | 58.7 MB | 输入:4096 个 token 的 hidden 向量 |
dy | M × N | 58.7 MB | 输出,同形状 |
dw | N | 14 KB | 可学权重,所有行共用,每个 hidden 维一个增益。 它很小,一直留在 L1/L2 里,不计入字节账 |
dm | M | 16 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 讲这件事)。
图 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 做。
图 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 个线程一起。
图 13 · 三层作用域,三种通信手段。
__shared__ 和 __syncthreads() 都在中间的框里,
它们是 block 级的:每个 block 有自己独立的一份 smem,
__syncthreads() 也只同步本 block 的 256 个线程。
往里,warp 内部用不着它们(shuffle 直接读寄存器);
往外,block 之间没有这种机制。这就是 N1 必须拆成两个 kernel 的原因。
| 写法 | 有几份 | 值一样吗 | 代码里的例子 |
|---|---|---|---|
| 普通局部变量 | 每线程一份(256 份) | 各不相同 | acc、v、i、lane |
| 由 blockIdx 推出来的 | 每线程一份(256 份) | 全都相同 | row、xr、yr |
__shared__ |
整个 block 只有一份 | 就是同一块内存 | smem[0…7] |
第二行最容易看错:这不叫「共享」,它们只是恰好相等。
row 也是普通寄存器变量,也有 256 份。
只是 blockIdx.x 对同一个 block 的所有线程都相同,所以算出来的值相同。
图 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
图 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] |
smem[0] 被写了两次,为什么安全
它先在 ② 存 warp 0 的部分和,又在 ⑥ 被覆写成全 block 的和。 安全的关键是:「读」一定发生在「覆写」之前。
④ 那一行 v = (threadIdx.x < 8) ? smem[lane] : 0.0f; 里,
只有线程 0–7 真正读 shared memory(其余线程直接取幺元 0.0f),这 8 个线程都在 warp 0 里。
⑤ 的 warpReduceSum 用全掩码 shuffle,会让 warp 0 的 32 个 lane 同步一次,
所以读一定在覆写之前完成。warp 1–7 从头到尾不碰 smem[0],它们到 ⑦ 之后才读。
两个 __syncthreads() 各挡一头:③ 挡在「写 8 个部分和」与「读它们」之间,
⑦ 挡在「覆写全和」与「全员读走」之间。
④ 那行有一个隐含前提: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 在 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__ 扩展)。
图 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 // 这才是「指针本身不可改」,完全两码事
怎么自己验
仓库里的 check_sass.sh 支持换二进制:
BIN=./fused_ops bash check_sass.sh。
在反汇编里数 LDG.E.CI(走只读缓存)
和 LDG.E(普通 global load)的条数;去掉 __restrict__ 重编,再数一次。
这是本系列一贯的「条数账」做法,
和 N3→N4 的 float4 属于同一类改动:字节数不变,只动指令条数。
一个 block 一行,合适吗
N=3584,BLOCK=256 → 每个线程负责 14 个元素,循环 14 轮。 行数 M 就是 grid 大小。M=8192 时有 8192 个 block 排队, 128 个 SM 轮流执行,填得很满。 但 decode batch=1 时 M=1: 整张卡只有一个 block 在工作,另外 127 个 SM 空闲。 §7 的形状扫描测的就是这件事。
__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。
图 17 · N2 拿掉的是中间那个小方块,不是两头的读写。
rstd 从「显存里长度为 M 的数组」变成「寄存器里的一个 float r」,
省掉一次 launch 和 dm[] 的往返。
但 xr[i] 仍然出现两次:第一趟算平方和,第二趟算输出,
同一个地址从显存读两次。字节账的大头没变。
| 级 | 第一趟读 | 第二趟读 | 写回 | 中间数组往返 | 合计 |
|---|---|---|---|---|---|
| N1 | 4N | 4N | 4N | 8 字节 | 12N + 8 B |
| N2 | 4N | 4N | 4N | — | 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 的预测要保持原样,这是这套方法的规矩。
// 每线程最多存多少个 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·三) }
图 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 · 五 细说。
// 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; }
图 19 · 搬的字节一个不多不少,发的指令少了 3/4。 两边都是完全合并的访存,每字节的效率相同, 差别只在「同样多的字节,用几条指令搬完」。 所以字节账不变(还是 8N)。能不能变快要看另一本账: 访存指令的发射是否已成瓶颈。如果 N3 的 MBU 已经很高,说明瓶颈在带宽而不在指令, N4 就不会变快。这本身就是一个判据。
两边的 int i = threadIdx.x + c * BLOCK; 一字不差
,但 N3 里 i 是「第几个 float」,
N4 里是「第几个 float4」。
这是全部差异的来源,其余几处差异都由它引出。
图 20 · 同一个 c,N4 扫过的范围是 N3 的 4 倍。
所以轮数从 14 降到 4。两边都是完全连续的访存,合并程度相同,
区别只在一条指令覆盖多宽。
下半张最能说明问题:c = 1 时两边的
i = 0 + 1×256 相同,N3 落在 float #256,N4 落在 float #1024。
| 位置 | N3 | N4 | 为什么 |
|---|---|---|---|
| 循环次数 | MAXS = 32 | MAXV = 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 | 8N | 96 条32 读 x + 32 读 w + 32 写 y | 32 | 8192 |
| N4 | 8N | 24 条8 + 8 + 8,每条宽 4 倍 | 32 | 8192 |
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 应该几乎不变快。这一条本身就是判据。
这一节没有 CUDA,只有数学。 但它是整份报告最该花时间的一节:FlashAttention 的全部核心就是这三行。 在这里花两小时弄懂,能省下 04 阶段(FlashAttention 前向)的两天。
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,就得把整行看完 —— 于是朴素实现必须扫三趟。
| 趟 | 做什么 | 为什么不能和上一趟合并 |
|---|---|---|
| ① 求 max | m = 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。
| 量 | 怎么算 | 值 |
|---|---|---|
| 旧账 s_k | exp(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 ✓ |
图 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,退化成普通累加。
图 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,退化成普通累加。
| 步骤 | 新的最大值 | 缩放因子 | 新的部分和 | 独立校验 |
|---|---|---|---|---|
| 初始 | −∞ | — | 0 | 0 |
| 加 1 | 1 | — | exp(0) = 1.0000 | 1.0000 |
| 加 2 | 2 | exp(1−2) = 0.3679 | 1·0.3679 + 1 = 1.3679 | 1.3679 |
| 加 5 | 5 | exp(2−5) = 0.0498 | 1.3679·0.0498 + 1 = 1.0681 | 1.0681 |
| 加 3 | 5 | exp(5−5) = 1 | 1.0681 + exp(3−5) = 1.2034 | 1.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,就是普通累加。
省下来的是哪一趟
朴素版要先扫完一趟拿 max,才能开始第二趟累加。 online 版一趟就同时得到了 max 和 sum。 从 3 趟变成 2 趟(剩下的一趟读入、归一化、写回), 字节从 16N 降到 12N。 注意:online 递推本身不能把趟数降到 1 —— 最后总要再扫一遍把每个元素除掉。降到 1 趟靠的是把 x 留在片上(S3),那是另一件事。
这是最容易被跳过、但绝不能跳过的一步
上面的递推是串行推出来的:一个一个往里加。 但 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 的摘要 —— 不回头翻原始数据?
能,公式就是它。
图 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 · 二 那个递推是什么关系
§5 · 二 的递推,就是 ⊕ 的一个特例。
单个元素 {x} 的摘要是什么?
m = max({x}) = x,s = exp(x − x) = 1 —— 也就是
(x, 1)。代进去:
(m_k, s_k) ⊕ (x, 1) = ( max(m_k, x), s_k·exp(m_k−m′) + 1·exp(x−m′) ) = ( m′, s_k·exp(m_k−m′) + exp(x−m′) ) // 和 §5·二 一字不差
写成一般形式,是因为蝶形归约和分块合并时, 第二个操作数不是一个元素,而是另一整段的摘要。 FlashAttention 尤其如此:块一块一块地来, 拿不到全局 max,只能把摘要两两合并。
| 第二个操作数 | 用在哪 | |
|---|---|---|
| §5 · 二 的递推 | 一个元素 (x, 1) | 串行扫描:一个一个往里加 |
| 一般形式 ⊕ | 任意一段的摘要 (m₂, s₂) | 蝶形归约、跨 warp 合并、FlashAttention 分块合并 |
图 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 的变体)时,需要一套可靠办法判断「这个算子能不能乱序合并」。
「找一个到已知可结合结构的同态」就是那个通用办法。
证明思路:给 (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) // ∎
图 25 · 换坐标,是为了把难证的搬到好证的地方。
下面那层的结合律是白送的(max 和 + 谁都知道可结合);
上面那层才是我们真正要的,但直接证很别扭。
φ 的作用就是在两层之间架桥 —— 横着走(先合并再换坐标)和绕着走(先换坐标再合并)结果一致,
桥又是双向的(可逆),于是下层的性质原样抬到上层。
取 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⊕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,正是 ② 说的那件事。
结论
在 (max, T) 坐标下,⊕ 就是
「取最大值」和「求和」并排放着;这两个运算可结合可交换,幺元是 (−∞, 0)。
换句话说 (⊕, e) 构成一个可交换幺半群。
于是任意的合并顺序 —— 串行扫描、蝶形归约、
分块再合并 —— 结果完全一样。
而「分块再合并」这一条,正是 FlashAttention 的地基。
(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 不负责。
把 §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 · 三 那半页证明。
这条阶梯和 RMSNorm 那条对称,但多一级:朴素版要三趟。
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 结束之后才开始。这正是趟与趟之间唯一的同步手段。
图 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 省掉的是 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。
图 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 把最后一趟也省掉了:整行只经过显存一次,(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; }
图 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)。
到 S3 这一级,blockReduceMaxSum 的广播才有用
S1 的 sm_s1_sum、S2 的 sm_s2_ms
归约完都只有 thread 0 把结果写进小数组,广播那几行是白做的。
而 S3 里 256 个线程每一个都要拿
(m, s) 去归一化自己手上的元素(主阶梯 N=2048 时每个线程 8 个)。
这正是 §3 · 一 那句话的落点:
只有「归约完所有线程都要用结果」的场合,才值得为广播付那一次 shared memory 往返。
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.x = 1 | 1 − 5 = −4 | 0.018316 | |
| v.y = 2 | 2 − 5 = −3 | 0.049787 | |
| v.z = 5 | 5 − 5 = 0 | 1.000000 | 最大的那个,恰好是 1 |
| v.w = 3 | 3 − 5 = −2 | 0.135335 | |
| 合计 sv | mv = 5 | 1.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」的优势就显出来了。
| 尺度 | 数据在哪 | 用什么 | exp 次数 | 为什么这么选 |
|---|---|---|---|---|
| 块内4 个元素 | 寄存器 | 朴素两趟先 max 再求和 | 4 | 四个数同时在手,「两趟」不花钱 |
| 块与块之间8 轮循环 | 累计值在寄存器 | online 递推 ⊕ | 每块 2 | 块一个一个来,拿不到全局 max |
FlashAttention 是同一个结构的放大版
tile 内部用朴素两趟(数据在 shared memory 里,扫两遍不碰显存),
tile 与 tile 之间用 ⊕。
和这里的区别只有两点:块从 4 放大到 128×128,
摘要从寄存器挪到 shared memory。
「朴素」和「递推」不是谁取代谁,
而是看数据能不能同时握在手里:握得住就朴素,握不住才递推。
图 29 · 这段代码已经是 FlashAttention 的最小形态。
S3 往累计值里并的是一个元素 (v, 1);S4 并的是一个小块的摘要 (mv, sv)。
块大小从 1 变成 4。FlashAttention 做的事,就是把块从 4 放大到 128×128,
把摘要从寄存器挪到 shared memory。
合并公式一个字都不用改,因为 §5 · 三
证明了任意两段摘要都能这样合并。
B + 2 怎么数出来先定义「块」:块 = 一次循环迭代里拿到的那几个元素。 两个 kernel 覆盖的元素总数相同,差别只在每轮拿几个:
| 循环 | 每轮拿到几个 | 所以 B = | |
|---|---|---|---|
| sm_s3 | 372 行 for (c < MAXS) | float v = xr[i] → 1 个 | 1 |
| sm_s4 | 401 行 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
图 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。
| 块大小 | 逐元素做 | 分块做 | 分块摊到每元素 | 出现在哪 |
|---|---|---|---|---|
| 1 | 2 | 3 | 3.00 | S3(所以 S3 用的是逐元素那条路) |
| 2 | 4 | 4 | 2.00 | 打平的分界点 |
| 3 | 6 | 5 | 1.67 | 分块开始占便宜 |
| 4 | 8 | 6 | 1.50 | S4(每元素总计 1.5 + 1 = 2.5,比 S3 的 3 还少) |
| B | 2B | B + 2 | 1 + 2/B | 一般情形 |
| 128×128 | — | — | ≈ 1.00 | FlashAttention —— 几乎只剩不可省的那一次 |
这不是 FlashAttention 分块的主要动机
主要动机是 §1 · 二 算过的那笔账: 打分矩阵不写回显存,省掉 209 ms 的搬运。 exp 摊薄是顺带的好处。 但它解释了为什么「块做大」在算力账上也没有惩罚: 不会出现「省了带宽、赔了算力」的折中。
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,而且更慢。
让它明确失败,是为了让约束显形。
和前面三份报告一样:先写预测,再看结果。 如果看着数字再编解释,什么账都能"对得上"。
| # | 预测 | 判决标准 | 理由 | 实测 | 判决 |
|---|---|---|---|---|---|
| ① | 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 ✓ | 成立 |
| # | 形状 | 预测 | 理由 | 实测 | 判决 |
|---|---|---|---|---|---|
| ⑨ | 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%(反而更高) | 落空 |
本节有两个文件,用一条命令跑完。
两个文件原样收录,磁盘路径是 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 |
# 测试机 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「怎么复现」的四条规矩。
// 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,是输入 |
| 换机器重跑 | 数字失去可比性 |
<random> 把「产生随机位」和「映射成某种分布」分成两步:
引擎输出 32 位整数流,uniform_real_distribution 负责映射。
按标准,区间是左闭右开的 [-3, 3)。dist 是函数对象,
每调用一次,就从 rng 取走一段位。
三个条件共同把 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 | 均方值 | 缩放因子 | |
|---|---|---|---|
| 1 | 0.333 | 1.7320 | |
| √3 ≈ 1.7321 | 1.000 | 1.000000 | 退化点 ——
这里漏写 * r 也照样通过校验 |
| 2 | 1.333 | 0.8660 | |
| 3 | 3.000 | 0.5774 | 离退化点有 1.73 倍余量 |
| 10 | 33.33 | 0.1732 | |
| 50 | 833.3 | 0.0346 |
② 区间必须跨零,否则上面的公式不成立
只有区间对称、E[x] = 0 时,方差才等于 E[x²]。
常见写法 (b−a)²/12 算的是方差,不是
E[x²]。两者的关系是 E[x²] = Var(x) + (E[x])²。
| 区间 | 均值 | 方差 | 均方值 |
|---|---|---|---|
| [−3, 3] | 0 | 3 | 3 |
| [0, 6] | 3 | 3 | 12 |
跨零还有一个好处:「漏写平方」的后果从细微偏差变成数量级的错误。 下表是 20 万样本的实测:
| 区间 | 正确的均方值与因子 | 漏平方后 | 能不能看出来 |
|---|---|---|---|
| [−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 位置写错之类的问题 |
③ 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 | 最小的指数项 | 落在哪 |
|---|---|---|
| 3 | 2.479e-03 | 离 fp32 下限很远 |
| 6 | 6.144e-06 | |
| 10 | 2.061e-09 | |
| 50 | 3.720e-44 | 已经是非正规数 |
④ 行和的量级
// 把 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=2048 | 340.5 | 2.9971 | 主阶梯的 softmax |
| N=3584 | 595.9 | 2.9983 | RMSNorm 的行长,softmax 不跑 |
| N=152064 | 25281.2 | 3.0000 | logits 形状 |
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 一大,兜底区很快就覆盖整行。
| 半宽 L | 行和 | 自动通过的门槛 | 占比 |
|---|---|---|---|
| 3 | 340.5 | x−max < −10.29 | 0.0%最低只到 −6,够不着 |
| 5 | 204.8 | x−max < −10.80 | 0.0% |
| 6 | 170.7 | x−max < −10.98 | 8.5% |
| 10 | 102.4 | x−max < −11.49 | 42.6% |
| 50 | 20.5 | x−max < −13.10 | 86.9% |
方法:用 fp32 模拟 N=2048,植入两种 bug,再跑同一套容差判定。
| 区间 | 版本 | 算出的行和 | 超出容差的元素 |
|---|---|---|---|
| ±3 | 正确 | 339.04 | 0 / 2048 |
| 漏掉整个 rescale | 347.76 | 2048 / 2048(100%) | |
| rescale 乘错位置 | 344.45 | 2048 / 2048(100%) | |
| ±50 | 正确 | 23.27 | 0 / 2048 |
| 漏掉整个 rescale | 25.09 | 198 / 2048(9.7%) | |
| rescale 乘错位置 | 22.43 | 190 / 2048(9.3%) |
差别不在「能不能抓」,在余量
±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 真正生效。
| 植入的 bug | 全 0 | 全常数 1.5 | U(−3,3) |
|---|---|---|---|
漏写平方 acc += v | ✗ 漏掉 | 抓到 8000 | 抓到 3000 |
漏乘缩放因子 * r | ✗ 漏掉 | 抓到 8000 | 抓到 8000 |
权重下标写成 w[c] | ✗ 漏掉 | 抓到 7976 | 抓到 7976 |
| 写回整体错位一列 | ✗ 漏掉 | 抓到 8000 | 抓到 7993 |
漏掉 i < N 越界判断 | ✗ 漏掉 | 抓到 7000 | 抓到 7000 |
常数输入下 RMSNorm 还能抓住「写回错位」,因为它有两个变化源:
输入 x 和权重 w。
把 w 也换成全 1,这两个 bug 在常数输入下就都抓不到了:
| 植入的 bug | 全 0 | 全常数 1.5 | U(−3,3) |
|---|---|---|---|
权重下标写成 w[c] | ✗ 漏掉 | ✗ 漏掉 | ✗ 漏掉 |
| 写回整体错位一列 | ✗ 漏掉 | ✗ 漏掉 | 抓到 7990 |
| 植入的 bug | 全 0 | 全常数 1.5 | U(−3,3) |
|---|---|---|---|
| 漏掉 rescale | ✗ 漏掉 | ✗ 漏掉 | 抓到 8000 |
| rescale 乘错位置 | ✗ 漏掉 | ✗ 漏掉 | 抓到 7879 |
m 初值写成 0.0f | ✗ 漏掉 | ✗ 漏掉 | ✗ 漏掉 |
| 写回整体错位一列 | ✗ 漏掉 | ✗ 漏掉 | 抓到 7991 |
两条阶梯的差别在于变化源的个数
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)。
| 用 w = 1 会漏掉的错误 | 为什么漏掉 | 随机 w 怎么抓住 |
|---|---|---|
kernel 里漏写 * w[i] |
乘 1 等于没乘,结果照样对 | 沿列方向变化 → 少乘一下立刻对不上 |
w 的下标误写成 w[row*N+i] |
w 全是 1 时,读哪儿都对 | 所有行共用同一份 → 用展平偏移就会读到别处 |
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。
§2 · 一 的账是 8N(读 x + 写 y),不是 12N,因为 w 由 M 行共用。
每行都要读一遍 w,但全份只有 14 KB,摊到每行只有
4N/M = 1.75 字节,占 8N(28672 字节)的 0.006%。
| 假设 | 搬运量 | 按 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 |
和 §9 · 三 是同一件事的两面
一次访问要不要付显存的代价,取决于重用距离,不取决于访问次数。
w 是重用距离最小的极端情形,
小到每个 block 都能命中。
N2 意外快 1.49 倍,
也是这条规律,只是作用在 14 KB 的一行数据上(§9 · 三)。
这段代码在计时之前打印三行诊断。目的是:如果结果不对,能立刻知道哪个前提不成立。
// 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 |
|---|---|---|---|
| N1 | pass1 + pass2 | rms_n1_pass140 个寄存器 | pass2 只用 26 |
| S1 | max + sum + div | sm_s1_max39 个寄存器 | sum 用 40、div 用 24 |
| S2 | ms + div | sm_s2_ms22 个寄存器 | div 用 24 |
// 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]
| 源码里的声明 | 算出来 | PTXAS 报的 | 哪些 KERNEL |
|---|---|---|---|
| __shared__ float smem[BLOCK / 32] | 8 个 float × 4 | 32 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 × 4 | 72 bytes smem | sm_s2_ms · sm_s3 · sm_s4要同时传 m 和 s,再加 2 个广播位 |
cmem[0] 是常量内存 bank 0,CUDA 用它传 kernel 参数。
代码里看不到它的声明,但它的内容就是函数签名里的参数,编译器把参数放进了常量内存。
按「指针 8 字节、int/float 各 4 字节」数一遍:
| KERNEL | 形参 | 参数共 | CMEM[0] | 差值 |
|---|---|---|---|---|
| sm_s4 | 8+8+4 | 20 | 372 | 352 |
| sm_s3 | 8+8+4 | 20 | 372 | 352 |
| sm_s2_ms | 8+8+8+4 | 28 | 380 | 352 |
| sm_s1_div | 8+8+8+8+4 | 36 | 388 | 352 |
| sm_s1_sum | 8+8+8+4 | 28 | 380 | 352 |
| sm_s1_max | 8+8+4 | 20 | 372 | 352 |
| rms_n4 | 8+8+8+4+4 | 32 | 384 | 352 |
| rms_n3 | 8+8+8+4+4 | 32 | 384 | 352 |
| rms_n2 | 8+8+8+4+4 | 32 | 384 | 352 |
| rms_n1_pass2 | 8+8+8+8+4 | 36 | 388 | 352 |
| rms_n1_pass1 | 8+8+4+4 | 24 | 376 | 352 |
为什么 cmem 不在那三条限额里
常量内存是一块独立的 64 KB 空间,不按 SM 切分,也不随 block 数复制。 所以它不限制每个 SM 能驻留几个 block。 只有参数表超过 4 KB、编译不通过时,它才成为问题。 这里是 372–388 字节,离上限很远。
| KERNEL | 寄存器 | 每 WARP | 每 BLOCK | 寄存器限 | 线程限 | SHARED 限 | 取最小 | API 返回 |
|---|---|---|---|---|---|---|---|---|
| rms_n1_pass1 | 40 | 1280 | 10240 | 6 | 6 | 3200 | 6 | 6 ✓ |
| rms_n2 | 39 | 1280 | 10240 | 6 | 6 | 3200 | 6 | 6 ✓ |
| rms_n3 | 52 | 1792 | 14336 | 4 | 6 | 3200 | 4 | 4 ✓ |
| rms_n4 | 55 | 1792 | 14336 | 4 | 6 | 3200 | 4 | 4 ✓ |
| sm_s1_max | 39 | 1280 | 10240 | 6 | 6 | 3200 | 6 | 6 ✓ |
| sm_s2_ms | 22 | 768 | 6144 | 10 | 6 | 1422 | 6 | 6 ✓ |
| sm_s3 | 56 | 1792 | 14336 | 4 | 6 | 1422 | 4 | 4 ✓ |
| sm_s4 | 48 | 1536 | 12288 | 5 | 6 | 1422 | 5 | 5 ✓ |
第四个参数那个 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 数 | 占用率 |
|---|---|---|
| ≤ 40 | 6 | 100.0% |
| 41 – 48 | 5 | 83.3% |
| 49 – 64 | 4 | 66.7% |
| 65 – 72 | 3 | 50.0% |
两者都要在寄存器里存 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 · n1 | 1 | 6 | 768 | 0.00 | 99.9% |
| decode-b32 · n1 | 32 | 6 | 768 | 0.04 | 95.8% |
| prefill · n1 | 2048 | 6 | 768 | 2.67 | 11.1% |
| prefill · n3 | 2048 | 4 | 512 | 4.00 | 0.0% |
| 主阶梯 · n1 | 8192 | 6 | 768 | 10.67 | 3.0% |
| 主阶梯 · n3 | 8192 | 4 | 512 | 16.00 | 0.0% |
| 主阶梯 · s4 | 8192 | 5 | 640 | 12.80 | 1.5% |
| 大批 · n3 | 16384 | 4 | 512 | 32.00 | 0.0% |
两点保留
① 「整波推进」是粗模型,上面那列百分比只能当上界看。 真实的调度器每退休一个 block,就立刻补一个新的, 不等整波结束,所以尾部浪费比模型算的小。
② smFill < 1.0 这个告警阈值偏松。
prefill 的 2.67 已经有 11% 的尾波浪费,但不触发告警。
footprint 这一行是 §9 · 三那个误导信号的来源
bytes = nElem * sizeof(float) 是 x 的大小,
2.0 * bytes 是 x + y。L2 是 75497472 字节 = 72 MiB。
| 形状 | x | x + y | 程序判定 | X 单独装得下吗 |
|---|---|---|---|---|
| RMSNorm 主阶梯 | 112.0 MiB | 224.0 | 装不下 | 否 |
| softmax 主阶梯 | 64.0 MiB | 128.0 | 装不下 | 是 ← |
| softmax 大批 | 128.0 MiB | 256.0 | 装不下 | 否 |
| prefill · n1 | 28.0 MiB | 56.0 | 装得下,虚高 | 是 |
| logits | 18.6 MiB | 37.1 | 装得下,虚高 | 是 |
// 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 上的竞态。
同时满足下面三个条件,就是一处竞态:
__syncthreads()。
漏写 __syncthreads() 不会让程序崩溃,也不会报错。
它只会让结果偏一点点。偏多少取决于 warp 的执行顺序。
换一个形状或换一张卡,结果可能就不同。
所以「✓ 抽样校验通过」不能证明没有竞态。
这一次结果对,可能只是执行顺序碰巧合适。
racecheck 不看结果对不对,只看访问顺序。 只要访问顺序满足上面三个条件,它就报告一处竞态,哪怕这次读到的值碰巧正确。 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] |
下面是 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 个线程读同一个地址,只读不写,没有竞态
}
blockReduceMaxSum(s3 和 s4 用)把结果写到单独的槽
它把最终结果写进 smem[2*nwarp] 和 smem[2*nwarp+1],不覆盖 smem[0]。
部分和占 smem[0..15](m 和 s 各 8 个),最终结果占另外两个槽。
读部分和与写最终值的地址不重叠,所以不需要像 ⑤ 那样依赖「同一个线程」来保证安全。
这种写法更不容易出错。
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。
| 取舍 | 原因 |
|---|---|
只测 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,所以也不在检查范围内。
小结:三项检查各回答一个问题。
三段都是 0 hazards,说明 blockReduceSum 和 blockReduceMaxSum 里的
__syncthreads() 位置正确。
run_fused.sh 全文↑一条命令跑完八个 kernel × 五种形状 × 三轮,外加边界、长行拒绝和 racecheck。 这次实跑的产物就是 §9 那三张表。
${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 · 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 · 三。
| kernel | 字节/行 | 耗时 ms | GB/s | MBU | 相对基线 | 理论值 | 寄存器 |
|---|---|---|---|---|---|---|---|
| N1 两趟 | 12N | 0.3859 | 912.9 | 90.6% | 基准 | — | 40 / 26 |
| N2 融合但读两次 | 12N | 0.2591 | 1359.9超过峰值 → 虚高 | 134.9% | 1.49× | 1.00× | 39 |
| N3 寄存器驻留 | 8N | 0.2560 | 917.5 | 91.0% | 1.51× | 1.50× | 52 |
| N4 +float4 | 8N | 0.2560 | 917.4 | 91.0% | 1.51× | 1.50× | 55 |
| S1 三趟 | 16N | 0.2509 | 1070.0超过峰值 → 虚高 | 106.1% | 基准 | — | 39 / 40 / 24 |
| S2 online 两趟 | 12N | 0.2202 | 914.5 | 90.7% | 1.14× | 1.33× | 22 / 24 |
| S3 一趟 | 8N | 0.1475 | 910.2 | 90.3% | 1.70× | 2.00× | 56 |
| S4 +float4 | 8N | 0.1475 | 910.2 | 90.3% | 1.70× | 2.00× | 48 |
| kernel | M=1 decode b1 |
M=32 decode b32 |
M=2048 prefill |
M=16384 大批 |
|---|---|---|---|---|
| N1 | 0.7% | 22.2% | 328.2% | 90.9% |
| N3 | 0.5% | 15.3% | 289.4% | 91.0% |
| N4 | 0.7% | 22.2% | 316.0% | 91.2% |
| S1 | 0.4% | 13.0% | 295.5% | 91.7% |
| S3 | 0.3% | 10.2% | 154.8% | 90.3% |
| S4 | 0.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%,这个指标在那里已经量不了显存 |
字节账算的是 kernel 发出了多少访存请求;耗时取决于显存实际读写了多少。 两者之间隔着 72 MiB 的 L2。 同一行数据被读两次,两次读之间隔了多少数据,叫重用距离。 重用距离小于 L2 容量,第二次读就不花钱。
图 31 · 条的长度只是示意,不按比例。要看的是条落在虚线哪一侧。 N1 和 N2 的代码字节账相同(都读 x 两次), 差别只在两次读之间隔多远。
把 N3 实测的 917.5 GB/s 当作这张卡显存带宽的实际上限 (峰值 1008 GB/s 的 91.0%,八级里六级都贴着它跑)。 用「耗时 × 917.5 GB/s」反推每一级实际搬了多少,再和账面比:
| 级 | 账面 | 反推实际 | 兑现率 | 折算 |
|---|---|---|---|---|
| N1 | 336.0 MiB | 337.7 MiB | 100.5% | 12.1N |
| N2 | 336.0 MiB | 226.7 MiB | 67.5% | 8.1N |
| N3 / N4 | 224.0 MiB | 224.0 MiB | 100.0% | 8.0N |
| S1 | 256.0 MiB | 219.5 MiB | 85.8% | 13.7N |
| S2 | 192.0 MiB | 192.7 MiB | 100.4% | 12.0N |
| S3 / S4 | 128.0 MiB | 129.1 MiB | 100.8% | 8.1N |
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,而是基线 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。基线虚快,比值就缩水。
| 形状 | x 大小 | S1 兑现率 | S3 对 S1 | S4 对 S1 | 理论 |
|---|---|---|---|---|---|
| M=8192 | 64 MiB装得进 72 MiB L2 | 85.8% | 1.70× | 1.70× | 2.00× |
| M=16384 | 128 MiB装不下 | 99.2% | 1.97× | 1.98× | 2.00× |
更糟的是,程序当时给出了相反的保证
日志里 softmax 主阶梯那一行印的是
footprint = x + y = 128.0 MB(L2 72.0 MB)→ 装不下,必须走显存。
这个信号本身是误导的。
它只比较 x + y 的总和;决定跨 kernel 重用能否命中的,
是被重用的那个数组自己的大小。x 单独只有 64 MiB,装得进 72 MiB 的 L2。
检查代码的完整拆解见 §8 · 二的「跑之前的三道前置检查」。
MBU = 账面字节 ÷ 耗时 ÷ 1008 GB/s。只要数据整个装进 L2,分子就不再是显存流量,这个数也就失去意义。
| 场合 | 实测 MBU | 整份数据 | 为什么虚高 |
|---|---|---|---|
| N2主阶梯 | 134.9% | 224 MiB | 数据装不下,但第二次读的重用距离只有 14 KB |
| S1主阶梯 | 106.1% | x 单独 64 MiB | x 装得进 L2,跨 kernel 的第二次读部分命中 |
| ⑪ M=2048prefill | 155–328% | 32–56 MiB | x 和 y 全部装进 L2,整个基准没有碰显存 |
| ⑭ M=32 N=152064logits | 147.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 和更长的依赖链照样要花时间。 阶梯倒过来了。 这反证了一点:整套优化的前提是「瓶颈在显存」,前提不成立,结论也不成立。
| 行数 | M=1 | M=32 | M=2048 | M=8192 | M=16384 |
|---|---|---|---|---|---|
| N3 对 N1 | 1.00× | 1.02× | 1.32× | 1.51× | 1.50× |
| grid 填充度按 N3 的 4 block/SM 算 | 0.002 | 0.06 | 4.00 | 16.0 | 32.0 |
一个例外: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 那边可信得多。 |
__shfl_xor_sync,而第 10 章 R6 用 __shfl_down_sync?
换过来会怎样?float xs[MAXS] 必须配 #pragma unroll 和常量下标?
写成 xs[cnt++] 会发生什么?从哪里能看出来?-INFINITY?什么输入会触发这个 bug?第 ⑥ 题是整份报告的出口
答案不在「怎么把 kernel 写得更快」, 而在「这个 kernel 本来就不该存在」。 S3 的 8N 是「必须把打分矩阵写出来再读回」这个前提下的最优; FlashAttention 直接否定了这个前提。 算子级优化的天花板,是算法级优化的地板。