
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-gpuutilization.gpu,utilization.memory,memory.used,power.draw --formatcsv -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_workers0意思是数据加载在主进程里同步做。这时候 GPU 算完一个 batch得等 CPU 读完下一个 batch 才能继续利用率自然上不去。把num_workers调大是最直接的改善手段但调多大有讲究。经验值是num_workers 4 * num_gpu起步然后根据 CPU 核数和 IO 情况微调。调太大反而会因为进程切换和内存拷贝开销导致更慢。pin_memoryTrue这个参数也经常被忽略。它把数据放在页锁定内存里使得 CPU 到 GPU 的拷贝可以用异步方式配合non_blockingTrue从而让拷贝和计算重叠。如果你的数据量不大但拷贝频繁这个改动能带来肉眼可见的提升。还有一个隐藏的坑persistent_workersTrue。在 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 通信与计算的重叠是否生效DDPDistributedDataParallel的核心优势是梯度同步和反向计算可以重叠。但如果模型的反向传播很快梯度同步还没完成下一个 step 就得等利用率就下来了。判断方法是看 Profiler 里 allreduce 的时间占比。如果超过 20%说明通信是瓶颈。优化方向有几个一是用gradient_as_bucket_viewTrue减少内存拷贝二是调大 bucket size 让通信更连续三是用 NCCL 的NCCL_ALGO和NCCL_PROTO环境变量调优四是如果网络带宽是瓶颈考虑用梯度压缩或者分层同步。4.2 卡间负载不均的定位8 卡训练里如果只有部分卡跑满先看数据分片是否均匀。如果用了DistributedSampler一般没问题如果手动分片要检查是否有余数处理不当。另一个常见原因是模型并行切分不均比如某些层放在一张卡上计算量远大于其他层。排查方法是每张卡单独打日志记录每个 step 的计算时间和通信时间对比看哪张卡明显偏慢。也可以用torch.distributed.monitored_barrier来检测卡间同步等待。4.3 通信后端与拓扑感知NCCL 会自动选择最优的通信路径NVLink、PCIe、网络但有时候自动选择不是最优的。可以通过NCCL_DEBUGINFO看它实际用了哪条路径。如果发现走了慢路径比如该走 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参数很关键。默认的wait0, warmup1, active3, repeat1对大多数场景够用但如果你的 step 时间很短要适当调大 active 的步数否则采样太少看不出规律。另外record_shapesTrue和profile_memoryTrue会带来额外开销排查阶段可以开正式跑的时候关掉。看时间线时重点关注三个东西GPU 空隙的位置、空隙的持续时间、空隙出现的周期性。位置告诉你瓶颈在哪个阶段持续时间告诉你严重程度周期性告诉你是不是和某个固定操作相关比如每 N 步一次的 checkpoint 保存。还有一个技巧是用torch.profiler.record_function给自定义代码段打标签。比如把你的数据加载函数、损失计算、指标更新都包起来这样时间线里就能直接看到它们的耗时不用去猜。6. 一份可以照着走的排查清单前面五章讲的是原理和思路这一章给一份可以直接执行的清单。顺序很重要从便宜到贵从粗到细避免一上来就做重活。第一步五分钟粗筛用nvidia-smi --query-gpuutilization.gpu,memory.used,power.draw --formatcsv -l 1采样一分钟看显存占用是否合理太低说明 batch 小或模型小看利用率是持续低还是周期性低看功耗是否接近 TDP功耗低说明计算单元没吃满第二步检查数据供给把num_workers翻倍看利用率是否改善开pin_memoryTrue和non_blockingTrue用iostat -x 1看磁盘利用率是否饱和检查训练循环里有没有.item()、.cpu()等同步调用第三步单卡 Profiler 分析跑 10 个 step 的 profiler看时间线定位 GPU 空隙的位置和持续时间看 kernel launch 开销占比看是否有大量小算子第四步多卡通信检查开NCCL_DEBUGINFO看通信路径看 allreduce 时间占比分卡打日志看负载是否均匀检查 NUMA 绑定和网卡选择第五步针对性优化与验证每次只改一个变量改完重新采样对比记录优化前后的吞吐samples/sec而不只是利用率注意优化可能带来的副作用显存增加、精度变化提示优化是个迭代过程不要指望一次改完就到位。我一般会维护一个优化日志记录每次改了什么、效果如何、有没有副作用这样避免重复踩坑也方便回滚。最后说个心态问题。GPU 利用率低不一定是坏事——如果你的任务是访存密集型的利用率本来就不可能很高。关键是搞清楚低是不是合理的。一个简单的判断标准是看你的吞吐是否达到了硬件理论峰值的合理比例。如果算力利用率不是 nvidia-smi 那个采样值能达到 50% 以上对于大多数模型来说已经不错了。盲目追求 100% 利用率可能会为了压榨最后一点性能而引入不必要的复杂度得不偿失。