GPU 性能剖析:nsys 时间轴定位与 ncu kernel 级归因
一句话:跑分告诉你“差多少”,遥测告诉你“现在健不健康”,只有剖析能告诉你“差在哪”。剖析本身是两级流水:先用 nsys 在时间轴上找出该看谁,再用 ncu 把那个 kernel 拆开看为什么。顺序反了,就会在一堆 kernel 里挑一个看着最慢的,然后把两天时间花在一个本来只占 2% 的算子上。
一、三类工具,三个不同的问题
同一个"训练慢"的现场,三类工具回答的是完全不同的问题:
| 工具类 | 代表 | 回答的问题 | 典型产出 |
|---|---|---|---|
| 基准测试 | HPL、nccl-tests、STREAM、IOR | 这套硬件/这张网达没达线 | 一个效率百分比、一条带宽曲线 |
| 遥测监控 | DCGM、dcgm-exporter | 运行期有没有异常(降频、Xid、重放) | 时间序列、告警 |
| 性能剖析 | Nsight Systems、Nsight Compute | 这段时间/这个 kernel 为什么慢 | 时间轴、指标结构、stall 归因 |
这三者的边界很硬:基准不达标不等于知道原因,监视器没告警也不等于没瓶颈。一台机器 HPL 效率只有 62%,DCGM 全绿,唯一能说清"是访存受限还是启动配置浪费了 SM"的,是剖析。
站点已有一篇讲 DCGM 遥测的专文,本文只讲剖析这一段,以及它和另外两类工具怎么衔接。
二、nsys:先回答"时间去哪了"
Nsight Systems 做的是全时间轴采样:它同时记录 CUDA API 调用、kernel 执行、内存拷贝、NVTX 标记、OS runtime 调用,以及 CPU 侧的采样与线程调度。默认行为就是全开:
# 默认:trace cuda / opengl / nvtx / osrt,并采集 CPU IP 采样与线程调度
nsys profile ./train_step.sh默认选项很适合第一次摸底,但它有两个代价:数据量大、采集时长有硬限制。文档明确写着,Nsight Systems 不正式支持超过 5 分钟的运行。所以真实用法是相反的——先收窄,再采集。
收窄的三层手段
第一层:砍掉不需要的 trace 域。
nsys profile --trace=cuda,nvtx -d 20 \
--sample=none --cpuctxsw=none \
-o step20 ./train_step.sh--trace 的可选域里,cuda、nvtx、osrt、cublas、cudnn、mpi、nccl、ucx、gds 都是常用项;关掉 CPU 采样(--sample=none)和线程切换(--cpuctxsw=none)能把体积压下来一个量级,代价是看不到"CPU 在等谁"。
第二层:只在关心的区间采。 两种标准做法:
# CUDA 提供的开关,代码里包住热点段
cudaProfilerStart();
... hot loop ...
cudaProfilerStop();
# 或者用 NVTX 范围触发采集,全程只抓这个 range
nsys profile -c nvtx -w true -p train_step@training ./train_step.sh对训练任务,一个 step 就是天然的分析单元。要在每个 step 开头 range_push("step")、结尾 range_pop(),采集只覆盖第 20–40 个 step,避免把 warmup、数据加载、checkpoint 全卷进来。
第三层:延迟启动。 -y/--delay 让进程先跑一段再开始采,绕开初始化阶段。
采集完先看什么
# 采完直接出汇总(会额外生成 SQLite,大数据量时要等几分钟)
nsys profile --stats=true -o step20 ./train_step.sh
# 或事后按需出报告
nsys stats --report cuda_gpu_kern_sum --report cuda_api_sum step20.nsys-rep默认报告集里已经包含 cuda_gpu_kern_sum(按 kernel 聚合的 GPU 时间)、cuda_api_sum(按 API 聚合的耗时)、显存操作的时间与大小汇总。这三张表基本能回答第一轮问题:GPU 到底忙不忙、时间被哪些 kernel 吃掉、API 调用里有没有同步阻塞。
时间轴上有三个位置最值得盯:
- kernel 之间的空隙:如果 GPU 上一段段空白,说明主机侧在等(同步、Python 开销、数据加载),不是 kernel 本身慢;
- 计算与通信是否重叠:
-t nccl同时打开 NCCL 与 CUDA,才能看出 all-reduce 是在算的间隙里跑的,还是把计算整个挡住了; - memcpy 与 kernel 的串行:H2D/D2H 拷贝把流水线切开,是"卡等数据"的最直观证据。
多机训练时有个坑:每个 rank 都开 GPU 计数采集会互相踩。官方给的写法是每节点只让 local rank 0 采:
#!/bin/bash
# 配合 srun / mpirun 使用
if [ $SLURM_LOCALID -eq 0 ]; then
nsys profile --gpu-metrics-devices=all "$@"
else
nsys profile "$@"
fi三、ncu:把单个 kernel 拆开
nsys 给出候选名单,ncu 负责回答"这个 kernel 为什么只跑到 30%"。它的采集单位是单次 kernel launch,靠把 kernel 反复 replay 来收集不同组的指标。
# 默认采 basic 集,输出报告文件
ncu -o attn.ncu-rep python bench_attn.py
# 全量 section(慢,但一次看全)
ncu --set full -o attn.ncu-rep python bench_attn.py不指定 --set、--section、--metrics 时采的是 basic 集:少量高层的利用率指标,加上 launch 与 occupancy 这类静态信息(后者不需要 replay,开销接近零)。要看全就用 --set full。官方给出的 section 清单及各自回答的问题:
| Section | 回答的问题 |
|---|---|
SpeedOfLight | 计算与访存各自到峰值的百分比,谁最接近瓶颈 |
ComputeWorkloadAnalysis | SM 各条管线(含 Tensor)的利用率与 IPC |
MemoryWorkloadAnalysis / _Tables | 各级缓存与显存的实际吞吐、命中率、扇区/请求比 |
Occupancy | 实际驻留 warp 与理论上限的差距,被什么资源卡住 |
SchedulerStats | 每个周期有多少可发射 warp,issue slot 空转多少 |
WarpStateStats | warp 停在哪类原因上(各自的周期占比) |
InstructionStats | SASS 指令构成,是否只压在少数几条管线上 |
LaunchStats | grid/block 配置与资源需求是否合理 |
WorkloadDistribution | SM、缓存、DRAM 之间的负载是否均衡 |
这些 section 名字是可以用 --section 点选的,也是可以用 --list-sections 现场查的——版本升级后 section 集合会变,写死在脚本里之前先查一遍。
replay:ncu 的代价从哪来
一个 kernel 要采几十组硬件计数器,而同一 pass 里能同时采的计数器有限,所以工具会把 kernel replay 多次。官方 quickstart 里那个玩具 kernel 就打出了 46 passes。这带来三个必须知道的后果:
- 耗时被放大:一次 200 µs 的 kernel,全量剖析可能要秒级,长训练里绝不能无脑全量;
- 缓存默认被清空:每次 replay 前清 L1/L2,让结果可复现,但也让"带缓存预热"的真实行为失真。要复现真实场景加
--cache-control none; - 时钟默认被锁定:
--clock-control的base/boost/none决定按额定频率还是加速频率测量。默认锁频是为了可比,但测出来的时间不等于线上时间。
这也是为什么"用 ncu 的数字当 benchmark"是错的:它测的是被工具改造过的执行,用来做归因,不用来做验收。
只采该采的那几个
# 只看第 6 次起的 3 个 kernel
ncu -s 5 -c 3 --set full python bench.py
# 按名字过滤(正则)
ncu -k regex:attention --set full python bench.py
# 只抓 NVTX 范围内的 kernel
ncu --nvtx --nvtx-include "train_step@training" python train.py
# 只抓某个 Python 函数发起的 kernel
ncu --call-stack-type python --python-include train.py@forward python train.py输出侧:默认打印 details 页(按 section 组织,带规则提示);--page raw 打全部原始指标;--page source 关联到 SASS/源码。要给别人看,-o 存成 .ncu-rep 最稳,后续用 --import 打开再筛。
四、ncu 起不来的两类原因
权限:ERR_NVGPUCTRPERM
Linux 上从 418.43 驱动起,GPU 性能计数器默认只给管理员。普通用户直接跑 ncu 会拿到:
ERR_NVGPUCTRPERM - The user does not have permission to access NVIDIA GPU Performance Counters on the target device.三条合法解法,按驱动代际选:
- 临时:
sudo ncu ...,或给用户CAP_SYS_ADMIN(R565 起CAP_PERFMON也行); - R610+ 驱动:改用能力节点。
profiler-context(上下文内剖析)、profiler-device(含设备级)、trace-device(硬件 trace),分别对应/dev/nvidia-caps/nvidia-cap<minor>;给某个组授权就是chmod g+r那个节点,比全局放开更细; - 旧驱动/regkey 方式:在
/etc/modprobe.d下放一个.conf,写options nvidia NVreg_RestrictProfilingToAdminUsers=0,然后重建 initrd 或重启。验证是否生效看/proc/driver/nvidia/params里的RmProfilingAdminOnly(0 表示已开放)。
容器里还有一层:宿主必须先放开权限,容器再带 --cap-add=SYS_ADMIN 启动,缺一不可。
冲突:计数器被别人占着
第二类失败不是权限,而是有人正在用计数器。DCGM、CUPTI 的其他客户端、Nsight Graphics、另一个 ncu 实例都会持有这块资源,表现为"权限明明有,就是采不到"。
官方建议很具体:怀疑是 DCGM 时,先把它暂停。
dcgmi profile --pause # 剖析期间让 DCGM 让出计数器
# ... ncu 采集 ...
dcgmi profile --resume这条正好接上一篇讲 DCGM 的文章:遥测和剖析共用同一套硬件计数器,二者在时间上必须错开。集群里做性能归因时,别忘了监控 agent 就是那个"看不见的抢占者"。
五、读 SpeedOfLight 的顺序
打开报告不要从第一个表格开始读。固定顺序是:
第一步,看两个百分比谁更高。 SpeedOfLight 给出计算吞吐与访存吞吐各自占峰值的比例,先判断这是 compute bound 还是 memory bound。
第二步,别把"高"当成"好"。 百分比表示该单元忙,不表示有用地忙。90% 的 DRAM 吞吐配上 15% 的计算吞吐,说明数据搬得很多但算得很少——这可能是算子本身的算术强度低,也可能是访问模式让每次请求只用到一部分缓存行。
第三步,看 Occupancy 的理论值与实际值。 sm__maximum_warps_per_active_cycle_pct 是理论上限,实际驻留 warp 是另一回事。两者差得远,通常不是"occupancy 不够"这种笼统结论,而是 wave 量化(grid 大小不是 SM 数的整数倍)或负载不均。
第四步,只在 issue slot 真的空转时才看 stall。 文档写得很清楚:SchedulerStats 里如果每个周期都有可发射的 warp,stall 再多也不会拖慢它;selected 与 not_selected 是调度器状态而不是真 stall。优化的目标是提高发射槽利用率,不是把每个 stall 计数器清零。
第五步,stall 要落到源码。 warp 停采样的样本记在等待的那条消费者指令上,不是产生依赖的生产者指令。smsp__pcsamp_warps_issue_stalled_* 这一族指标只能告诉你"停在哪类原因",具体是哪一行,得进 --page source 对着 SASS 找。
六、三条典型结论路径
| 现象 | 大概率结论 | 下一步查什么 | 常见改法 |
|---|---|---|---|
| 计算吞吐低、DRAM/L2 吞吐接近峰值 | 访存受限 | MemoryWorkloadAnalysis 的扇区/请求比、命中率、bank conflicts | 合并访问、提高数据复用、改 layout、用 shared memory 与异步拷贝 |
| 计算与访存都不高、occupancy 低、long scoreboard 类 stall 占比高 | 延迟受限(依赖链太长) | Occupancy 的资源限制项、LaunchStats 的寄存器/共享内存用量 | 提高并行度、缩短依赖链、预取、降低寄存器压力 |
| barrier / wait 类 stall 高,或 kernel 之间空隙大 | 同步与串行 | WarpStateStats、时间轴上的 launch 间隔 | 减少分支发散、拆小 block、融合 kernel、把同步移出热路径 |
三条都要加一个前提:stall 的名字不能直接当地结论。比如 barrier 高在 tensor 工作流里可能只是正常的同步阶段;drain 高往往是 kernel 尾部的尾效应。判断依据始终是"issue 利用率是否真的掉下去了"。
七、多卡多机的现实约束
剖析在单机上是精确工具,一上规模就得换打法:
- nsys 只覆盖本节点。跨节点问题要看 NCCL 段(
-t nccl),通信侧的硬数据还得靠 nccl-tests 与 IB 计数器; - ncu 会串行化。它 replay 单个 kernel、独占计数器,对通信密集的 kernel 意义有限,反而会把 overlap 打断;
- 多进程要指定目标。
--target-processes all会跟子进程,但训练脚本 fork 一堆 worker 时,更实用的做法是按 rank 过滤,每节点只抓一个; - kernel 太多就得抽样。一个 step 里成千上万个 launch,先用 nsys 按 GPU 时间排序,再对 top 3 做 ncu,剩下 97% 的 kernel 合起来可能都不到 10% 的时间。
时间预算上要有个预期:单 kernel 一次 --set full 通常是秒级到分钟级,取决于 pass 数;nsys 采集 20 秒的时间轴,报告几十到几百 MB。剖析是抽样活动,不是常驻活动——常驻的那一层是 DCGM。
八、把剖析做成固定流程
不要求每次排障都从头想,固化成一个顺序:
- 基准不达标 / 训练变慢:先跑 20 秒 nsys(只标一个 step 的 NVTX),拿到
cuda_gpu_kern_sum排序; - 取 top 3 kernel:每个跑一次
ncu --set full,报告存.ncu-rep; - 按第五节顺序读:先 compute/memory 谁高,再 occupancy 理论 vs 实际,最后才看 stall;
- 改一处、复测同一 kernel:只改一个变量,回到 ncu 看那个百分比有没有动;
- 回归基准:改完必须回 HPL/nccl-tests/端到端 step 时间做确认,ncu 的数字不作验收依据;
- 归档:
.nsys-rep、.ncu-rep、驱动与 CUDA 版本、采集命令、当时的 clock 设置,一起入库。
第 6 步经常被跳过,代价是两周后没人能复现那次 30% 的差异。
九、边界与常见误判
- 剖析改变行为:清缓存、锁频、replay 都会让被测代码跑出与线上不同的数字。归因可信,绝对值不可信;
- overhead 随指标数量增长:section 越多、kernel 越多,pass 越多,长训练里可能放大到无法接受;
- 软件补丁类指标最贵:指令级 source 指标需要改写 kernel 并重放,只在定位到具体指令时才用;
- 采样有平台限制:PM 采样的上下文切换 trace 在 MIG、MPS 多客户端、WSL 上不支持,容器内需要较新的驱动;
- 版本会变:section 名、指标名、默认集都可能随版本调整,脚本里写死的部分要配
--list-sections/--query-metrics自检。
收口三句
- 顺序不能反:nsys 决定"该看谁",ncu 决定"为什么",直接上 ncu 等于在一万次 launch 里随机抽签;
- 百分比只说明单元忙,不说明有效:90% 的 DRAM 吞吐可以是高效的,也可以是算术强度太低的证据,结论必须落到源码与 SASS;
- 剖析数字不替代基准:它解释基准差距的来源,本身是被工具改造过的执行,拿它当验收指标会把优化做反。