Flash Attention CUDA Kernel 优化: 从 56 到 986 TFLOPS on B300 — G6: Cluster 与 2CTA 探索 (727→880)

背景
G5 从 V21 到 V36,核心是 pipeline 重构 + 双缓冲 + 精细同步。V21 的 ping-pong 流水线把性能从 220 直接拉到 355 TFLOPS,后续经过一系列优化,最终收在 V35 的 727.25 TFLOPS。G5 的结论大概是:
- pipeline 重构是收益最大的改动:让 TMA 和 tensor core 重叠,解决了之前串行等待的问题。
- 指令副本膨胀需要控制:模板 × 循环展开的组合会导致 icache 压力,需要在外层做 unroll 1。
- TC 利用率从 7.8% 涨到了约 18-20%:说明 pipeline 重构的方向是对的,但距离饱和还有距离。
G5 结束时隐约有一个感觉:单 CTA 的资源可能已经用得差不多了。无论怎么调 barrier、怎么压缩指令,TC 利用率就是上不去。也许问题不在 pipeline 本身,而在于一个 CTA 能调度的 warp 数量有限,无法让 tensor core 持续饱和。
于是 G6 打算尝试一个新方向:引入 cluster,用 2 CTA 来协同工作。
V37: 2CTA 雏形 — 先跑起来再说
SM90 之后 NVIDIA 引入了 cluster 的概念,多个 CTA 可以协同工作,共享一些资源。之前一直只用 1 CTA,也许 occupancy 上不去的原因就在这里。
改动
为了写出正确的 2CTA kernel,回退了 G5 所有 barrier 上的细致优化。先把基础跑通,再考虑性能。
主要改动如下:
PV 改用 tcgen05 ts:QK 阶段得到的 P 存在 TMEM 里,之前 softmax 的时候会把 P 搬到 SMEM,现在让它一直留在 TMEM 里。
2CTA 共享 KV 加载:两个 CTA 协同做 tcgen05,把 B 切片存在 SMEM 里,理论上能省一半的 TMA 流量。不过 softmax 这边不一定能直接受益,先跑起来看看。
KV 分成 6 个环(K、V 各 3 级交错):前文省去了大量 SMEM 空间,现在可以容纳更多环。KV 的预加载粒度变细了,或许能增加 overlap 的机会。
还有一个值得关注的地方:w15 负责协调 cluster 内的指令。这个设计有点风险——w15 既要做 PV 发射又要做 cluster 同步,职责有点重。不过第一版先这样,后续再调整。
另外,SM103 大概有 148 个 SM,每个 CTA 占一个 SM 的话,2CTA 模式下 cluster 的分配可能会有一些不均匀。这会影响到负载均衡,但目前还只是小问题。
信号图大概如下:
性能
1 | Elapsed Time (total): 0.0051 seconds |
从 V35 的 0.047 ms / 727 TFLOPS 回退到了 0.051 ms / 668 TFLOPS。性能掉了大概 8%,但考虑到这是回退了所有 barrier 优化后的第一版,这个跌幅不算太夸张。
ncu 里 barrier stall 非常之多。说明当前的流水线还是依托答辩——2CTA 的同步开销比预期的要大,很多 warp 在等 barrier,计算单元没有充分利用起来。
不过至少跑通了,后续需要针对 cluster 场景重新做 barrier 的精细优化,把 G5 积累的经验移植过来。
V38: setmaxnreg — 运行时寄存器限制
G5 以来,spill 主要集中在 softmax 相关的代码段。分析发现,wg0/wg1 在 softmax 阶段寄存器需求较高(约 176 个),而 wg2/wg3 需求较低(不到 80 个)。当前 __launch_bounds__(512, 1) 统一按 128 寄存器编译,导致 softmax 区域生成 spill。
改动
PTX 提供了 setmaxnreg 指令,可以在运行时调整 warp 的寄存器上限:
1 | template<uint32_t RegCount> |
在 kernel 入口处对不同 warp group 做差异化设置:wg0/wg1 升至 176 寄存器,wg2/wg3 降至 80 寄存器,期望通过运行时按需分配来消除 softmax 区域的 spill。
性能
1 | Elapsed Time (total): 0.0051 seconds |
相比 V37 的 668 TFLOPS,无明显变化。ncu 显示寄存器分配仍为 128,spill 没有减少。
原因
setmaxnreg 只控制运行时寄存器分配上限,不能撤销编译器已经生成的 spill。ptxas 在编译时按 __launch_bounds__(512, 1) 的 128 寄存器目标完成了寄存器分配和 spill 决策,local memory 的 load/store 已经写入指令流。运行时动态调整上限不会让编译器重新决策。
此方向不可行,转向流水线排布优化。
V39: Interleave — 交错 QK/PV 发射
参考了 Flash Attention 4 的实现,对每个 phase 的 mma 发射顺序做了调整。
改动
原来的顺序是按 key 分组,每个 key 完整做完 QK 和 PV 后再进入下一个 key:
1 | for key = 0..7: |
问题在于:QK(key+1) 必须等当前 key 的 PV 全部完成才能开始,导致 softmax → PV → QK 形成串行链。
新顺序改为交错发射:
1 | 开始: 发 QK0(0), QK1(0) |
核心变化是 QK 不再被上一阶段的 PV 阻塞,而是整体提前了一个 phase。由于当前采用 6 环 KV 流水线(K、V 各 3 级交错),提前发射 QK 不会造成数据冒险。
性能
1 | Elapsed Time (total): 0.0047 seconds |
回到 G5 SOTA 水平。ncu 数据:barrier 2.88(中继 + per-task 重 init)、long_sb 5.99(softmax 的 TMEM 物理往返)、spill 476k。
V40: Barrier PTX — 拒绝外联
CUTLASS 的 Barrier::wait() 编译后,ptxas 会把自旋重试路径(PHASECHK)外联成慢路径代码。更糟的是,barrier 地址是运行期算的(key 是循环变量),这个地址寄存器要跨越外联边界存活——ptxas 的处理是把它 spill 到 local memory。
结果就是 softmax warp 每次在 score_ready 上醒来重试,都要先从 local memory 把 barrier 地址读回来。spill 走到了关键路径上。
既然这样,那我们不用你的。直接手写一个 barrier wait:
1 | asm volatile( |
拒绝外联!
1 | Elapsed Time (total): 0.0047 seconds |
性能没涨。但 spill 从 47 万降到了 7 万。至少关键路径上那点 spill 污染被清掉了。
V41: Conditional Scale — 允许 b 滞后
我们之前的 softmax 求出最大值之后会直接用这个 max 去做 exp。但仔细想想,我们真的需要严格的无溢出吗?max 存在的意义是防止指数运算溢出。如果把数值控制在合理范围内,那 b 也是可以有一些宽松处理的。
基于这个思想,我们做了一个 conditional scale(换基判定):
流程大概是这样的:
① QK 完成,S 是 128 个原始点积(fp32,还没乘 scale)
② tile_max = 128 个 s 的最大值(4 次 ld.red)
③ candidate = max(旧b, tile_max × ScaleLog2)
④ 换基判定:
— candidate ≤ 旧b + 8 ? 是 → b 不动,history = 1(绝大多数情况)
— 否 → b = candidate,history = 2^(旧b - 新b)(只在 max 快速上升时触发)
⑤ 逐 score:p = 2^(s·ScaleLog2 - b),转 fp16,打包写回 TMEM(P区)
— 因为 b 允许滞后 8,p 的范围变成 (0, 256],不再是 (0, 1]
⑥ tile 内求和:packed_sum 用 half2 累加 → tile_sum(fp32)
⑦ row_sum = row_sum × history + tile_sum,history=1 时原样保留
⑧ history_scale[row] = history,交给 wg2
然后 rescale 阶段:
1 | fp32_t scale = storage.history_scale[row]; // ① 从 smem 读自己的系数 |
因为整个 warp 里的 32 行用的是同一个 scale,__ballot_sync 一票否决,要么全做要么全不做。所以 warp divergence 是不会有的。
这真的是穷途末路,无计可施了。硬凑出来的。
1 | Elapsed Time (total): 0.0043 seconds |
效果还挺不错。指令数降了 17%,tensor core 利用率到了 31%。可以认为这一版提升是有效的。
V42: Barrier 合并
p_cluster 和 correction cluster 这两组 barrier 可以合并,省一次同步。
1 | Elapsed Time (total): 0.0043 seconds |
收益不大,可改可不改。
V43: PV 细分
尝试把 P 拆成前 96 列 + 后 32 列。softmax_tile 把 max 计算和 history 计算提到 exp 循环之前,算完立刻 arrive。wg2 的 rescale 可以和 softmax 的 exp 循环并行——原来 wg2 要等整个 softmax 结束。
依赖链的变化:
旧:softmax(全128列) → PV(8块)
新:softmax(96列) → PV(6块) + softmax(余32列) → PV(2块),PV 起点从 “softmax 100% 完成” 提前到 “75% 完成”,TC 提前介入;rescale 也从 softmax 尾部移到头部之后。粗粒度的生产者-消费者关系被细化成流水。
1 | Elapsed Time (total): 0.0041 seconds |
long scoreboard 下降了 18%,这是真的精修。
V44: Remove Rescale WG
尝试把 rescale wg 移除。现在 softmax 的逻辑是:
1 | // softmax_tile 里,算完 history 之后: |
epilogue 也交给 wg0/wg1 来做。现在变成 3 个 wg,总共 384 线程,寄存器变多了。
1 | Elapsed Time (total): 0.0039 seconds |
spill 消失了,168 reg per thread。long scoreboard 也少了 33%,推测是因为不需要等到 rescale 了。
后面尝试了多版本优化,都没能突破 0.039 ms。用 ncu 逆向看了一下 torch sdpa,发现它用的是 1 CTA。于是我们决定换技术路线,回到 1 CTA 继续优化。
G6 小结
G6 从 V37 到 V44,核心主题是 2CTA cluster。
V37 首次引入 2CTA,性能从 G5 的 727 掉到 668。V38 的 setmaxnreg 尝试失败,V39 的 interleave 回到 726,V40 的手写 barrier PTX 把 spill 从 47 万降到 7 万,V41 的 conditional scale 直接冲到 795,V42 合并 barrier 微涨到 796,V43 的 PV 细分到 837,V44 移除 rescale wg 后最终收在 880.14 TFLOPS。
性能演进
| 版本 | 核心改动 | Time/step | TFLOPS |
|---|---|---|---|
| V35(G5 终点) | rescale ld/store 位宽 16 | 0.047 ms | 727.25 |
| V37 | 2CTA 雏形 | 0.051 ms | 668.12 |
| V38 | setmaxnreg | 0.051 ms | 669.47 |
| V39 | Interleave QK/PV | 0.047 ms | 726.84 |
| V40 | 手写 barrier PTX | 0.047 ms | 727.64 |
| V41 | Conditional scale | 0.043 ms | 795.37 |
| V42 | 合并 barrier | 0.043 ms | 796.63 |
| V43 | PV 细分 96+32 | 0.041 ms | 837.10 |
| V44 | 移除 rescale wg | 0.039 ms | 880.14 |
G6 从 668 爬到 880,整体提升了约 32%。其中 V41 的 conditional scale 是单版收益最大的改动,从 727 到 795,涨了约 9%。
几个比较明确的结论
2CTA 本身不直接带来收益:V37 比 G5 终点还低。cluster 的价值在于提供了更多的寄存器资源和并发度,但需要配合其他优化才能释放出来。
b 允许滞后 8 是个好思路:大多数 tile 的 max 不会剧烈变化,history=1 的常态路径 bypass 了 rescale,省掉了大量指令。只有在 max 快速上升时才触发换基,这个设计把“保险”和“常态”分开了,是非常实用的工程手法。
PV 细分把生产者-消费者关系打得更散:softmax 不需要全部做完才能开始 PV,96 列够用就先发一批,TC 提前介入。这种“边生产边消费”的思路比一次性算完再交棒更高效。
torch sdpa 用的是 1CTA:ncu 逆向确认了这一点,说明 1CTA 在 SM103 上并没有资源受限的问题。既然如此,搞 2CTA 反而多了一套 cluster 同步的开销,性价比存疑。
下一步方向
既然 torch sdpa 用 1CTA 能跑到 1156 TFLOPS,说明 1CTA 的天花板还没摸到。G6 在 2CTA 上验证的技术(conditional scale、PV 细分、手写 barrier)可以迁移回 1CTA,看看能否在更简单的同步模型下继续往上优化。
Flash Attention CUDA Kernel 优化: 从 56 到 986 TFLOPS on B300 — G6: Cluster 与 2CTA 探索 (727→880)