GPU利用率低怎么排查?从数据供给到多卡通信的完整优化指南

1. 先搞清楚"利用率低"到底低在哪一层

很多人一看到nvidia-smi里 GPU 利用率只有 30%,第一反应是"显卡没吃满,是不是代码写得不行"。这个判断太粗糙了。GPU 利用率这个指标本身是个"采样占比"——它统计的是在一个采样周期内,GPU 上是否有 kernel 在跑,而不是"算力被榨干了多少"。换句话说,一个只用了 1% 计算单元但一直在跑 kernel 的任务,利用率照样能显示 90%+。所以排查的第一步,不是盯着那个百分比,而是先分清你遇到的是哪一类"低"。

我一般把"利用率低"拆成三种完全不同的现象。第一种是利用率数值低且抖动大,比如在 0% 和 80% 之间反复横跳,这通常意味着 GPU 在等数据,瓶颈在数据供给链路。第二种是利用率数值高但吞吐上不去,比如稳定在 95% 但每秒处理的样本数远低于预期,这往往是 kernel 本身效率低或者算子实现有问题。第三种是多卡场景下卡间利用率严重不均,比如 8 卡里 2 卡跑满、6 卡闲着,这基本是并行策略或数据分片的问题。

这三种现象的排查路径完全不同。如果你一上来就去看 profiler,很可能被海量的 kernel 时间线淹没,最后什么结论都得不出。我的习惯是先做一个"五分钟粗筛":用nvidia-smi dmon或者nvidia-smi --query-gpu=utilization.gpu,utilization.memory,memory.used,power.draw --format=csv -l 1连续采样一分钟,把数据存下来看趋势。如果显存占用很低(比如 24G 卡只用了 6G),那大概率是 batch size 太小或者模型本身就不大,GPU 根本没被喂饱;如果显存占满了但利用率低,那更可能是数据加载或者 CPU 预处理拖了后腿。

这里有个容易被忽略的点:nvidia-smi的利用率采样周期和你的训练 step 时间可能不在一个量级。一个 step 如果只要 50ms,而采样是 1 秒一次,那你看到的利用率其实是这一秒内"有没有 kernel 在跑"的粗略平均,很多细节被抹掉了。所以粗筛之后,如果趋势不明显,就得用更细粒度的工具,比如nvidia-smi pmon看进程级,或者直接上 PyTorch Profiler 抓时间线。

提示:不要用watch -n 0.1 nvidia-smi这种高频刷新去看,刷新本身会带来额外开销,而且人眼根本跟不上。老老实实采样存文件,用脚本分析趋势。

我见过太多团队在排查时直接跳到"优化模型结构",结果折腾两周发现是 DataLoader 的num_workers设成了 0。所以这一层的核心任务是:先定性,再定量,最后才定位。定性靠粗筛,定量靠采样数据,定位才需要 profiler。顺序错了,时间全浪费。

2. 数据供给链路:最容易被低估的头号瓶颈

数据供给是 GPU 利用率低最常见的原因,没有之一。道理很简单:GPU 算得再快,如果数据在 CPU 那边还没准备好,它就只能干等。这个等待在时间线上表现为 GPU 空闲,在利用率上就是数值上不去。但"数据供给慢"这个说法太笼统了,它至少包含四个子环节,每个环节的排查方法和优化手段都不一样。

2.1 DataLoader 的 worker 数量与预取机制

PyTorch 的DataLoader默认num_workers=0,意思是数据加载在主进程里同步做。这时候 GPU 算完一个 batch,得等 CPU 读完下一个 batch 才能继续,利用率自然上不去。把num_workers调大是最直接的改善手段,但调多大有讲究。经验值是num_workers = 4 * num_gpu起步,然后根据 CPU 核数和 IO 情况微调。调太大反而会因为进程切换和内存拷贝开销导致更慢。

pin_memory=True这个参数也经常被忽略。它把数据放在页锁定内存里,使得 CPU 到 GPU 的拷贝可以用异步方式(配合non_blocking=True),从而让拷贝和计算重叠。如果你的数据量不大但拷贝频繁,这个改动能带来肉眼可见的提升。

还有一个隐藏的坑:persistent_workers=True。在 PyTorch 1.7 之前,每个 epoch 结束 DataLoader 会销毁再重建 worker 进程,这个开销在小数据集上可能占掉不少时间。开启持久化 worker 可以避免反复创建销毁。

2.2 磁盘 IO 与数据格式的匹配度

如果你的数据集是几万张小图片(比如 ImageNet 那种),每个 batch 都要从磁盘随机读几百个文件,那 IO 就是瓶颈。这时候单靠加 worker 治标不治本,得从数据格式入手。常见做法是把散碎文件打包成连续的大文件,比如 LMDB、WebDataset 的 tar 分片、或者 HDF5。连续读比随机读快一个数量级,这不是玄学,是磁盘寻道时间的物理限制。

我做过一个对比测试:同样 5 万张 224x224 的图片,散文件随机读的吞吐大概是 800 张/秒,打包成 tar 分片后能到 6000 张/秒以上。这个差距直接决定了你的 GPU 是跑在 30% 还是 90%。

2.3 CPU 预处理的计算开销

数据增强(augmentation)如果写得重,比如用 PIL 做复杂的几何变换、或者用 numpy 做大量矩阵运算,CPU 会成为瓶颈。这时候可以考虑把部分增强搬到 GPU 上做,用 DALI 或者 torchvision 的 GPU 版本算子。但要注意,GPU 增强会占用显存和算力,得权衡。

另一个思路是预计算。如果某些增强是确定性的(比如固定尺寸的 resize),可以在数据准备阶段离线做完,训练时只做随机性强的部分。这样能大幅降低 CPU 负担。

2.4 数据加载与计算的 overlap 是否真正生效

即使上面都做对了,如果代码里不小心加了同步点,overlap 也会失效。比如在训练循环里调用了.item()或者.cpu()去取 loss 值打印,这会强制同步,打断流水线。正确的做法是用loss.detach()累积到列表里,每隔 N 步再统一取出来。

排查这一层最有效的工具是 PyTorch Profiler 的record_function标注,把数据加载、前向、反向、优化器更新分别打上标签,看时间线里 GPU 的空隙出现在哪个阶段。如果空隙出现在前向之前,那就是数据供给问题;如果出现在反向之后,可能是优化器或者梯度同步的问题。

子环节典型症状快速验证方法优化方向
worker 不足GPU 周期性空闲把 num_workers 翻倍看是否改善调大 worker + pin_memory
磁盘 IO 慢空闲与 IO 等待相关iostat 看磁盘利用率数据打包成连续格式
CPU 增强重CPU 占用满top 看 CPU 使用率GPU 增强或预计算
overlap 失效时间线有同步点Profiler 看空隙位置去掉循环内同步调用

3. 计算图与算子层面的隐性浪费

数据供给没问题了,GPU 利用率还是上不去,那就要往计算图内部看。这一层的瓶颈往往更隐蔽,因为它不表现为"GPU 闲着",而是"GPU 在忙但忙得没效率"。典型表现是利用率数值不低,但吞吐就是上不去,或者显存占用异常高。

3.1 小算子过多导致的 kernel launch 开销

深度学习模型里如果充斥着大量小算子(比如逐元素的加法、激活函数、归一化),每个算子都要单独启动一个 kernel。kernel launch 本身有开销(几微秒到几十微秒),如果算子计算量很小,launch 开销占比就会很高。这时候 GPU 看起来在忙,实际上大部分时间花在调度上。

解决办法是算子融合。PyTorch 2.0 的torch.compile能自动做很多融合,实测在 Transformer 类模型上能带来 20%-40% 的加速。如果不想用 compile,也可以手动用torch.jit.script或者把多个小操作合并成一个自定义 kernel。

3.2 显存带宽与计算强度的平衡

有些算子(比如大矩阵乘)是计算密集型的,GPU 算力是瓶颈;有些算子(比如 embedding lookup、逐元素操作)是访存密集型的,显存带宽是瓶颈。如果一个模型里访存密集型算子占比过高,GPU 的计算单元就会大量空闲,利用率上不去。

判断方法是看算子的 arithmetic intensity(计算量/访存量)。如果低于 GPU 的 roofline 拐点,那就是带宽瓶颈。优化方向是提高数据复用率,比如把多个小矩阵乘合并成大矩阵乘,或者用更高效的数据布局。

3.3 混合精度与算子选择

FP16/BF16 混合精度不仅能省显存,还能提升吞吐,因为很多 GPU 的半精度算力是单精度的 2-8 倍。但如果代码里没有正确使用 autocast,或者某些算子不支持半精度而被迫回退到 FP32,就会出现"部分加速"的情况,利用率提升不明显。

排查方法是看 Profiler 里各算子的 dtype 分布。如果发现大量 FP32 算子在混合精度训练里出现,就要检查是不是某些自定义算子没有实现半精度版本。

3.4 梯度累积与 batch size 的权衡

batch size 太小会导致每个 step 的计算量不足以掩盖 kernel launch 和通信开销,利用率自然低。但显存有限,不能无限加大 batch。这时候可以用梯度累积:跑多个小 batch,累积梯度后再更新。这样既保持了大的有效 batch size,又不爆显存。但要注意,梯度累积不减少 kernel launch 次数,只是减少了优化器更新次数,所以对利用率低的问题只能部分缓解。

注意:梯度累积时如果用了 BatchNorm,统计量是按小 batch 算的,可能和真实大 batch 有差异。这时候要考虑用 SyncBatchNorm 或者 GroupNorm 替代。

4. 多卡并行与通信开销的排查

单卡利用率正常,多卡就拉胯,这是分布式训练里最经典的问题。根因通常是通信开销吃掉了计算时间,或者并行策略导致负载不均。

4.1 通信与计算的重叠是否生效

DDP(DistributedDataParallel)的核心优势是梯度同步和反向计算可以重叠。但如果模型的反向传播很快,梯度同步还没完成,下一个 step 就得等,利用率就下来了。判断方法是看 Profiler 里 allreduce 的时间占比。如果超过 20%,说明通信是瓶颈。

优化方向有几个:一是用gradient_as_bucket_view=True减少内存拷贝;二是调大 bucket size 让通信更连续;三是用 NCCL 的NCCL_ALGO和NCCL_PROTO环境变量调优;四是如果网络带宽是瓶颈,考虑用梯度压缩或者分层同步。

4.2 卡间负载不均的定位

8 卡训练里如果只有部分卡跑满,先看数据分片是否均匀。如果用了DistributedSampler,一般没问题;如果手动分片,要检查是否有余数处理不当。另一个常见原因是模型并行切分不均,比如某些层放在一张卡上,计算量远大于其他层。

排查方法是每张卡单独打日志,记录每个 step 的计算时间和通信时间,对比看哪张卡明显偏慢。也可以用torch.distributed.monitored_barrier来检测卡间同步等待。

4.3 通信后端与拓扑感知

NCCL 会自动选择最优的通信路径(NVLink、PCIe、网络),但有时候自动选择不是最优的。可以通过NCCL_DEBUG=INFO看它实际用了哪条路径。如果发现走了慢路径(比如该走 NVLink 却走了 PCIe),可能是拓扑检测有问题,需要手动设置NCCL_TOPO_FILE或者调整NCCL_P2P_LEVEL。

另外,网卡绑定也很关键。多机训练时如果网卡和 GPU 不在同一个 NUMA 节点,通信延迟会明显增加。用numactl --hardware看拓扑,然后用CUDA_VISIBLE_DEVICES和NCCL_SOCKET_IFNAME做绑定。

并行问题症状排查工具优化手段
通信未重叠allreduce 占比高Profiler + NCCL_DEBUGbucket 调优、梯度压缩
负载不均部分卡利用率低分卡日志重新分片、调整并行策略
拓扑不优通信延迟高NCCL_DEBUG + numactl手动指定路径、NUMA 绑定
同步点过多周期性卡顿monitored_barrier减少不必要的同步

5. 用 Profiler 把时间线读成"故事"

前面讲的都是"可能的原因",真正定位还得靠 profiler。但很多人打开 TensorBoard 的 Profiler 页面就懵了——密密麻麻的色块,不知道从哪看起。我的经验是:不要试图看懂全部,带着假设去看。

比如你怀疑是数据加载慢,那就只看每个 step 开头到第一个 GPU kernel 之间的空隙。如果这个空隙很大,假设成立。如果空隙很小但 step 中间有大量小空隙,那可能是 kernel launch 或者通信问题。带着假设去看,时间线就从"天书"变成了"证据"。

具体操作上,PyTorch Profiler 的schedule参数很关键。默认的wait=0, warmup=1, active=3, repeat=1对大多数场景够用,但如果你的 step 时间很短,要适当调大 active 的步数,否则采样太少看不出规律。另外record_shapes=True和profile_memory=True会带来额外开销,排查阶段可以开,正式跑的时候关掉。

看时间线时重点关注三个东西:GPU 空隙的位置、空隙的持续时间、空隙出现的周期性。位置告诉你瓶颈在哪个阶段,持续时间告诉你严重程度,周期性告诉你是不是和某个固定操作相关(比如每 N 步一次的 checkpoint 保存)。

还有一个技巧是用torch.profiler.record_function给自定义代码段打标签。比如把你的数据加载函数、损失计算、指标更新都包起来,这样时间线里就能直接看到它们的耗时,不用去猜。

6. 一份可以照着走的排查清单

前面五章讲的是原理和思路,这一章给一份可以直接执行的清单。顺序很重要,从便宜到贵,从粗到细,避免一上来就做重活。

第一步:五分钟粗筛

  • 用nvidia-smi --query-gpu=utilization.gpu,memory.used,power.draw --format=csv -l 1采样一分钟
  • 看显存占用是否合理(太低说明 batch 小或模型小)
  • 看利用率是持续低还是周期性低
  • 看功耗是否接近 TDP(功耗低说明计算单元没吃满)

第二步:检查数据供给

  • 把num_workers翻倍,看利用率是否改善
  • 开pin_memory=True和non_blocking=True
  • 用iostat -x 1看磁盘利用率是否饱和
  • 检查训练循环里有没有.item()、.cpu()等同步调用

第三步:单卡 Profiler 分析

  • 跑 10 个 step 的 profiler,看时间线
  • 定位 GPU 空隙的位置和持续时间
  • 看 kernel launch 开销占比
  • 看是否有大量小算子

第四步:多卡通信检查

  • 开NCCL_DEBUG=INFO看通信路径
  • 看 allreduce 时间占比
  • 分卡打日志看负载是否均匀
  • 检查 NUMA 绑定和网卡选择

第五步:针对性优化与验证

  • 每次只改一个变量,改完重新采样对比
  • 记录优化前后的吞吐(samples/sec)而不只是利用率
  • 注意优化可能带来的副作用(显存增加、精度变化)

提示:优化是个迭代过程,不要指望一次改完就到位。我一般会维护一个"优化日志",记录每次改了什么、效果如何、有没有副作用,这样避免重复踩坑,也方便回滚。

最后说个心态问题。GPU 利用率低不一定是坏事——如果你的任务是访存密集型的,利用率本来就不可能很高。关键是搞清楚"低"是不是合理的。一个简单的判断标准是:看你的吞吐是否达到了硬件理论峰值的合理比例。如果算力利用率(不是 nvidia-smi 那个采样值)能达到 50% 以上,对于大多数模型来说已经不错了。盲目追求 100% 利用率,可能会为了压榨最后一点性能而引入不必要的复杂度,得不偿失。