
1. 开局一张图8 张卡只有 1 张在卖力问题出在哪先说个我上周实际遇到的事。朋友发来一张nvidia-smi截图机器是 8 卡的 A100跑的是一个多卡分布式训练脚本用的 PyTorch 自带的DistributedDataParallel。截图里 8 张卡的显存都占上了但利用率一栏非常难看GPU 0 在 95% 上下跳动GPU 1 到 GPU 7 的利用率基本趴在 5% 到 15% 之间个别卡直接是 0%。他说了句很经典的话数据都分到 8 张卡上了显存也都吃满了为什么只有一张卡在算这个问题我见过太多次了几乎每隔一段时间就会有人拿着类似的截图来问。很多人第一反应是网络问题、NCCL 通信配置不对甚至怀疑是显卡本身有故障。但在我帮他看了一圈之后真正的答案并不在通信层而在更基础的地方——单机训练的数据管线本身就堵死了。这也是我把这一章定名为训练与调度上的原因。很多人一上来就想搞分布式、搞大规模并行但连单机 8 卡都喂不饱上再多卡也只是让显卡排队摸鱼。单机调优不是进阶课恰恰是欠下的第一笔账。先把单机这条链路打通再去谈多机多卡顺序不能反。这篇文章不打算讲高深的理论就围绕一个核心问题展开当你拿到一台 8 卡机器训练跑起来之后怎么判断瓶颈到底在哪怎么把空转的那 7 张卡真正用起来。我会按排查的真实路径走一遍从测量工具、定位方法到最终的操作调整全程都是可以直接照抄的实践经验。2. 你看到的利用率可能从头就是错的2.1 nvidia-smi 的利用率数字到底在说什么第一步先把判断标准纠正过来。很多人的调优从第一步就陷入了被动因为他们在用nvidia-smi的 GPU-Util 列判断显卡是否在工作。nvidia-smi里那个 utilization 百分比其实是 GPU 在一段时间采样窗口内有计算任务在执行的时间占比。换句话说它衡量的是是否有内核在跑而不是计算单元有多繁忙、多高效。它会把任何类型的 GPU 活动都算进去包括很轻量的内存拷贝、很小的 kernel launch甚至一些空闲周期的轮询。举个例子如果你的代码在 GPU 上频繁做很小的张量操作中间穿插大量 CPU 同步等待nvidia-smi的利用率可能会显示 50% 甚至更高但实际的计算吞吐低得可怜。反过来如果当前正跑着一个很大的卷积层采样窗口恰好落在计算间隙利用率显示也可能掉到 30% 以下。也就是说这个数值只能作为粗略参考不能当作判断瓶颈的唯一依据。很多人的误区在于看到某张卡利用率 90% 就觉得没问题看到 10% 就觉得是这张卡的锅。在 8 卡训练场景里这个判断会直接误导排查方向。2.2 真正的监控组合别只用一把尺子量我在实际工作中会同时用三类工具来还原训练的真实状态第一类是nvidia-smi dmon它能按秒输出 GPU 利用率、显存读写带宽、温度、功耗。注意看 SM 利用率和显存带宽这两列如果 SM 利用率高但显存带宽不高通常是计算密集反过来如果显存带宽打满而 SM 利用率一般那大概率是访存瓶颈。第二类是nsysNVIDIA Nsight Systems这个工具能精确抓取每个 kernel 的耗时、CPU 侧的调用栈、GPU 空闲时间段分布在什么地方。它会告诉你GPU 到底是在等数据、等同步还是真的在算。用nsys profile --tracecuda,nvtx,osrt python train.py跑一小段训练生成的可视化时间线能直观看清楚 CPU 和 GPU 的重叠情况。第三类是 PyTorch 自带的torch.profiler它可以从框架层面看到每个算子的耗时、显存分配情况以及是否有 CPU 侧的阻塞等待。配合with torch.profiler.profile(...)包住训练循环里的一个 step就能定位到具体是哪个操作在拖后腿。这三类工具的组合信息能超过 90% 靠nvidia-smi猜测的排查方式。特别是当你需要向别人证明瓶颈不在 GPU 而在 CPU/数据侧的时候一份nsys时间线比任何口头解释都有效。2.3 还有一个最容易忽略的指标SM 占用率我调优时会更关注一个叫 SMStreaming Multiprocessor占用率的东西。简单说GPU 内部有很多计算单元一个 kernel 运行时未必能把所有计算单元都填满。如果 kernel 很小、线程块block数量不足或者显存访问冲突严重SM 占用率就会低。nvidia-smi dmon里sm那一列能直接看到这个数值。如果你发现 SM 占用率常年低于 60%即使 GPU-Util 显示 90% 以上训练效率也没有想象中高。这时候要看的就不是数据管线而是模型本身的 kernel 效率比如 batch size 是否太小、张量形状是否不合理、是否有太多小算子串行执行。顺带一提ncuNsight Compute是专门做 kernel 级分析的能直接告诉你每个 kernel 的 SM 利用率、访存效率、分支发散程度。但如果只是排查训练管线的宏观瓶颈nsys的性价比已经足够不必一上来就上ncu。3. 顺着训练链路找凶手数据、CPU、通信逐一排查3.1 第一站DataLoader 到底是不是真凶回到朋友那个案例。我让他先用nsys抓了一次 profile结果非常典型GPU 的 kernel 之间出现了大段的空闲空洞空洞的位置恰好对应 CPU 侧在等待数据加载。这个模式的本质是GPU 算得太快数据根本供不上。训练循环里的 step 是这样的——先由 DataLoader 从磁盘读数据、做预处理、把张量搬到 GPU然后才能开始前向和反向计算。如果每一步都要 GPU 干等着这批数据准备好了才开工那计算利用率一定会被数据准备速度卡死。我让他看了 DataLoader 的num_workers默认是 0。这意味着数据加载发生在主进程里和 forward/backward 是串行的——GPU 在算的时候 CPU 没在准备下一批数据CPU 在准备数据的时候 GPU 闲着。这种一卡一卡的执行方式在单卡上尚且能忍在多卡 DDP 下就是灾难8 个进程同时抢占主 CPU 做数据预处理CPU 直接被拖垮8 张卡一起等着喂饭。num_workers的设计意图就是让数据预处理并行化用多个子进程提前把数据准备好。把num_workers设成 0等于主动放弃了这层流水线加速。实践中最简单的验证方式逐步增加num_workers同时观察 GPU 利用率曲线。通常在 4 到 16 之间会有一个明显的拐点过了拐点再增加收益就很小了反而可能引入额外的进程调度开销。3.2 第二站磁盘 IO 和预处理 CPU 开销把num_workers调高之后朋友的利用率有所上升从 12% 涨到了 40% 左右但距离理想状态还差得远。于是继续排查——这次问题出在数据读取这一层。他的数据集是几万张高清图片直接从机械硬盘里读。即使开了 16 个 worker每个 worker 都在争抢同一个机械硬盘的 IO 带宽。磁盘寻道时间在这种随机小文件读取场景下特别致命16 个进程同时读不同图片反而让磁盘头来回摆动吞吐比串行高不了多少。这里有几个实际可用的调整思路把数据放到 SSD 或 NVMe 盘上特别是训练集较大的时候。IO 延迟降低一个数量级数据加载瓶颈会瞬间缓解。提前把数据打包成webdataset/tfrecord这类顺序读取格式或者直接用内存映射mmap方式读取。核心逻辑是减少随机 IO改成顺序 IO让磁盘的吞吐优势发挥出来。如果数据集不大直接把它缓存进内存。可以用lmdb或者直接在 DataLoader 里加一层缓存逻辑首次读取后后续步骤都走内存。除了磁盘 IO还要留意预处理本身是否太耗 CPU。比如图片解码、随机裁剪、归一化这些操作如果每 step 都要在 CPU 上重复做worker 再多也不够用。比较激进的做法是预先批量做一次数据增强并落盘训练时直接读已经处理好的数据。当然这会牺牲一部分数据多样性实际是否要这么做取决于模型对数据增强的敏感程度。3.3 第三站通信在单机多卡里到底占多大比重排查完数据侧朋友的利用率终于爬到了 70% 左右。但 8 卡之间依然有差距有的卡 85%有的卡只有 55%。这时候就轮到通信上场了。DDP 训练里每张卡算完梯度之后需要把所有卡的梯度做一次 AllReduce确保每张卡拿到一模一样的平均梯度然后才能进行下一轮参数更新。这个 AllReduce 的时间是纯开销它不做任何计算只是卡之间互相等来等去。在单机多卡场景卡间通信走的是 NVLink 或 PCIe带宽比网络传输高得多但如果 batch size 比较小、每个 step 的计算时间很短那通信的相对耗时占比就会变大。打个比方如果每个 step 计算只要 50ms但 AllReduce 需要 20ms那就有 28% 的时间花在通信上这个开销不可忽略。我让他跑了一个很简单的测试torch.distributed.all_reduce在 8 卡上对一个小张量做 1000 次同步统计平均耗时。如果单次同步耗时和 step 总耗时在一个数量级通信就是瓶颈之一。缓解通信开销比较直接的手段有这么几个把 batch size 翻倍让每个 step 的计算时间变长通信占比自然下降。开torch.backends.cudnn.benchmark True让 cuDNN 在启动阶段搜索当前输入形状下的最优卷积算法虽然不影响通信本身但能缩短计算时间间接缓解通信等待。尝试梯度压缩或梯度累积策略减少通信频率。不过梯度累积在 DDP 下要小心它等效于增大 batch size但学习率的调整并不是简单的线性关系需要配合 warmup 之类的策略不然收敛效果容易翻车。单机多卡的 AllReduce 通信优化空间没有多机那么大因为 NVLink 带宽已经非常可观了。在单机环境里通信问题多数是计算太快、通信占比高导致的而不是通信本身慢。3.4 第四站CPU 和 GPU 之间的搬运开销排查到这一步朋友的训练利用率稳定在 75% 上下还能不能继续压榨其实还有一个隐藏瓶颈CPU 和 GPU 之间的数据搬运。很多人都知道.to(cuda)这个操作会把数据搬到 GPU但没想过这个搬运过程本身是同步的。默认情况下PyTorch 调用tensor.cuda()时如果数据在 CPU 侧没有提前准备好会阻塞当前线程等待搬运完成。在 DDP 的每个进程里如果主线程在 CPU 侧做数据处理、然后同步搬到 GPU、再开始计算这个搬运等待时间就是 GPU 的空闲时间。pin_memoryTrue的作用是让 CPU 侧的数据张量锁定在物理内存里这样 CPU 到 GPU 的拷贝可以走更快的 DMA 路径而且能实现异步拷贝不需要在拷贝期间阻塞计算。配合.non_blockingTrue的.to()调用可以让数据搬移和当前 step 的计算重叠起来。这是一个常被忽略的细节。很多人明明num_workers也加了数据也在 SSD 上但利用率就是上不去问题往往就出在 host 到 device 的搬运是同步的训练循环里每 step 都有一段搬运-等待-再计算的串行模板。把pin_memoryTrue打开再用non_blockingTrue这一步的收益常常比调num_workers更明显。4. 8 张卡喂得饱数据管线和进程配置的实操配方4.1 一个可以直接抄的 DataLoader 模板把上面说的要点汇总成一个可复用的配置我自己的训练脚本里 DataLoader 这一层通常是这么写的from torch.utils.data import DataLoader, DistributedSampler train_loader DataLoader( dataset, batch_size64, samplerDistributedSampler(dataset, shuffleTrue), num_workers8, # 按 CPU 核心数的一半起步 pin_memoryTrue, # 锁定 CPU 物理内存加速搬运 persistent_workersTrue, # 避免每个 epoch 反复创建 worker prefetch_factor4, # 每个 worker 预取 4 个 batch drop_lastTrue, )num_workers不是越大越好。它取决于 CPU 核心数和数据预处理本身的耗时。一般经验是从 CPU 核心数的一半开始向上调然后观察 GPU 利用率和 CPU idle 比率找到拐点。persistent_workersTrue只适合 epoch 数量较多且 dataset 不会极端变化的场景它可以省掉每个 epoch 结束后重新创建 worker 子进程的开销但如果 dataset 在训练过程中会变化比如某些在线数据增强要注意预处理逻辑对子进程的可见性。prefetch_factor是每个 worker 在手中预加载的 batch 数量。增大它能打深流水线的缓冲区域给 CPU 和 GPU 之间创造更大的重叠空间但会占用更多内存。如果你的机器内存比较紧不要贪大4 到 8 就够用。关于DistributedSampler多嘴一句它负责把数据集切分成 8 份分给 8 个进程并且保证每个 epoch 的 shuffle 方式不同。注意每个 epoch 开始时要调用train_loader.sampler.set_epoch(epoch)否则 shuffle 顺序在多个 epoch 里会保持一致这可能影响收敛效果。4.2 torchrun 启动参数的几个细节启动 DDP 训练时大多数人会这么写torchrun --nproc_per_node8 train.py这个命令本身没问题但有几个隐藏参数值得留意。--master_port默认是 29500如果同一台机器上同时跑多个训练任务端口会冲突。显式指定一个不常用的端口比如--master_port47777省得任务莫名挂掉时还要怀疑是不是代码问题。--nnodes1是单机多卡的默认值不需要显式写但如果哪天要扩展到多机这个参数是和--nproc_per_node配合着来的。还有--node_rank单机下默认 0多机时每一台机器的 rank 要分别指定。环境变量这一侧CUDA_VISIBLE_DEVICES是一个绕不开的坑。在单机 8 卡环境里如果代码里没有用torchrun而只是设置了CUDA_VISIBLE_DEVICES0,1,2,3,4,5,6,7然后手动用mp.spawn或者multiprocessing启动多进程你要小心进程间 GPU 分配的对齐问题。torchrun会自动帮你把 local_rank 映射到物理 GPU 上但如果绕开它自己写启动逻辑很容易出现多个进程抢同一张卡、其他卡反而空着的乌龙。我见过最典型的错误8 个进程都通过torch.device(cuda)隐式使用 GPU 0然后第 1 个进程占满显存后面 7 个进程直接 OOM。如果你不用torchrun就必须显式在代码里用local_rank设置torch.cuda.set_device(local_rank)并且在进程初始化之前确保CUDA_VISIBLE_DEVICES的顺序和local_rank的映射关系是一致的。4.3 监控脚本别等训练挂了才看排查问题的时候我最常用的是下面这个简单的监控循环它和nsys不同能在训练全程持续输出帮助你观察整个训练生命周期里 GPU 使用率的变化nvidia-smi dmon -s pucvmet -d 5 -o T这条命令每 5 秒输出一次-s参数指定显示的内容p功耗、u利用率、cSM 时钟、v显存使用、m显存带宽利用率、e温度、tPCB 温度。如果训练前 5 分钟利用率正常过了一段时间开始下跌用这个命令能看到一整个时间序列里利用率的变化趋势定位到是不是特定 epoch 或特定数据批次的读取有问题。-o T会输出时间戳方便和训练日志对应起来。排查时如果发现利用率呈周期性下跌下跌的间隔恰好和 epoch 长度吻合那大概率是每个 epoch 结束时的验证阶段在拖时间或者那个 epoch 末尾有较大的数据读取操作。这些周期性波动在nvidia-smi单次快照里根本看不出来但持续监控下会非常清楚。我在训练脚本里还会加一个轻量的自监控每个 step 记录一次这个 step 消耗的时间然后每隔 50 个 step 打印一次平均 step 时间。如果 step 时间突然翻倍那一定是有某个环节出问题了可以立刻止损去查不用等训练结束才发现。5. 从 75% 到 95% 的最后一公里5.1 混合精度不只是省显存还是加速器数据管线修好之后朋友的训练利用率稳定在 75% 到 80% 之间。这个数字对很多任务来说已经不差了但我告诉他还有最后一公里可以走那就是自动混合精度AMP。AMP 的作用是让模型里的部分算子用 FP16 计算部分算子保持 FP32。FP16 的计算在 A100 上有专门的 Tensor Core 加速吞吐量是 FP32 的好几倍。很多人只知道 AMP 能省显存容易忽略它对计算吞吐的提升是实打实的——同一个 kernel用 FP16 在 Tensor Core 上跑比 FP32 快得多。在 A100 这类支持 Tensor Core 的卡上开启 AMP 之后SM 利用率不一定显著变化但每个 step 的墙钟时间会明显缩短。这一点很容易误导人你以为 GPU 利用率既然没变AMP 就没什么用其实它让 GPU 用更短的时间干完了同样的活单位时间处理的数据量变大这才是训练加速的本质。代码层面用 PyTorch 自带的torch.cuda.amp就够了scaler torch.cuda.amp.GradScaler() for batch in loader: with torch.autocast(device_typecuda, dtypetorch.float16): loss model(batch) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() optimizer.zero_grad()GradScaler的作用是防止梯度下溢。FP16 能表示的数值范围比 FP32 小很多梯度值在反向传播过程中可能因为太小而变成 0。scaler 把梯度放大一定的倍数再做更新避免这个问题。如果你开 AMP 后发现损失曲线和中途收敛出问题可以先检查是不是scaler的使用有问题。5.2 小 kernel 太多怎么办把算子的粒度做大AMP 开启后朋友的 step 时间又缩短了一截。这时候再抓一次 profile发现 GPU 的空闲时间已经不多了但 kernel 的执行密度还是偏低——大量耗时不到 10 微秒的小 kernel 在排队每次 kernel launch 都有固定开销kernel 越小launch 开销占比就越大。这在小模型或小 batch size 的训练里特别常见。模型越深算子切得越碎越容易出现大量微 kernel。解决办法有几种把 batch size 增大显存允许的情况下让每个 kernel 的计算量变大launch 开销相对摊薄。用torch.jit.script或torch.compile对模型做一层编译优化把多个小算子融合成一个大算子。torch.compile在 PyTorch 2.x 里已经很成熟了很多场景下能直接降低 kernel launch 次数。如果你不想对整个模型做编译也可以只对个别模块做torch.jit.trace把固定形状的算子序列固化下来减少 Python 层的调度开销。这一步的收益取决于模型结构不用强求。如果你的模型本身已经是几个大矩阵乘法为主融合算子的收益就有限。5.3 梯度累积的副作用小心隐性 batch size 变大的坑很多人为了让8 张卡都有活干而选择梯度累积。梯度累积的思路是每张卡每次前向用小 batch梯度攒好几步之后再统一更新参数。它确实能让通信频率降下来因为每个 step 的 AllReduce 可以减少但代价是等效 batch size 变大了。等效 batch size 变大意味着学习率相应要调整否则收敛可能不稳。比如原来预计 batch size 512 配 lr 1e-3改了梯度累积之后每 4 步更新一次参数等效 batch size 变成 2048学习率通常需要跟着放大或至少做 warmup。没有这种意识的人调完梯度累积之后往往感觉模型收敛变慢了还以为是新配置的锅。梯度累积更适合的是显存不够、不得不减小 batch size 的场景而不是用来让卡忙起来的手段。对于后者优先应该回头检查数据管线和进程配置——先把每张卡的 batch size 顶到显存上限再考虑累积。5.4 最后压榨减少验证和日志带来的周期性卡顿还有一个容易被当成玄学的问题就是训练里穿插了 validate、checkpoint、日志写入这些操作。很多时候这些操作是在前一个 epoch 结束、下一个 epoch 开始的时间窗口里执行的正好卡在 GPU 等待同步的空档上。checkpoint 保存一个大模型到磁盘如果直接用torch.save同步写入几秒钟的磁盘 IO 就能让 8 张卡全部干等。优化手段很直接把保存放到独立的后台线程里执行或者保存到内存缓冲区再异步落盘。torch.save也可以接收文件对象你可以把它写到一个有缓存层的路径上减少对训练主循环的阻塞。验证阶段如果用的是和训练同一个 GPU额外的前向计算必然抢占训练资源。合理的做法是把验证拆到更稀疏的间隔或者干脆用单独的进程/卡去跑验证不要和训练公用 GPU。这些细碎操作全部处理完之后朋友的 8 卡利用率终于稳定在了 90% 以上个别卡能到 95%。和最初只有 1 张卡在干活相比同样的训练任务迭代速度提升了接近 6 倍。6. 一些不会写进文档的排查心得这一路调下来有几个经验我想单独拎出来说一下因为它们不是查文档能查到的东西而是踩过坑之后形成的直觉。第一调优之前先确认你的 baseline 是合理的。如果你拿一个本来只需要 1 张卡 10 分钟就能跑完的小任务去做 8 卡 DDP那么通信开销、进程调度开销反而会让总时长变长。单机多卡的优势在于吞吐量不在于单次任务的启动速度。先跑一批数据算出单个 step 的理论耗时再对比多卡下的实际耗时你才能判断优化有没有效果。第二任何一个参数都要单变量改、单变量回滚。我见过不少人一次性把num_workers、prefetch_factor、pin_memory、AMP 全改了然后发现利用率上去了但不知道是谁的功劳。这倒还好最怕的是同时改太多导致训练结果变差你根本不知道该回滚哪一项。每次只改一个变量记录 step 时间的变化这是最笨但也最快的方法。第三Linux 系统下别忘了检查 CPU 频率和节能策略。有时候 GPU 利用率上不去原因居然是 CPU 处于省电模式导致数据预处理的性能撑不起来。可以先用cpupower frequency-info查看当前 CPU 频率状态如果发现频率波动很大可以考虑把 CPU governor 设为 performance 模式。这在服务器上不一定总是适用有些机房可能限制但排查到山穷水尽的时候值得看一眼。第四也是最重要的一点调优的目标是让训练更稳定、更高效不是让 GPU 利用率变成 100%。利用率 95% 和 100% 之间差的那 5%可能是 kernel launch 的固有开销、可能是通信必须付出的代价强行追求极致利用率往往需要牺牲代码可维护性和灵活性。我的习惯是跑到 85% 到 90% 就可以收手了把时间留给模型本身的迭代这才是真正提升产出的做法。回到开头那个朋友的问题。他没花一分钱换硬件也没改一行模型代码只是把 DataLoader 的线程数、pin_memory、AMP、checkpoint 异步保存这些单机基本功做好训练速度就提升了将近 6 倍。这种提升在 AI Infra 领域里一点都不性感但它是实实在在的账。先把这笔账还清再去碰那些更复杂的分布式调度系统路才会走得稳。最后再给一个马上能用的检查清单看到 8 卡训练利用率不均匀时先查nvidia-smi dmon的 SM 利用率和显存带宽是不是一致偏低再查 DataLoader 的num_workers和pin_memory配置然后看 batch size 是否达到显存上限最后用nsys看一眼 GPU 的空闲时间是不是集中在 kernel 之间。这套排查流程走完绝大多数单机多卡的空转问题都能得到解释。