ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

GPU利用率低?从数据供给到通信同步的六层瓶颈排查指南

GPU利用率低?从数据供给到通信同步的六层瓶颈排查指南

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

1.1 为什么“利用率低”是个伪命题,得先拆开看

很多人一看nvidia-smi里 GPU-Util 只有 30%,第一反应就是“显卡没吃满,肯定是代码写得烂”。这个判断太粗糙了。GPU 利用率这个指标本身是个“瞬时采样值”,它统计的是在采样周期内,GPU 上有没有至少一个 kernel 在跑。注意,是“有没有”,不是“跑了多少”。也就是说,只要有一个 kernel 在跑,哪怕它只用了 1% 的 SM(流多处理器),这个数字也可能显示 100%。反过来,如果 GPU 在等数据、等 CPU、等锁、等通信,那它就是 0%。

所以真正要排查的不是“利用率为什么低”,而是“GPU 到底在等什么”。我把这些年踩过的坑归成六类瓶颈:数据供给瓶颈、CPU 预处理瓶颈、显存与 batch 配置瓶颈、通信同步瓶颈、kernel 本身效率瓶颈、以及环境与驱动配置瓶颈。这六类不是互斥的,很多时候是叠加的,但排查必须一个一个来,否则你改了半天可能改的是次要矛盾。

提示:排查顺序建议从“最外层”往“最内层”走。先看数据管道,再看 CPU,再看显存和 batch,最后才去动 kernel 和通信。因为越外层的问题越容易改,收益也越直接。

1.2 一张排查清单先给你,照着走不迷路

我习惯用下面这张表做第一轮快速筛查,基本上十分钟内能定位到大致方向:

现象最可能的瓶颈层第一手排查动作
GPU-Util 忽高忽低,呈锯齿状数据供给 / CPU 预处理看 DataLoader 的 num_workers 和 pin_memory
GPU-Util 长期低于 20%,但 CPU 也不高通信同步 / 锁等待看是否有分布式 all_reduce 阻塞
GPU-Util 高但显存占用很低batch 太小 / kernel 太碎尝试增大 batch,看吞吐是否线性增长
显存快满了但利用率上不去显存带宽瓶颈 / 碎片看是否频繁触发显存分配与释放
单卡正常,多卡就掉通信瓶颈对比单卡与多卡的 step time
换了机器就掉驱动 / CUDA 版本 / PCIe 带宽对比 nvidia-smi 和 nvcc 版本

这张表不是万能的,但它能帮你快速排除掉一大半“明显不是问题”的方向。下面我逐层展开讲。

2. 数据供给与 CPU 预处理:最容易被忽视的隐形杀手

2.1 DataLoader 的 num_workers 不是越大越好

我见过太多人把num_workers设成 0 或者 1,然后抱怨 GPU 利用率低。num_workers=0意味着数据加载在主进程里同步做,GPU 每跑完一个 batch 就得等 CPU 把下一个 batch 读完、解码、增强完。这时候 GPU 利用率就是典型的“锯齿状”——跑一下停一下。

但反过来,num_workers也不是越大越好。我实测过一个图像分类任务,num_workers从 4 加到 16,GPU 利用率确实上去了,但再往上加到 32,反而掉了。原因是 worker 进程之间的上下文切换和内存拷贝开销开始超过收益。一般来说,num_workers设成 CPU 物理核心数的 1 到 2 倍比较稳妥。你可以用下面这段代码快速测一下不同num_workers下的吞吐:

import time import torch from torch.utils.data import DataLoader def benchmark_workers(dataset, batch_size=64, workers_list=[0, 2, 4, 8, 16]): for nw in workers_list: loader = DataLoader(dataset, batch_size=batch_size, num_workers=nw, pin_memory=True) start = time.time() for i, batch in enumerate(loader): if i >= 50: # 只测前50个batch break elapsed = time.time() - start print(f"num_workers={nw}, 50 batches耗时: {elapsed:.2f}s, " f"吞吐: {50*batch_size/elapsed:.1f} samples/s")

跑一遍,你就能看到吞吐随num_workers的变化曲线。通常会在某个点之后趋于平缓甚至下降,那个拐点就是你的最优值。

2.2 pin_memory 和 non_blocking 的配合,很多人只做了一半

pin_memory=True的作用是把数据放到“锁页内存”里,这样 CPU 到 GPU 的拷贝可以用 DMA(直接内存访问)异步完成。但光设pin_memory=True还不够,你在把数据搬到 GPU 的时候必须加non_blocking=True,否则还是同步拷贝,等于白设。

for images, labels in dataloader: images = images.to(device, non_blocking=True) labels = labels.to(device, non_blocking=True) # 前向、反向...

这两个参数是配套使用的。我见过有人只设了pin_memory没设non_blocking,然后说“没效果”,其实是没配对。

注意:pin_memory会占用额外的锁页内存,如果主机内存紧张,开太多 worker 加 pin_memory 可能导致 OOM。建议先看free -h确认内存余量。

2.3 数据增强放在 CPU 还是 GPU,是个权衡

图像任务里,数据增强(随机裁剪、翻转、颜色抖动)如果放在 CPU 上做,worker 多了之后 CPU 会成为瓶颈。这时候可以考虑把增强搬到 GPU 上,用torchvision.transforms.v2或者kornia在 GPU 上做。但代价是 GPU 要分出一部分算力做增强,可能反而拉低训练吞吐。

我的经验是:如果增强操作简单(比如只是归一化和翻转),放 GPU 上划算;如果增强很复杂(比如 RandAugment、MixUp、CutMix),放 CPU 上让 worker 并行做更合适。你可以用torch.profiler看一下增强操作占了多少时间,再决定搬不搬。

3. 显存、batch size 与 kernel 效率:别让“小碎步”拖垮 GPU

3.1 batch size 太小,GPU 根本“热不起来”

GPU 的算力是靠大量并行线程堆出来的。batch size 太小,每个 kernel 的线程数不够,SM 占用率低,利用率自然上不去。我见过有人用 batch size=1 跑 ResNet,GPU-Util 只有 15%,还以为是显卡坏了。

但 batch size 也不是越大越好,受显存限制。这里有个实用技巧:先用一个较大的 batch size 跑,看显存占用,然后逐步增大直到接近显存上限的 80% 到 90%。留一点余量给显存碎片和临时变量。

如果你确实需要小 batch(比如某些对比学习任务),可以用梯度累积来模拟大 batch:

accumulation_steps = 4 optimizer.zero_grad() for i, (images, labels) in enumerate(dataloader): outputs = model(images) loss = criterion(outputs, labels) / accumulation_steps loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()

这样等效 batch size 是原来的 4 倍,但显存占用不变。不过要注意,梯度累积不能减少前向传播的次数,所以如果瓶颈在前向的 kernel 效率上,梯度累积帮助有限。

3.2 显存带宽瓶颈:为什么利用率高但速度上不去

有一种情况很迷惑:nvidia-smi显示 GPU-Util 90% 以上,但训练速度就是慢。这时候大概率是显存带宽瓶颈。GPU 的算力(FLOPS)和显存带宽是两回事。如果 kernel 是 memory-bound 的(比如大量的 element-wise 操作、归一化层),那算力再强也没用,数据搬不过来。

判断方法:用ncu(Nsight Compute)或者nsys(Nsight Systems)profile 一下,看 kernel 的Memory Throughput和Compute Throughput哪个先到瓶颈。如果 Memory Throughput 接近 100%,那就是带宽瓶颈,这时候增大 batch size 或者融合算子(比如用torch.compile)可能有帮助。

# 用 nsys 做一次快速 profile nsys profile -o report --force-overwrite true python train.py # 然后用 nsys-ui 打开 report.nsys-rep 看时间线

3.3 kernel 太碎:小算子堆叠的代价

PyTorch 的 eager 模式会逐个执行算子,每个算子都是一次 kernel launch。如果模型里有很多小算子(比如大量的add、mul、relu),kernel launch 的开销会累积起来。每次 launch 大概几微秒到几十微秒,算子多了之后,GPU 大量时间花在“等 launch”而不是“算”上。

解决办法有两个:一是用torch.compile做算子融合,二是手动把一些操作合并。torch.compile在 PyTorch 2.0 之后已经比较成熟了,我实测在 Transformer 类模型上能提升 20% 到 40% 的吞吐。

model = torch.compile(model, mode="max-autotune")

mode="max-autotune"会让编译器花更多时间搜索最优 kernel,适合训练时间较长、不在乎编译开销的场景。如果只是快速迭代,用默认模式就行。

4. 通信与同步:多卡训练里的“隐形墙”

4.1 单卡正常多卡掉,先查通信

多卡训练时,GPU 利用率低最常见的原因是通信等待。数据并行里,每个 step 结束后要做梯度 all_reduce,如果通信时间比计算时间长,GPU 就在等。这时候你看单卡的 GPU-Util 可能是 100%,但整体吞吐上不去。

判断方法很简单:对比单卡和多卡的 step time。如果多卡的 step time 不是接近单卡的 1/N,而是明显偏大,那通信就是瓶颈。可以用torch.distributed的barrier加计时来测:

import torch.distributed as dist import time dist.barrier() start = time.time() # 训练一个 step dist.barrier() elapsed = time.time() - start

如果通信时间占比超过 20%,就要考虑优化了。常见手段包括:用 NCCL 的all_reduce替代reduce加broadcast、开启梯度压缩、用bucket把多个小梯度合并成一次通信。

4.2 梯度累积与通信频率的权衡

梯度累积不仅能模拟大 batch,还能降低通信频率。因为每累积 N 个 step 才做一次 all_reduce,通信次数变成原来的 1/N。这在通信瓶颈明显的场景下很有效。

但要注意,梯度累积会改变优化的动态。如果用了 BatchNorm,累积多个 step 的梯度可能导致统计量不匹配。这时候可以考虑用 SyncBatchNorm,或者干脆不用 BatchNorm 改用 LayerNorm。

4.3 数据加载的分布式采样器,别让 worker 抢数据

多卡训练时,每个进程都要有自己的 DataLoader。如果没用DistributedSampler,每个进程都会读全量数据,不仅浪费 I/O,还可能导致数据重复。DistributedSampler会把数据切分到各个进程,保证每个 epoch 每个样本只被一个进程读到。

from torch.utils.data.distributed import DistributedSampler sampler = DistributedSampler(dataset, num_replicas=world_size, rank=rank) dataloader = DataLoader(dataset, batch_size=batch_size, sampler=sampler)

记得在每个 epoch 开始前调用sampler.set_epoch(epoch),否则每个 epoch 的切分方式都一样,可能影响训练效果。

5. 环境、驱动与配置:那些“换了机器就掉”的坑

5.1 驱动版本与 CUDA 版本的匹配,别只看 nvidia-smi

nvidia-smi右上角显示的 CUDA 版本是驱动支持的最高CUDA 版本,不是你实际用的版本。实际用的版本要看nvcc --version或者torch.version.cuda。如果 PyTorch 编译时用的 CUDA 版本和驱动不匹配,可能跑在兼容模式上,性能会打折。

nvidia-smi # 看驱动版本和最高支持的CUDA版本 nvcc --version # 看实际安装的CUDA版本 python -c "import torch; print(torch.version.cuda)" # 看PyTorch用的CUDA版本

这三个版本最好一致,或者至少 PyTorch 的 CUDA 版本不高于驱动支持的版本。我遇到过驱动太老导致torch.compile无法启用某些优化的情况,升级驱动后吞吐直接涨了 15%。

5.2 PCIe 带宽与 GPU 拓扑,多卡时影响很大

多卡训练时,GPU 之间的通信走 PCIe 还是 NVLink,带宽差很多。PCIe 4.0 x16 的理论带宽是 32 GB/s,NVLink 能到 300 GB/s 以上。如果多卡通信瓶颈明显,先确认一下 GPU 之间的拓扑:

nvidia-smi topo -m

输出里会显示 GPU 之间的连接方式。如果是SYS(走系统总线)或者PHB(走 PCIe 桥),那通信带宽就是瓶颈。这时候可以考虑用NCCL_P2P_DISABLE=1强制走 PCIe,有时候反而比不稳定的 P2P 更快。

5.3 电源管理与频率限制,笔记本 GPU 尤其要注意

笔记本上的 GPU 受功耗墙和温度墙限制,长时间训练可能降频。我见过有人用 RTX 4060 Laptop 跑训练,前几分钟 GPU-Util 很高,后面就掉到 50% 以下,一看温度 90 度以上,频率从 2.5 GHz 降到 1.2 GHz。

# 查看当前GPU频率和温度 nvidia-smi -q -d CLOCK,TEMPERATURE

如果是笔记本,确保插电、散热良好、电源模式设为“高性能”。台式机的话,检查一下是否被nvidia-smi -pl限制了功耗。

6. 常见问题速查与排查技巧实录

6.1 六个瓶颈的快速定位表

瓶颈类型典型现象快速验证方法优先尝试的解决手段
数据供给GPU-Util 锯齿状,CPU 某核跑满增大 num_workers 看吞吐调 num_workers、pin_memory、non_blocking
CPU 预处理CPU 总占用高,GPU 等数据用 cProfile 看预处理耗时简化增强、搬到 GPU、用 DALI
显存/batch显存占用低,利用率低增大 batch 看吞吐是否线性增大 batch、梯度累积
通信同步单卡正常多卡掉对比单卡多卡 step time梯度累积、NCCL 调优、拓扑检查
kernel 效率利用率高但速度慢ncu 看 Memory/Compute Throughputtorch.compile、算子融合
环境配置换机器就掉对比驱动、CUDA、PCIe 拓扑统一版本、检查功耗温度

6.2 几个我踩过的坑

坑一:num_workers 设太大导致内存爆了。有一次我把num_workers设成 32,每个 worker 都开了一份数据集副本,主机内存直接 OOM。后来改成用persistent_workers=True加较小的 worker 数,问题解决。

坑二:pin_memory 和 non_blocking 没配对。这个前面说过了,但真的很多人犯。记住:pin_memory=True是前提,non_blocking=True是执行。

坑三:多卡训练忘了 set_epoch。结果每个 epoch 的数据切分都一样,模型在同一个子集上过拟合,验证集指标上不去。这个坑很隐蔽,因为 loss 看起来在降,但泛化很差。

坑四:torch.compile 第一次编译太慢,以为没效果。torch.compile第一次跑会花几十秒到几分钟编译,之后才快。如果你只跑几个 step 就下结论,会误判。建议跑够 100 个 step 再看吞吐。

坑五:笔记本 GPU 降频。这个前面也说了,但值得再强调:笔记本训练一定要监控温度和频率,必要时用nvidia-smi -lgc锁频,或者外接散热。

6.3 一个实用的排查脚本

我把常用的排查命令整理成一个脚本,每次遇到利用率低先跑一遍:

#!/bin/bash echo "=== GPU 状态 ===" nvidia-smi echo "=== GPU 拓扑 ===" nvidia-smi topo -m echo "=== CUDA 版本 ===" nvcc --version python -c "import torch; print('PyTorch CUDA:', torch.version.cuda)" echo "=== CPU 信息 ===" lscpu | grep -E "Model name|Core|Socket" echo "=== 内存 ===" free -h echo "=== 磁盘 I/O ===" iostat -x 1 3

这个脚本能帮你快速收集环境信息,很多时候问题就藏在这些输出里。

7. 最后再分享几个实操心得

排查 GPU 利用率低这件事,最忌讳的就是“凭感觉改”。我见过有人一上来就把 batch size 翻倍,结果显存爆了;也有人直接把num_workers拉到 64,结果 CPU 上下文切换开销比省下来的时间还多。正确的做法是:先测量,再定位,最后才改。

测量工具方面,nvidia-smi只是最粗粒度的,真正有用的是nsys和ncu。nsys看时间线,能告诉你 GPU 在等什么;ncu看 kernel 细节,能告诉你 kernel 本身效率如何。这两个工具花点时间学一下,收益远超你的投入。

另外,别忽视“非技术”因素。我遇到过好几次利用率低是因为机器上还有别人在跑任务,GPU 被分时复用了。还有一次是因为数据盘是网络存储,I/O 延迟高得离谱。这些用nvidia-smi看不出来,但用iostat和top一看就明白。

最后说一个反直觉的点:有时候 GPU 利用率低是正常的。比如在强化学习里,环境交互的时间远大于 GPU 计算时间;在推荐系统里,Embedding 查表是 memory-bound 的,利用率天然上不去。这时候你要优化的不是利用率,而是端到端的吞吐。别为了追求一个好看的利用率数字,把真正重要的指标丢了。

返回列表