GPU系统拆解-09-Profiling 与性能定位:先找到瓶颈,再谈优化

本文是「GPU系统拆解」系列第 09 篇。
系列导读:GPU系统拆解-00-导读:从架构认知到推理系统的学习路线
上一篇:GPU系统拆解-08-Tensor Core、GEMM 与 FlashAttention:AI 计算热点为什么这样组织
下一篇:GPU系统拆解-10-面试表达与系统思维:怎么把 GPU 理解讲成工程判断

这一篇要解决的是一个非常实际的问题:当一个 CUDA 程序或推理系统变慢时,你应该先看什么、怎么判断、哪些指标真正有用,以及这些指标最后怎样转化成优化动作。学完这一篇之后,你的目标不是“会点开 Nsight”,而是建立一套从现象走到结论的性能诊断框架。

1. 先给结论

  • Profiling 的核心价值不是“看很多指标”,而是把“感觉慢”变成“知道慢在哪里、为什么慢”。
  • 第一层先分系统问题和 kernel 问题,所以通常先看 Nsight Systems,再看 Nsight Compute
  • 第二层先分 compute-boundmemory-bound,不要一上来就盯着 occupancy
  • occupancy 很重要,但它只是手段,不是目标;高 occupancy 也可能很慢。
  • warp stall 的意义,不是告诉你“线程没开起来”,而是告诉你“调度器此刻没有足够 ready 的 warp 可发射”。
  • memory workloadroofline 能帮助你把“算慢了”与“喂不动了”区分开。
  • 对推理系统来说,prefilldecode 往往是两类完全不同的性能问题。
  • 真正成熟的性能分析,不是背指标定义,而是能把指标翻译成下一步动作。

2. 为什么 Profiling 是 GPU 学习的分水岭

很多人学 CUDA 会停留在这一步:

  • 会写 kernel
  • 会 launch kernel
  • 知道 shared memorycoalescingoccupancy 这些词

但一到真实工程就开始失效,原因很简单:

性能问题不是靠猜出来的,而是靠 profile 定位出来的。

没有 profiling 时,优化很容易沦为这些动作:

  • 盲目调 block size
  • 盲目上 fusion
  • 盲目加 shared memory
  • 盲目追高 occupancy

这些动作偶尔会碰巧有效,但你无法稳定复用。

所以 profiling 是一个明显分水岭:

  • 会写 CUDA,不代表会做 GPU 工程
  • 会看 profile,才开始真正具备定位能力

3. 先建立一张工具分工图

在 NVIDIA 的工具链里,你最常用的通常是两个 profiler:

  • Nsight Systems:看系统级时间线
  • Nsight Compute:看单个 kernel 的微观表现

一定要把它们的职责分开。

3.1 Nsight Systems:先看“时间到底花在哪”

Nsight Systems 更像一张全局时间线,它擅长回答:

  • CPU 在干什么
  • GPU 有没有持续在工作
  • kernel 有没有被切得很碎
  • 有没有频繁的 H2D / D2H
  • stream 是否真的并发
  • 同步是不是太多

它解决的第一个关键问题是:

慢,到底是慢在系统层,还是慢在某个 kernel 本身?

3.2 Nsight Compute:再看“这个 kernel 为什么慢”

Nsight Compute 是 kernel 级 profiler,它更擅长回答:

  • 这个 kernel 更像 compute-bound 还是 memory-bound
  • warp stall 主要来自什么
  • global memoryL2shared memory 的行为如何
  • 寄存器是不是太多
  • occupancy 被什么限制
  • 有没有真的用到预期的 Tensor Core 路径

所以更合理的顺序通常是:

先用 Nsight Systems 找热点,再用 Nsight Compute 解剖热点。

4. 一个标准的性能定位流程

以后遇到“程序慢”,建议先固定按这个顺序走。

4.1 第一步:先判断是系统层问题还是 kernel 层问题

先看 Nsight Systems

如果你发现:

  • CPU 线程经常空转或阻塞
  • kernel 很碎
  • memcpy 很频繁
  • graph 没 capture 住
  • 每一步都在同步

那这更像系统层问题。

如果你发现:

  • GPU 大部分时间都花在少数几个 kernel 上
  • 某个 kernel 明显占了主要时间

那才值得往 kernel 层继续深挖。

4.2 第二步:在热点 kernel 上先分大类

到了热点 kernel,不要第一眼就看 occupancy。

先回答一个更基础的问题:

这个 kernel 主要是算力受限,还是带宽受限?

这一步会决定你后面看哪些指标、做哪些优化。

4.3 第三步:再看 launch、occupancy、stall、memory workload

这时再进入 Nsight Compute 去看:

  • launch statistics
  • occupancy
  • warp stall breakdown
  • memory workload
  • source correlation

注意顺序很重要。正确顺序是“先有方向,再看细节”,不是“先淹死在一堆细节里”。

5. compute-bound 和 memory-bound 应该怎么分

这是 GPU 性能分析里最关键的第一道分流。

5.1 什么更像 compute-bound

如果一个 kernel 有这些特征:

  • 算术强度高
  • ALU 或 Tensor Core 很忙
  • 内存吞吐没有逼近瓶颈
  • stall 更像执行依赖或 pipe 压力

那它更接近 compute-bound

典型例子:

  • 大 GEMM
  • 大 batch dense matmul
  • Tensor Core 友好的矩阵 kernel

这类 kernel 的常见优化方向是:

  • 改 tile
  • 改 pipeline
  • 提高 Tensor Core 利用率
  • 减少多余指令
  • 提高 ILP

5.2 什么更像 memory-bound

如果一个 kernel 有这些特征:

  • DRAM 或 L2 很忙
  • 运算量不算大,但耗时不低
  • warp 大量时间在等内存
  • 算力单元并没有特别忙

那它更接近 memory-bound

典型例子:

  • gather / scatter
  • embedding lookup
  • 某些 layernorm / softmax
  • decode 阶段和 KV cache 相关的操作
  • 低算术强度的 elementwise / reduction

这类 kernel 更常见的优化方向是:

  • 改访问模式
  • 提高 coalescing
  • 做数据复用
  • 减少 global round-trip
  • 融合中间步骤

6. occupancy 为什么重要,但不能迷信

很多人学 profiling 时最容易犯的错误,就是把 occupancy 当成性能目标。

更准确的理解应该是:

occupancy 的价值,在于判断一个 SM 上有没有足够多的活跃 warp 来隐藏延迟。

如果 occupancy 太低,常见风险是:

  • 某些 warp 一等内存
  • 调度器没有足够多的其他 warp 可以切换
  • SM 更容易空转

但这不等于 occupancy 越高越快。

6.1 为什么 occupancy 高也可能慢

因为高 occupancy 只是说明“挂上去的 warp 多”,不代表:

  • 访存模式合理
  • 数据复用足够
  • stall 小
  • 指令选择合理
  • Tensor Core 用起来了

而且很多时候,为了追高 occupancy,反而会付出代价:

  • 每线程寄存器预算更紧
  • spill 风险更高
  • shared memory tile 变小
  • 算法结构变差

所以更合理的态度是:

  • 把 occupancy 当成约束信息
  • 不把 occupancy 当成最终目标

7. Warp Stall 到底在告诉你什么

从硬件视角看,调度器每个周期都想找到一个 ready warp 发射下一条指令。

如果找不到,就会出现 stall。

所以 warp stall 的真正含义不是“线程少”,而是:

此刻缺少能立刻继续执行的 warp,或者即使有 warp,也被某种资源或依赖卡住了。

7.1 等内存

这类 stall 通常暗示:

  • global memory latency 高
  • 访问不连续
  • cache 命中不好
  • 数据复用不足

优化方向更偏:

  • 改访问模式
  • 做 tiling
  • 提高 coalescing
  • 增强局部复用

7.2 等执行依赖

这类 stall 更像:

  • 指令链太直
  • 独立工作不够多
  • ILP 不足

优化方向更偏:

  • 重排计算顺序
  • 合理 unroll
  • 让独立数据块并行推进

7.3 等 barrier / 同步

这通常提示:

  • block 内工作不均衡
  • 同步太频繁
  • 共享数据结构设计不够顺

优化方向更偏:

  • 减少不必要同步
  • 重构 block 内工作划分
  • 调整 shared memory 使用方式

7.4 等调度资源或 pipe

这更像是:

  • 某类指令过于集中
  • 某条执行管线过热
  • 指令混合不够均衡

优化方向更偏:

  • 看指令构成
  • 看是否能走更合适的矩阵或向量路径
  • 调整 tile 和 pipeline

7.5 不要孤立看 stall 细项

这点非常重要。

单看某个 stall 细项高,不一定说明它就是主矛盾。更合理的方式是把它和整体 issue 效率一起看。

只有当发射效率本身就不高时,stall breakdown 才真正有很强的诊断价值。

8. Memory Workload Analysis 应该怎么看

这一页往往是判断 memory-bound 问题的核心证据层。

它在帮你回答这些问题:

  • global load / store 多不多
  • L1 / L2 / DRAM 吞吐高不高
  • cache 利用率怎么样
  • 访问模式规整不规整

这里最典型的两种坏情况是:

8.1 带宽接近极限

这说明 kernel 真的可能被带宽卡住了。

常见动作是:

  • 减少数据搬运量
  • 提高复用
  • 融合算子
  • 降精度

8.2 带宽没打满,但依然很慢

这通常更麻烦,因为它往往意味着:

  • 访问模式差
  • 请求不规整
  • 事务合并不好
  • cache 利用率低

这时重点通常不是“带宽不够”,而是“带宽没被高效用起来”。

对推理来说,这类问题很常见于:

  • embedding / gather
  • 不规则 KV 访问
  • 小 kernel 串联造成的重复读写

9. Roofline 思维为什么重要

你不一定需要熟练画 roofline 图,但一定要有这个脑子。

它的核心思想是:

一个 kernel 的性能上限,要么更接近算力天花板,要么更接近带宽天花板。

而决定它偏向哪边的关键,是 arithmetic intensity

简单说就是:

  • 每搬一个单位数据,做了多少计算

这会把很多现象统一起来:

  • GEMM 算术强度高,更容易逼近 compute roof
  • gather、elementwise 算术强度低,更容易逼近 bandwidth roof
  • prefill 更可能偏 compute-bound
  • decode 更可能偏 memory-bound

这也是为什么:

  • 更高的 TFLOPS 不一定线性改善 decode
  • 有些优化对 prefill 很有效,对 decode 却一般

10. 一个更贴近工程的诊断顺序

这里给你一套更实用的工程化流程。

10.1 场景 A:端到端推理吞吐差

先看 Nsight Systems

  1. GPU 是否持续有活
  2. stream 是否真的并发
  3. CPU 是否成了瓶颈
  4. kernel 是否过碎
  5. memcpy 是否频繁
  6. 是否存在过多同步

如果这里已经发现问题,比如:

  • launch 太碎
  • CPU pipeline 不顺
  • graph 没 capture
  • H2D 太频繁

那先改系统问题,不要急着抠单个 kernel。

10.2 场景 B:某个 kernel 特别慢

这时再看 Nsight Compute

  1. 先看它在总时间里占比多大
  2. 再看 launch config 和 occupancy 是否明显异常
  3. 再看 memory workload,先判断大方向
  4. 再看 scheduler / warp stall
  5. 必要时做 source correlation,映射回代码

10.3 场景 C:某个优化后反而更慢

这时最常见的原因不是“优化思路完全错了”,而是 trade-off 没算对。

高频原因包括:

  • 寄存器上升太多
  • shared memory 占用过大
  • fusion 虽然减少了访存,但引入了更多同步或更差的指令调度
  • block size 换了之后,不再适合当前架构
  • 编译器没按你预期生成更好的代码

所以 profile 的作用不是证明“某个技巧很高级”,而是帮你看清:

你到底是在用什么换来了什么。

11. 结合推理场景看,最常见的几类 profile 现象

11.1 prefill 快,decode 慢

这往往不是 bug,而是 workload 形态不同。

  • prefill 更接近 dense compute
  • decode 更容易暴露缓存、带宽和 KV cache 问题

所以两者瓶颈不同很正常。

11.2 GPU 利用率不低,但吞吐还是一般

这通常说明“GPU 有活”不等于“活的质量高”。

可能的问题包括:

  • memory-bound 很重
  • 活跃 warp 多,但 stall 也多
  • 有很多小 kernel 串联
  • 系统层流水并不顺

11.3 做了 fusion,延迟反而升高

fusion 不是天然正确。

常见反效果包括:

  • 寄存器更高
  • shared memory 压力更大
  • block 内同步更多
  • 指令调度变差

11.4 block size 更大,反而没更快

更大的 block 有时会带来:

  • 更高资源占用
  • 更低驻留数量
  • 更差 occupancy
  • 更差的工作均衡

所以 block size 不是越大越好,而是要和 kernel 结构匹配。

12. 4090 / Ada 上做 profile 时,应该注意什么

你现在主要是在 4090 上学习,这很合适,但结论不能机械外推。

12.1 4090 上的结论,不等于 H100 / B200 上的结论

不同 GPU 在这些方面都会影响 profile 结论:

  • 显存体系
  • 带宽规模
  • cache 行为
  • Tensor Core 路径
  • 系统互连和部署方式

所以 4090 非常适合学习方法论,但不要把某个具体数值结论直接外推到数据中心 GPU。

12.2 4090 上更容易看出某些 memory-bound 问题

很多推理实验在 4090 上会更明显地暴露:

  • KV cache 访问
  • 小 kernel 反复读写
  • decode 里的带宽和缓存问题

这对学习其实是好事,因为现象更直观。

13. 怎样把指标翻成动作

Profiling 最终不是为了“会解释图”,而是为了知道下一步改什么。

可以把思路压成三层:

13.1 先分层

  • 系统层
  • kernel 层

13.2 再分流

  • compute-bound
  • memory-bound

13.3 最后翻成动作

例如:

  • 如果系统层碎,就减少 launch、减少同步、改善流水
  • 如果 memory-bound,就优先改访问模式和复用
  • 如果 compute-bound,就看 tile、pipeline、指令和 Tensor Core 路径
  • 如果 trade-off 变差,就看寄存器、shared memory、occupancy 的代价

只有走到这一步,profile 才真正转化成工程价值。

14. 这一篇必须记住的几句话

  • 先分系统问题和 kernel 问题,通常先看 Nsight Systems,再看 Nsight Compute
  • 先分 compute-boundmemory-bound,不要一上来就盯 occupancy。
  • occupancy 重要,但不是性能目标。
  • warp stall 的意义是“为什么调度器现在发不出去”,不是“线程没开起来”。
  • memory workloadroofline 能帮助你把“算慢了”和“喂不动了”分开。
  • 推理里的 prefilldecode 往往是两类不同问题。
  • 真正成熟的 profiling,不是看指标本身,而是能把指标翻成下一步动作。

15. 精简版面试表达

如果面试官问你平时怎么做 GPU profiling,可以这样答:

我一般先用 Nsight Systems 看系统级时间线,先区分问题是出在 CPU 调度、同步、memcpy、kernel 过碎,还是集中在少数热点 kernel 上。找到热点后,再用 Nsight Compute 判断这个 kernel 更像 compute-bound 还是 memory-bound,再结合 occupancy、warp stall、memory workload 和 source correlation 去看瓶颈究竟是算力、访存、寄存器压力还是控制流问题。对推理系统来说,我会特别注意 prefill 和 decode 的差异,因为前者更接近 dense compute,后者更容易暴露 KV cache、带宽和缓存系统问题。


系列导航