CUDA系统拆解-08-Occupancy、寄存器压力与Launch参数:调优不是把占用率拉满

本文是「CUDA系统拆解」系列第 08 篇。
系列导读:CUDA系统拆解-00-导读:从编程模型到 AI 推理系统的学习路线
上一篇:CUDA系统拆解-07-同步、原子操作与内存一致性:并发正确性怎么保证
下一篇:CUDA系统拆解-09-Streams、异步拷贝与Overlap:如何把拷贝和计算叠起来

1. 这篇解决什么问题

  • occupancy 到底是什么,它为什么重要。
  • 为什么 occupancy 高不等于最终性能高。
  • 一个 SM 上到底是被哪些资源限制住的。
  • block size 为什么会影响并发度、调度粒度和资源切分方式。
  • 什么是 register pressure,为什么会引出 spilling
  • Launch 参数调优为什么本质上是在平衡多个约束,而不是套固定公式。

2. 先记住的核心结论

  • occupancy 指的是一个 SM 上活跃 warp 数相对于该 SM 最大可支持活跃 warp 数的比例。
  • occupancy 的意义主要是帮助隐藏延迟,而不是直接代表性能。
  • 限制 occupancy 的常见资源包括:线程数、warp 数、block 数、寄存器总量、shared memory 总量。
  • block size 不只是线程数设置,它还决定了资源分配粒度、warp 数量和调度灵活性。
  • register pressure 高会压低并发,严重时会触发 spilling,让本该在寄存器里的数据掉到更慢的路径。
  • Launch 调优的本质是平衡:更多活跃 warp,还是更强的数据复用与单线程效率。

3. 正文讲解

3.1 先把核心问题说透:一个 SM 上到底能挂多少活

到了这一篇,你应该把关注点从“线程怎么写”转到“资源怎么被占满”。

同一个 kernel launch 之后:

  • thread 组成 block
  • block 被分配到 SM
  • block 再拆成多个 warp

所以这一篇真正要回答的问题是:

一个 SM 上,在同一时刻到底能同时驻留多少 block、多少 warp、多少 thread?

这个问题之所以重要,是因为它直接影响两件事:

  • 有没有足够多的 warp 可以轮换执行,用来隐藏延迟
  • 每个线程和每个 block 是否还能拿到足够多的寄存器、shared memory 和局部工作空间

occupancy 就是在描述这个大背景。

3.2 occupancy 的含义和局限

最直白的定义是:

occupancy = 活跃 warps 数 / SM 最大可支持 warps 数

它本质上反映的是:

  • 一个 SM 上当前有多少 warp 处于可调度状态
  • GPU 有没有足够多的候选 warp 去隐藏等待

为什么这件事有意义?
因为 GPU 的强项不是把单线程做到极致快,而是:

  • 某个 warp 在等 global memory
  • 调度器马上切去跑另一个 ready warp
  • 用足够多的活跃 warp 覆盖掉等待时间

所以 occupancy 重要,是因为它和延迟隐藏能力直接相关。

但一定要立刻记住:

occupancy 不是性能分数。

它的局限在于:

  • occupancy 高,不代表访存就一定高效
  • occupancy 高,不代表寄存器复用就一定好
  • occupancy 高,不代表指令吞吐、tensor core 利用率、数据复用就一定更强

所以更准确地说:

occupancy 是“隐藏延迟的能力指标”,不是“最终性能指标”。

3.3 为什么 occupancy 太低通常不行,但高也不一定更快

如果 occupancy 很低,常见问题是:

  • 某些 warp 一旦 stall
  • SM 上没有足够多的其他 warp 可以补位
  • 管线更容易空转

这时提高 occupancy 往往有帮助。

但为什么它不是越高越好?
因为提高 occupancy 往往不是免费的。你通常要付出某些代价,例如:

  • 减少每线程寄存器
  • 减少 shared memory 缓冲
  • 让 tile 变小
  • 降低数据复用
  • 降低单线程或单 block 的工作效率

于是就会出现一种典型情况:

  • occupancy 提高了
  • 但每个线程“变瘦了”
  • 访存更多了,复用更差了
  • 总性能反而下降

所以成熟的判断不是“能不能把 occupancy 拉满”,而是:

现在的瓶颈到底是活跃 warp 不够,还是别的资源已经更关键。

3.4 SM 资源如何限制并发

一个 SM 上能同时驻留多少 block / warp,不是你想开多少就能开多少,而是受多种资源共同限制。

最常见的限制项包括:

  • 每个 SM 支持的最大线程数
  • 每个 SM 支持的最大 warp 数
  • 每个 SM 支持的最大 block 数
  • 每个 SM 的寄存器总量
  • 每个 SM 的 shared memory 总量

这意味着:

  • 即使线程上限还没满,只要寄存器不够,新 block 也上不去
  • 即使寄存器还有剩余,只要 shared memory 不够,也挂不下更多 block
  • 即使资源看似都还有一点,只要 block slot 用完,也会被卡住

所以你可以把可驻留 block 数理解成一个“多重门槛共同取最小值”的结果。

3.5 block size 为什么影响资源利用和调度粒度

很多人把 block size 理解成“每个 block 开多少线程”。这当然没错,但太浅了。

它更深一层的作用是:

  • 决定每个 block 有多少 warp
  • 决定资源按多大粒度被切分给 block
  • 决定一个 block 自己吃掉多少寄存器和 shared memory

比如:

  • block size = 128,意味着一个 block 有 4 个 warp
  • block size = 256,意味着一个 block 有 8 个 warp

这不只是线程翻倍,而是:

  • block 的寄存器需求可能翻倍
  • block 的调度粒度变大
  • 一个 SM 上可同时驻留的 block 数可能下降

所以 block size 调优本质上是在权衡:

  • 并行度
  • 资源切分粒度
  • block 内协作需求
  • 调度灵活性

这也是为什么 block size 不是越大越好,也不是越小越好。

3.6 什么是 register pressure

寄存器是按线程分配的。
如果一个 kernel 每线程需要很多寄存器,那么:

  • 单线程局部计算可能更舒服
  • 但整个 block 的寄存器消耗会很高
  • 一个 SM 上能并行驻留的线程和 block 就会减少

这就是 register pressure

它的本质可以理解成:

这个 kernel 对寄存器的需求太高,已经开始明显挤压并发资源。

常见来源包括:

  • 临时变量很多
  • live range 很长
  • 展开过多
  • fusion 后中间值变多
  • 一个线程承担了过多局部状态

所以寄存器不是越多越好。
它和 occupancy 常常天然冲突。

3.7 什么是 spilling,它为什么会伤性能

当寄存器压力继续升高,超过编译器或硬件能高效承受的范围时,就会发生 spilling

也就是:

  • 本来想放在寄存器里的数据
  • 被迫溢出到更慢的内存路径

这时问题就变成了双重打击:

  • 并发下降,因为寄存器压力高
  • 单线程也变慢,因为部分数据已经不在寄存器里了

所以 spilling 的后果通常很明显:

  • 更多访问开销
  • 更差的延迟隐藏
  • 更差的整体吞吐

这也是为什么很多“看起来没什么问题”的 kernel,只是因为寄存器数暴涨,就会突然慢很多。

3.8 shared memory 为什么也会压低 occupancy

前面你已经学过,shared memory 能带来:

  • 数据复用
  • 访存重排
  • block 内协作

但它也是按 block 分配的。
这意味着一个 block 用得越多,一个 SM 上能同时挂的 block 数通常就越少。

所以 shared memory 的好处和代价永远是一起出现的:

好处:

  • 更少的 global memory 访问
  • 更高的数据复用
  • 更规则的数据流

代价:

  • 可能降低并发 block 数
  • 降低 occupancy
  • 增加同步和管理开销

这再次说明:

所有优化本质上都是 trade-off,不存在白拿的性能。

3.9 Launch 参数调优的平衡逻辑

现在把前面几件事收束起来。

你调 launch 参数时,真正做的不是“找一个固定最优数字”,而是在平衡下面这些约束:

  • 是否有足够多的活跃 warp 去隐藏延迟
  • 每线程寄存器够不够
  • block 的 shared memory 开销大不大
  • block 内协作是否需要更大粒度
  • tile 大小是否还能维持足够数据复用
  • 问题规模是否足够大,能否填满设备

所以 launch 调优的成熟理解应该是:

不是单独追求更高 occupancy,而是在并发能力、数据复用、同步结构和资源消耗之间找到平衡点。

3.10 block size 到底怎么选

没有万能答案,但有一套很实用的起点。

通常先从 warp 的整数倍开始试,比如:

  • 64
  • 128
  • 256
  • 512

为什么这样做?

  • 避免大量不完整 warp
  • 更贴近硬件调度粒度
  • 更容易得到合理的初始 occupancy

很多普通 kernel,默认会从 128256 作为 baseline 开始。
但如果 kernel:

  • shared memory 很重
  • 寄存器很多
  • 需要复杂 block 内协作
  • tile 很大

那 block size 往往要更保守。

所以真正的判断方式永远是:

  • 先拿一个合理 baseline
  • 再用 profiler 看资源使用和瓶颈
  • 再决定是该追更多并发,还是保更强复用

3.11 为什么高性能 kernel 往往不追最高 occupancy

这点在 GEMM、attention、fused kernel 里特别常见。

这类 kernel 通常会刻意使用:

  • 更多寄存器
  • 更多 shared memory
  • 更复杂的 tile
  • 更重的 pipeline

原因是它们追求的不是“挂更多线程”,而是:

让每个 block、每个 warp 干得更值。

这时即使 occupancy 不是最高,性能也可能非常好,因为:

  • 数据复用更强
  • global memory 流量更少
  • 单个 warp 的有效工作量更大

所以以后看到高性能 kernel 的 occupancy 没到 100%,不要本能地认为它有问题。先看瓶颈是不是本来就不在这里。

3.12 一个实用的调优流程

这篇最实用的部分,是把调优思路固定下来。

第一步,先拿 baseline:

  • 当前 block size
  • registers / thread
  • shared memory / block
  • 理论和实际 occupancy
  • kernel 时间

第二步,判断瓶颈:

  • 如果明显是带宽瓶颈,不要只盯着 occupancy
  • 如果 stall 很重且活跃 warp 不够,再重点看并发问题

第三步,扫几个 block size:

  • 64
  • 128
  • 256
  • 512

观察:

  • 时间怎么变
  • occupancy 怎么变
  • 寄存器和 shared memory 开销怎么变

第四步,看寄存器压力和 spilling:

  • 是否临时变量太多
  • 是否 live range 太长
  • 是否某些展开或 fusion 把寄存器压爆了

第五步,再看 shared memory 是否值得:

  • 它换来了多少复用
  • 降低了多少 global memory traffic
  • 是否值得为此付出更低并发

这套流程比“拍脑袋改 256”稳得多。

4. 和 AI 推理的关系

这篇和 AI 推理的关系非常强,因为推理 kernel 经常在“更高并发”和“更强复用”之间拉扯。

几个典型场景:

  • fused kernel:常减少 global memory traffic,但也会显著增加寄存器压力
  • GEMM / attention:经常依赖更大的 tile、更多 shared memory 和寄存器片段,不一定追求最高 occupancy
  • decode 小 batch 场景:工作量本来就偏小,更容易出现设备填不满的问题
  • FlashAttention 一类 IO-aware 实现:经常是在主动接受某些资源压力,以换取更好的数据流

所以推理优化里常见的真实问题不是:

  • “怎样把 occupancy 拉到最高”

而是:

  • 现在更缺的是活跃 warp,还是更缺数据复用
  • fusion 带来的寄存器成本是否值得
  • 当前 kernel 更像 memory-bound 还是 compute-bound

5. 常见误区

  • occupancy 越高越好。不是,它只是延迟隐藏能力指标。
  • 看到 occupancy 低就一定要降寄存器。不是,可能会破坏复用和单线程效率。
  • block size 固定写 256 就够了。不是,不同 kernel 的最优配置可能完全不同。
  • 只要线程数很多就一定快。不是,还要看寄存器、shared memory、访存和同步结构。
  • spilling 只是编译器小问题。不是,它经常直接带来明显性能损失。
  • launch 调优就是试几个数字。不是,真正要调的是整个资源平衡结构。

6. 复习自测

  • occupancy 的定义是什么,它为什么重要?
  • 为什么 occupancy 不等于最终性能?
  • 哪些资源会限制一个 SM 上的并发?
  • block size 为什么会影响资源利用和调度粒度?
  • 什么是 register pressure,它为什么会压低性能?
  • spilling 到底意味着什么,为什么会伤性能?
  • 为什么很多高性能 GEMM / attention kernel 不追求最高 occupancy
  • Launch 参数调优为什么本质上是在平衡多个约束?

系列导航