CUDA系统拆解-08-Occupancy、寄存器压力与Launch参数:调优不是把占用率拉满
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 个 warpblock 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 的整数倍开始试,比如:
64128256512
为什么这样做?
- 避免大量不完整 warp
- 更贴近硬件调度粒度
- 更容易得到合理的初始
occupancy
很多普通 kernel,默认会从 128 或 256 作为 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 / threadshared memory / block- 理论和实际
occupancy - kernel 时间
第二步,判断瓶颈:
- 如果明显是带宽瓶颈,不要只盯着
occupancy - 如果 stall 很重且活跃 warp 不够,再重点看并发问题
第三步,扫几个 block size:
64128256512
观察:
- 时间怎么变
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 参数调优为什么本质上是在平衡多个约束?

