
“8 张卡 7 张闲”——这是我入职现在这个团队后第一次做 AI Infra 巡检时在监控屏上看到的真实画面。8 张 A100只有 1 张在跑GPU 利用率稳定在 40% 上下其余 7 张要么空转要么卡在某个同步点上干等。当时组里正准备上一个多机训练计划leader 的意思是直接铺调度器、把任务摊到更多机器上。我跟他说先别急着上集群单机调优这笔账不还上再多卡也是给供应商送钱。这一篇是 AI Infra 系列的第五章聊训练与调度。标题里的“8 张卡 7 张闲”不是段子是很多团队每天都在发生的现实硬件买了一大堆利用率却低得惊人。而问题的根源往往不在集群调度层而在单机内部。很多人一谈训练性能就想到分布式、想到调度器、想到算力调度系统却忽略了一个最基础的事实——单机都没调明白多机只会把问题放大。所以这第一笔账欠在单机调优上早晚要还。1. 为什么单机调优是“第一笔账”1.1 先算一笔经济账先说钱的事这最直接。假设一张 A100 80G按云厂商按时计费大约每小时 10 到 15 元人民币自建机房折算服务器折旧、电费、运维人力单卡每小时的综合成本也在这个量级。8 张卡跑一天就是 2000 元左右。如果利用率只有 20%相当于每天烧掉 1600 元在“付费闲置”上。一年下来大几十万就没了。这个账算完很多人的第一反应是“那就多买卡分摊”——方向就错了。利用率低不是卡不够是卡没被用起来。买更多卡只会让闲置的绝对成本翻倍而不是降低。正确的思路是先看单卡到底能不能打满打不满的原因是什么再把瓶颈一层层剥出来。我见过不少团队一到训练慢就先提“上多机”“搞调度”结果单机 8 卡利用率不到 30%上了集群调度后反而更慢。原因后面会讲调度解决的是“任务分给谁”的问题解决不了“任务在单机上为什么跑不动”的问题。这是两层完全不同的事。1.2 集群调度救不了单机瓶颈很多人把“训练性能差”归咎于“没有好的调度器”于是引入各种集群调度框架。但调度器能做的事情本质上是把任务放到有空闲资源的机器上保证排队公平、资源不冲突、失败能重试。它回答的是“这张卡给谁用”不回答“这张卡为什么用不好”。举个很直观的例子如果单机训练时 DataLoader 喂数据太慢8 张卡每跑完一个 step 都要停下来等数据那么无论调度器多聪明每张卡的利用率都被数据加载卡死。这个时候把任务扩展到两台机器通信开销只会更高问题反而更严重。再比如如果代码里梯度同步频繁、kernel 启动开销巨大多机场景下这些开销会进一步放大。所以我在团队里立过一条规矩任何分布式改造之前必须先做单机压测。单卡利用率低于 90%不准谈多机单机 8 卡利用率低于 85%不准谈集群调度。这条规矩被验证过很多次——绝大多数“训练太慢”的问题到最后都发现不是集群不行是单机代码根本没写对。2. 从“8 卡 7 闲”到定位瓶颈先看指标再动手2.1 三个基础指标GPU 利用率、显存占用、SM 活跃度要定位单机训练的瓶颈第一步永远是指标不是猜。三个最基础的指标GPU 利用率、显存占用、SM 活跃度。这三个指标经常被混为一谈但它们说的是完全不同的事情。GPU 利用率是 nvidia-smi 里最常见的那个百分比它代表 GPU 在采样周期内有多少时间处于“忙”的状态。注意这个“忙”是很粗粒度的只要有任何 kernel 在执行哪怕只占用了 GPU 很小一部分计算单元利用率都可能显示很高。显存占用就更直白了它只表示显存用了多少跟计算效率完全无关——显存满了不代表计算快显存没满也不代表计算慢。SM 活跃度才是更贴近真相的指标它统计的是流式多处理器上执行的 warp 占比。如果 GPU 利用率很高但 SM 活跃度很低说明 kernel 在频繁启动和退出或者存在大量的空转等待。这种状态有个很形象的说法——“假忙”看起来卡没闲着实际上一半时间在摸鱼。判断假忙有一个很简单的办法跑起来后用 nvidia-smi dmon 盯一两分钟看 SM 活跃度曲线。如果是参差不齐的锯齿状说明任务没有持续喂满计算单元如果是相对平滑的高位曲线说明计算是饱满的。2.2 nvidia-smi 与 DCGM识别“假忙真闲”入门定位用 nvidia-smi 就够了。我常用的命令是nvidia-smi --query-gpuindex,utilization.gpu,memory.used --formatcsv -l 1这条命令每秒刷新一次显示每张卡的利用率和显存。更细一点的监控可以用 dmonnvidia-smi dmon -c 60 -d 1 -s pucvmet-s 参数里 p 是电源、u 是利用率、c 是 SM 时钟、v 是显存、m 是显存时钟、e 是温度。这个命令能看到比 query 丰富得多的信息。如果做长期监控或者想采集到 Prometheus就需要上 DCGM。NVIDIA 官方提供的 dcgm-exporter 可以暴露非常细粒度的 GPU 指标下面这几个是我在排查训练性能时最常看的指标说明典型问题DCGM_FI_DEV_GPU_UTIL整体利用率长期低于 50% 说明有瓶颈DCGM_FI_DEV_SM_OCCUPANCYSM 占用率kernel 太小或 launch 过密时偏低DCGM_FI_DEV_NVLINK_TX_BYTESNVLink 发送字节数多卡通信是否打满链路DCGM_FI_DEV_GPU_TEMP核心温度超 85 度可能触发降频DCGM_FI_DEV_POWER_USAGE实时功耗利用率高但功耗低说明计算不饱和我排查问题时最依赖的是 SM occupancy 和 NVLink 流量这两个指标。前者告诉我们 GPU 内部的并行度是否饱和后者告诉我们多卡通信是否真的是瓶颈。很多人只看利用率一个数字结果被“假忙”骗了很久。2.3 计算瓶颈还是通信瓶颈一张图判断法定位瓶颈时第一个要分清的问题是当前限制训练速度的是计算、数据加载还是多卡通信有一个非常实用的小实验不需要任何 profile 工具把 batch size 减半观察每个 step 的训练时间。如果 step 时间也近似减半说明当前处于计算密集状态GPU 在满负荷运转如果 step 时间几乎不变说明瓶颈不在计算而在数据加载或者通信等待。这个判断方法虽然不是万能的但对于快速分流非常有效我在多个项目里靠它省下了大量时间。更精确的做法是用 Nsight Systems 采集时间线。它能看到每个 kernel 的实际执行时间和间隙gap时间。如果 kernel 之间的 gap 很长大概率是数据加载或者同步等待如果 kernel 本身执行时间很长那就是计算密集。Nsight 的时间线还有一个好处能直观看到通信 kernelNCCL和计算 kernel 是否重叠——训练调优的一个重要目标就是让通信跟计算重叠而不是通信时计算全部停下来等。3. 单机多卡训练调优五个落地手段3.1 DataLoader 是第一个背锅侠也往往是真凶如果做一次统计“训练慢”的根因里DataLoader 能排到前三。它的表现很有迷惑性GPU 利用率像心电图一样忽高忽低而 CPU 和内存看起来也没满。很多人因此怀疑网络、怀疑 GPU唯独没想到数据加载早就悄悄拖住了整个训练。DataLoader 调优有四个关键参数num_workers、pin_memory、prefetch_factor、persistent_workers。我见过太多代码直接跳过这些参数用默认值然后 GPU 等数据的间隙时间长达一半以上。先说 num_workers。它控制用于数据加载的子进程数量。经验值是 CPU 核数的 2 到 4 倍但并不是越大越好——worker 太多会增加上下文切换和内存拷贝的开销反而变慢。我实践下来常见服务器配置下设置成 16 到 32 是一个合理区间具体需要结合单 step 耗时来调。pin_memoryTrue 能减少 CPU 到 GPU 的内存拷贝时间这个基本是无脑开的。prefetch_factor 控制每个 worker 预取多少批次数据默认值偏小可以适当调大。persistent_workersTrue 可以避免每个 epoch 重启 worker 进程的开销。另外容易被忽略的是 transforms 的执行位置。很多人把图像增强、格式转换这些操作直接写在 Dataset 的__getitem__里但它们其实是被各 worker 进程并行执行的只要 num_workers 充足就没什么问题。真正的问题往往出现在“CPU 转换完但 GPU 没活干”的间隙这时候需要把 torch.cuda.Stream 并行化让数据拷贝和计算重叠。我在一个目标检测训练任务里做过一次对照实验num_workers 从默认的 2 调到 16pin_memory 打开prefetch_factor 调到 4训练吞吐直接提升了 40%。改的全是参数一行算法代码没动。3.2 混合精度一分钱不花就能提效的“白嫖”优化混合精度是单机调优里性价比最高的一项没有之一。它的原理说起来不复杂在训练过程中用 FP16 做大部分矩阵计算和卷积用 FP32 维护权重和梯度对梯度做动态缩放避免下溢。核心收益来自 GPU 的 Tensor Core。以 A100 为例FP16 的 Tensor Core 算力大致是 FP32 的两倍以上某些矩阵运算能达到 3 到 4 倍。这意味着同样的 GPU光靠混合精度切到低精度计算训练速度就能有明显提升。PyTorch 原生实现很简单scaler torch.cuda.amp.GradScaler() for data, label in dataloader: optimizer.zero_grad() with torch.cuda.amp.autocast(): loss model(data) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()这里要注意的是 GradScaler 的使用不能省。FP16 的数值范围比 FP32 窄很多梯度小了直接变 0梯度大了直接变 inf。GradScaler 会在反向传播前对 loss 乘一个缩放因子更新权重前再除回来并且动态调整这个因子把数值稳定性兜住。还有一个容易踩的坑是某些算子在 FP16 下精度不稳定比如 LayerNorm、Softmax 这类归约型算子。autocast 会自动把它们保留在 FP32 下计算所以正常使用没问题。但如果遇到自定义算子就必须手动关注精度表现。另外Ampere 架构之后还支持 BF16它的指数范围和 FP32 一样比 FP16 稳但精度位数更少不是所有任务都适合需要实验验证。3.3 AllReduce 同步陷阱通信占比怎么算单机 8 卡如果用 DistributedDataParallelDDP训练每过一个 step所有卡之间都要做一次梯度同步这会跑一个叫 AllReduce 的集合通信操作。很多人以为 8 卡在同一个机箱里、有 NVLink 就不会有通信问题其实大模型场景下通信照样能占总时间的 30% 以上。AllReduce 的通信量是可以提前算的。Ring AllReduce 实现下同步一次梯度的通信量大约是2 * (N-1) / N * 模型参数量 * 参数字节数。以 8 卡为例2 * 7 / 8 1.75也就是要同步大约 1.75 倍模型大小的数据。一个 70 亿参数的模型FP32 下就是 28GBFP16 下也有 14GB。这个数据量每步都要在 NVLink 上跑一遍模型越大通信占比越高。所以 DDP 在单机多卡上并不是“免费”的。如果你的模型很大、通信占比很高有几个调优思路第一尽可能让反向传播和梯度同步重叠。DDP 默认是梯度算完一部分就同步一部分bucket 机制这个行为可以通过参数调整比如增大 bucket 大小来减少同步次数。第二检查 NVLink 是否真的跑满了。单机 8 卡一般通过 NVLink 全互联但双路 CPU 平台上如果卡分布在两个 NUMA 节点跨节点的 NVLink 带宽可能受影响。排查方法就是看 DCGM 的 NVLink 流量指标。第三NCCL 的环境变量。比如NCCL_P2P_DISABLE0默认开启 P2P 传输、NCCL_SHM_DISABLE0一般不需要手动改但遇到通信异常时可以通过调整这些变量来排查。单机场景下大多数通信问题出在驱动和拓扑识别上先确认nvidia-smi topo -m的拓扑输出是否符合预期。3.4 显存不够时怎么办梯度累积与激活重计算8 卡训练另一个常见问题是单卡显存装不下一个 batch于是很多人直接调小 batch size结果 GPU 利用率直线下降。这是因为小 batch 导致 kernel 执行时间变短启动开销占比变大GPU 往往在一个 kernel 刚热起来就结束了。更好的方案是梯度累积。把 batch size 设为 B显存只能放下 batch size B/4那就分 4 个小 batch 前向传播并累积梯度攒满 4 次再做一次反向更新。这样扩大了“等效 batch”而显存和计算利用率都保持在高位。唯一要注意的是 BatchNorm 的统计量问题——梯度累积时 BN 的 running mean/variance 是基于小 batch 更新的跟真正的大 batch 不完全等价。如果模型用了 BN建议换成 SyncBN或者干脆换用 LayerNorm 类结构。激活重计算activation checkpointing是另一种换法。前向传播时把大部分中间激活值丢掉反向传播时重新计算一遍前向拿到它们。代价是计算量增加了大约 30%收益是显存占用能降一个数量级。在 A100 这类算力充足的卡上这个交换经常是划算的。大模型训练几乎都会开 activation checkpointing就是这个原因。如果显存还是不够再往前一步就是把优化器状态和梯度搬到 CPU 上这就是 ZeRO-Offload 的思路。代价是 CPU 和 GPU 之间的 PCIe 传输有延迟上限需要仔细做性能评估。单机场景下我建议优先把 DataLoader、混合精度、梯度累积这几项做完再考虑 offload因为 offload 的工程复杂度明显更高。3.5 存储与 IO被低估的瓶颈最后说存储。很多训练任务的瓶颈根本不在 GPU而在存储系统。尤其是数据集很大的场景比如目标检测、语义分割每轮 epoch 都要把几十 GB 甚至几个 TB 的数据从磁盘读一遍读不快GPU 就只能干等。判断存储是不是瓶颈有个很直接的办法训练时看 CPU 的 iowait 和使用率。如果 CPU 使用率不高但 iowait 偏高大概率是磁盘读得太慢。另一个间接信号是第一个 epoch 比后续 epoch 慢很多说明后来数据进了 page cache靠内存加速了。解决办法从简单到复杂排列数据量不大就把数据全部载入内存数据量中等问题就用 NVMe 本地盘而不是网络文件系统数据量太大就上流式读取框架比如 WebDataset 或者 TFRecord把数据做成连续的大文件分片减少小文件随机读的性能损失。我强烈建议把训练数据放到本地 NVMe 盘上先跑一遍排除存储因素的干扰。如果本地盘速度正常就说明代码本身没问题再去优化远端存储的带宽和缓存。如果本地盘也慢问题就在代码里别让存储背锅。4. 常见问题与排查技巧实录4.1 我踩过的坑一张速查表这几年调过的训练性能问题不少挑几个最常见的整理成一张速查表碰到类似情况可以直接对照着排查。现象可能原因检查方法解决办法GPU 利用率锯齿状波动DataLoader 喂数跟不上看 CPU 使用率、iowait调大 num_workers、pin_memory单卡利用率高但总吞吐低kernel launch 太频繁Nsight Systems 看 gap 时间用 CUDA Graph 减少启动开销8 卡中有部分卡利用率明显低负载不均衡、同步等待dmon 看每卡状态检查 batch size 是否均匀、通信是否阻塞显存够用但速度上不去存储读取慢对比本地盘和远端盘耗时数据先缓存到本地 NVMe混合精度开启后 loss 异常梯度溢出观察 GradScaler 的 scale 值是否趋近 0检查损失函数和自定义算子的数值稳定性扩展到多卡反而变慢通信占比过高算一下 AllReduce 的理论通信量模型过大时换用 ZeRO/FSDP4.2 一次真实的“8 卡 7 闲”排查记录去年做 YOLOv8 训练自己的数据集时遇到过非常典型的“8 卡 7 闲”问题。当时 8 张卡跑起来只有 0 号卡利用率到了 90% 以上其余 7 张卡在 10% 到 30% 之间疯狂跳动。第一反应是数据并行出了问题查了 DDP 初始化、rank 分配都没毛病。后来用 dmon 盯了十分钟发现一个规律0 号卡的利用率先抬起来其他卡隔了大约 500 毫秒才跟上然后一起掉下去。说明每步训练都有人先到其他人还在等数据。接着排查 DataLoader。CPU 使用率才 20%看起来不像算不过来。再看磁盘发现数据放在 NFS 网络挂载盘上每秒读取不到 20MB而数据集里有大量几百 KB 的小图片文件。真相大白了——数据并行下 8 个 worker 进程同时去 NFS 上读小文件每一次随机读都要经历网络往返数据加载直接变成了整个训练的铁短板。解决也简单把数据集预先拷贝到每台机器的本地 NVMe 盘上训练脚本里加了一行逻辑启动时检查本地有没有数据、没有就同步一次。改完之后 GPU 利用率从 30% 左右稳定到了 85% 以上训练速度提升了两倍多。这个案例我印象太深了因为从头到尾没有动过一行网络结构纯粹是 IO 路径的问题。4.3 单机调优与集群调度的边界在哪里聊到最后回到标题里的“调度”。很多人理解的调度是集群调度层的事情把任务分发给多台机器。但单机训练内部其实也有两层调度一层是 GPU 上 kernel 的调度另一层是多卡之间通信任务的调度。这两层没优化好上层集群调度做得再漂亮都没用。举一个我对接过的实际案例某团队开发算力调度系统把训练任务调度到一批 GPU 节点上但每次跑起来任务都超时。系统层面看资源分配、优先级、抢占都正常。最后发现是训练代码本身的问题——DataLoader 的 worker 数太少、没开混合精度、梯度同步全是串行等待。调度系统再努力也没法让一段本质低效的训练代码“飞”起来。所以我的一个明确观点是单机调优是集群调度的前置条件。单卡利用率达不到 90% 以上先别搞多机单机 8 卡做不到稳定打满先别上集群调度。这不是说集群调度不重要而是说任何上层系统都需要一个健康的地基。先做单机调优再谈分布式和调度这个顺序不能反。根据我个人的经验最好把单机调优沉淀成一套上线前的固定压测流程每次新模型训练前跑固定数据量盯十分钟 dmon记录利用率、SM occupancy、通信耗时这三个关键数字。这样既能在问题爆发前发现隐患也给后面多机扩展留下一个可对比的基准线。我后来做的很多多机性能优化都是拿这套单机基准数据做参照才能快速定位出到底是网络拖了后腿还在代码本身没调好。