ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

单机8卡GPU利用率低?一文讲透训练调优核心方法

单机8卡GPU利用率低?一文讲透训练调优核心方法 1. 从核弹级问题说起调度不是银弹单机才是第一现场这篇文章是我 AI Infra 系列里拖了最久才动笔的一篇。原因很简单“训练与调度”这个题目太大大到一上来就想聊集群调度、资源池、任务编排、弹性扩缩容。但真到了现场我被问得最多的问题往往不是“怎么上集群”而是“我 8 张卡为什么 7 张在睡觉”先说结论80% 的“多卡利用率低”问题根子不在调度器而在单机训练环境本身就没调平。集群调度解决的是“多个人抢卡时怎么分”的问题单机调优解决的是“卡分到你手里后怎么把它的算力榨干”的问题。这两件事顺序不能反。你单机的 8 卡跑得像 8 个人轮流用一台电脑上再多新节点也只是把浪费复制更多份。这篇文章是整个“训练与调度”系列的上半篇我先把单机这笔账算清楚。集群调度那套等单机跑得又快又稳了再谈否则就是空中楼阁。1.1 先看一个扎心的现场8 张卡到底“闲”在哪我在帮团队做训练性能排查时最常见到的画面是这样用nvidia-smi看8 张 A100 的显存全部占满GPU-Util 那一栏忽高忽低平均下来可能 30% 到 60%。更有意思的是显存占用是满的但 SM流式多处理器的实际计算单元大量处于等待状态。注意显存占用率和 GPU 算力利用率是两个完全不同的概念。显存满只代表你数据放得下不代表计算在跑。真正决定训练快不快的是 SM 的 busy 率也就是算力利用率。很多人的误区是只要显存不爆、loss 在降就觉得训练没问题。实际上这时候模型可能正在以一半不到的效率“龟速前进”而你不会意识到直到你自己去量化每一步的时间。我刚开始做 AI Infra 时也是一样第一次用 8 卡训练一个视觉模型跑起来一看8 张卡里有一半的 Util 在 0% 到 20% 之间来回蹦loss 倒是每步都在降但总训练时间比预估多出了快三倍。那时候我才意识到训练速度和“能不能跑”是两回事后者只保证下限前者才决定产出效率。1.2 单机调优的核心原则先定位再动手很多人一听说调优第一反应是改 batch size、换优化器、上 AMP。这些都是好手段但顺序不对。真正专业的做法是先建立测量基线再根据数据说话。我个人的实操顺序是这三步测用工具量化每一步的耗时分布搞清楚时间花在了数据加载、前向计算、反向传播还是通信同步上。归因找出耗时占比最大的环节判断是计算密集、访存密集还是通信密集。优化针对瓶颈做定向调优而不是盲目堆配置。这也是我为什么说“你欠下的第一笔账”——很多人根本没有做第一步和第二步直接跳到第三步改了一堆参数结果改完还是慢最后得出“8 卡不如 1 卡”的错误结论。2. 先学会看数据测量工具怎么选、怎么看单机调优的第一步就是测量。这一步做扎实了后面所有的优化动作才有依据。我常用的工具其实就三个nvidia-smi、nvidia-smi dmon和Nsight Systemsnsys。这三个工具互相配合基本能解决 80% 的定位需求。2.1 nvidia-smi 的正确看法别只看显存很多人敲完nvidia-smi后只扫一眼显存和 Util 就完事了。这在日常巡检没问题但在调优场景下远远不够。nvidia-smi里有两列数据要重点关注GPU-Util和Memory。GPU-Util列显示的是 GPU 在过去一个采样周期内有一个或多个内核在执行的百分比。注意它不等于 SM 的满负荷时间。你跑一个很小的算子只要内核在启动Util 也能显示 100%但这时候 GPU 大部分计算单元其实是空闲的。经验之谈如果你看到 GPU-Util 接近 100%但训练速度还是很慢说明 GPU 在执行大量小算子没有把计算单元真正堆满。这时候要关注的是核函数启动开销和算子融合而不是简单的利用率数字。另外还有一个命令我几乎每次定位问题都会用nvidia-smi dmon -c 10 -s puc。它每秒输出一次 GPU 的利用率、功耗、温度和 SM 时钟频率。功耗是最容易被忽略的信号——如果 GPU 功耗一直很低比如 300W 的 A100 只跑在 150W说明它根本没在满负荷工作瓶颈大概率在数据供给或通信等待上。2.2 Profiler 帮你把时间账算清楚nvidia-smi只能看到 GPU 整体状态要精准定位瓶颈还得用 Profiler。我主要用 Nsight Systems也就是nsys。nsys profile能看 CPU 侧的线程活动、GPU 内核的执行时间、CUDA API 的调用耗时、显存拷贝时长。它最大的价值是能告诉你每一步训练里GPU 是在等数据、等同步还是真的在算。我习惯用这样的命令抓 profilensys profile --tracecuda,nvtx,osrt -o train_trace -w true \ python train.py --config configs/my_config.yaml抓完会生成train_trace.nsys-rep文件用 Nsight Systems 打开后我一般只看三个时间线CUDA API 耗时如果发现cudaMemcpy类调用占比很高说明数据从 CPU 传到 GPU 的路径有瓶颈。GPU 内核间隙如果 GPU 时间线上有明显的空白缝隙说明 GPU 在等待 CPU 侧的数据准备或调度典型的喂不饱。通信时间多卡训练里还能看到 NCCL 相关调用。如果每步训练中通信时间占比超过 20%就要考虑梯度压缩或通信拓扑的优化。补充说一点很多 PyTorch 用户也会用torch.profiler。它的优点是和训练代码耦合紧能准确输出每个模块的耗时。但它的侧重点在张量算子和 Python 层对系统级瓶颈比如 CPU 线程调度、NVMe 排队延迟的覆盖不如 nsys 全面。我的习惯是先用 torch.profiler 做粗筛再用 nsys 做细查。2.3 建立你的基准数据测量不是一次性的。我建议每次改动训练配置前都跑一个固定步骤数的 benchmark比如 50 步记录以下几个指标每秒处理样本数samples/s也就是吞吐量单步耗时s/stepGPU 平均利用率和显存峰值CPU 侧的峰值内存和 IO 等待时间把这些数据记下来改动配置后再跑同样的 benchmark对比变化。这样做的好处是你永远不会凭感觉判断哪个改动有效而是有数据支撑。我见过太多人改完 DataLoader 的 num_workers 后觉得“好像快了一点”但到底快了多少说不清楚。这些问题在 benchmark 数据下会立刻现原形。3. 单机 8 卡跑不满的核心原因拆解做完了测量接下来就要看数据背后的原因。根据我的经验单机多卡训练利用率低的成因基本能归为四类数据供给不足、计算效率过低、通信开销过大、框架配置不合理。下面我逐一拆开讲。3.1 数据供给GPU 等饭吃的隐形浪费“8 张卡 7 张闲”的极常见根因是 DataLoader 喂数据的速度赶不上 GPU 的消费速度。GPU 是高速计算引擎每秒钟能吃掉成千上万张图而硬盘读数据、CPU 做预处理、图片解码这些工作如果没跟上GPU 就只能空转等待。我踩过一个很典型的坑用 8 卡训练 YOLOv8 的自定义数据集数据集存在机械硬盘上num_workers设成了默认的 2。结果 GPU 利用率连 30% 都到不了单步耗时高得离谱。后来把数据先全部拷到 NVMe SSDnum_workers调到 16pin_memory打开训练吞吐直接翻了三倍。教训数据供给问题本质是“生产者-消费者”模型失衡。GPU 是消费者CPU/IO 是生产者消费者越快生产者的压力就越大。生产者的任何一环拖后腿最终都会体现在 GPU 等待上。这里我列一下最常见的四个配置点及其推荐值配置推荐值原因num_workers每张卡 4 ~ 8不等同于越大越好过多会引入 CPU 上下文切换开销pin_memoryTrue锁页内存可以加速 CPU 到 GPU 的数据拷贝prefetch_factor2 ~ 4让 DataLoader 提前预取减少等待时间persistent_workersTrue多次 epoch 间复用 worker 进程避免频繁重启另外数据集如果以大量小文件形式存放比如几万张小图文件随机读取的 IOPS 会成为瓶颈。我的建议是能打包就打包转成lmdb、tfrecord或者webdataset这类格式顺序读取远快于随机读。3.2 计算效率算子碎片化与混合精度数据喂得上了GPU 还不满那就该看计算本身。PyTorch 里一个常见问题是算子碎片化模型里写了大量小算子每个算子的执行时间很短比如几微秒但每次调用都有固定的调度开销。在 GPU 上启动一个小内核的开销可能比内核本身执行时间还长这就会造成 GPU 一直“忙但没干多少活”。我用一个生活类比来解释你知道为什么把行李箱装满比装满几个小袋子效率高吗因为每一次开箱、打包、搬运都有成本。GPU 执行算子也是同理一次执行一个大规模张量运算远比拆成十几次小规模运算高效。解决方法有两种一种是算子融合。把多个连续小算子合并成一个。在 PyTorch 里你可以用torch.compile或者手动把多次矩阵运算合并成一次大的。多卡训练时FlashAttention 这类融合注意力实现也能显著减少小算子数量这也是它在 LLM 训练里普及的原因之一。另一种是混合精度训练AMP。这个属于性价比极高的优化改动小、收益大。torch.cuda.amp可以用半精度FP16 或 BF16跑前向和反向用单精度做参数更新和 loss scaling。BF16 尤其适合大模型训练因为它和 FP32 有同样的指数位范围训练更稳定很多开源大模型的训练流程都默认用它。用torch.cuda.amp的典型写法是scaler torch.cuda.amp.GradScaler() for batch in dataloader: with torch.cuda.amp.autocast(dtypetorch.bfloat16): loss model(batch) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() optimizer.zero_grad()我在只改动这一项的情况下多次实测性能提升都在 1.5 到 2 倍之间属于投入产出比很高的优化手段。3.3 通信开销8 张卡之间的同步成本多卡训练和单卡训练最大的区别就是每次梯度更新前所有卡要交换梯度。这个交换过程用到的库通常叫 NCCLNVIDIA Collective Communications Library。它的耗时取决于两个因素单卡算得快不快以及卡之间的通信带宽够不够。很多人跑多卡训练慢问题就出在通信上。A100 这类卡单卡算力很强如果训练时每个 batch 的计算量不大通信和数据传输的时间反而可能超过计算时间这种场景叫“通信密集”。GPU 的时间线里会反复出现“计算→等待通信→再计算”的锯齿波。这里要聊一个多数人忽视的细节通信路径并不一样。在一台 8 卡的服务器里GPU 之间通过 NVLink 互联带宽极高A100 大约是 600GB/s但如果你的卡在物理上跨了 PCIe 交换机跨交换机的通信走 PCIe 带宽通常每通道 32GB/s 左右就会比 NVLink 慢很多。实操建议如果是在单机多卡下做训练跑之前先执行nvidia-smi topo -m查看当前环境的 GPU 拓扑。如果发现 8 张卡不是全互联的 NVLink 拓扑而是 PCIe 拼接出来的那就要对通信方式格外上心必要时可以牺牲一部分并行度换取通信链路效率。NCCL 自身的调优参数也值得一看。遇到通信瓶颈时我会先开 NCCL 的调试日志export NCCL_DEBUGINFO export NCCL_DEBUG_SUBSYSINIT,GRAPH打开之后能看到 NCCL 选择了什么通信算法、走了哪条链路。常见的调优方向有NCCL_P2P_DISABLE1关闭 GPU 间直接 P2P强制走共享内存。在部分 PCIe 拓扑下反而更快但这是特例一般不建议默认设置。NCCL_SHM_DISABLE1同理强制走 NVLink/IB。某些虚拟化环境下有用。NCCL_BUFFSIZE调整通信缓冲区大小对吞吐有一定影响。我不建议一上来就乱调这些环境变量。先看默认行为再根据NCCL_DEBUG的输出决定要改哪个。很多时候瓶颈不在 NCCL 本身而是数据或计算的问题。3.4 框架配置DDP 与分布式训练的起点聊到“8 卡”就绕不开 PyTorch 的分布式训练。很多人直接在脚本里写torch.nn.DataParallelDP这是最方便的写法但效率通常不高因为它会在每个 forward 之后做一次全量数据广播GPU 利用率上不去。我建议从一开始就使用DistributedDataParallelDDP它是目前 PyTorch 多卡训练的事实标准。DDP 的核心思想是每个进程持有模型的一个副本用独立的 GPU 处理自己的 batch 子集在反向传播时通过 ring-allreduce 同步梯度。启动 DDP 训练的标准命令是torchrun --nproc-per-node8 train.py在train.py里关键代码如下import torch.distributed as dist from torch.nn.parallel import DistributedDataParallel as DDP dist.init_process_group(backendnccl) local_rank int(os.environ[LOCAL_RANK]) torch.cuda.set_device(local_rank) model model.to(local_rank) model DDP(model, device_ids[local_rank])注意DDP 模式下每个进程的数据加载要用DistributedSampler它会自动把数据集切分成互斥的子集给每个进程。另外分布式训练时学习率通常要跟着 batch size 一起调整——batch size 变大了学习率也要相应放大常见做法是线性缩放规则这一条很多人会忽略。关于“单处理器系统就绪队列采用 FCFS 非抢占调度”这类操作系统级的调度概念它更多是底层系统调度的话题AI Infra 领域在单机场景里通常是操作系统把我们训练进程跑在哪个核上这件事替你做了。不过如果你是在 Windows 的台式机上跑小规模训练并且遇到了奇怪的 CPU 调度导致训练变慢的问题可能需要手动限制进程的 CPU 亲和性把训练主进程固定在性能核上数据加载进程放在能效核上这一招我在调试本地推理环境时用过效果立竿见影。4. 实操从 20% 到 80% 的单机调优实录讲了这么多理论我用一个实际案例把整个过程串起来。这个案例是我帮一个团队调优的 8 卡训练任务模型是类 YOLOv8 的检测模型数据集大约 20 万张图片最初跑起来 8 张 A100 的平均利用率不到 30%。4.1 现状评估先抓到真实瓶颈第一步我先用nsys抓了一份 profile。看到的典型现象是GPU 时间线上有大量空隙每个 step 大约是 1.8 秒但 GPU 实际计算时间只有 0.6 秒。换句话说GPU 每跑 0.6 秒就要等 1.2 秒的数据。这个现象非常直观GPU 在等饭数据供给是瓶颈。第二步我又用nvidia-smi dmon看了一眼功耗。8 张卡的平均功耗只有额定功耗的 40% 左右进一步验证了 GPU 没有满负荷工作。4.2 逐项修改数据、预处理、精度、并行定位到数据供给问题后我做了一系列改动每一项都单独验证效果第一项升级存储与 DataLoader 配置数据集原本在机械硬盘上先把它副本迁移到 NVMe SSD。然后将num_workers从 2 调到 16pin_memoryTrueprefetch_factor4persistent_workersTrue。改动后单步耗时从 1.8 秒降到 1.1 秒。GPU 利用率升到了 50% 左右。这说明数据瓶颈缓解了一半还没完全消除。第二项预处理放到 GPU 上做通过 profile 发现 CPU 端的图片 resize、归一化操作耗时很长。我把这些操作改成torchvision.transforms里的 GPU 版本比如把ToTensor、Normalize这些操作放到 CUDA 上执行而不是在 CPU 上处理完再拷贝。这里的核心思路是能用 GPU 做的就不要让 CPU 做。CPU 承担的是数据读取和格式解码这类非做不可的工作剩下的尽量留给 GPU。第三项开混合精度给训练脚本加上 AMPBF16单步耗时从 1.1 秒降到 0.7 秒。GPU 利用率升到了 70% 左右。第四项调整 batch size 与梯度累积因为显存还有余量我把每张卡的 batch size 从 16 提高到 32。在显存不够的情况下可以用梯度累积模拟大 batch但要记得在累积多个 micro-batch 后 scale 一下学习率。另外说一下梯度累积的适用边界。梯度累积的本质是用时间换显存——如果你的目标是跑更大的 batch这是一个好办法但如果你的问题仅仅是显存不够最好的方案是优化显存分配比如开启gradient_checkpointing激活检查点而不是一味调大累积步数。梯度累积还会影响 BatchNorm 层的统计量BN 统计量只会基于最后一个 micro-batch而不是累积的全局统计这一点很多人会踩坑。在使用 BatchNorm 的模型里我通常不建议开启梯度累积。调整后单步耗时降到 0.5 秒GPU 利用率稳定在 85% 左右。4.3 通信优化多卡收益的最后一公里到这一步GPU 利用率已经达到 85%看起来不错。但我在 profile 里注意到 NCCL 通信仍然占据了每步 0.1 秒左右的时间占 20%。我检查了拓扑发现这台 8 卡机器不是全 NVLink 互连而是跨了两组 PCIe 交换机。这种情况下我调整了 DDP 的进程分配尽量保证同一个通信组里的卡在物理拓扑上相邻。如果你用的是更大的模型也可以考虑梯度压缩梯度量化为 FP16 再通信、梯度分桶把多组小梯度合并成一次通信等优化。PyTorch DDP 其实内置了梯度分桶机制bucket_cap_mb参数可以控制分桶大小。默认是 25MB有些场景调到 50~100MB 能减少通信次数换来更高吞吐。这个参数我试过在部分模型上有明显收益值得列为 DDP 调优的候选选项。经验DDP 里设置find_unused_parametersTrue只在模型有 unused parameters 时才需要。如果一个模型没有未使用参数却开了这个选项额外的梯度遍历会拖慢速度。所以别盲目抄配置先确认你的模型结构有没有 unused parameters。4.4 调优结果汇总整个调优过程下来最终效果如下阶段单步耗时GPU 平均利用率说明初始状态1.8s30%数据、计算、通信都未调优数据链路优化1.1s50%存储升级、DataLoader 配置GPU 预处理 AMP0.7s70%减少 CPU 负担、半精度训练batch 与通信优化0.5s85%增大 batch、调 DDP 分桶整体训练时间缩短到原来的 28% 左右8 张卡终于算“物尽其用”了。整个过程没有任何高深的魔法就是测量、归因、定向优化三步循环。5. 常见问题与排查技巧实录最后分享一些我在实际调试中积累的排查经验和踩坑记录都是文档里不会细写的东西。5.1 显存 OOM 的排查与救急训练时报 CUDA out of memory几乎是每个人都要面对的问题。这里有个关键误区第一反应不应该是调小 batch size而是先搞清楚显存被谁吃掉了。先用torch.cuda.memory_summary()看显存分配明细再判断是模型参数占大头、激活值占大头还是优化器状态占大头。如果是激活值占大头优先考虑开启gradient_checkpointing或者给激活函数加checkpoint。如果你遇到 “CUDA error: out of memory” 但显存似乎还有余量很可能是显存碎片化导致的。解决办法是调整 PyTorch 的显存分配策略比如设置PYTORCH_CUDA_ALLOC_CONFmax_split_size_mb:128减少显存碎片。这个参数在很多长期运行的训练任务中能救你一命尤其是反复调 batch size 的调优期。5.2 训练变慢但利用率正常算子碎片化在作怪有一种情况是 GPU 利用率看着是 100%但训练速度就是不达标。跑 profile 后发现GPU 在执行大量短小内核利用率虚高。比如模型里写了大量的view、permute、transpose操作这些操作在 GPU 上很轻量但多到一定程度调度开销就比计算还大。这时优先考虑算子融合和 torch.compile。torch.compile在我的配置下将一个大模型训练的速度提升了 5% 到 20%具体收益取决于算子的融合潜力。要注意的是torch.compile在首次运行时有编译预热时间这个时间在调参阶段可能让人非常烦躁。我的建议是调参阶段先关掉torch.compile快速试探确定参数后再开启跑正式训练。5.3 断点续训会影响最终效果吗热词里有人问“大模型训练断点续训影响训练效果吗”这个话题我也被问过很多次。我的结论是只要断点保存和恢复的实现正确几乎不会影响最终效果。核心注意点有三个优化器状态必须保存。只保存模型权重不保存优化器的 momentum 和 variance恢复后的训练会有一段“重新热身”的混乱期相当于动了优化器的历史状态。随机性来源要可控。数据加载的 shuffle 种子、Dropout 的随机种子是否恢复到原来的状态会影响后续训练轨迹是否一致。学习率调度器状态。如果用了带步数的 LR scheduler比如 cosine恢复时也要恢复到对应步数否则学习率曲线会突变。保存 checkpoint 时建议至少包含这几样东西checkpoint { model: model.state_dict(), optimizer: optimizer.state_dict(), lr_scheduler: lr_scheduler.state_dict(), epoch: epoch, global_step: global_step, scaler: scaler.state_dict(), }把这个 package 存成checkpoint_{epoch}.pt在训练循环中每隔 N 步定期写入然后保留最近几个版本。这在长时间训练中不是“可选项”而是“必须项”。5.4 我踩过的一个隐蔽坑NCCL 超时和初始化卡死多卡训练刚启动时可能卡在 NCCL 初始化明明 8 张卡都在却一直无法建连。这个问题多数时候是环境变量没有设置一致导致的。torchrun会自动设置MASTER_ADDR、MASTER_PORT、WORLD_SIZE、RANK这些变量但如果你手动起多个进程就容易漏设置导致卡死。我的排查顺序是确认所有进程看到的WORLD_SIZE和RANK是否一致确认MASTER_PORT没被防火墙拦截查看 NCCL 日志确认卡在WARN还是在INIT阶段另外一个极易忽略的问题容器场景里NCCL 需要通过--shm-size设置共享内存大小太小会导致 NCCL 初始化失败或通信异常。我在 Docker 里跑多卡训练时固定会加上--shm-size64g之类的设定否则时不时就遇到奇怪的内存段错误或者通信超时。5.5 训练和推理的区别显存测算为什么不能用同一套标准热词里有一条“gpu显存容量 是测算推理还是训练用的”这个问题其实是很多刚入门的人都会困惑的点。训练和推理对显存的需求逻辑完全不同。推理阶段显存主要给模型权重和中间激活值且 batch size 通常较小显存需求相对可控。训练阶段除了模型权重还要存放优化器状态Adam 动量和方差、梯度、中间激活值显存消耗比推理大得多。一个大致的估算逻辑训练时显存占用大约是模型参数量的 12 到 20 倍具体取决于 batch size、输入尺寸、混合精度策略和是否开启激活检查点。Llama 7B 在 BF16 下做推理权重大约占 14GB 左右但训练时单卡 batch size 设为 1显存都可能在 40GB 以上。这也是“训练显存测算和推理显存测算不能用同一套标准”的原因。我自己在实际项目中会先跑一个小 batch 的测试用torch.cuda.max_memory_allocated()精确统计而不是纯靠公式心算。6. 写在最后单机调优的思路比工具本身更重要这篇我刻意没聊集群调度因为在这个阶段谈集群调度就像地基没打牢就讨论盖几层楼。回头总结一下单机 8 卡跑不满这件事核心就是三个问题数据喂不喂得饱、算力用得够不够透、通信扛不扛得住。顺序上先解决数据再优化计算最后再看通信。很多人的教训是把顺序搞反了一上来就调 NCCL 的环境变量结果数据链路那关还没过改什么都是白改。从我自己这些年踩坑的经验来看最值钱的习惯不是记住多少个优化参数而是在每次改动前先问自己“我要解决什么瓶颈证据在哪里”。没有 profile 数据支撑的调优基本就是瞎猫碰死耗子今天碰对了也不知道为什么对明天碰错了更不知道为什么错。下一篇我会聊聊集群调度层面的设计和常见的调度策略但前提是你的单机训练先跑出高效率来。把这一篇里的账还清你再去看分布式训练的文章会发现很多困扰都是同构的问题解法也是一脉相承的。
返回列表