单机多卡训练调优:破解“8卡7闲”的GPU利用率瓶颈

发布时间:2026/9/11 9:25:04
单机多卡训练调优:破解“8卡7闲”的GPU利用率瓶颈 我们做 AI Infra 的朋友可能都有过这种体验明明买的是 8 卡机器一看nvidia-smi只有 0 号卡在忙剩下 7 张卡 GPU-Util 是 0%。这时候很多人第一反应是“是不是我没配好分布式框架”“是不是该上 Kubernetes 或者 Slurm 了”。但我想说一句可能不太中听的话大概率不是集群调度的问题而是你单机内部就已经在“空转”了。这就像一个人连自己手头的算力都没理顺就急着去搞“全局统筹”那调度器再智能也救不了你。这篇是 AI Infra 系列里训练与调度章节的上半部分聚焦一个非常不起眼、但一旦出问题就极其致命的话题单机多卡训练调优。我会从自己实际调过的训练任务出发讲清楚“8 张卡 7 张闲”背后的三种典型瓶颈给出可操作的观测手段和参数调整方案也会附上完整的排查流程和踩坑记录。如果你正在做大模型微调、LoRA 训练、YOLO 系列目标检测训练或任何跑在单机多卡上的深度学习任务这篇文章应该能帮你省下好几个星期的无效加班。1. “8 张卡 7 张闲”先把问题看透很多人一看到 7 张卡闲着就本能地把问题归到“并行策略”上是不是没装 NCCL是不是 DDP 没写对是不是网卡没起来实际上在我接触的大量训练任务里单机多卡利用率低的根因根本不在“多卡通信”那一层而是单卡本身就没吃饱。1.1 这不是调度器的锅很可能是你亲手埋的单机雷我们先把概念理清楚。集群调度器比如 Kubernetes、Slurm管的是“哪台机器上跑哪个任务”它解决的是多任务、多用户、多节点之间的资源分配。而单机上的 GPU 利用率由你的训练进程内部决定数据能不能喂够、计算内核有没有被正确启动、梯度同步会不会互相等待。有个很形象的类比你把一栋楼的几十间房分给几十户人住这是物业调度器的职责但每一户人家里水管通不通、电闸有没有跳那得你自己修。8 卡机器 7 张卡闲着就好比 7 户邻居家的水龙头一打开根本没水物业再公平也没用。在我排过的问题里出现“8 卡 7 闲”最常见的原因其实是这几个代码里把模型放在了显存最大的 0 号卡其他卡的模型虽然创建了但因为数据加载线程卡死根本没有有效的计算任务喂过去。训练循环里用了同步操作比如每个 step 都调用一次torch.cuda.synchronize()导致 7 张卡都在等某一张卡完成。DataLoader 的num_workers设置过低CPU 根本来不及把数据搬运过来GPU 只能干等着。单机内 PCIe 带宽不够数据在 CPU 和 GPU 之间搬运的时间比 GPU 计算时间还长表现为 GPU 一会儿忙一会儿停。这些问题的共同点是你从表面上看是“GPU 利用率低”但根子全在单机训练链路的下游。不等你理清这一层上什么调度器都是白搭。1.2 三种最常见的“卡闲”剖面我做了一个简单的分类法方便大家对照自己遇到的情况。每当有人截图给我说“8 张卡 7 张闲”我都会先问一句那个“闲”是持续性的还是周期性脉冲这会直接影响排查方向。第一类是持续饥饿型。GPU-Util 始终很低比如一直 0% 或者个位数。这种最常见的原因就是数据供给断了CPU 预处理跟不上GPU 每个 step 大部分时间都在空转等数据。你可以通过nvidia-smi反复看会发现 GPU 显存占用正常但利用率极低。这种时候要查 DataLoader而不是查网络。第二类是周期性打嗝型。GPU-Util 在 0% 和 90% 之间来回跳像心电图一样。这种通常发生在每个 step 结束后的梯度同步阶段8 张卡算完各自的梯度后要等所有卡都算完再做 all-reduce。如果各卡的计算负载不均衡或者通信链路有瓶颈就会表现为每分钟出现若干次“利用率塌下去再弹回来”。第三类是静默哑火型。显存占用很高但利用率也是 0%看起来像卡住了。这种情况往往是显存碎片化、OOM 后重试或者代码里某个分支逻辑错误导致根本没有调度内核。有时也和 NCCL 超时、通信组初始化卡住有关。这种最容易误判为“多卡通信问题”其实 8 张卡连数据都没开始算。你只有先看清楚当前属于哪一类后续的优化动作才有针对性。我见过有人花了一整周去调 NCCL 参数结果最后发现是num_workers0导致 CPU 只用一个线程在跑数据加载真是典型的“方向错了越努力越尴尬”。2. 观测先行先量化再动手很多人在调优时有个坏习惯凭感觉。感觉 GPU 利用率低就随手把batch_size调大感觉数据加载慢就把num_workers从 4 改成 8。这种“盲调”偶尔能碰对但大多数时候会引入新问题而且你根本说不清楚优化到底有没有效果。2.1 五类必看的指标我在实际调优时会固定收集五类指标。第一类是 GPU 利用率与显存用nvidia-smi或nvidia-smi dmon看每张卡的GPU-Util、memory-used和温度。第二类是 CPU 与内存用top或htop看 CPU 核心占用率和内存剩余量CPU 占用率如果长时间超过 90%大概率是预处理瓶颈。第三类是数据加载时延在 DataLoader 的__getitem__里加时间戳或直接用 PyTorch 自带的 Profiler 看DataLoader的耗时占比。第四类是 GPU 内核时间分布用nsys采集看计算内核、内存拷贝、通信分别占了多少。第五类是网络与通信时间在 DDP 训练脚本里记录每个 step 的总耗时和 all-reduce 耗时。这五类指标不需要全部常驻收集但它们组合起来能帮你快速画出整个数据流的水位图。以我个人的经验很多问题的答案在指标观测阶段就已经浮出水面了根本不需要真正动手改代码。2.2 一套可以直接照搬的排查脚本和工具链如果你手头条件比较紧张不想一上来就上重型 Profiler可以先从几条命令行开始。我自己最常用的组合是这样的# 实时看 GPU 利用率和显存 watch -n 1 nvidia-smi # 看每张卡的指标变化采样间隔 1 秒 nvidia-smi dmon -s pucvmet -d 1 # 看 CPU 每个核心的使用率 htop # 定位 Python 进程里哪个线程在占用 CPU py-spy top --pid 训练进程 pid # 更细粒度看 CPU 上的函数调用栈 py-spy dump --pid 训练进程 pid我特别想推荐py-spy这个工具。它不需要重启训练进程就能把 Python 侧当前所有线程的调用栈打出来。当 GPU 利用率掉到 0% 而你怀疑是数据加载线程卡住时直接py-spy dump往往能一眼看到线程卡在PIL.Image.open还是cv2.imdecode省去了到处打日志的痛苦。在深入优化之前我还会用 PyTorch Profiler 做一次完整采集。它的写法非常简单能直接给出每个算子和 DataLoader 的耗时占比from torch.profiler import profile, ProfilerActivity with profile( activities[ProfilerActivity.CPU, ProfilerActivity.CUDA], record_shapesTrue, profile_memoryTrue, ) as prof: for step, batch in enumerate(train_loader): # 正常训练逻辑 ... if step 50: break print(prof.key_averages().table( sort_bycuda_time_total, row_limit20 ))这里要注意Profiler 本身会带来额外开销别在生产训练里一直开。一般我是先跑五六十个 step采集一轮记录基线然后关掉 Profiler 再跑正式训练。观测这件事核心目的不是好看的图表而是帮你把“感觉慢”变成“具体哪里慢”。量化永远先于优化这句话在 AI Infra 里怎么强调都不过分。3. 数据供给瓶颈优化 DataLoader 的关键参数排在我碰到过的问题榜首的永远是数据供给。你要知道GPU 的计算速度是以“每秒多少亿次浮点运算”计的而 CPU 读图、解码、缩放、归一化用的是每秒多少 MB 的 I/O 和单核指令。两者之间天然存在掉链子的可能。3.1 四个参数和背后的数学PyTorch 的 DataLoader 是我调优的重灾区。很多人的默认写法是DataLoader(dataset, batch_size64, shuffleTrue)这一行代码等于在告诉 PyTorch请用主线程、不预取任何数据、不要把数据锁在页锁定内存里。然后就奢望 4 张甚至 8 张 GPU 能跑满。要优化先得理解四个关键参数。第一个是num_workers。它决定起多少个 worker 子进程来做数据预处理。注意是把数据集读到一个 Python 列表里再shuffle还是直接访问磁盘文件会造成巨大差异。一般经验是设为 CPU 核心数的一半到三分之二给训练主进程留点余量。第二个是prefetch_factor它控制每个 worker 在内存里预取多少个 batch 的数据。默认是 2如果训练脚本对数据顺序不太敏感可以适当调大比如 4 甚至 8。第三个是pin_memoryTrue它把数据分配到页锁定内存page-locked memory里GPU 从这种内存拷贝数据走的是高速通道能减少一部分 H2D 拷贝开销。第四个是persistent_workersTrue意思是训练完一个 epoch 后 worker 不销毁重建省掉频繁初始化子进程的损耗。这中间还有一层数学关系。假设单个 batch 在 CPU 上的预处理时间是t_prepGPU 上一个 step 的计算时间是t_train。如果你想 GPU 不闲等就必须满足num_workers * (单 worker 每 batch 时间) * prefetch_factor t_train更通俗地说CPU 每生产一个 batch 的时间必须小于等于 GPU 每消费一个 batch 的时间。如果 CPU 侧生产一个 batch 要 20msGPU 算一个 batch 只要 5ms那不管你num_workers开多少如果 Python GIL 或磁盘 I/O 成为上限GPU 也得原地等 15ms。所以关键不是某个参数开多大而是让整条流水线的瓶颈尽量往 GPU 侧移动。3.2 训练脚本里直接能用的配置基于上面的数学关系我一般会把 DataLoader 写成这样作为多卡训练里的初始配置from torch.utils.data import DataLoader train_loader DataLoader( dataset, batch_size64, shuffleTrue, num_workers8, prefetch_factor4, persistent_workersTrue, pin_memoryTrue, drop_lastTrue, )这里drop_lastTrue也是我比较偏爱的选项。多卡训练时如果最后一个 batch 不够分配DDP 会报错与其处理那些边界情况不如直接丢掉不足整数倍的批次。当然如果你的数据集非常小丢掉可能会影响训练效果这种情况下你可以用torch.utils.data.distributed.DistributedSampler自己控制补全逻辑而不是简单粗暴地drop_last。我特别想强调一点num_workers不是越大越好。因为每个 worker 都是独立进程它们之间如果需要共享内存比如共享某个查找表可能会引发大量拷贝和序列化开销。另外当 worker 数量超过 CPU 物理核心数后操作系统会做大量上下文切换反而拖慢整体速度。我见过有人把num_workers设成 128结果 CPU 被打满数据加载时间比原来还长。3.3 数据预处理优化的三个方向如果调完 DataLoader 参数后CPU 还是忙不过来那就得往数据预处理本身动刀了。我按性价比从高到低排一下。第一个方向是做缓存。如果你的数据不是无限流而是固定的训练集可以提前把所有样本做完 decode、resize、归一化之后以 tensor 形式缓存到内存或本地磁盘。这样训练时少了解码和预处理CPU 压力会小很多。缺点是占用大量内存适合单机显存比较大而 CPU 比较弱的场景。第二个方向是换数据格式。比如把几万张小图片打包成一个 TFRecord 或者 WebDataset 格式的文件利用顺序读减少随机 I/O 的寻址开销。尤其是在机械硬盘或者网络文件系统上这种优化收益非常明显。我在一个目标检测项目里把 JPEG 文件从散装改成 TFRecord训练吞吐直接提升了 30% 以上。第三个方向是异步卸载。把预处理放到 GPU 上做比如用 DALI 这类库让 GPU 直接读图片、解码、做增强。这看起来有点“奢侈”但 GPU 大部分时间都在闲着等数据你无非是把它的空闲时间利用起来而已。DALI 的上手成本略高但对视频、图像解码这类重负载任务效果是立竿见影的。这些都是我在单机调优中真正用过的方案没有哪一个是银弹关键还是要看你卡在哪一层。4. 通信与混合精度让 8 张卡真正并行起来数据供给问题解决之后GPU 利用率通常会好很多但“8 卡 7 闲”还有一个常见因素就是多卡之间的通信等待。这种问题在单机场景下也经常出现因为即便是在同一台机器里PCIe 带宽和 NVLink 拓扑也会明显影响通信开销。4.1 all-reduce 开销如何影响全局等待分布式训练里最核心的通信原语是 all-reduce每张卡算完自己的梯度之后需要把所有卡的梯度加总再广播回每一张卡。在 DDP 中这个操作在每个 step 结束都会发生。如果通信链路比较慢或者梯度张量特别大那么整个训练就会被卡在“等同步”上。那要怎么判断是不是通信瓶颈简单方法是在代码里手动记录每个 step 的总耗时和只计算梯度、不做通信的耗时import time train_loader iter(train_loader) batch next(train_loader) t0 time.time() output model(batch) loss loss_fn(output, target) loss.backward() # 手动调用 all-reduce 前的同步 torch.cuda.synchronize() t_compute time.time() - t0 t1 time.time() # DDP 内部的梯度 all-reduce 在这里进行 optimizer.step() torch.cuda.synchronize() t_comm time.time() - t1 print(fcompute: {t_compute:.3f}s, comm: {t_comm:.3f}s)如果t_comm占比超过 30%通信就是你要优先优化的问题。这个时候我们有几个可以操作的杠杆。第一个杠杆是减少通信频率。梯度累积gradient accumulation可以让多个 step 的梯度先累计再统一做一次 all-reduce。比如原来是每 step 同步一次改成每 4 步同步一次通信次数就直接降到原来的 1/4。代价是显存不变的情况下等效 batch size 变大了可能会影响收敛。后面我会详细算这笔账。第二个杠杆是使用梯度压缩或者稀疏化。对某些模型来说梯度变化很小的参数可以延迟同步或者只同步部分重要梯度。这种做法在训练初期通常有效但实现复杂度高需要谨慎调参。第三个杠杆是检查拓扑。nvidia-smi topo -m可以查看 GPU 之间的连接方式。如果是 PCIe 互联通信带宽一般没法和 NVLink 相比。如果 8 张卡里有部分卡是通过 PCIe 交换机跨接通信开销会很高这时你可以通过环境变量NCCL_P2P_DISABLE1或NCCL_SOCKET_IFNAME等参数做实验看哪种配置实际最快。不要盲信默认值很多厂家给的默认 NCCL 配置并不适合你的具体场景。4.2 混合精度训练性能与收益的权衡聊完通信再聊聊另一种极其常见的情况显存足够但 GPU 利用率依然不高原因可能是你没有开启混合精度训练AMP。混合精度训练的核心思路是用 FP16 做前向和反向计算用 FP32 保存主权重和累加梯度。这样既能把计算速度提上去又能把显存占用降下来。很多人以为 AMP 只是省显存其实它对计算速度的提升也不可忽视。特别是 Ampere 及更新的 GPU 架构FP16 的吞吐往往是 FP32 的两倍甚至更多。在大模型训练里开启 AMP 后8 张卡的利用率可能会有明显改善因为每个 step 的计算时间变短了流水线更容易跑满。但这里有个大坑FP16 的数值范围比较窄很容易出现梯度下溢或溢出导致训练不稳定。所以 PyTorch 的torch.cuda.amp里默认用了动态损失缩放dynamic loss scaling核心是先用一个缩放因子放大损失反向传播后再缩小梯度。这个过程是自动的但如果你用了自定义优化器或者自定义损失函数有可能会干扰它的缩放逻辑。我的建议是尽量使用 PyTorch 官方支持的GradScaler不要省事手动去对梯度做缩放。另一个细节是混合精度对某些算子特别不友好比如 LayerNorm、Softmax 等它们在 FP16 下的精度损失会被放大。PyTorch 的 autocast 会自动对这类算子保持 FP32 精度所以你不必太过担心但如果是自定义算子就要小心了。从我实测的经验看开启 AMP 往往是一步“低垂的果实”改动少提升明显。如果你目前还没有做我建议先把这一步补上再考虑更复杂的优化策略。4.3 梯度累积和 batch size 的数学关系梯度累积是一个非常实用的手段适合在显存不足、又想把 batch size 变大的场景。简单说就是用多个小 batch 的梯度累加代替一个大批次的梯度。假设你显存只够放下batch_size16但你想用等效batch_size64的效果那就把累积步数设为 4。这个过程要注意两点。第一optimizer.zero_grad()只应该在累积结束时调用一次第二loss.backward()每次都会累加梯度所以累积步数越多梯度值就越大需要配合损失缩放来平衡。在实际代码里它长这样accum_steps 4 for step, batch in enumerate(train_loader): loss model(batch) / accum_steps # 先把 loss 缩小 loss.backward() if (step 1) % accum_steps 0: optimizer.step() optimizer.zero_grad()这里把 loss 除以accum_steps是为了保证累积后的梯度量级和真正的大 batch 一致。你可能觉得这是小细节但如果你不除瞬间的梯度范数就会变大造成优化器动一步过大很容易训飞。梯度累积还直接影响通信频率如果配合 DDP你每累积 N 个 step 才做一次 all-reduce通信开销能显著下降。我遇到过的一个案例是在 8 卡单机上训练一个中等规模的检测模型原来是每个 step 同步一次通信耗时占比接近 40%。改成accum_steps4后通信占比降到 15% 以内整体吞吐提升了近 20%。代价是梯度更新频率变低模型收敛变慢但在很多任务上这种取舍是划算的。需要注意的是如果你用的是 PyTorch 2.0 之后的版本它引入了torch.compile和图优化这也会与梯度累积产生一些交互。比如某些融合内核在累积模式下没法被完全缓存反而会变慢。所以我每次调完参数后都会用 Profiler 重新测一遍而不是直接套理论。5. 单机调优实战一个完整案例前面讲了不少原理和参数这章我带大家完整走一遍我在实际项目中做单机调优的流程。案例背景很简单一台 8 卡 A100 的机器跑一个多卡微调任务数据是几万张图片模型是一个 base 级别的视觉模型。初始状态是GPU-Util 大部分时间在 15%-25% 之间徘徊显存占用到是挺满但就是算得慢loss 下降也慢。5.1 初始状态与瓶颈定位我先用nvidia-smi dmon和py-spy做了一次快照发现每张卡都存在明显的周期性利用率塌陷每过几秒钟GPU-Util 会从 80% 掉到 0%持续几百毫秒再弹回来。这个脉冲形态非常典型说明问题大概率不在计算本身而在数据供给或通信同步上。接着我用 PyTorch Profiler 采集了 50 个 step表格里最刺眼的几行是 DataLoader 的耗时占了整个 step 的 60% 以上。具体到函数层面cv2.imdecode和PIL.Image.resize各吃掉了一大半 CPU 时间。然后我又看了一眼 CPU 占用8 个物理核心全部被打满。哦原来这个任务根本没开多进程数据加载就是单线程在那儿一张一张读图。怪不得 GPU 利用率上不去CPU 每生产一个 batch 的时间比 GPU 算完一个 batch 的时间长好几倍GPU 只能干等。5.2 一步步优化与效果对比我做的第一步优化很简单重写 DataLoader把num_workers设为 12、prefetch_factor4、pin_memoryTrue、persistent_workersTrue。这一个改动之后GPU-Util 从 20% 提到了 60% 左右整个训练时长缩短到原来的二分之一到三分之一。但我还不满足因为 CPU 依然接近满负荷说明它已经成为新的瓶颈只是比原来缓解了。第二步优化是针对图像预处理。因为这些图片都是固定尺寸的小图不需要在每次加载时做随机裁缩放了的。我把所有样本在训练开始前做了预处理先 decode 和 resize 到统一尺寸再缓存为一个内存张量列表。这样训练时 DataLoader 只做 tensor 切片和归一化CPU 的压力大幅下降。这一步又把 GPU-Util 拉到了 85% 左右。第三步优化是开启 AMP同时把torch.compile打开。AMP 让每个 step 的计算时间显著变短而torch.compile做了算符融合也能稍微提速。这时候 GPU-Util 已经能稳定跑到 90% 以上了。不过这里有个经验教训torch.compile第一次调用需要编译特别慢所以别直接在生产脚本里开先在验证集上跑几十个 step看没问题再正式启用。我最后又做了一次通信检查发现这个模型相对不大all-reduce 开销只有 10% 左右所以我没有上梯度累积。如果模型再大一些参数再多一些我大概率会考虑加上accum_steps2或者 4来压缩通信开销。最终效果对比大概是这样优化阶段GPU 利用率每 epoch 耗时主要瓶颈初始状态15%-25%72 分钟CPU 数据加载调优 DataLoader 参数后约 60%31 分钟CPU 预处理缓存预处理结果后约 85%22 分钟显存带宽有限开启 AMP torch.compile 后90%18 分钟小幅通信开销从这个案例里你可以看到单机调优的价值是巨大的从 72 分钟到 18 分钟四倍加速。很多时候你以为需要升级机器或者上调度器其实不过是把单机内部的“水龙头”拧开而已。5.3 常见问题速查表最后给出一张速查表是我在实际操作中总结出来的高频问题和解决思路方便大家直接对照。现象常见原因检查手段推荐方案GPU-Util 持续为 0%显存却很高数据加载卡死 / 进程挂起py-spy dump 看线程栈检查 DataLoader、数据路径是否可读GPU-Util 周期塌陷像心电图CPU 预处理慢 / 通信等待Profiler 看 DataLoader 耗时调大 num_workers、prefetch_factor考虑缓存单卡占用 90% 以上其他卡接近 0%DDP 初始化失败 / 模型只放在单卡查看进程数量和启动命令用 torch.distributed.launch 能正确拉起多进程训练跑到一半卡住GPU 利用率 0%NCCL 超时 / 通信组不完整查看 NCCL 报错日志检查网卡、NCCL_P2P_DISABLE 等环境变量显存 OOM但显存曲线有明显碎片动态 shape / 显存碎片用 PyTorch Profiler 看 memory 分布固定输入 shape关掉 cudnn.benchmark 或开启 max_split_size_mbCPU 占用极高GPU 占用上不去DataLoader worker 太多或 CPU 资源不足htop 看各核心占用降低 num_workers用更高效的预处理库这里面要特别提醒一句NCCL 报错是很吓人的动不动就是 “Timeout” 或者 “unhandled system error”但它不一定代表你的网络或者集群有问题。单机多卡训练中NCCL 超时常常是因为某张卡的训练进程因为 DataLoader 异常死掉或者卡住了导致其他卡等不到同步最后超时。所以遇到 NCCL 报错先从 CPU 侧和 Python 进程状态查起很多时候根子不在通信。我个人在实际操作中还有一个习惯每次训练启动后先不急着跑完整流程而是把train_loader单独拿出来跑几个 epoch打印每个 epoch 的耗时。如果数据加载本身就要花 20 秒一个 epoch而 GPU 上训练 1000 张图只要 2 秒那问题已经很明确了根本不用上 Profiler。这个习惯帮我省了很多时间尤其是在接手别人的老旧训练脚本时。另外我还想分享一个关于“先单机后集群”的心得。做 AI Infra 很容易被“集群调度”“云原生”“大规模分布式”这些词吸引好像不搞点大架构就不够高级。但真正稳定的系统一定是先把单机这块地基打牢再谈横向扩展。如果你的单机多卡任务都跑不出应有的利用率那即使上了 Kubernetes也只是把一个低效的任务复制到更多机器上浪费的资源只会成倍增加。先把 8 张卡都调动起来再去想如何调度 1000 张卡这才是正确的顺序。这篇文章里讲到的 DataLoader 调优、AMP、通信开销观测、梯度累积其实都属于单机调优的入门动作。后面我会继续写训练与调度的中篇和下篇讲讲集群调度器选型、任务排队、资源配额这些偏系统的内容。但不管后面写得多复杂我都会以“单机内部是否真正跑满”作为第一准绳。你也可以这样记看到 8 张卡闲着先别急着怀疑调度器很可能只是你欠下了单机调优这笔账该还了。