CUDA 算子工程:手写 FlashAttention v2 之路
第 19 章 Nsight Compute / Systems 实战
前四篇的每一个优化,都需要一个能证明它有效的读数。 这一章讲 Nsight Systems 与 Nsight Compute 的分工,以及一份 kernel 报告该按什么顺序读下去。
19.1 两个工具的分工
NVIDIA 面向 CUDA 的主力 profiler 有两个:
flowchart LR
subgraph NSYS [Nsight Systems · nsys]
NS1[系统级 timeline]
NS2[CPU + GPU 行为]
NS3[Kernel launch 顺序]
NS4[CUDA API call latency]
NS5[NVLink / PCIe 流量]
end
subgraph NCU [Nsight Compute · ncu]
NC1[Kernel 内部细节]
NC2[每个 SM 的 metrics]
NC3[Memory 层级 hit rate]
NC4[Stall reason 分解]
NC5[Source-level annotation]
end
NSYS -->|"发现『哪个 kernel 慢』"| NCU
NCU -->|"发现『为什么这个 kernel 慢』"| FIX[修代码]
Nsight Systems(nsys)回答"哪个慢":拿到一段完整的 LLM 推理 trace,看哪些 kernel 占了大头、kernel 之间的 gap 是不是有问题、是否有 host 端阻塞。
Nsight Compute(ncu)回答"为什么慢":选定一个 kernel 后,深入到 SM 内部——algorithm level 是带宽 bound 还是算力 bound?memory level 是 L1 miss 还是 L2 miss?warp 是否在等内存?
新人常见的错误是直接用 ncu 看一切。但 ncu 的 metrics 很多很复杂,没有 nsys 提供的"宏观视角",容易陷入"调一个不是瓶颈的 kernel"的陷阱。
19.2 Nsight Systems 实战
19.2.1 抓 trace
最简单的命令:
nsys profile \
--output=my_trace \
--trace=cuda,nvtx,cudnn,cublas \
--capture-range=cudaProfilerApi \
python my_inference.py
参数说明:
--trace:跟踪哪些层。cuda是 CUDA API 和 kernel;nvtx是用户自定义 marker;cudnn/cublas是这些库的 API 调用。--capture-range=cudaProfilerApi:只在cudaProfilerStart() / cudaProfilerStop()之间录制,避免初始化阶段干扰。程序里必须真的调用这对 API(PyTorch 里是torch.cuda.profiler.start()/stop()),否则什么也录不到。
跑完得到一个 .nsys-rep 文件,用 Nsight Systems GUI 打开看 timeline。
19.2.2 看 timeline 的关键点
打开 timeline 后,关注几条 row:
- CUDA API row:CPU 上的 CUDA API 调用(
cudaMemcpyAsync、cudaLaunchKernel)。每个 API call 有 latency;如果某个 API 阻塞太久(比如cudaStreamSynchronize),可能是同步逻辑写错。 - GPU Kernels row:实际跑在 SM 上的 kernel。看 kernel 之间有没有 gap。
- NVLink / PCIe Throughput:多卡推理时跨卡通信带宽。这几行来自 GPU Metrics 采样,默认不采(
--gpu-metrics-devices默认为none),要在上面的命令里加--gpu-metrics-devices=all才会出现。注意 H100 那个 900 GB/s 是双向聚合口径(18 条链路 × 25 GB/s 单向 = 450 GB/s 单向),all-reduce 这种双向都跑满的负载才可能贴近 900;单向流量看到 450 就已经是打满了,别拿 900 去对。 - NVTX ranges(如果你打了 marker):用户标注的逻辑段,方便定位代码位置。
19.2.3 常见 timeline pattern
Pattern 1:Kernel 之间有大 gap
GPU: [kernel1] [kernel2] [kernel3]
← gap 5μs ← ← gap 8μs ←
可能原因:
- CPU 端有同步等待(
cudaDeviceSynchronize) - Kernel launch 慢(参数太多、没有 graph capture)
- 上下游依赖经过 host 中转(例如 kernel2 的启动参数要等 kernel1 的结果拷回 CPU 才能算出来);同一 stream 里的普通依赖由 GPU 按序执行,本身不产生 gap
修复:把多个 kernel 用 CUDA Graph 录制,整体提交。
Pattern 2:CPU 串行调度
CPU: [launch1] [launch2] [launch3] [launch4] ...
GPU: [k1][k2][k3][k4][...]
↑ GPU 已经空闲, CPU 还在派发
可能原因:CPU 单线程串行调度太慢,GPU 把队列里的活干完了还在等下一批。
修复:减少要派发的次数(kernel fusion、CUDA Graph 把整段 forward 一次提交),或者让派发本身并行(多 stream + 多个 host 线程各自派发)。注意 cudaLaunchCooperativeKernel 解决的是 grid 级同步,不是派发速度,别拿它治这个病。
Pattern 3:内存拷贝阻塞
GPU: [kernel1] [memcpy H2D] [kernel2]
↑ 5ms 拷贝
修复:用 page-locked memory + async memcpy + 用 stream 把 memcpy 和 kernel 重叠。
19.3 Nsight Compute 实战
19.3.1 抓 metrics
最常用的命令:
ncu --set full \
-k regex:gemm \
--launch-skip 5 --launch-count 1 \
--target-processes all \
-o my_kernel_profile \
python my_inference.py
--set full:抓完整的 section 集合。嫌慢可以换成更小的集合——用ncu --list-sets看当前版本支持哪些,别照抄别人博客里的集合名,Nsight Compute 各版本之间改过:不加--set时采集的默认集合在 2023.1 及以前叫default,2023.2 起改名basic;roofline集合是 2021.3 才加的。-k regex:gemm:只选名字匹配gemm的 kernel。--launch-skip 5 --launch-count 1:在匹配-k的 launch 里跳过前 5 次(warm-up),抓第 6 次。不加-k时计数的是进程里所有 kernel 的 launch,前 5 次未必是你关心的那个 kernel。-o:输出文件名。
运行时 ncu 需要把同一个 kernel 重放(replay)多次才能凑齐一整套 metric,还会串行化所有 kernel launch,整个程序的墙钟时间会比正常执行慢很多(metric 越多、replay 次数越多越慢)。报告里的 Duration 也不等于生产环境的耗时:ncu 默认锁定 GPU 时钟(--clock-control),并在每次 replay 前清空各级缓存(--cache-control all),kernel 之间也不再并发。所以 ncu 报告里的耗时不能当性能数据用,它只用来诊断。要测真实延迟,请用 nsys 或自己插 cudaEvent。
19.3.2 ncu 的关键指标
打开 .ncu-rep 文件,最重要的几个指标(下面各段的读数都是示意,不是实测):
A. SM utilization
sm__cycles_active.avg.pct_of_peak_sustained_elapsed : 96.3% ← SM 平均 96.3% 的周期处于活跃
Issue Slots Busy : 78.5% ← 78.5% 的 issue slot 发出了指令
第一行是 metric 名(SM 活跃率),第二行是 Compute Workload Analysis 里的显示名,两者反映 SM 的"忙碌程度"。如果 SM 活跃率很低(比如不到一半,这是经验阈值),说明某些 SM 闲置——可能是 grid 太小或负载不均。
B. Compute / Memory Throughput
Compute (SM) Throughput : 72.1%
Memory Throughput : 35.4%
Compute Throughput高 +Memory Throughput低 → 算力 bound(某条计算流水接近饱和,未必是 Tensor Core,要展开 breakdown 看是哪条)。Compute Throughput低 +Memory Throughput高 → 访存 bound(某一级存储是瓶颈,是 HBM 还是 L1/L2/shared memory,要看 breakdown 和 Memory Workload)。- 两个都高 → 接近最优。
- 两个都低 → kernel 有其他瓶颈(latency hiding 不够、寄存器 spill、warp stall)。
C. Roofline Plot
ncu 直接给出 Roofline 图(需要采集 roofline 相关 section,--set full 已包含),自动标出当前 kernel 的位置。看到一个点在带宽屋顶下方很远,说明带宽利用率低;在算力屋顶下方,说明算力利用率低。
D. Stall Reason
ncu 把每个 warp 在执行时 stall 的原因分解成几类(Warp State Statistics / Source Counters 里的名字):
Warp Stall Reasons:
Stall Long Scoreboard : 23.5% ← 等 L1TEX 访存(global/local 等)的结果
Stall MIO Throttle : 8.2% ← MIO 指令队列满
Stall Wait : 12.1% ← 等固定延迟的指令依赖
Stall Tex Throttle : 3.4%
Stall Math Pipe Throttle : 5.1%
...
Stall Long Scoreboard 高 → 内存延迟没掩盖好,需要更多 warp(提高 occupancy)或更深 pipeline。
Stall Wait 高 → 固定延迟的依赖链长(例如 FFMA 接 FFMA),需要重排指令、展开循环或增加 warp;官方文档提醒它通常只在已经高度优化的 kernel 里才排到前面。
Stall Math Pipe Throttle 高 → 数学流水已经被喂饱、发不进去了。对 GEMM/FA 这类 compute-bound kernel 来说这恰恰是好消息(说明瓶颈真的在算力);但如果这个 kernel 本该是带宽 bound 的,那多半是某类低吞吐的运算(__expf 这类走 MUFU 的指令、会展开成一长串指令的整数除法、FP64)被误用在了热路径上。
另外,只有当调度器不是每个周期都能发出指令时,stall 分解才值得细看(官方 Source Counters 说明原话:Only focus on stalls if the schedulers fail to issue every cycle)。
E. Memory Workload Analysis
L1/TEX Hit Rate : 92.1%
L2 Hit Rate : 65.3%
DRAM Throughput : 78.5% ← 这一项在 Speed of Light 表里
- L1 命中率 高 → 数据访问局部性好。GEMM 这类靠 shared memory 显式复用的 kernel,这个数参考意义不大。
- L2 命中率 低 → 跨 block 数据复用差。
- DRAM Throughput 78.5% → 已经把 HBM 用得差不多。
F. Source-level Counters
ncu 最强大的功能之一:把 SASS(以及 PTX)指令和 CUDA 源码对应,每行看 stall 占比、access 数量。前提是编译时加 -lineinfo(例如 nvcc -arch=sm_90a -O3 -lineinfo),否则 Source 页只能看 SASS,对不上源码行。
打开 source view,能看到类似(示意):
// CUDA C source | Counter (Stall)
for (int k = 0; k < K; ++k) { // 0.1%
sum += A[i*K + k] * B[k*N + j]; // 23.5% ← 这一行卡了
} // 0.0%
可以精确定位到"哪一行代码导致最多 stall"。
19.3.3 一个完整案例
假设一个 GEMM kernel 实测 30% 算力,预期 80%。怎么诊断?
Step 1:跑 nsys 确认 GEMM kernel 是否真的是热点。
- 时间占总推理 80%? → 是热点。
- 时间占 5%? → 不是瓶颈,去优化别的。
Step 2:跑 ncu 看 GEMM kernel 的 metrics。
Compute Throughput = 30%,Memory Throughput = 88%→ 带宽 bound。(注意 ncu 的Memory Throughput是整个访存子系统各级利用率的最大值,所以它不会低于下一步看到的 DRAM Throughput。)
Step 3:检查 Memory Workload。
L2 Hit Rate = 30%→ L2 miss 很多。DRAM Throughput = 88%→ HBM 已经接近峰值。
结论:当前 kernel 已经把 HBM 带宽用到 88%,但因为 L2 miss 导致从 HBM 拉数据多于必要——可能是访存模式让 L2 cache 频繁驱逐。
修复方向:
- 调 tile 大小,让一个 SM 上的连续 tile 复用同一份 A/B 分块。
- 用 L2 Persistence API 锁定热数据。
- 重新排列 GEMM 的 swizzle,让 L2 cache 友好。
这种诊断流程在性能调优里天天都会用到,熟练掌握 ncu 是 CUDA 工程师的核心技能。
19.4 一组高频指标速查
常用指标速查表:
| 指标 | 意义 | 健康值(经验值,非官方阈值) |
|---|---|---|
sm__cycles_active.avg.pct_of_peak_sustained_elapsed |
SM 活跃率 | > 90% |
sm__sass_thread_inst_executed_op_dadd_pred_on.sum |
FP64 加法指令数 | 在 LLM kernel 里应该是 0——H100 SXM5 的 FP64(非 Tensor Core)峰值只有 FP32 的一半(34 vs 67 TFLOPs),误用 double 字面量就会悄悄掉进来 |
dram__throughput.avg.pct_of_peak_sustained_elapsed |
HBM 利用率 | > 85% |
lts__t_sector_op_read_hit_rate.pct |
L2 读命中率(要看 miss 的扇区数用 lts__t_sectors_op_read_lookup_miss.sum) |
> 70% |
l1tex__data_bank_conflicts_pipe_lsu_mem_shared_op_ld.sum |
SMEM 读的 bank conflict(写用 ..._op_st.sum) |
0 是理想 |
sm__warps_active.avg.pct_of_peak_sustained_active |
Warp occupancy | 看具体场景 |
smsp__sass_average_branch_targets_threads_uniform.pct |
分支一致性 | > 95% (避免 divergence) |
sm__inst_executed_pipe_tensor.sum.per_cycle_active |
Tensor Core 发射密度(Hopper 上一条 WGMMA 按 4 条 warp 指令计) | 与同尺寸 cuBLAS 对比比看绝对值有意义;看 Tensor Core 忙不忙,更直接的是 sm__pipe_tensor_cycles_active |
19.5 NVTX:让 nsys timeline 更可读
默认情况下 nsys timeline 上每个 kernel 是它的真实 C++ 名字(很长很难读)。可以用 NVTX 给关键代码段打 marker:
#include <nvtx3/nvtx3.hpp>
void llm_forward(...) {
nvtx3::scoped_range layer_range{"layer-12"};
{
nvtx3::scoped_range attn_range{"attention"};
attention_kernel<<<...>>>(...);
}
{
nvtx3::scoped_range ffn_range{"ffn"};
gemm_kernel<<<...>>>(...);
gemm_kernel<<<...>>>(...);
}
}
跑 nsys 时加 --trace=nvtx,timeline 上会显示这些 marker——一眼看出每一层、每个子模块的耗时。
19.6 自动化 profiling:CUPTI 与 PyTorch profiler
工业级生产环境的 profiling 通常是自动化的:
- CUPTI (CUDA Profiling Tools Interface):底层 C API,自定义抓 metrics。
- PyTorch Profiler:PyTorch 内置的 profiler,可以一键导出 Chrome trace 格式。
- NVIDIA DCGM + Nsight Systems:服务器级监控。
常用工作流:
import torch.profiler as profiler
with profiler.profile(
activities=[profiler.ProfilerActivity.CPU,
profiler.ProfilerActivity.CUDA],
schedule=profiler.schedule(wait=1, warmup=1, active=3),
on_trace_ready=profiler.tensorboard_trace_handler('./logs'),
record_shapes=True,
with_stack=True,
) as prof:
for step, batch in enumerate(loader):
outputs = model(batch)
prof.step()
if step >= 5:
break
tensorboard_trace_handler 实际写出的是 Chrome trace 格式的 *.pt.trace.json,可以直接用 Perfetto 打开;在 TensorBoard 里看要另装 torch-tb-profiler 插件,该插件已被 PyTorch 标为弃用、不再维护。
19.7 这一章的小结与下一章
性能调优的工具链是日常必备:
- nsys 看宏观,ncu 看微观:先 nsys 定位热点 kernel,再 ncu 深入分析。
- 关键指标三件套:Compute Throughput、Memory Throughput、Stall Reason。
- Source view 是诊断神器:把 stall 精确归到代码行。
- NVTX marker 让 timeline 可读:花几分钟标记关键代码段,省几小时盯着名字猜。
- 生产环境用 PyTorch Profiler / DCGM:自动化抓 trace,不用每次手动跑。
第 20 章我们继续往低层走——讲 PTX 与 SASS。当 ncu 已经告诉你"这一行慢",下一步可能需要看编译出的 PTX 中间代码、甚至 SASS 机器码,才能搞清楚"编译器到底干了什么"。读完第 20 章,读者掌握读 PTX/SASS 的能力,能诊断那些单看 ncu 读数解释不了的"指令级"问题。
本章动手练习:
- 用 nsys 抓一段 LLM 推理 trace,找出占时最长的 5 个 kernel。
- 用 ncu 分析其中一个 kernel,记录 SM 活跃率、Compute / Memory Throughput、Top 3 Stall Reason。
- 给你的 kernel 加 NVTX marker(attention、FFN、layernorm 各一个),看 timeline 区分。