我在云上租来的那张 A100 上遇到过一件怪事:nvidia-smi里 GPU-Util 稳稳卡在 97%,看着满屏绿色,可训练吞吐就是比同配置的基准低了 40%。团队把 batch size、DataLoader、学习率调了个遍,性能纹丝不动。最后查出来,问题压根不在“显存不够”或“算子不高效”,而是 GPU 一直在一个又一个极短 kernel 的空转间隙里消耗时间。这种“GPU 利用率虚高、收益上不去”的问题,在模型训练、微调和推理服务里太常见了,但传统的 Profiling 方案要么要改代码、要么会拖慢运行、要么给的数据根本不够细。今天聊聊我用的一款零侵入 AI Profiling 工具,以及怎么靠它把“低利用率”的根因一层层挖出来。适合做深度学习训练、GPU 集群调优和推理服务性能排查的工程师直接参考。
1. 为什么 GPU 利用率低的根因这么难定位
1.1 nvidia-smi 告诉你的是假“利用率”
很多人对“GPU 利用率”的理解有个根深蒂固的误区:以为 GPU-Util 90% 以上,就说明 GPU 的算力已经被榨干了。实际上,nvidia-smi顶部的GPU-Util表示的是“最近一段时间里,GPU 是否有 kernel 在流水线上执行”,它统计的是 busy 时间占比,而不是 SM(流式处理器)真正做有用计算的占比。
举一个我常用来跟人解释的例子:假设你每隔 10 毫秒往 GPU 上丢一个小 kernel,每个 kernel 只跑 0.1 毫秒就结束。从 GPU 的角度看,每 10 毫秒里有 0.1 毫秒在干活,剩下的 9.9 毫秒在等待;但因为那 0.1 毫秒“有 workload”,GPU 的 busy 百分比可以冲到接近 100%。SM 的实际负载却只有 1%。这就是“GPU 一直在上班,但大部分时间在摸鱼”的典型形态。
所以当你说“GPU 利用率低却难定位根因”的时候,第一步就该分清:你说的是 busy 时间低,还是 SM 有效计算时间低?这两个数字背后的优化方向完全不同。如果只看 nvidia-smi 那一栏,等于拿着一块坏掉的秒表去排查“时间都去哪了”,自然会被带到沟里。
1.2 传统定位手段的三个断层
为什么根因难找?我把过去用过的传统手段拆开来看,问题主要出在三个地方。
第一,埋点式 Profiling 会改变执行环境。像很多 C++/CUDA 层面的 profiler,为了拿到 kernel 的起止时间,需要在运行环境中插入回调或者拦截 API 调用,这会改变 GPU 的调度节奏。你测出来的“慢”,可能不是业务真实运行时的“慢”,而是 profiler 自己引入的开销。对于一些对延迟极其敏感的小 batch 推理,这种失真非常致命。
第二,采样粒度太粗,分不清瓶颈层级。很多工具可以告诉你某个 kernel 占总耗时的比例,但说不清这个 kernel 是卡在显存带宽、PCIe 传输,还是 Tensor Core 没有用起来。你看到一堆数字,却不知道下一步改哪里。
第三,缺少“CPU 侧等待”和“GPU 侧执行”的时间轴对齐。GPU 利用率低往往不是 GPU 自身的问题,而是 CPU 没有及时把数据、指令送上来。可传统单机工具很难同时告诉你 CPU 在哪个环节卡住了、GPU 又在哪个间隙里空等。你得手动把 CPU 日志和 GPU 日志做时间对齐,费时费力,而且对不上。
这三个断层叠加在一起,就形成了“利用率低,但谁都不承认是自家代码问题”的困局。要破局,需要一种不干扰业务进程、又能从 GPU 硬件层拿到多维计数器的观察方式。
2. 零侵入 AI Profiling 工具的设计与原理
2.1 零侵入到底指什么
先说清楚“零侵入”的含义。它不是指“工具不带任何性能开销”,而是指不需要你修改业务代码、不需要重新编译、不需要在框架里注册回调、不需要往容器里额外塞 Agent 库。工具以纯旁路方式,从宿主机侧观察 GPU 的活动。
我当时把这套工具叫做 GPU Lens,其实它本质上是 NVIDIA DCGM(Data Center GPU Manager)暴露的硬件计数器,加上一层时间轴对齐和根因规则判断的封装。部署它,只需要在宿主机上跑一个独立进程,业务侧完全感知不到。你训练脚本里import torch之后发生的一切都不会受影响。
这在生产集群里价值极大。我见过很多团队,调优调得好好的,一说要上 profiler,开发同学第一反应就是“会不会把我代码搞挂”“要不要开个调试开关”,最后不了了之。零侵入工具没有这些心理门槛,你甚至可以把它挂在已经跑起来的长期任务边上,不用重启进程。
2.2 工具如何“旁听”GPU:硬件计数器与 DCGM
GPU 芯片内部其实有一批持续运行的性能计数器,它们记录着 SM 活跃周期、内存控制器吞吐、PCIe 收发字节数、张量核心活跃率等细粒度事件。这些计数器属于硬件本身,一直开着,不需要编译器插桩,也不需要 CUDA 代码配合。
NVML 和 DCGM 做的事情,就是从宿主机侧读取这些硬件计数器并汇总成指标。你可以把它理解成给 GPU 装了一个智能电表:电力公司不需要拆开你的冰箱,就能知道冰箱内部压缩机什么时候在转、什么时候在待机。GPU Lens 的工作方式与之类似,它轮询驱动层暴露的计数器,然后打上时间戳,输出成结构化的 Profiling 报告。
我实际使用的采集频率默认是 100 毫秒一档。对于稳定周期的训练任务,这个频率足够看到 kernel 间隙和带宽波峰;对于推理服务这种突发性较强的场景,可以切到 10 毫秒一档。频率往上调,数据会更多,但采集进程本身的 CPU 开销也会涨,所以不是越快越好。我们的经验是:先用 100ms 粗跑定位问题区间,再针对可疑区间做细粒度重采。
2.3 指标设计与根因映射
GPU Lens 最有价值的地方,是把几十个硬件计数器和常见的根因模式做了映射。下面这几个指标,是我每次排查利用率问题都会优先看的:
| 指标 | 含义 | 什么情况说明有问题 |
|---|---|---|
| GPU Busy | GPU 流水线上有没有活跃 kernel | 长期低于 60%,说明任务本身喂不饱 GPU |
| SM Active | SM 实际处于计算活跃的周期占比 | 远低于 GPU Busy,说明 kernel 间隙太多 |
| SM Occupancy | 每个 SM 上活跃 warp 与容量比例 | 长期低于 30%,说明线程块太小或资源限制 |
| DRAM Active | 显存控制器活跃占比 | 接近满,说明访存密集型,计算单元在等数据 |
| PCIe TX/RX | PCIe 总线传输带宽利用率 | 长期高企,说明数据搬运盖过了计算 |
| Tensor Active | Tensor Core 活跃占比 | 低且业务是 Transformer,说明矩阵乘没走到专用单元 |
| GPU Context | 上下文切换次数 | 频繁切换,说明多个进程在抢同一张卡 |
我一般会先看“GPU Busy 和 SM Active 的关系”:如果 GPU Busy 高但 SM Active 很低,典型病根是“kernel 碎”;如果 SM Active 也高但 DRAM Active 极高,病根转向“访存模型”;如果 SM Active 高、DRAM 不高,但 Tensor Active 低,那要怀疑算子实现没有走 Tensor Core。这套映射关系不是玄学,它就是从上百万个硬件事件里归纳出来的规律,就像医生先量体温再查血常规,顺序很重要。
3. 实操:5 分钟跑出一个根因侧写
3.1 环境准备与安装清单
使用 GPU Lens 的前提并不复杂,我建议按这个清单准备:
- Linux 宿主机,且有 NVIDIA GPU;
- NVIDIA 驱动版本尽量新一些,至少 470 系列以上,老驱动可能缺少部分硬件计数器;
- 不需要安装 PyTorch 的 Profiler,也不需要修改训练脚本;
- Docker 容器场景里,工具装在宿主机,不是装进容器。
安装 DCGM 非常简单,几行命令就能搞定。装完之后,先跑一条自检命令确认驱动能看到这块卡上的计数器。如果自检失败,大概率是驱动版本问题,升级驱动前先确认你的 CUDA 环境兼容性,别为了 Profiling 把整个训练环境搞崩。
GPU Lens 的封装脚本本身也只是一个 Python 进程,依赖少量系统库。我会在第一次使用前跑一个“干跑”模式,只读取 3 秒数据然后退出,确认输出的 JSON 文件里各项字段都有值,没有 NaN 或者全 0 的异常。
3.2 采集配置与命令示例
启动采集的典型命令是这样:
gpulens profile --gpu 0 --duration 300 --interval 100 \ --output ./report.json --force-pci 0含义是:对 0 号 GPU 采 300 秒,每 100 毫秒记录一次快照,结果输出到 report.json。这里的--force-pci是我加的开关,用来强制标记 PCIe 指标,因为有些老驱动默认不导出 PCIe 计数器。
跑起来之后,采集进程会安静地蹲在后台。你该训练训练、该推理推理,全程不用动业务。等 300 秒结束,它会生成一份报告,里面至少包含三块内容:全量时间切片表、指标聚合统计、自动判读的“可疑根因列表”。
如果业务是推理服务,我建议采集时长覆盖至少 5 到 10 个完整的请求周期,否则突发流量特征会被平均掉。如果业务是分布式训练,建议把多张卡的采集时间对齐,用同一个时间源生成时间戳,不然没法判断是不是某一张卡的性能拖累了整体。
3.3 看懂三张关键报表
第一张是“利用率全景表”,每一行对应一个时间切片,列出了 GPU Busy、SM Active、DRAM Active、PCIe TX/RX、Tensor Active 这些关键数值。这张表用来快速圈定“问题时间段”,比如某个 batch 结束前后 SM Active 突然掉到 20%,这里往往埋着同步等待。
第二张是“根因判读表”,工具根据映射规则自动给嫌疑排个序。例如它发现 GPU Busy=93%,SM Active=19%,DRAM Active=15%,就会把“Kernel 碎片化 / 启动间隙过长”标为高嫌疑,并且附上建议:优先检查循环里的小算子、尝试算子融合、考虑用静态图消除 kernel 启动开销。
第三张是“kernel 时间聚合表”,按启动频率和单次耗时把 kernel 排序。你会看到一批平均执行 0.02 毫秒的 kernel 被反复启动几千次,这种数据往往一眼就能定案。需要注意的是,第三张表的粒度来自采样统计,未必能覆盖所有瞬时 kernel,但它足够给方向。
看表的顺序也有讲究:先看 GPU Busy 是否饱满,再看 SM Active 和 DRAM Active 有没有错位,最后看 Tensor Active 是否符合业务预期。不要上来就盯 PCIe,PCIe 高通常是果,不是因。
4. 三个真实案例复盘:低利用率的三种病根
4.1 案例一:小算子满天飞
有个做推荐模型微调的团队找我,说 A100 利用率 96%,但训练一 step 要 3.2 秒,比他们参考的优化基准慢了 60%。GPU Lens 的报告一出来,问题非常扎眼:GPU Busy 92%,SM Active 只有 17%,DRAM Active 也才 20%。说明 GPU 流水线几乎一直在被“唤醒”,但每次真正干活的量极少。
进一步翻 kernel 聚合表,发现排名前几的 kernel 全是类似elementwise_add、relu、mask_fill这类粒度过小的算子,单次执行只有十几微秒,启动频率却高达每秒几千次。这些 kernel 的启动开销远大于执行开销,GPU 真正算的时间被淹没在调度间隙里。
解决办法很朴素:把小的 elementwise 算子用算子融合合并,或者直接改用torch.compile这类静态编译方案;同时把 batch size 拉大,让每个 kernel 的规模上去。修改之后,SM Active 从 17% 跳到了 64%,单 step 时间从 3.2 秒降到 1.1 秒。这个案例的启发是:利用率高不代表没问题,恰恰是“看上去很忙”的碎片化执行最容易骗过眼睛。
4.2 案例二:数据搬运盖过计算
另一个做多模态训练的团队,问题表现完全相反:SM Active 有 48%,不低了,但 GPU Lens 里的 PCIe RX 长期稳定在 78% 以上,显存带宽却没什么压力。进一步看,每个训练 step 都要从 CPU 侧拷贝将近 4GB 的图像预处理结果,而且拷贝是同步的,GPU 在拷贝期间只能原地等待。
这种病根在 DataLoader 和图像预处理环节。传统nvidia-smi看不出 PCIe 压力,你会误以为是模型算不动。GPU Lens 把 PCIe RX 指标暴露出来后,排查方向立刻转向数据管道。
我们最后用了三招:数据增强和预处理全部放到 DataLoader 的 worker 进程里异步做,用 pinned memory 加速 H2D 传输,再把预处理结果缓存到 GPU 显存里,避免每个 epoch 重复搬运。最终 PCIe RX 从 78% 降到了 22%,端到端吞吐提升了 110%。数据搬运类问题在大模型微调、多模态任务里特别常见,因为输入样本大、预处理重,GPU 本身的计算能力反而可能被闲置。
4.3 案例三:GPU 一直在等 CPU 的信号
第三个案例是推理服务。现象是 GPU 平均利用率只有 35%,但 SM Active 本身不低,问题是 GPU 流水线经常出现长达 5 到 8 毫秒的“千年空白”。时间轴对齐后发现,这些空白全部出现在请求入口的某个 Python 回调之后。
原因是服务端代码在推理链路里调用了tensor.item()这类同步操作,把 GPU 数据拉回 CPU 做条件判断,导致 GPU 必须停在那里等 CPU 回来了才能继续发下一轮算子。这种问题在 PyTorch 服务化场景里非常多。
GPU Lens 在这个案例里的价值不在于告诉你“要删掉 item()”,而在于把 kernel 间隙的分布画了出来。我们看到间隙集中在固定环节,而不是随机分布,这就有针对性了。删掉同步点、改用异步标志位之后,GPU 利用率从 35% 涨到 71%,p99 延迟降低了一半。GPU 利用率低,很多时候病根不在 GPU,而在 CPU 侧没有及时“投喂”。
5. 常见问题与排查技巧实录
5.1 工具本身会不会影响性能?
这是所有人第一个要问的问题。我的实测结果是:GPU Lens 的采集进程只有几十 MB 内存,CPU 占用率在 100 毫秒采样间隔下几乎可以忽略,对 GPU 本身没有任何插桩动作。因为它走的是 DCGM 暴露的硬件计数器,不是 CUPTI 的 API 拦截,所以不会给 CUDA 流里塞额外的同步点。
但要记住,DCGM 的计数器读取本身也会占用一点点驱动总线带宽,在极端高频采集(比如 1 毫秒间隔)时可能造成细微抖动。日常排查用 100 毫秒、200 毫秒完全够,不需要追求极限频率。想采集 kernel 级的精确时间,还是得上 Nsight 这类重工具,先让轻量工具缩小范围,再用重工具精准打击,这个配合最舒服。
5.2 几个指标出现“矛盾”怎么判读
我整理了一个指标组合速查表,方便大家对着判断:
| 指标组合 | 判读结论 |
|---|---|
| GPU Busy 高、SM Active 低 | Kernel 过多过碎,调度间隙吃掉了时间 |
| SM Active 高、DRAM Active 高 | 访存密集型任务,瓶颈在显存带宽 |
| SM Active 高、DRAM Active 低、Tensor Active 低 | 算子没走 Tensor Core,可能用错实现 |
| PCIe RX 高、DRAM Active 中低 | 数据搬运管道有瓶颈,优先看 DataLoader |
| GPU Context 切换频繁、SM Active 忽高忽低 | 多进程共享 GPU,调度竞争严重 |
如果同时出现两种高嫌疑,我的经验是先处理“kernel 碎片化”,再处理带宽问题。因为碎片化会导致 GPU 长期处于低水位,这时候即便带宽不高,吞吐也起不来;先把有效计算时间养肥,带宽问题才更容易被暴露出来。
5.3 如何把指标和业务代码对应起来
工具给出的数据是硬件视角,业务代码是逻辑视角,中间需要桥接。我的做法是:看 PID 和 kernel 名,再结合业务日志里的时间戳做粗对齐。GPU Lens 会记录当前活动的进程 ID,如果容器里只有一个训练进程,那基本能锁定;如果是多进程服务,可以先用环境变量给每个进程区分标识,避免混淆。
对于动态图场景,kernel 名经常带数字后缀,不要被吓到。聚合时按 kernel 的“基名”归组,比如把elementwise_kernel_123归到elementwise_kernel,趋势立刻清晰。实在对不上代码行,就用“重复模式”倒推:如果一个 kernel 每隔固定时间出现一次,而且时间点和训练循环的某一行高度吻合,那它就是我们要找的主角。
5.4 几个踩过的坑
最后分享几个我踩过的坑,大家能避则避。
第一个坑是只盯平均利用率。平均值会把尖峰和低谷都抹平,特别是推理服务,p99 利用率才代表真实压力,建议同时看 p50、p99、最大值三档。
第二个坑是采样时间太短。跑 30 秒有可能只覆盖到几个 batch,根本看不到间歇性卡顿。我建议至少跑 5 分钟以上,最好覆盖一个完整 epoch 或者一个完整的压测周期。
第三个坑是在容器内采集。容器里通常拿不到宿主机路径下的驱动导出表,容易导致读到一堆零。老老实实把采集进程放到宿主机,或者挂载对应路径,别图方便塞进容器里。
第四个坑是忽略“其他进程”的干扰。如果一张卡上同时跑了多个业务,SM Active 会被平均掉。遇到这种情况,先看 GPU Context 切换次数,如果很高,优先解决共享卡问题再做单进程优化。
工具不是万能的,但它确实能把排查时间从“按天算”压缩到“按小时算”。我个人在实际使用中的体会是,零侵入的价值不只是省事,它让你敢在生产环境里抓现场,而现场往往就是 bug 最明显的时刻。建议每个团队把这种轻量采集做成例行巡检,跑在长期任务旁边留曲线,等哪天指标异常了,翻之前的历史报告,新旧对比一下,比临时抱佛脚高效得多。顺手再把“SM Active 低于 30% 且 GPU Busy 高于 80%”设成自动告警条件,下次这类问题还没浮出水面,你就能先收到信号了。