1. 先搞清楚GPU利用率低到底卡在哪一层1.1 为什么“利用率低”是个伪命题很多人一看nvidia-smi里 GPU-Util 只有 30%第一反应是“显卡没吃满得加 batch size”。这个判断在多数情况下是错的。GPU-Util 这个指标本身只表示“在采样周期内GPU 上至少有一个 kernel 在跑的时间占比”它不区分这个 kernel 是在做有效计算还是在空转等数据。换句话说利用率 100% 也可能是在跑一个毫无意义的 memset利用率 30% 也可能是计算密度极高的算子被数据供给拖住了。我在实际项目里踩过最典型的一次坑一个 8 卡 A100 的推荐模型训练nvidia-smi显示每张卡利用率在 25% 到 40% 之间跳团队第一反应是把 batch size 从 512 拉到 2048结果 OOM 了利用率反而掉到 20% 以下。后来用 PyTorch Profiler 一抓发现 70% 的时间耗在 DataLoader 的__getitem__上GPU 大部分时间在等 CPU 把样本喂过来。这就是典型的“瓶颈不在 GPU而在数据管道”。所以排查 GPU 利用率低第一步不是调参而是定位瓶颈在哪一层。我习惯把整条链路拆成六层数据供给层、主机内存与 PCIe 传输层、CPU 预处理层、GPU 计算层、通信层多卡/多机、以及框架与算子层。下面这张表是我自己用的速查表先给出来后面逐层展开。层级典型症状首选排查工具常见根因数据供给GPU-Util 周期性掉到 0DataLoader 计时、iostat磁盘 IO 慢、num_workers 不足主机内存/PCIeH2D 拷贝时间长nsight systems、pcie 带宽测试pinned memory 未开、PCIe 降速CPU 预处理CPU 占用 100% 但 GPU 空闲top、py-spy增强算子太重、Python GILGPU 计算GPU-Util 高但吞吐低nsight compute算子低效、kernel launch 开销通信多卡时利用率锯齿状nccl-tests、nsight梯度同步阻塞、拓扑不优框架/算子报错或 fallback 到 CPUtorch profiler算子不支持、版本不匹配1.2 排查前必须建立的三个基线认知在动手之前有三件事必须先确认否则后面所有排查都是盲人摸象。第一确认 GPU 真的被用上了。听起来很蠢但我见过不止一次代码里model.to(cuda)写了但数据还在 CPU 上或者某个自定义算子偷偷 fallback 到 CPU。用torch.cuda.is_available()和next(model.parameters()).device双重确认再在训练循环里打印一次inputs.device。第二确认利用率指标的采样口径。nvidia-smi默认采样间隔是 1 秒对于短 kernel 密集的训练这个粒度太粗。用nvidia-smi dmon -s u -d 1或者nvidia-smi --query-gpuutilization.gpu --formatcsv -lms 100拿到更细的粒度才能看出真实的波动模式。第三确认基线吞吐。同一个模型、同一份数据先用最小可运行配置跑 100 个 step记录 step time 和 samples/sec作为后续所有优化的对照。没有基线你永远不知道优化有没有效果。提示排查 GPU 利用率问题最忌讳一上来就改代码。先观测再假设最后验证。观测阶段不要动任何配置否则你连“改之前是什么样”都不知道。2. 数据供给层最容易被忽视的头号瓶颈2.1 DataLoader 到底慢在哪数据供给层是 GPU 利用率低的第一大嫌疑犯没有之一。我统计过自己经手的十几个训练项目超过一半的利用率问题最终都追溯到 DataLoader。原因很简单GPU 算力每两年翻一倍但磁盘 IO 和 Python 的单线程预处理能力基本没怎么涨这个剪刀差越来越大。DataLoader 的耗时可以拆成四块磁盘读取、解码比如 JPEG 解码、增强变换、以及 collate 拼 batch。任何一块慢都会让 GPU 饿着。定位方法很直接在__getitem__里打时间戳或者用下面这段代码直接测import time from torch.utils.data import DataLoader loader DataLoader(dataset, batch_size64, num_workers8, pin_memoryTrue) # 测纯数据加载耗时 t0 time.time() for i, batch in enumerate(loader): if i 50: break t1 time.time() print(f50 batches took {t1-t0:.2f}s, {(t1-t0)/50*1000:.1f}ms per batch)如果每 batch 数据加载时间超过 GPU 单步计算时间的 1.5 倍那 GPU 必然等数据。我一般要求数据加载时间控制在 GPU 计算时间的 60% 以内留出余量。2.2 num_workers 不是越大越好很多人一听数据慢就把num_workers从 4 调到 32。这个做法在 CPU 核数足够时有效但有两个坑。第一个坑是进程启动开销。每个 worker 都要 fork 一份数据集对象如果数据集初始化时加载了大文件比如把整个标注 json 读进内存32 个 worker 就是 32 份拷贝内存直接爆掉。我见过一个项目num_workers16时主机内存吃了 200GB就是因为数据集在__init__里加载了一个巨大的 embedding 表。第二个坑是IO 争抢。如果数据在机械硬盘或者网络存储上32 个 worker 同时读磁头来回寻道总吞吐反而比 4 个 worker 还低。这时候应该先解决存储问题而不是加 worker。我的经验值是num_workers设为 CPU 物理核数的 50% 到 75%并且配合persistent_workersTrue避免每个 epoch 重启 worker。如果是 SSD可以适当加大如果是网络存储先测单线程读带宽再决定并发数。2.3 用 pinned memory 和 prefetch 把数据管道拉满pin_memoryTrue这个参数很多人加了但不知道为什么要加。简单说CPU 内存分页是可以被换出到磁盘的而 GPU 的 DMA 引擎要求源内存在物理上连续且不可换出也就是“锁页内存”。如果不开 pinned memoryPyTorch 在 H2D 拷贝前要先在锁页区做一次中转拷贝多了一次内存拷贝开销。开了之后拷贝可以直接从锁页内存发起带宽能提升 20% 到 50%。配合prefetch_factor每个 worker 预取的 batch 数和persistent_workers可以让数据管道在 GPU 计算当前 batch 时后台已经在准备后面几个 batch。这三个参数一起用效果最明显loader DataLoader( dataset, batch_size64, num_workers8, pin_memoryTrue, prefetch_factor4, persistent_workersTrue, shuffleTrue )注意prefetch_factor乘以num_workers就是预取的总 batch 数这个值乘以单 batch 内存占用不能超过主机可用内存。我一般控制在预取 8 到 16 个 batch。2.4 数据格式的选择直接决定 IO 上限如果数据是海量小文件比如几百万张 JPEG无论怎么调 DataLoader 都救不了因为文件系统的元数据操作就是瓶颈。这时候要把小文件打包成大文件格式比如 LMDB、WebDataset 的 tar 分片、或者 HDF5。我做过对比测试同样 200 万张图片散文件读取的吞吐是 800 images/sec打包成 WebDataset tar 分片后是 6500 images/sec差了 8 倍。打包的代价是数据预处理变复杂而且不能随机访问单张图片。但对于训练场景顺序读取加 shuffle buffer 完全够用。如果你的项目还在用散文件并且 GPU 利用率上不去优先考虑这一步。3. 主机内存与 PCIe 传输层看不见的搬运工3.1 H2D 拷贝到底占了多少时间数据从主机内存搬到显存走的是 PCIe 总线。PCIe 3.0 x16 的理论带宽是 16GB/s实际有效带宽大概 12GB/sPCIe 4.0 x16 翻倍到 32GB/s。听起来很快但如果你的模型输入是大尺寸图像或者长序列拷贝时间会非常可观。举个例子一个 batch 是 64 张 512x512x3 的 float32 图像数据量是 64 × 512 × 512 × 3 × 4 字节 201MB。在 PCIe 3.0 上光拷贝就要 201/12000 ≈ 17ms。如果 GPU 单步计算只要 30ms那拷贝就占了 36% 的时间利用率自然上不去。定位方法是用 nsight systems 抓一条时间线看 H2D 拷贝和 kernel 执行是否重叠。如果拷贝是串行的说明没有用上异步拷贝。PyTorch 里只要开了pin_memoryTrue.to(cuda, non_blockingTrue)就能让拷贝和计算重叠inputs inputs.to(cuda, non_blockingTrue) labels labels.to(cuda, non_blockingTrue) # 此时拷贝在后台进行可以继续做其他 CPU 操作 outputs model(inputs)3.2 PCIe 降速的隐蔽陷阱有一次我排查一个 4 卡服务器的利用率问题怎么调都上不去最后用nvidia-smi -q | grep -A 10 GPU Link一看发现两张卡跑在 PCIe 3.0 x8 而不是 x16 上。原因是主板插槽共享带宽插了 4 张卡之后每张只能分到 x8。这种情况在消费级主板上非常常见尤其是那些标称支持 4 卡但实际带宽会打折的板子。排查命令nvidia-smi --query-gpupcie.link.gen.current,pcie.link.width.current --formatcsv如果显示的不是你预期的 gen 和 width就要去查主板手册的 PCIe 通道分配。另外某些服务器 BIOS 里有 PCIe 速率设置默认可能是 auto遇到兼容性问题会降到 gen3手动锁到 gen4 能恢复。3.3 显存不足导致的隐性等待显存不够时PyTorch 的 caching allocator 会频繁触发cudaMalloc和cudaFree这两个操作是同步的会让 GPU 停下来等。更隐蔽的是显存碎片化明明总空闲显存够但找不到连续的大块于是反复整理。表现就是 GPU-Util 忽高忽低step time 抖动很大。用torch.cuda.memory_summary()看碎片情况如果 reserved 远大于 allocated说明碎片严重。解决办法是设置PYTORCH_CUDA_ALLOC_CONFexpandable_segments:True让 allocator 用可扩展段减少碎片。这个环境变量在 PyTorch 2.0 以后支持实测能缓解大部分碎片问题。4. CPU 预处理层Python 的锅还是算子的锅4.1 用 py-spy 抓 CPU 热点当 DataLoader 的 worker 已经开够但 CPU 还是跑满、GPU 还是空闲时问题就在预处理逻辑本身。这时候不要用 cProfile因为它对多进程支持不好用 py-spy 直接 attach 到 worker 进程上py-spy top --pid worker_pid # 或者生成火焰图 py-spy record -o profile.svg --pid worker_pid --duration 30火焰图能直接告诉你哪个函数吃 CPU 最多。我遇到过的典型热点包括PIL 的图像 resize换成 opencv 的cv2.resize能快 3 到 5 倍、numpy 的 fancy indexing换成 torch 的 tensor 操作、以及自定义的 Python 循环增强用 albumentations 或者 kornia 的 GPU 增强替代。4.2 把增强搬到 GPU 上如果 CPU 预处理确实是瓶颈最彻底的解法是把增强搬到 GPU 上做。现在 kornia、torchvision 的 v2 API、以及 nvidia 的 DALI 都支持 GPU 增强。代价是显存占用增加但换来的是 CPU 彻底解放数据管道只需要做最轻量的解码。DALI 是我用得最多的方案它把解码、增强、甚至部分模型前处理都放在 GPU 上用 CUDA kernel 实现吞吐比 CPU 方案高一个数量级。配置起来也不复杂from nvidia.dali import pipeline_def import nvidia.dali.fn as fn import nvidia.dali.types as types pipeline_def(batch_size64, num_threads4, device_id0) def data_pipeline(): images fn.readers.file(file_root/data/images) decoded fn.decoders.image(images, devicemixed) resized fn.resize(decoded, size(224, 224)) normalized fn.crop_mirror_normalize( resized, mean[0.485*255, 0.456*255, 0.406*255], std[0.229*255, 0.224*255, 0.225*255], output_layoutCHW ) return normalizeddevicemixed表示解码在 CPU 上做因为 JPEG 解码硬件在 CPU 侧后续的 resize 和 normalize 在 GPU 上做。实测下来同样的数据管道DALI 比纯 PyTorch DataLoader 快 4 到 6 倍。4.3 GIL 与多进程的取舍Python 的 GIL 让多线程在 CPU 密集任务上几乎无效所以 DataLoader 用的是多进程。但多进程有序列化开销worker 和主进程之间传数据要 pickle。如果单样本很大比如高分辨率图像pickle 开销会吃掉多进程的收益。这时候可以考虑用共享内存或者干脆用torch.multiprocessing的 shared tensor。另一个思路是把 batch 拼装放在 worker 里做主进程只接收拼好的 tensor减少传输次数。这些优化在数据量大时效果明显数据量小时不值得折腾。5. GPU 计算层利用率高但吞吐低怎么办5.1 kernel launch 开销与算子融合有一种情况是 GPU-Util 显示 90% 以上但实际吞吐很低。这通常是因为 kernel 太小太碎launch 开销占比过高。每个 kernel launch 有 5 到 10 微秒的固定开销如果一个模型有几千个小算子光 launch 就耗掉几十毫秒。用 nsight systems 看时间线如果 kernel 之间有明显空隙且每个 kernel 执行时间只有几微秒那就是 launch bound。解决办法是算子融合比如用torch.compile自动融合或者手动用 CUDA graph 把整个前向反向捕获成一个图一次性提交。# CUDA graph 示例 g torch.cuda.CUDAGraph() with torch.cuda.graph(g): static_output model(static_input) # 后续每次训练 static_input.copy_(real_input) g.replay()CUDA graph 对固定 shape 的训练特别有效能把 launch 开销降到接近零。但要注意动态 shape 和动态控制流不支持需要先固定下来。5.2 低效算子的识别与替换有些算子天生低效比如torch.nn.functional.interpolate的某些模式、自定义的 Python 循环算子、以及没有用上 tensor core 的矩阵乘。用 nsight compute 抓单个 kernel 的 roofline看它离理论峰值有多远。我遇到过一个案例模型里的 attention 用的是手写的 Python 循环实现GPU-Util 很高但吞吐只有理论值的 15%。换成torch.nn.functional.scaled_dot_product_attention之后吞吐直接翻了 5 倍。所以遇到利用率高但吞吐低先怀疑算子实现再怀疑硬件。5.3 混合精度与 tensor core 的启用A100、H100 这些卡的 tensor core 在 FP16 和 BF16 下才能发挥全部算力。如果还在用 FP32 训练算力利用率天然只有一半。用torch.cuda.amp开启自动混合精度大部分算子会自动走 FP16吞吐能提升 1.5 到 2 倍。scaler torch.cuda.amp.GradScaler() with torch.cuda.amp.autocast(dtypetorch.bfloat16): outputs model(inputs) loss criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()BF16 比 FP16 更稳不容易溢出Ampere 以后的卡优先用 BF16。但要注意某些算子对精度敏感比如 layernorm、softmaxautocast 会自动保持 FP32这是正常的。6. 通信层与框架层多卡场景的专属瓶颈6.1 多卡训练的梯度同步阻塞单卡利用率正常一上多卡就掉八成是通信问题。DDP 的梯度 all-reduce 是同步操作如果某张卡算得慢其他卡都要等它。表现就是利用率呈锯齿状同步点附近掉到 0。排查方法是用nccl-tests测实际通信带宽对比理论值。如果 all-reduce 带宽只有理论值的 30%说明拓扑有问题可能是走了 PCIe 而不是 NVLink或者网卡没配对。用nvidia-smi topo -m看拓扑矩阵确认卡间连接方式。另一个常见问题是 bucket size 设置不当。DDP 默认把梯度分成 25MB 的 bucket 做 all-reduce如果模型梯度很小bucket 太多通信次数就多。调大bucket_cap_mb能减少通信次数但会增加显存峰值。我一般从 25 调到 50 试看利用率曲线是否变平滑。6.2 框架版本与算子兼容性PyTorch、CUDA、cuDNN、驱动这四个东西的版本必须匹配否则会出现算子 fallback 到 CPU、或者用了低效的 kernel 实现。最典型的是 cuDNN 版本太老卷积走了 implicit gemm 而不是 winograd性能差一倍。用torch.backends.cudnn.version()和torch.version.cuda确认版本对照 PyTorch 官方文档的兼容性矩阵。另外torch.backends.cudnn.benchmark True能让 cuDNN 在启动时自动选最快的卷积算法代价是第一次迭代慢之后都快。这个开关在输入 shape 固定时必开。6.3 常见问题速查表现象可能原因排查命令/方法解决方向GPU-Util 周期性归零数据供给断流DataLoader 计时加 worker、换数据格式GPU-Util 高但吞吐低kernel launch 开销大nsight systems算子融合、CUDA graph多卡利用率锯齿梯度同步阻塞nccl-tests调 bucket、查拓扑step time 抖动大显存碎片memory_summaryexpandable_segments单卡正常多卡掉通信带宽不足nvidia-smi topo检查 NVLink、网卡报错 fallback CPU算子不支持torch profiler升级版本、换算子7. 我自己的排查顺序与几条硬经验7.1 从外到内从粗到细我自己的排查顺序固定是先看nvidia-smi dmon的利用率曲线形态判断是持续低还是周期性低再用 nsight systems 抓 10 秒时间线看 GPU 空闲区间对应的是数据加载、H2D 拷贝还是通信最后用 nsight compute 或 py-spy 深入具体热点。这个顺序能保证每次只动一个变量避免瞎调。7.2 几条踩坑换来的经验第一条不要迷信 batch size。batch size 加大确实能提升利用率但前提是瓶颈不在数据侧。数据侧没解决加大 batch 只会让等待时间更长。第二条先测存储再调参数。用fio或dd测一下数据所在盘的顺序读和随机读带宽如果随机读只有几十 MB/s那再怎么调 DataLoader 都是白费。第三条profiler 一定要在真实训练循环里跑。单独跑一个 forward 测出来的时间和真实训练差很远因为真实训练有反向、有优化器、有梯度同步这些都会影响利用率。第四条记录每次改动的对照数据。我习惯用一个简单的表格记录改动内容、step time、samples/sec、GPU-Util 均值。没有对照你永远不知道哪个改动真正有效。7.3 一个完整的排查实例最后分享一个我最近处理的案例。一个 4 卡 A100 的 ViT 训练GPU-Util 只有 35%。按流程走dmon 显示利用率周期性掉到 5%周期约 200msnsight systems 显示每个周期里 GPU 空闲 150ms对应 DataLoader 取数据py-spy 抓 worker 发现 60% 时间在 PIL 的 resize 上。解决把 resize 换成 opencvnum_workers 从 4 加到 12开 persistent_workers。改完利用率稳定在 78%step time 从 420ms 降到 180ms。整个过程没有动模型和优化器纯粹是数据管道优化。这个案例说明大部分 GPU 利用率问题根本不在 GPU 本身。把数据管道理顺比调任何超参都管用。