动机
在编写 Triton kernel 时,num_warps 是最常调节的编译参数之一。直觉上,更多的 warp 意味着更高的 occupancy,应该提升性能。但在某些场景下,增加 num_warps 反而会导致显著的性能回退——非单调的寄存器压力崩溃。
本文通过一系列实验,系统调查了以下问题:
num_warps=16到底比num_warps=2慢多少?- 变慢的物理机制是什么?是传统的 local memory spill,还是别的什么?
- 这个现象是 cherry-pick 的巧合,还是普遍存在的?
- Ampere 和 Blackwell 两代架构有何差异?
- 有没有办法故意触发真正的 STL/LDL spill?
所有代码和实验均在以下环境完成:
- GPU: NVIDIA GeForce RTX 3080 (SM 8.6, Ampere) + RTX 5060 Ti (SM 12.0, Blackwell)
- PyTorch: 2.11.0 + CUDA 13.0
- Triton: 3.6.0
- ptxas: Triton 捆绑版 (CUDA 2025)
代码全文见文末。
实验 A:性能退化有多严重?
首先从一个最简单的 FlashAttention Decode kernel 开始。固定参数 N=4096, H=32, D=128, BLOCK_N=64,只变化 num_warps:
1 | num_warps p50 (ms) vs nw=2 分析 |
76.9% 的退化! nw=16 比 nw=2 慢了 77%。但这只是 FA Decode——计算密集的 GEMM 会如何?
对于 GEMM (M=N=K=2048, BLOCK_K=32):
1 | num_warps p50 (ms) vs nw=4 分析 |
GEMM 的退化 “只有” 28%,但趋势完全一致。
实验 B:打开编译器黑盒
性能退化的根源在哪里?我们需要看编译器生成的代码。
Triton 的编译流水线是两阶段的:
- Triton → PTX(虚拟中间表示):Triton 编译器已知
num_warps,生成不同的 PTX - PTX → SASS(GPU 实际指令):ptxas 后端根据 occupancy 信息进一步压缩寄存器
通过 nvdisasm 反汇编 cubin 来分析不同 num_warps 下的 SASS:
1 | nw PTX 虚拟寄存器 SASS 物理寄存器 代码量 STL/LDL |
三个关键发现:
① PTX 层面已经开始变化
Triton 编译器知道最终的 num_warps,在前端就调整了寄存器分配。nw=2 的 PTX 声明了 1407 个虚拟寄存器,而 nw=16 只有 379 个——减少 73%。
② ptxas 后端进一步压缩
从 PTX 虚拟寄存器到 SASS 物理寄存器,经历了又一次压缩。nw=2 从 1407 → 168(8.4x 压缩),nw=16 从 379 → 61(6.2x 压缩)。
③ STL = 0, LDL = 0
没有任何 local memory spill 指令。性能退化不是因为寄存器溢出到显存,而是编译器主动压缩了寄存器配额。
实验 C:Occupancy 模型验证
“寄存器配额”到底是多少?硬件限制在哪里?
对于 RTX 3080 (Ampere):65536 × 32-bit 寄存器 / SM,48 warp 上限。
1 | nw 线程/块 配额上限 FA 实际 寄存器文件占用率 Occupancy |
有趣的是,nw=16 时寄存器文件利用率仅 48%,远未达到硬件极限。但 ptxas 已经主动压缩了寄存器。这意味着 ptxas 的决策不是”满了才压缩”,而是根据 occupancy 目标预分配。
核心机制澄清:这不是 LMEM spill,而是 Register Rationing(寄存器配额压缩)。
高 occupancy → 每线程寄存器预算被硬性压缩 → 编译器不能充分展开循环 / 管线化访存 → ILP 下降 → 吞吐跌落 77%。
实验 D:编译器崩溃呢?
用户最初提到 ptxas C7907 编译器内部错误。我们在两个平台上测试了 15 种 arch + maxrregcount 组合:
- Triton 3.6 捆绑的 ptxas (CUDA 2025) 在 sm_86 ~ sm_120a 全部编译通过
- 未触发 C7907(可能与 ptxas 版本有关,新版已修复)
- maxrregcount=32~255 全部编译成功
C7907 可能只在特定 ptxas 版本和特定 kernel 结构下触发,我们的实验未复现。
实验 E:这是 cherry-pick 吗?
到目前为止,我们只测试了一组参数(BLOCK_N=64, N=4096)。如果这是精心挑选的参数才有的现象呢?
E1:不同 BLOCK_N
1 | BLOCK_N nw=2 nw=4 nw=8 nw=16 max 退化 |
在所有 BLOCK_N 下都存在退化! 但幅度差异很大:BLOCK_N=16 时 nw=16 比 nw=2 慢了接近 3 倍 (195%),而 BLOCK_N=128 时退化为 44%。
规律:BLOCK_N 越小,退化越严重。因为小 BLOCK_N 意味着更少的 per-iteration 工作量,寄存器压缩对 ILP 的影响更容易暴露。
E2:不同序列长度 N
1 | N nw=2 nw=4 nw=8 nw=16 max 退化 |
长序列退化更严重。N=8192 时退化 77%,N=1024 时 “仅” 40%。
E3:原版 FA 与”高压力”FA 对比
为了测试更极端的寄存器压力场景,我特意编写了一个 high_pressure_fa_decode kernel,通过保持更多中间变量、分步计算来推高寄存器需求。结果却令人意外——与原版 FA 的寄存器使用完全相同。
这验证了一个重要事实:Triton 编译器的优化能力很强,会消除”假的”中间变量。不能简单通过代码拆分来强制寄存器消耗。
E4:扩展 num_warps
1 | nw p50 (ms) 退化 vs nw=2 |
nw=32 在 Ampere 上几乎达到 3x 退化。这是一个极端但明确的证据:num_warps 和性能之间不存在单调关系。
实验 F:编译器倾向性
实验 F 的结果简洁明了:
1 | 原版 FA (Ampere): |
每个 num_warps 的 PTX 和 cubin 都不同——不仅是寄存器数,代码组织方式也不同。这意味着编译器在前后端都进行了感知 occupancy 的优化。
Torch Inductor 的 autotune 配置:
1 | # Torch Inductor flex_decode 搜索空间 |
PyTorch 官方 autotuner 已经排除了 num_warps=16,默认值也是 num_warps=2。这不是 bug,是已知行为。
实验 G:真正的 LMEM Spill 能触发吗?
既然 FA decode 的 nw=16 不会产生 STL/LDL,那什么情况下才会?我设计了一个极端 GEMM kernel——4 个 64×32 并行累加器:
1 | nw SASSreg STL LDL 溢出? |
仍然没有 STL/LDL! 即使 4 个累加器(相当于标准 GEMM 的 4 倍寄存器需求),ptxas 仍然将所有变量保持在寄存器中,只是压缩了每个累加器的展开度。
这说明:ptxas 的寄存器配额机制在 Triton 场景下几乎永远不会产生传统的 local memory spill。它采用的是主动压缩策略,而非被动溢出。
Ampere vs Blackwell 对比
前面所有实验都在 RTX 3080 (Ampere) 上完成。在 RTX 5060 Ti (Blackwell) 上呢?
性能退化对比
1 | BLOCK_N=16: Ampere +195% | Blackwell +138% |
Blackwell 对 FA Decode 明显更宽容。在 BLOCK_N=64 时仅退化 16%(vs Ampere 的 76%)。但这不意味着 Blackwell 没有这个问题——BLOCK_N=16 仍然退化 138%。
扩展 num_warps
1 | nw Ampere 退化 Blackwell 退化 |
Ampere 的退化曲线陡峭且单调增加。Blackwell 相对平缓,但 nw=32 仍有 74% 退化。
注意: Blackwell 的 SASS 寄存器数未能通过
SHI_REGISTERS=N模式解析(SM 12.0 使用 EIATTR_REGCOUNT 的二进制属性)。我们的 Blackwell 寄存器分析是不完整的——“更宽容”的结论基于端到端延迟,不能完全排除是架构 IPC 提升而非寄存器管理改进。
最终结论与实用建议
物理机制
1 | 用户原始假设: LMEM Spill (STL/LDL) ❌ |
关键数据
| 发现 | 数值 |
|---|---|
| FA Decode nw=16 vs nw=2 最大退化 | +195% (Ampere, BLOCK_N=16) |
| 扩展 nw=32 退化 | +188% (Ampere) |
| SASS 寄存器悬崖 | 168 → 61 r/t (降 64%) |
| 代码量下降 | 82KB → 28KB (降 65%) |
| 真实 LMEM spill | 未触发(即使 4 累加器 GEMM) |
| Blackwell 宽容度 | 退化仅 Ampere 的 20-50% |
实用建议
永远不要假设更大的 num_warps 更好。对于 memory-bound 的 decode kernel,nw=2 通常最优。对于 compute-bound GEMM,nw=4~8 最优。nw=16 极少最优,nw=32 总是灾难。
使用 autotuner。Triton 的 autotune 机制能自动搜索最优 num_warps。Torch Inductor 已经将 nw=16 排除在搜索空间外。
理解编译器行为。性能退化不是因为 local memory spill——不会看到 STL/LDL 指令。真正的机制是 register rationing:编译器主动压缩寄存器分配以支持更高 occupancy,从而牺牲了 ILP。
如果遇到非单调性能,优先检查 SASS 寄存器数。用
nvdisasm -gi cubin检查SHI_REGISTERS=N。如果 nw=16 的寄存器数明显少于 nw=8,且性能退化,就是 register rationing。多架构测试很重要。Blackwell 对 decode workload 更宽容(退化仅 +16% vs Ampere 的 +76%),但 GEMM 更敏感(+35% vs +28%)。最优 num_warps 取决于硬件特性。
可复现代码
以下是核心实验脚本。完整代码见 mvp/ 目录。
reproduce_bug.py:实验 A-D(基本验证)
1 |
|
investigate_generality.py:实验 E-G(通用性调查)
核心数据结构——Triton 的 device_caches:
1 | # Triton 3.6 JIT Cache 结构: |
完整的代码(~850 行)可在 mvp/reproduce_bug.py 和 mvp/investigate_generality.py 查看。
参考文献
- CUDA Runtime API: Occupancy (注:原文 CUDA Occupancy Calculator 链接已失效,该独立工具曾发布于 docs.nvidia.com/cuda/cuda-occupancy-calculator/)
- Triton Issue #9933: Non-monotonic register pressure crash
- FlashAttention: Fast and Memory-Efficient Exact Attention
- Torch Inductor FlexDecode Config (具体配置见 torch/_inductor/flex_attention.py 相关源码)