1. 先搞清楚GPU利用率低到底意味着什么很多人一看到nvidia-smi里 GPU 利用率只有 30% 甚至个位数第一反应就是“显卡没吃满肯定是代码写得不行”。这个判断不能说错但太粗糙了。GPU 利用率这个指标本身就有坑它统计的是“在采样周期内GPU 上是否有至少一个 kernel 在跑”而不是“计算单元有多忙”。换句话说只要有一个极小的 kernel 在跑利用率也可能显示 100%反过来如果 GPU 在等数据利用率就是 0。我踩过最典型的一个坑模型训练时nvidia-smi显示利用率在 0% 和 100% 之间反复横跳平均下来大概 40%。一开始以为是模型太小后来用torch.profiler一抓才发现GPU 有超过一半的时间在等 DataLoader 把数据搬过来。所以排查 GPU 利用率低第一步不是优化模型而是先定位瓶颈到底在哪一环。这篇文章适合谁看如果你正在跑 AI 训练任务发现 GPU 利用率上不去、训练速度比预期慢很多或者你刚接手一个训练脚本不知道从哪里开始调优那这篇排查清单可以直接拿去用。我会按“从外到内、从易到难”的顺序把 6 个最常见的瓶颈逐一拆开每个都给出定位方法和实操命令。提示排查之前先确认一件事——你的 GPU 到底有没有被用上。有些环境里 PyTorch 装的是 CPU 版本或者 CUDA 版本和驱动不匹配GPU 根本没参与计算这种情况利用率永远是 0跟优化无关。2. 瓶颈一数据管道拖后腿GPU 在等饭吃2.1 为什么数据加载最容易成为第一个瓶颈训练一个模型GPU 做前向和反向传播的速度往往远快于 CPU 从磁盘读数据、做预处理、再搬到显存的速度。尤其是图像、视频、点云这类任务数据增强操作随机裁剪、旋转、色彩抖动全在 CPU 上做CPU 核心数不够或者预处理太重GPU 就只能干等。判断方法很简单在训练循环里打印每个 step 的耗时同时用nvidia-smi dmon观察 GPU 利用率。如果 GPU 利用率呈现“锯齿状”——一会儿 0% 一会儿 90%——基本可以确定是数据管道的问题。# 实时监控 GPU 利用率、显存、功耗 nvidia-smi dmon -s pucvmet -d 1另一个更精确的办法是用 PyTorch 的 profilerimport torch from torch.profiler import profile, ProfilerActivity with profile(activities[ProfilerActivity.CPU, ProfilerActivity.CUDA]) as prof: for i, (data, target) in enumerate(train_loader): if i 10: break data, target data.cuda(), target.cuda() output model(data) loss criterion(output, target) loss.backward() optimizer.step() optimizer.zero_grad() print(prof.key_averages().table(sort_bycuda_time_total, row_limit20))如果 profiler 输出里cudaLaunchKernel的间隔很大而 CPU 侧的enumerate(DataLoader)耗时很长那瓶颈就在数据加载。2.2 数据管道的调优清单调数据管道有几个立竿见影的手段我按优先级排一下提高num_workersPyTorch DataLoader 的num_workers默认是 0意味着数据加载在主进程里同步做GPU 必然等。一般设成 CPU 物理核心数的 2 到 4 倍。比如 8 核 CPU可以设 16 或 32。但也不是越大越好worker 太多会导致进程间通信开销上升反而变慢。开启pin_memoryTrue这个参数让 DataLoader 把数据放到锁页内存里从 CPU 到 GPU 的拷贝可以用异步方式减少等待。配合non_blockingTrue使用效果更好。使用persistent_workersTrue每个 epoch 结束后不销毁 worker 进程避免反复创建进程的开销。这个参数在 PyTorch 1.7 之后才有。把预处理搬到 GPU 上如果数据增强操作可以用 GPU 实现比如用 DALI、Kornia 或者自己写 CUDA kernel就把这部分从 CPU 卸载到 GPU。这样 CPU 只负责读原始数据GPU 同时做增强和训练。换更快的存储如果数据集在机械硬盘上随机读取会成为瓶颈。换 SSD 或者把数据预加载到内存盘tmpfs里效果非常明显。from torch.utils.data import DataLoader train_loader DataLoader( dataset, batch_size64, shuffleTrue, num_workers16, pin_memoryTrue, persistent_workersTrue, prefetch_factor4, # 每个 worker 预取 4 个 batch drop_lastTrue )注意num_workers调大之后如果每个 worker 都做很重的数据增强CPU 内存占用会飙升。建议先用htop观察内存和 CPU 使用率找到平衡点。另外Windows 上num_workers大于 0 时如果代码没有放在if __name__ __main__:保护块里会报错。2.3 一个真实案例的排查过程我之前跑一个图像分类任务ResNet-50batch size 256GPU 是单卡 3090。nvidia-smi显示利用率只有 35% 左右。按上面的清单逐步排查先把num_workers从 4 调到 16利用率涨到 55%。加上pin_memoryTrue和persistent_workersTrue涨到 68%。用torch.profiler发现数据增强里的RandomRotation和ColorJitter耗时最长换成 Kornia 的 GPU 版本后利用率稳定在 85% 以上。最后把数据集从机械硬盘移到 NVMe SSD利用率到了 92%。整个过程没有改模型结构也没有换 GPU纯粹是数据管道的问题。所以遇到利用率低先别急着怀疑模型把数据管道捋一遍往往就能解决大半。3. 瓶颈二CPU 与 GPU 之间的同步操作太频繁3.1 哪些操作会强制同步PyTorch 是异步执行的CPU 把 kernel 发射到 GPU 的流里就返回了不会等 GPU 算完。但有些操作会强制 CPU 等待 GPU 完成这叫“同步点”。同步点太多GPU 就会频繁空转。常见的同步操作包括loss.item()或loss.cpu().numpy()把 GPU 上的标量取回 CPU必须等 GPU 算完。print(loss)如果 loss 是 GPU tensor打印时会触发同步。torch.cuda.synchronize()显式同步一般用于计时。在训练循环里做if loss threshold这种判断需要把 loss 取回 CPU 比较。频繁调用nvidia-smi或其他查询 GPU 状态的命令这些命令会短暂占用 GPU影响训练。我见过最夸张的一个脚本每个 step 都打印 loss而且打印的是 GPU tensor 本身导致每个 step 都同步一次。GPU 利用率直接掉到 20% 以下。把 loss 累积到一定步数再打印利用率立刻回到 80%。3.2 减少同步的实操方法累积 loss 再打印不要每个 step 都loss.item()可以每 100 个 step 取一次平均值。用torch.cuda.Stream做异步拷贝如果确实需要把数据从 GPU 取回 CPU可以用单独的流来做避免阻塞计算流。避免在训练循环里做 GPU 到 CPU 的频繁拷贝比如把预测结果每个 step 都存到 CPU 列表里可以改成先在 GPU 上累积最后一次性拷贝。用torch.cuda.set_sync_debug_mode排查PyTorch 提供了同步调试模式可以打印出哪些操作触发了同步。import torch # 开启同步调试会打印触发同步的调用栈 torch.cuda.set_sync_debug_mode(warn) # 训练循环里这样写避免每个 step 同步 running_loss 0.0 for i, (data, target) in enumerate(train_loader): data, target data.cuda(), target.cuda() output model(data) loss criterion(output, target) loss.backward() optimizer.step() optimizer.zero_grad() running_loss loss.detach() # detach 避免计算图累积 if i % 100 0: avg_loss running_loss.item() / 100 print(fStep {i}, Loss: {avg_loss:.4f}) running_loss 0.0提示loss.detach()很重要。如果不 detachrunning_loss会一直保留计算图导致显存泄漏。detach 之后只保留数值不保留梯度信息。3.3 用 CUDA Event 做精确计时如果你想知道 GPU 到底花了多少时间在计算上用torch.cuda.Event比time.time()准确得多。time.time()测的是 CPU 时间包含同步等待CUDA Event 测的是 GPU 流上的时间不受 CPU 影响。start_event torch.cuda.Event(enable_timingTrue) end_event torch.cuda.Event(enable_timingTrue) start_event.record() # 这里放要计时的 GPU 操作 output model(data) loss criterion(output, target) loss.backward() optimizer.step() end_event.record() torch.cuda.synchronize() # 等待 event 记录完成 elapsed_time_ms start_event.elapsed_time(end_event) print(fGPU time: {elapsed_time_ms:.2f} ms)如果 CUDA Event 测出来的时间和time.time()测出来的差距很大说明同步等待很多需要检查上面的同步点。4. 瓶颈三显存不够导致的隐式降速4.1 显存不足的几种表现显存不够不一定直接报 OOMOut of Memory有时候会表现为训练变慢、GPU 利用率上不去。原因有几个显存碎片化频繁申请和释放不同大小的显存块导致虽然有足够的总空闲显存但没有连续的大块可用PyTorch 会反复做垃圾回收拖慢速度。显存交换某些框架在显存不够时会把部分数据换到主存虽然不报错但速度极慢。Batch size 被迫调小为了不 OOM把 batch size 设得很小导致 GPU 并行度不够利用率上不去。判断显存是否成为瓶颈用nvidia-smi看显存占用。如果显存占用接近显卡总显存比如 24G 卡用到 23G那就要注意了。# 每秒刷新一次观察显存变化 nvidia-smi -l 14.2 显存优化的实操手段梯度累积显存不够时不要减小 batch size而是用梯度累积。比如想要 batch size 256 但显存只够 64就做 4 次前向反向累积梯度后再更新一次参数。这样等效 batch size 是 256但显存占用只有 64 的水平。accumulation_steps 4 optimizer.zero_grad() for i, (data, target) in enumerate(train_loader): data, target data.cuda(), target.cuda() output model(data) loss criterion(output, target) / accumulation_steps loss.backward() if (i 1) % accumulation_steps 0: optimizer.step() optimizer.zero_grad()混合精度训练AMP用torch.cuda.amp把部分计算转成 float16显存占用可以减少 30% 到 50%同时计算速度也能提升。现在 PyTorch 的 AMP 已经很成熟基本是标配。from torch.cuda.amp import autocast, GradScaler scaler GradScaler() for data, target in train_loader: data, target data.cuda(), target.cuda() optimizer.zero_grad() with autocast(): output model(data) loss criterion(output, target) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()梯度检查点Gradient Checkpointing用计算换显存。前向传播时不保存中间激活值反向传播时重新计算。显存占用能降很多但计算量增加约 30%。适合显存极度紧张的情况。from torch.utils.checkpoint import checkpoint # 在模型的前向传播里用 checkpoint 包裹某些层 def forward(self, x): x checkpoint(self.layer1, x) x checkpoint(self.layer2, x) return x及时释放无用显存del掉不用的 tensor调用torch.cuda.empty_cache()。但注意empty_cache()本身有开销不要在每个 step 都调用。注意torch.cuda.empty_cache()只是把 PyTorch 缓存里没用的显存还给系统不会释放还在用的显存。频繁调用会导致 PyTorch 反复向系统申请显存反而变慢。一般只在 epoch 结束时调用一次。4.3 显存碎片化的排查如果怀疑是显存碎片化可以用torch.cuda.memory_summary()查看显存分配情况print(torch.cuda.memory_summary(deviceNone, abbreviatedFalse))输出里会显示 reserved、allocated、free 等指标。如果 reserved 远大于 allocated说明缓存了很多没用的显存可以调empty_cache()。如果 allocated 接近 reserved 但还有很多 free 块可能是碎片化。PyTorch 1.10 之后引入了PYTORCH_CUDA_ALLOC_CONF环境变量可以调整显存分配策略export PYTORCH_CUDA_ALLOC_CONFgarbage_collection_threshold:0.8,max_split_size_mb:128garbage_collection_threshold控制什么时候触发垃圾回收max_split_size_mb控制最大分割块大小调小可以减少碎片。5. 瓶颈四模型本身的计算效率问题5.1 小算子太多导致 kernel 发射开销大GPU 执行一个 kernel 有固定的发射开销大概几微秒到几十微秒。如果模型里有很多小算子比如逐元素加法、激活函数、归一化每个算子单独发射一个 kernel发射开销可能比计算本身还大。这种情况下 GPU 利用率看起来不低但实际算力没用在刀刃上。判断方法用torch.profiler看 kernel 数量和平均耗时。如果 kernel 数量极多但每个耗时很短就是小算子问题。解决办法算子融合用torch.jit.script或torch.compilePyTorch 2.0把多个小算子融合成一个大 kernel。torch.compile对 Transformer 类模型的加速效果尤其明显。用融合好的算子库比如用apex的 fused optimizer或者用flash-attention替代标准的 attention 实现。减少不必要的算子比如在推理时去掉 dropout、去掉训练专用的归一化等。# PyTorch 2.0 的 torch.compile一行代码加速 model torch.compile(model, modemax-autotune)modemax-autotune会让 PyTorch 花更多时间编译但生成的 kernel 更快。如果不想等太久可以用modereduce-overhead。5.2 模型并行度不够如果模型太小或者 batch size 太小GPU 的并行计算单元用不满利用率自然上不去。比如用一个 3090 跑 MNIST 分类模型只有几层卷积batch size 32GPU 利用率可能只有 10%。这种情况不是代码问题而是任务本身不适合大 GPU。解决办法增大 batch size在显存允许的前提下尽量加大。用更大的模型如果任务允许换更深的网络。多任务并行在同一张 GPU 上同时跑多个训练任务提高利用率。但要注意显存和计算资源的竞争。换小 GPU如果任务确实很小用大 GPU 是浪费换一张小卡反而更划算。5.3 检查是否有 CPU 上的计算有时候模型的一部分被意外放在了 CPU 上导致 GPU 等 CPU。比如# 错误写法新建的 tensor 默认在 CPU 上 new_tensor torch.zeros(10, 10) # 在 CPU 上 result gpu_tensor new_tensor # 会报错或隐式拷贝 # 正确写法显式指定 device new_tensor torch.zeros(10, 10, devicegpu_tensor.device)排查方法在模型 forward 里打印每个 tensor 的 device或者用torch.profiler看是否有 CPU 上的算子。# 检查模型参数是否都在 GPU 上 for name, param in model.named_parameters(): if not param.is_cuda: print(fParameter {name} is on CPU!)6. 瓶颈五多卡训练中的通信开销6.1 多卡训练为什么利用率反而低单卡利用率正常多卡之后利用率下降大概率是卡间通信成了瓶颈。数据并行DDP需要在每次反向传播后做梯度 all-reduce如果卡间带宽不够比如用 PCIe 而不是 NVLink通信时间可能超过计算时间。判断方法用nccl-tests测卡间带宽或者用torch.profiler看ncclAllReduce的耗时占比。如果 all-reduce 占了 step 时间的 30% 以上就是通信瓶颈。# 测试 NCCL 带宽需要安装 nccl-tests ./build/all_reduce_perf -b 8 -e 128M -f 2 -g 86.2 减少通信开销的手段梯度累积累积多个 step 再 all-reduce减少通信频率。比如每 4 个 step 同步一次梯度。用torch.distributed.algorithms.ddp_comm_hooksPyTorch 提供了通信钩子可以做梯度压缩、量化等减少通信量。检查是否用了 NVLinknvidia-smi topo -m可以看卡间连接方式。如果是SYS说明走 PCIe带宽有限如果是NVLink说明走高速互联。调整bucket_cap_mbDDP 默认把梯度分成 25MB 的桶来 all-reduce调大桶可以减少通信次数但会增加显存占用。import torch.distributed as dist from torch.nn.parallel import DistributedDataParallel as DDP # 调整 bucket 大小 model DDP(model, device_ids[local_rank], bucket_cap_mb100)提示多卡训练时如果每张卡的 batch size 太小通信开销占比会更高。建议每张卡的 batch size 至少 16最好 32 以上。6.3 数据并行 vs 模型并行的选择如果模型太大单卡放不下就需要模型并行。但模型并行的通信开销通常比数据并行更大因为每层的前向反向都需要通信。选择哪种并行策略取决于模型大小和卡间带宽场景推荐策略原因模型能单卡放下想加速数据并行DDP通信只在梯度同步时发生开销小模型单卡放不下模型并行 数据并行必须拆分模型但尽量用数据并行提高吞吐卡间带宽高NVLink数据并行通信快数据并行效率高卡间带宽低PCIe梯度累积 数据并行减少通信频率7. 瓶颈六环境与驱动层面的隐藏问题7.1 驱动和 CUDA 版本不匹配这个坑很隐蔽。驱动版本太老不支持新版的 CUDAPyTorch 可能回退到 CPU 或者用兼容模式跑性能大打折扣。排查方法# 查看驱动版本和 CUDA 版本 nvidia-smi # 查看 PyTorch 使用的 CUDA 版本 python -c import torch; print(torch.version.cuda) # 查看 cuDNN 版本 python -c import torch; print(torch.backends.cudnn.version())nvidia-smi右上角显示的 CUDA 版本是驱动支持的最高版本PyTorch 实际用的 CUDA 版本可能不同。如果 PyTorch 的 CUDA 版本高于驱动支持的版本可能会出问题。7.2 GPU 降频和功耗限制GPU 温度过高或者功耗墙设置太低会导致 GPU 降频计算速度下降。用nvidia-smi -q -d PERFORMANCE查看当前频率和降频原因。# 查看 GPU 当前状态 nvidia-smi -q -d PERFORMANCE,CLOCK,POWER,TEMPERATURE如果发现Clocks Throttle Reasons里有SW Power Cap或HW Slowdown说明 GPU 因为功耗或温度被限制了。解决办法改善散热清理风扇灰尘增加机箱风道。调整功耗墙nvidia-smi -pl 瓦数可以设置功耗上限但一般不建议调高除非散热足够。锁定频率nvidia-smi -lgc 频率可以锁定 GPU 频率避免波动。7.3 其他进程占用 GPU有时候 GPU 利用率上不去是因为有别的进程在抢 GPU。用nvidia-smi看进程列表或者用fuser -v /dev/nvidia*查看哪些进程在用 GPU。# 查看 GPU 上的进程 nvidia-smi --query-compute-appspid,process_name,used_memory --formatcsv # 查看谁在用 GPU 设备 fuser -v /dev/nvidia*如果是共享服务器可能其他同事的任务在跑。这种情况只能协调或者用CUDA_VISIBLE_DEVICES指定用哪张卡。# 只用第 0 和第 1 张卡 export CUDA_VISIBLE_DEVICES0,17.4 容器环境下的 GPU 透传问题如果用 Docker 跑训练GPU 透传配置不对也会导致利用率低。检查--gpus参数是否正确以及容器内是否能正常访问 GPU。# 启动容器时透传所有 GPU docker run --gpus all -it pytorch/pytorch:latest # 容器内验证 nvidia-smi python -c import torch; print(torch.cuda.is_available())如果容器内torch.cuda.is_available()返回 False说明 GPU 没透传成功。需要检查 NVIDIA Container Toolkit 是否安装正确。8. 排查清单速查表与实操建议8.1 六步排查流程把上面的内容整理成一个可执行的排查清单按顺序做下来基本能覆盖 90% 的利用率低问题步骤检查项工具/命令判断标准1GPU 是否被使用nvidia-smi、torch.cuda.is_available()利用率是否大于 02数据管道是否瓶颈torch.profiler、nvidia-smi dmonGPU 利用率是否锯齿状3是否有频繁同步torch.cuda.set_sync_debug_mode是否有大量同步警告4显存是否不足nvidia-smi、torch.cuda.memory_summary()显存占用是否接近上限5模型计算效率torch.profiler、torch.compilekernel 数量是否过多6多卡通信/环境nccl-tests、nvidia-smi -q通信占比是否过高、是否降频8.2 我个人的实操心得说几个文档里不会写、但实际排查中很有用的经验第一先看最简单的指标。很多人一上来就抓 profiler结果发现是num_workers0这种低级问题。先花 30 秒看nvidia-smi和训练脚本的 DataLoader 配置能省很多时间。第二二分法定位。如果不知道瓶颈在哪先把训练循环拆成“只加载数据不训练”和“只训练不加载数据”两部分分别测速度。哪部分慢就是哪部分的问题。第三不要迷信利用率数字。利用率 100% 不代表 GPU 在高效计算可能只是在跑很多小 kernel。真正要看的是吞吐量每秒处理的样本数和 step 时间。利用率只是参考不是目标。第四改一个变量测一次。调优最忌讳一次改好几个参数然后不知道哪个起了作用。每次只改一个记录前后数据这样才能积累经验。第五注意 warmup。GPU 刚启动时频率还没升上去前几个 step 会慢。测速度时先跑 10 个 step 热身再开始计时。8.3 常见问题速查利用率 0%检查 CUDA 是否可用、模型是否在 GPU 上、是否有报错被忽略。利用率忽高忽低数据管道瓶颈调num_workers、pin_memory。利用率稳定但很低模型太小或 batch size 太小增大 batch 或换更大模型。多卡比单卡还慢通信瓶颈检查 NVLink、调bucket_cap_mb、用梯度累积。训练一段时间后变慢显存碎片化或温度降频调PYTORCH_CUDA_ALLOC_CONF、检查散热。换了环境后变慢驱动/CUDA 版本不匹配检查版本兼容性。提示排查时建议开两个终端一个跑训练一个跑nvidia-smi dmon或watch -n 1 nvidia-smi实时观察 GPU 状态。这样能直观看到利用率和显存的变化趋势比事后看日志有效得多。最后再分享一个小技巧如果你用的是 PyTorch 2.0 以上版本直接加一行model torch.compile(model)很多时候不用做其他优化利用率就能提升 20% 到 30%。这个命令会自动做算子融合和 kernel 优化对 Transformer 和 CNN 都有效。我实测下来在一个 ViT 模型上torch.compile把 step 时间从 120ms 降到了 85msGPU 利用率从 65% 涨到了 88%。当然编译本身需要几分钟适合长时间训练的任务。