把 GPU 想象成一家大型连锁餐厅的后厨。每家分店都有一个灶台区(SM),灶台前站着很多厨师(CUDA 核心)。客人不是一个人一个人进门的,而是 32 人一桌地被服务员带进来,这一桌就是一个 Warp。占用率(Occupancy)就是此刻一个灶台区里坐了多少桌客人。如果只坐了两桌,其中一桌在等食材(读显存),灶台就可能空转;如果坐了八桌,一桌等菜时,其他桌还能继续翻炒。这个“上座率”就是 GPU 性能优化里最常用的一把尺子。
懂得占用率,就懂得为什么同一个 Kernel 换个块大小就能快一倍,是 GPU 性能优化中绕不开的诊断指标。
目录
可视化演示
本节配有交互动效(进入视口后自动加载,约 3KB)。
1. 核心概念:先看懂“上座率”
想象你经营一家后厨,灶台一次最多能同时开八桌菜。如果只安排两桌客人,其中一桌在等食材时,厨师可能只能干站着。占用率就是“现在开了几桌 / 最多能开几桌”这个比例。GPU 的道理一模一样:SM 是一个灶台区,Warp 是一桌 32 位客人,占用率就是活跃 Warp 数占 SM 最大 Warp 数的百分比。
在 CUDA 编程里,一个线程块(Block)由很多线程组成,一个 Warp 固定是 32 个线程。GPU 会把 Warp 当作最基本的调度单位,而不是单个线程。因此,真正决定 SM 能不能忙起来的是“同时有多少个 Warp 在执行”,也就是占用率。占用率越高,意味着调度器手里有越多“备选桌台”,当某一桌等显存数据时,立刻换另一桌继续炒菜。
这种“换桌炒菜”的能力叫延迟隐藏。显存访问通常几百个周期,如果 SM 里只有一两个 Warp,那么这段时间计算单元就会空转;如果 Warp 足够多,A 桌等菜时 B 桌、C 桌还能继续上灶,整体吞吐自然更高。占用率就是衡量这种“换桌余地”的指标。
占用率不是“GPU 卡用了百分之多少”,而是“一个灶台区坐了几成客人”。它是性能优化的起点,而不是终点:先让上座率足够高,再谈每一桌怎么炒得更快。
- 活跃 Warp 数指当前 SM 中已经就绪、可以被调度执行或正在等待资源的 Warp 数量。
- 最大 Warp 数由硬件决定,通常等于 SM 最大线程数除以 32。
- 相除得到占用率把实际活跃 Warp 数除以硬件上限,就得到占用率。
2. 硬件资源限制:四张板凳决定能坐几桌
一个灶台区能坐多少桌,不是服务员想安排多少就安排多少。后厨有四条硬规矩:座位数、私人餐具数、公共储物间大小、以及最多同时接待几批团客。GPU 的 SM 也一样,有四个资源上限:最大线程数、寄存器文件、共享内存、最大常驻块数。
首先是线程槽位。SM 能容纳的总线程数有限,比如 2048 个线程。如果每桌(Block)有 256 个线程,那么线程数这一条就限制最多放 8 个 Block。其次是寄存器:每个线程都有自己的“小抽屉”用来放局部变量,SM 的寄存器总数有限;每线程用掉的寄存器越多,能进场的线程就越少。第三是共享内存,它像一个公共储物间,每个 Block 会申请一块;每块申请越多,能同时存放的 Block 就越少。最后还有一个硬性块数上限,哪怕资源和线程都够,也不能超过硬件规定的 Block 数量。
这四个限制不是“选一个最强的”,而是“每条凳子都坐满才算”。任何一个资源耗尽,都会成为进场的瓶颈。所以计算占用率前,得先把四张板凳各自能坐几桌算出来。
把后厨的每一条凳子都看成资源上限:座位、餐具、储物间、团客数量。任何一个先到顶,后厨就不能再加桌了。这也是为什么“块大小”和“每线程寄存器数”会直接决定占用率。
- 线程数限制用 SM 最大线程数除以每个 Block 的线程数,向下取整。
- 寄存器限制整个 SM 的寄存器总数,除以“一个 Block 总共需要的寄存器数”。
- 共享内存限制SM 的共享内存总量除以每个 Block 申请的共享内存量。
3. 关键计算:四张凳子取最小
服务员要安排桌位,会同时看四条规矩,最后能开几桌,一定是最“小气”的那条规矩说了算。GPU 也一样:把线程数算出的块数、寄存器算出的块数、共享内存算出的块数,以及硬件最大块数放在一起,取最小值,才是真正能常驻的 Block 数。
例如一个 SM 最多 2048 个线程,每 Block 256 线程,线程数允许 8 个 Block;但每 Block 共享内存申请 48KB,SM 只有 96KB,共享内存只允许 2 个 Block。那么最终只能放 2 个 Block,哪怕线程槽位还空着。这就是性能分析中最常见的“找瓶颈”:先找出是哪一个资源把后厨的门关小了。
有了常驻 Block 数,再乘以每个 Block 的 Warp 数,就能得到活跃 Warp 数,最后除以 SM 最大 Warp 数,得到占用率。这个计算过程是 GPU 性能优化的基本功,也是后面所有调优判断的基础。
这一节其实就是“短板效应”:后厨能开几桌,取决于最挤的那条资源通道。做优化时,先算出这四个数,哪个最小,就先解决哪个。
- 取最小块数四种限制分别算出一个块数,最终结果取最小值。
- 块数转 Warp 数每个 Block 包含多少个 Warp,由线程数除以 32 后向上取整得到。
- 计算占用率用活跃 Warp 数除以 SM 最大 Warp 数。
4. 优化策略:调度员的四张牌
知道了短板在哪,调度员就能想办法多塞几桌。第一张牌是调整块大小。块太小,每个块的 Warp 数少,硬件块数上限会限制总量;块太大,又可能让寄存器或共享内存不够用。通常让块大小是 32 的整数倍,再在 128、256、512 之间做实验。
第二张牌是用 launch_bounds 限制每线程寄存器数。编译器默认可能给每个线程分配很多寄存器,导致占用率很低。如果你告诉编译器“我们最多需要 128 个寄存器”,编译器就会更节省地使用寄存器,从而让更多 Warp 进场。第三张牌是减少每 Block 的共享内存申请,比如把大数组改成分批处理,或者用寄存器替代部分共享内存。
不过,这些牌要组合着打。盲目把寄存器压得太低,可能导致寄存器溢出到本地内存,反而把“快菜”变成“慢菜”;块大小也不是越大越好,过大可能让 Block 之间调度不灵活。目标是在资源和并行度之间找到平衡。若想达到某个目标占用率,可以反向计算每线程最多能用多少寄存器。
好的调度员不是“无脑坐满”,而是知道哪张桌子最省资源、哪种客人的菜最容易等。优化占用率,本质上就是优化资源在“桌数”和“每桌效率”之间的分配。
launch_bounds 调参背后的数学依据。- 目标活跃线程数目标占用率乘以 SM 最大线程数,得到需要同时活跃的线程数。
- 寄存器总预算分摊把 SM 寄存器总数平分给这么多活跃线程。
- 向下取整寄存器按整数分配,所以向下取整得到每线程上限。
5. 性能误区:别只盯上座率
很多人以为上座率越高,餐厅营收一定越高。但真实情况是:如果每桌客人都点了一堆需要长时间准备的菜,哪怕满座,翻台率也可能很低。GPU 也是这样,100% 占用率不一定代表最快,它只是说明有足够多的 Warp 可供调度;如果这些 Warp 内部指令相关性很强、彼此都在等同一个结果,调度器依然无事可做。
所以第二个误区是“低占用率一定慢”。有时候 Kernel 里有大量指令级并行(ILP),单个 Warp 自身就有好几条独立指令可以连续执行,即使只有 50% 占用率,同样能把延迟藏住。反之,高占用率配合很差的 ILP,也可能跑不出好成绩。占用率是“潜力”,不是“结果”。
第三个误区是为了提占用率无限压低寄存器。寄存器是线程的私人餐具,压得太狠,编译器会把数据倒到本地内存,也就是“把菜放到后门外面的货架上”,取用速度慢很多,反而拖慢性能。理论占用的数字再漂亮,也要通过实测耗时来验证。
这就像“满座”和“赚钱”之间隔着翻台率、菜品结构、服务效率。GPU 优化也一样:先把上座率调到合理区间,再用性能分析工具看真实表现,而不是迷信某一个百分比。
- 性能不是占用率占用率描述可调度的 Warp 数量,性能描述每秒完成多少指令。
- 高占用率是必要条件之一足够多的 Warp 才能换桌,但每桌自身的并行度同样重要。
6. 工程实践:用工具看后厨
手工算占用率适合理解原理,真到了工程里,一定要用工具。CUDA 提供了 cudaOccupancyMaxActiveBlocksPerMultiprocessor 和 cudaOccupancyMaxPotentialOccupancy 这类 API,它们像“预订系统”,输入内核的块大小、寄存器用量、共享内存用量,直接给出理论最大常驻块数和最佳占用率。
另一个常用工具是 Nsight Compute。它能像“后厨监控录像”一样,报告内核实际的占用率、Warp 停顿原因、访存延迟等。理论占用率是“按菜单最多能接待多少桌”,实际占用率是“今天到底同时坐了几桌”。两者经常不一致,原因可能是 Block 数量不够多、负载不均衡、某个 Barrier 让 Warp 互相等待。
工程优化的标准流程是:先用 API 算出理论短板,再用 Nsight 看实测瓶颈,然后针对性地调整块大小、寄存器或共享内存,最后用逐次运行耗时来验证。每一步都要记录数据,因为 GPU 架构不同,参数最优值也不同。只有用数据说话,才算真正会调占用率。
后厨管理不能只靠感觉,要用预订系统算座位,用监控录像看实况。GPU 优化同样如此:理论公式负责缩小搜索范围,性能工具负责验证最终效果。
- 实测活跃 Warp 数通过 Profiler 统计内核运行期间每个周期实际活跃的 Warp 数。
- 除以硬件上限得到实际达到的占用率。
- 对比理论值若实际远低于理论,说明存在负载不均或调度等待。
AI 应用场景
大型语言模型推理服务调优
阶段:模型部署与推理优化
解决的问题:LLM 推理时有很多矩阵乘法和 Attention Kernel,访存密集且延迟敏感。传统做法是盲目选一个块大小,结果 SM 中 Warp 太少,显存等待明显,GPU 利用率低。利用占用率计算和 Occupancy API,可以快速选出让活跃 Warp 数更高的 Kernel 配置,从而提升推理吞吐。
具体案例:某推理团队发现 decode 阶段 Kernel 的实际占用率只有 30%,通过 cudaOccupancyMaxPotentialOccupancy 查到共享内存是瓶颈,于是把每 Block 的共享内存从 80KB 降到 64KB,块大小从 128 调到 256,最终 token 生成速度提升约 40%。
深度学习训练框架的卷积内核优化
阶段:训练框架底层算子开发
解决的问题:卷积 Kernel 在 GPU 上运行时,不同 shape 的最优块大小不同。手工枚举耗时且不通用,框架希望有一套自动选择 Launch 配置的规则。
具体案例:PyTorch 自定义 CUDA 算子时,开发者用 cudaOccupancyMaxActiveBlocksPerMultiprocessor 对不同输入尺寸做预算,再结合带宽实测,选择占用率不是最高但吞吐最高的配置,最终让训练整体 step 时间缩短 15%。
自动驾驶实时视觉流水线
阶段:实时感知前处理与后处理
解决的问题:摄像头图像预处理需要做大量访存操作,如果占用率太低,显存延迟容易造成流水线空泡,影响端到端延迟。
具体案例:自动驾驶平台在做图像缩放、归一化等 Kernel 时,通过 Nsight Compute 发现寄存器限制导致占用率只有 50%,使用 launch_bounds 将寄存器限制到 128 后,占用率升到 75%,整条感知流水线的帧耗时下降 20%。
Python 演示
def calc_occupancy(max_warps, max_threads, block_size, regs_per_thread, max_regs, smem_per_block, max_smem, max_blocks_hw): # 一个 Block 由几个 Warp 组成 warps_per_block = (block_size + 31) // 32 # 线程槽位允许的 Block 数 blocks_by_thread = max_threads // block_size # 寄存器允许的 Block 数 blocks_by_regs = max_regs // (block_size * regs_per_thread) # 共享内存允许的 Block 数 if smem_per_block > 0: blocks_by_smem = max_smem // smem_per_block else: blocks_by_smem = 10 ** 9 # 最终常驻 Block 数取所有限制的最小值 active_blocks = min(blocks_by_thread, blocks_by_regs, blocks_by_smem, max_blocks_hw) active_warps = active_blocks * warps_per_block occupancy = active_warps / max_warps return occupancy, active_blocks, active_warps if __name__ == "__main__": occ, blocks, warps = calc_occupancy( max_warps=64, max_threads=2048, block_size=256, regs_per_thread=32, max_regs=65536, smem_per_block=49152, max_smem=98304, max_blocks_hw=32, ) print("occupancy=%.1f%%" % (occ * 100)) print("resident_blocks=%d" % blocks) print("active_warps=%d" % warps) # 反推达到 75% 占用率时每线程最多能用多少寄存器 target_occ = 0.75 reg_budget = int(65536 // (2048 * target_occ)) print("target_75_occupancy_regs_per_thread<=%d" % reg_budget)
warps_per_block = (block_size + 31) // 32计算一个 Block 包含几个 Warp,等价于线程数除以 32 后向上取整。blocks_by_thread = max_threads // block_size线程槽位这条限制允许放几个 Block。blocks_by_regs = max_regs // (block_size * regs_per_thread)寄存器总量分摊到每个 Block 的每个线程后,能容纳几个 Block。blocks_by_smem = max_smem // smem_per_block共享内存总容量决定还能再放几个 Block。active_blocks = min(...)短板效应:最终常驻 Block 数由最小的那个限制决定。occupancy = active_warps / max_warps用活跃 Warp 数除以硬件最大 Warp 数,得到占用率。reg_budget = int(65536 // (2048 * target_occ))反推出达到目标占用率时,每线程最多允许使用多少寄存器。
常见误区
误区我只要把占用率调到 100%,Kernel 就一定是最快的,因为 GPU 看起来“坐满了”。
正解占用率只是延迟隐藏的潜力,不代表每一条指令都高效。如果 Warp 内部指令相互依赖、缺少独立运算,或者访存模式很差,即使满座,计算单元依然可能空转。还要注意提高占用率可能带来的寄存器溢出、块数减少等副作用,最终要以耗时和吞吐为准。
为何常见:因为“占用率”这个名字听起来像“利用率”,初学者很容易把“能坐满”和“很高效”划等号。
误区我算出来理论占用率是 80%,所以 Kernel 实际就是 80%,不需要再用 Profiler 看。
正解理论占用率假设所有 Warp 都能均匀被调度,但现实中还存在负载不均衡、Block 数量不足以填满整个 GPU、线程同步等待、访存冲突等问题。Nsight Compute 测出的实际占用率往往更低。只有把理论值和实测值对比,才能真正定位瓶颈。
为何常见:理论公式简单直观,手工算一次就有“我已经优化好了”的错觉,忽视了真实运行时的复杂调度。
误区既然寄存器用得多会降低占用率,那我就在编译时把寄存器限制调到很小。
正解寄存器是每个线程的私有存储,压得太低会让编译器把部分变量转移到本地内存,也就是“倒菜到门外”,访问速度骤降。这会导致每个 Warp 执行速度变慢,抵消甚至超过占用率提升带来的收益。正确的做法是结合目标占用率反推寄存器预算,再通过实验寻找拐点。
为何常见:初学优化时只关注“数字好看”,忽略了寄存器溢出带来的隐性成本,属于把手段当成了目标。
误区块大小越大,能装下的线程越多,占用率自然越高,索性直接用 1024。
正解块大小增大后,每 Block 需要的寄存器和共享内存总量也会变大,可能反而限制常驻 Block 数,导致占用率下降。而且 Block 数量太少时,负载分配不够灵活,容易出现“尾部效应”,最后几个 Block 不能占满 GPU。最优块大小通常要在 128、256、512 之间实测。
为何常见:人们下意识觉得“批量越大越省事”,却忽略了 GPU 的资源是按 SM 和 Block 双重限制分配的。
面试题
Q1. 占用率(Occupancy)在 GPU 性能优化中指什么?
- A.计算核心利用情况
- B.活跃Warp数占比值← 正确答案
- C.显存空间占用比例
- D.寄存器堆占用情况
答案:B。占用率是活跃 Warp 数与 SM 最大 Warp 数的比值。它衡量 SM 中有多少 Warp 可供调度,而不是显存或 GPU 卡的整体利用率。A 是笼统的核心利用率,C、D 都对应资源占用,与占用率定义不同。
Q2. 一个 Warp 通常由多少个线程组成?
- A.16 个线程
- B.32 个线程← 正确答案
- C.64 个线程
- D.128 个线程
答案:B。CUDA 以 Warp 为基本调度单位,一个 Warp 固定包含 32 个线程。每次 GPU 调度一桌就是 32 个线程,所以计算 Warp 数时,Block 线程数需要除以 32。
Q3. 某 SM 最大线程数为 2048,Block 大小为 256,那么线程数限制最多允许几个 Block 常驻?
- A.4 个
- B.8 个← 正确答案
- C.16 个
- D.32 个
答案:B。线程槽位限制的块数等于 2048 除以 256,结果是 8。这就像座位区有 2048 个座位,每桌占 256 个座位,最多能同时接待 8 桌。
Q4. 每个线程使用 64 个寄存器,SM 寄存器总数为 65536,Block 大小为 256,则寄存器限制允许几个 Block?
- A.2 个
- B.4 个← 正确答案
- C.8 个
- D.16 个
答案:B。每个 Block 需要 256 乘 64 等于 16384 个寄存器,65536 除以 16384 得到 4。寄存器像私人餐具,餐具总数固定,每桌客人要用的餐具越多,能接待的桌数就越少。
Q5. 下面哪些因素会限制 SM 中常驻 Block 的数量?
- A.最大线程槽位数← 正确答案
- B.寄存器文件大小← 正确答案
- C.共享内存容量← 正确答案
- D.显存带宽大小
答案:ABC。线程槽位、寄存器文件、共享内存和硬件块数上限都会限制常驻 Block 数。显存带宽虽然影响访存速度,但不直接决定 SM 能容纳多少 Block。
Q6. 若 SM 最大 Warp 数为 64,实际活跃 Warp 数为 32,则占用率是多少?
- A.25%
- B.50%← 正确答案
- C.75%
- D.100%
答案:B。占用率等于活跃 Warp 数除以最大 Warp 数,32 除以 64 等于 0.5,也就是 50%。相当于灶台区一共能开 64 桌,现在坐了 32 桌。
Q7. 下列哪些做法可能提高 Kernel 的占用率?
- A.调整块大小为合适值← 正确答案
- B.用 launch_bounds 限制寄存器← 正确答案
- C.减少每块共享内存用量← 正确答案
- D.直接增大显存容量
答案:ABC。调整块大小可以让线程数和资源分配更均衡;launch_bounds 能降低每线程寄存器数;减少共享内存可以增加常驻 Block 数。显存容量是全局资源,不会改变 SM 内共享内存和寄存器的分配,因此不能提高占用率。
Q8. 关于占用率与性能的关系,下列说法正确的是?
- A.100% 占用率一定最快
- B.低占用率一定导致性能差
- C.高占用率需配合指令级并行← 正确答案
- D.占用率与性能毫无关系
答案:C。高占用率提供延迟隐藏的潜力,但 Warp 内部也需要有足够的独立指令,否则调度器没有可执行的指令。因此不能说完美占用率一定快,也不能说低占用率一定慢,C 更全面。
Q9. 在 CUDA 中,用来查询给定内核最大常驻块数的接口属于哪一类?
- A.显存管理类接口
- B.占用率查询类接口← 正确答案
- C.流同步类接口
- D.数据拷贝类接口
答案:B。cudaOccupancyMaxActiveBlocksPerMultiprocessor 属于占用率查询 API,它接收内核函数、块大小和资源参数,返回一个 SM 上最多能常驻的块数。显存管理、流同步和数据拷贝都不负责计算常驻块数。
Q10. 下面哪些工具或接口可用于分析 CUDA 内核的实际占用率?
- A.Nsight 分析工具← 正确答案
- B.Occupancy API← 正确答案
- C.CUDA 事件计时
- D.内核打印输出
答案:AB。Nsight Compute 能测出实际占用率和 Warp 停顿原因,Occupancy API 能计算理论占用率。CUDA 事件计时只能测耗时,内核打印输出只是调试信息,它们无法直接给出占用率数据。