Skip to content

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 侧的采样与线程调度。默认行为就是全开:

bash
# 默认:trace cuda / opengl / nvtx / osrt,并采集 CPU IP 采样与线程调度
nsys profile ./train_step.sh

默认选项很适合第一次摸底,但它有两个代价:数据量大、采集时长有硬限制。文档明确写着,Nsight Systems 不正式支持超过 5 分钟的运行。所以真实用法是相反的——先收窄,再采集。

收窄的三层手段 ​

第一层:砍掉不需要的 trace 域。

bash
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 在等谁"。

第二层:只在关心的区间采。 两种标准做法:

bash
# 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 让进程先跑一段再开始采,绕开初始化阶段。

采集完先看什么 ​

bash
# 采完直接出汇总(会额外生成 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 调用里有没有同步阻塞。

时间轴上有三个位置最值得盯:

  1. kernel 之间的空隙:如果 GPU 上一段段空白,说明主机侧在等(同步、Python 开销、数据加载),不是 kernel 本身慢;
  2. 计算与通信是否重叠:-t nccl 同时打开 NCCL 与 CUDA,才能看出 all-reduce 是在算的间隙里跑的,还是把计算整个挡住了;
  3. memcpy 与 kernel 的串行:H2D/D2H 拷贝把流水线切开,是"卡等数据"的最直观证据。

多机训练时有个坑:每个 rank 都开 GPU 计数采集会互相踩。官方给的写法是每节点只让 local rank 0 采:

bash
#!/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 来收集不同组的指标。

bash
# 默认采 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计算与访存各自到峰值的百分比,谁最接近瓶颈
ComputeWorkloadAnalysisSM 各条管线(含 Tensor)的利用率与 IPC
MemoryWorkloadAnalysis / _Tables各级缓存与显存的实际吞吐、命中率、扇区/请求比
Occupancy实际驻留 warp 与理论上限的差距,被什么资源卡住
SchedulerStats每个周期有多少可发射 warp,issue slot 空转多少
WarpStateStatswarp 停在哪类原因上(各自的周期占比)
InstructionStatsSASS 指令构成,是否只压在少数几条管线上
LaunchStatsgrid/block 配置与资源需求是否合理
WorkloadDistributionSM、缓存、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"是错的:它测的是被工具改造过的执行,用来做归因,不用来做验收。

只采该采的那几个 ​

bash
# 只看第 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 时,先把它暂停。

bash
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。

八、把剖析做成固定流程 ​

不要求每次排障都从头想,固化成一个顺序:

  1. 基准不达标 / 训练变慢:先跑 20 秒 nsys(只标一个 step 的 NVTX),拿到 cuda_gpu_kern_sum 排序;
  2. 取 top 3 kernel:每个跑一次 ncu --set full,报告存 .ncu-rep;
  3. 按第五节顺序读:先 compute/memory 谁高,再 occupancy 理论 vs 实际,最后才看 stall;
  4. 改一处、复测同一 kernel:只改一个变量,回到 ncu 看那个百分比有没有动;
  5. 回归基准:改完必须回 HPL/nccl-tests/端到端 step 时间做确认,ncu 的数字不作验收依据;
  6. 归档:.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;
  • 剖析数字不替代基准:它解释基准差距的来源,本身是被工具改造过的执行,拿它当验收指标会把优化做反。

延伸阅读 ​

AI-HPC 智算超算专家 · Contact: openaihpc@gmail.com