如果你负责过任何一个吃 GPU 的 AI 训练或推理任务,大概率经历过这种场景:监控面板里 GPU 利用率只有 20%,显存却占了 60% 以上,任务没报错,训练速度就是上不去。最抓狂的不是问题本身,而是你根本不知道从哪里开始查。今天我聊的这套零侵入 AI Profiling 工具组合,就是专门用来解决这类“GPU 利用率低但根因难定位”的典型问题。不用改训练代码,不用重新编译,只要把观测工具挂在进程外面,从系统指标和 CUDA 调用轨迹两个维度把问题扒出来。
1. 先搞清楚:GPU 利用率低是谁的“锅”
1.1 三个指标先分清:利用率、内存带宽、活跃核数
很多人一看到 GPU 利用率低,第一反应就是“GPU 在偷懒”。其实“利用率”这个词在日常监控里被玩坏了。nvidia-smi里的utilization.gpu,严格来说是 GPU 内部计算单元在一个时间窗口内的平均活跃程度,它既不是最终跑分,也不是唯一的性能指标。我处理过好几个案例,GPU 利用率看起来只有 30%,但把nvidia-smi里的显存利用率、SM 时钟、显存时钟、功耗拉出来一看,会发现真正的瓶颈在显存带宽,或者说在“SM 在等数据”这件事上。
打个比方,GPU 相当于一个厨艺很好的厨师。如果配料切得很好,炉子又不缺火,他一直在炒菜,这时候你统计起来“利用率”很高。但很多时候他是拿着锅铲在等配菜,锅还烧着、人也在灶台前,但没在翻炒,这时候 UI 显示的占用率自然就低。GPU 自己不会“摸鱼”,它一定是被什么事件卡住了,只是你还没看到卡在哪一步。
所以定位问题的第一步不是纠结某一个数字,而是把下面几类数据拆开看:
GPU-Util:SM 上有计算指令在执行的采样百分比。它反映的是“有没有活干”,不反映“干得有没有效率”。Memory-Util:显存控制器读写带宽的活跃程度。很多算子并不吃算力但吃带宽,这会造成计算利用率低但显存利用率接近满。SM Clock / Memory Clock:是否降频。正常的 GPU 在负载高时频率会拉满,如果利用率低且频率也很低,要么是任务本身喂不过来,要么是功耗、温度被限制了。Power Draw:实际功耗。如果利用率只有 20%,但功耗已经冲到设计上限,说明有大量即使没怎么干事的模块在耗电,比如持续的内存搬运。Tensor Core 活跃度:部分 profiling 工具能把矩阵乘法和张量指令单独拆出来。很多模型看起来 SM 利用率不低,但 Tensor Core 利用率很低,这就说明计算模式不匹配硬件特性。
1.2 为什么 nvidia-smi 的 GPU-Util 容易误判
nvidia-smi带-l 1可以每秒刷一次,但这个采样逻辑本身就是拿“瞬时开关”来猜整体状态。它像你在高速公路上每隔十公里拍一张照片,如果刚好都拍到车流稀少的路段,不代表全程都堵车或者全程都通畅。
我见过一个真实例子:同一个训练脚本在一台机器上 GPU-Util 稳定 90% 以上,换到另一台机器只有 40% 左右。单看nvidia-smi会怀疑是卡坏了或者驱动没打好,可实际上问题出在另一台机器的 CPU 降频、内存跨 NUMA 节点访问,以及训练的dataloaderworker 数量太少,数据到 GPU 的链路经常断流。nvidia-smi只会告诉你“这段时间 GPU 没怎么干”,不会告诉你“是谁没把活递给 GPU”。
所以日常监控只是用来发现问题,不是用来定位问题的。定位问题需要 Timing Trace,也就是把每个 CUDA API 调用、每个 kernel 启动、每次数据搬运、每段 CPU 等待串成一条带时间戳的时间线。这才是 AI Profiling 工具真正该干的事。
2. 我为什么选择“零侵入”的方式做 AI Profiling
2.1 常规方案太“重”:改代码、加日志、重启训练
很多团队排查 GPU 利用率低,第一反应是往训练脚本里塞torch.profiler,或者手动给关键环节加print时间戳。这些方法本身没错,但在生产环境里非常难受。训练任务可能是别人维护的,也可能是从开源仓库拉下来的,动一行代码就要重新验证,还可能在分布式训练里影响集合通信的状态。更麻烦的是,如果问题只在某些大规模迭代或者某个特定数据批次出现,加了 profiling 代码后还得重新启动任务去碰运气。
我自己更倾向于所谓的“外部观测”:启动训练进程的时候在外面包一层工具,让它无感地采集底层数据,分析完再撤掉。训练代码一行不动,环境依赖也不加,这样可以最大限度保留线上行为的真实性。
这种零侵入的思路有两个核心工具:
- DCGM Exporter / Prometheus 这类系统级 GPU 指标采集器,用于长时间、全局地盯指标变化趋势。
- NVIDIA Nsight Systems,用于短周期、高精度地抓取 CUDA 调用和 kernel 执行细节。
2.2 DCGM + Nsight Systems 的定位分工
如果只用一个工具会容易出现“盲人摸象”。DCGM Exporter 能持续把 GPU 利用率和功耗等几十个指标暴露出来,形成 Dashboard,但它看不到单个 kernel 叫什么名字、执行多久、中间为什么停了。Nsight Systems 能看到完整的 kernel trace,但如果你跑大模型只抓一次全量 trace,开销会很高,而且一次拍摄只能覆盖一段时间,不适合放在监控里常驻。
我的用法是:先用 DCGM Exporter 做“体检报告”,确认 GPU 利用率低是持久的还是偶发的,波动跟训练步骤有没有固定规律;再针对问题窗口用 Nsight Systems 抓一次几十秒到几分钟的 trace,精确定位卡点。这种组合就像先用体温计确认发烧,再用 CT 看是哪块炎症。
具体来说,DCGM Exporter 会提供很多比nvidia-smi更细的指标,常见的有:
| 指标名 | 含义 |
|---|---|
DCGM_FI_DEV_GPU_UTIL | SM 整体利用率 |
DCGM_FI_DEV_MEM_COPY_UTIL | 显存拷贝/带宽利用率 |
DCGM_FI_DEV_SM_CLOCK | SM 实时时钟频率 |
DCGM_FI_DEV_POWER_USAGE | GPU 整卡功耗 |
DCGM_FI_DEV_FB_USED | 显存占用 |
DCGM_FI_PROF_SM_ACTIVE | Profiling 模式下 SM 活跃周期占比 |
测试的时候可以把采样间隔调小一点,比如 1 秒甚至 500ms,能看清十几个甚至几十秒内的波动。但要注意,指标粒度只能到“秒级毛刺”,要看到单个kernel级别的行为还是得往下钻。
3. 实操:从 20% 利用率到定位根因的完整过程
3.1 第一步:先用 DCGM 指标把范围缩小
假设我现在接到一个任务:某个推理服务或训练作业,GPU 利用率只有 20%,客户要求定位根因。我并不会一上来就跑 Nsight Systems,而是先起一个后台指标采集,尽量抓到真实业务高峰。
如果机器上不方便装完整监控栈,可以先手工拉一轮数据:
nvidia-smi \ --query-gpu=utilization.gpu,utilization.memory,power.draw,clocks.sm,temperature.gpu \ --format=csv -l 1 > gpu_status.log跑两分钟任务后看一下gpu_status.log。如果utilization.gpu和utilization.memory都很低,说明数据根本没到 GPU,大概率瓶颈在 Host 侧,比如 CPU 处理、磁盘 IO、网络拉取。如果utilization.gpu低但utilization.memory很高,说明计算指令发不出来,线程都在等显存数据,可能是 kernel 访存模式太差或者数据搬运本身占满了带宽。
用 DCGM Exporter 的好处是可以把这些数据直接暴露成 Prometheus 指标,挂在 Grafana 上长时间看曲线。启动方式大致是这样:
docker run -d --name gpu-monitor --gpus all \ -p 9400:9400 \ nvidia/dcgm-exporter:latest然后到http://<机器IP>:9400/metrics就能拉到指标。实际部署时根据版本确认环境变量和端口,默认规则很多是秒级采集,对长周期定位足够了。注意容器里如果没挂 GPU,或者宿主机驱动版本和 DCGM 版本不匹配,会拉不到指标,先检查nvidia-smi和容器 Runtume 是否正常。
这一阶段你不需要知道是哪一行代码出了问题,只需要知道“问题主要出现在哪个环节”。
3.2 第二步:用 Nsight Systems 抓一次关键 trace
确认 GPU 侧确实不忙之后,再上 Nsight Systems。它最让我喜欢的一点是直接包在训练命令外面,完全不改代码。比如原本启动命令是:
python train.py --epochs 50加上 profiling 后变成:
nsys profile -t cuda,nvtx,osrt \ -o /tmp/exp1 --force-overwrite true \ python train.py --epochs 50-t cuda,nvtx,osrt是让工具采集 CUDA 活动、NVTX 标记和操作系统运行时时间。如果你代码里没有 NVTX 标记也没关系,CUDA API 和 kernel 信息已经够用了。如果训练脚本太大,建议先只跑几个 step 就退出,或者用一个带了--limit-batches的评测脚本,否则会在第一步采样几万个 kernel,分析效率反而更低。
结束之后把.nsys-rep文件导入 Nsight Systems GUI,或者直接用命令行统计:
nsys stats --report cuda_gpu_kern_sum --report cuda_api_sum \ --format table --time-unit ms /tmp/exp1.nsys-rep输出里通常能看到几组关键数据:
cuda_gpu_kern_sum:每个 kernel 名字、调用次数、平均时长、总时长。cuda_api_sum:每个 CUDA API 调用占用的时间,包括等待时间。gpu_metrics_summary:GPU 整体活动时间占比。- 大量 API 的
Wait Time特别长时,说明 CPU 发出指令后 GPU 迟迟没开始执行,大概率队列里没有足够任务。
我遇到最多的情况是:一个训练 step 里,cudaMemcpyAsync或cudaStreamSynchronize的等待时间占掉整段时间的 50% 以上,而实际 kernel 加起来只占不到 20%。这种数据一出来,目标基本就很清晰了,接下来要看的是 CPU 侧到底在忙什么。
3.3 第三步:把 CPU 侧采样叠上去
GPU 利用率的另一半是 CPU。如果 CUDA API 的调用频率很低,说明训练脚本产生 CUDA 操作的速度跟不上 GPU 消费的速度。这时候要分析 CPU 时间花在哪了,我常用的方式是再加一个nsys的 CPU 采样:
nsys profile --sample=cpu \ -t cuda,nvtx,osrt \ -o /tmp/exp_cpu \ python train.py --epochs 1然后再按热点函数聚合:
nsys stats --report cpusamp --format table /tmp/exp_cpu.nsys-rep你会看到类似DataLoader的迭代、图像预处理、torchvision里的Resize、正则表达式解析、JSON 反序列化等函数占了大量 CPU 时间。这类问题在 NLP 那边经常表现为分词器开销,在 CV 这边经常表现为图片解码缩放。把这些 CPU 热点和 CUDA trace 放在同一条时间线上,就能看到数据下发不连续的规律。
比如有一次我们定位一个 CV 训练任务,GPU 利用率长期在 25% 上下浮动。Nsight 显示每两次 kernel 之间空了大概 30ms,这段时间 GPU 什么也没干。把 CPU 采样叠上去后发现,训练进程主线程一直在等 dataloader 的collate,而 dataloader 的 worker 全卡在 JPEG 解码上。当时训练采样卡上的图片分辨率特别大,解码耗时比 GPU 单次迭代还长,num_workers加满也没用,最后换用高吞吐的解码库和TFRecord格式才解决。
3.4 常见根因与快速对策速查表
把一例又一例的问题归纳起来,GPU 利用率低其实就那么几类原因。我整理了一个速查表,排查时可以直接对照:
| 表面现象 | 常见根因 | 下一步动作 |
|---|---|---|
| GPU-Util 低,Memory-Util 也低 | 线程没往 GPU 提交任务 | 查 CPU 热点、数据加载、请求队列 |
| GPU-Util 低,Memory-Util 高 | 带宽吃满,kernel 在等显存数据 | 查访存模式、张量布局、小 Batch 下频繁拷贝 |
| kernel 之间有明显空档 | Host 侧串行逻辑,比如同步等待、Python GIL | 用 Nsight 时间线找 Gap,重点看同步 API |
| 小 kernel 数量特别多 | 单个算子太小,启动开销远大于计算 | 看算子融合、Batch 合并、减少 Python 层调用 |
| 多卡训练里跨卡等待 | AllReduce 通信次数过多或通信带宽不足 | 看集合通信 timeline,检查网络/IB 链路 |
| 长时间功耗不高但频率低 | 电源管理、散热降频、GPU 利用率本来就不饱和 | 查时钟限制、温度、功耗策略 |
这张表不复杂,但很实用,能帮你在看到问题的时候不至于没方向。
4. 最容易踩的坑,以及我的排查习惯
4.1 Nsight 采集到“好看”的 trace,却掩盖了真实瓶颈
Nsight Systems 本身有额外开销,尤其是把osrt和nvtx全部打开的时候,训练过程中的同步行为会被放大。我也见过一个项目,加了nsys之后 GPU 利用率反而上去了,看起来很怪异,其实是 profiling 工具在逐个跟踪 CUDA API 调用,导致某些原本异步的操作被迫同步了,天然形成的空闲窗口被压缩,反而看起来更像“跑满了”。
所以我在做 profiling 之前会先确认一个原则:用工具找到的问题方向,还要用不做 profiling 的裸跑复测一遍。比如发现瓶颈疑似在 dataloader,就把num_workers调大后裸跑看nvidia-smi,确认 GPU 利用率是不是真的涨了。如果复测结果和 trace 结论不一致,别急着下结论,先检查采集参数是不是干扰了执行顺序。
另一种坑是设置错误或 kernel 数量过大导致文件巨大。全量采集一个训练 epoch 很容易生成几个 G 的.nsys-rep,分析时 GUI 都可能卡死。解决办法是只抓少量迭代,或者用环境变量让脚本跑固定步数退出。比如:
nsys profile -t cuda,nvtx \ -o /tmp/quick \ python -c "from train import run; run(max_steps=10)"这样既能覆盖问题窗口,又不会让采样文件大到失控。
4.2 别把“GPU-Util 高”当成没瓶颈
和标题里“利用率低”相反的场景同样要注意:如果系统级 GPU 利用率已经是 90% 以上,但训练速度还是不理想,瓶颈可能不是“GPU 没吃饱”,而是某些 kernel 用错了计算单元。比如矩阵规模小而算子启动次数多,GPU 看似繁忙但大量开销都在空转,这时候你反而需要看 kernel 总耗时和内存吞吐。
有一次我们优化一个推理服务,nvidia-smi显示 GPU Uil 94%,但 P99 延迟还是超标。直接用 Nsight 检查后发现,ResNet 前面的Batched小算子每个只跑十几微秒,虽然一直有活干,真正的计算密度却很低。问题不在 GPU 负载,而在算子碎片化。后来通过把前处理移到专用 CPU 进程、把多个小算子合并成一次 CUDA Graph 捕获,延迟才明显降下来。
这说明 Profiling 的价值不只是诊断“利用率低”,它同样能帮你判断“利用率高”是不是虚假繁荣。核心指标永远是端到端吞吐和延迟,而不是某个百分比的数字。
4.3 我的排查顺序和检查清单
我把这种问题查过很多轮之后,慢慢形成了一套固定动作。先把步骤列出来,方便你以后照做。
第一步,先确认环境层没问题:驱动版本、容器是否挂载 GPU、CUDA 是否正常、是否有别的进程占用显存。这一步最基础,但也是我翻车概率最高的地方。很多“GPU 利用率低”其实是 GPU 被另一个模型占了,业务进程根本抢不到计算单元。
第二步,用nvidia-smi或 DCGM 连续采样五分钟,看可视化曲线。如果利用率是持续的稳定低,优先查数据链路;如果是周期性“正常一下、低一下”,优先查同步等待和阶段性的 CPU 串行操作。
第三步,用 Nsight Systems 抓 trace,先看 CUDA API 的等待时间排名,再看 kernel 间的 Gap 位置。如果 Gap 固定出现在某个 API 之后,那就是核心矛盾点。
第四步,用 CPU sample 把热点函数导出来,把 GPU 时间线和 CPU 时间线拼在一起找重叠关系。这一步能分清是“没数据”还是“数据慢”。
最后,做复现实验验证结论。不要一次性修改太多变量,每次只改一个可能瓶颈,比如num_workers、pin_memory、Batch Size、算子融合等,改完裸跑再看指标,确认是否真的改善。
从团队协作的角度,这套方法的优势也很明显。不需要运维人员掌握深度学习代码,也不需要算法工程师临时学 CUDA,只要会用命令行跑 profiling 工具,就能把问题定位到模块级别。等确认是数据 pipeline 的问题,再交给对应模块的负责人去优化,责任边界非常清晰。
我个人在实际操作中的体会是:GPU 利用率低不可怕,可怕的是你手里只有nvidia-smi这一个工具,然后对着百分比猜。换到系统指标加调用轨迹的零侵入查看方式之后,大部分看起来玄乎的性能问题,最终都会落到一个很小的、可复现的执行细节上。先有数据,再有结论,整个排查过程会轻松得多。