ARTICLE DETAIL

资讯详情

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

告别GPU闲置:单机训练调优从数据供给到混合精度

告别GPU闲置:单机训练调优从数据供给到混合精度 前阵子接手一个训练环境客户反馈说在8卡机器上跑模型nvidia-smi一看8张卡经常只有1张在动其他7张跟放假似的。第一反应基本都是“是不是集群调度没配对”但查了一圈调度层没有任何问题任务明明已经独占整机了。这一类问题见得多了我基本能断定大概率是单机层面的资源没喂饱分布式调度只是替单机调优背了锅。这也是为什么我特别想把“单机调优”放在训练与调度系列的上篇来写。很多人一提训练性能第一反应就是上分布式、上集群、上调度器却忽略了一个事实——绝大多数训练任务在单卡上都没跑满更别说多卡了。如果单机这一层的账没算清楚上再多卡也只是让GPU排队摸鱼。1. 先别急着上分布式8卡闲置可能和集群没关系1.1 一次真实的8卡利用率摸底我习惯在接手任何性能问题之前先做一次基线摸底。具体操作其实很简单在目标机器上启动一个与线上一致的单卡训练脚本然后用nvidia-smi dmon -i 0 -s puct -d 1去实时监控单卡指标观察一分钟左右记录GPU利用率、显存占用、温度、功耗、SM时钟等数据。一个典型的“8卡7闲”场景摸底数据往往会呈现出这样几个特征只有1张卡有30%左右的利用率波动其余卡利用率为0显存占用全部正常每张卡都分配了几个G甚至几十个G的显存功耗远低于TDP说明GPU核心并没有真正工作起来训练日志里的loss在下降但每个step的耗时是正常情况的好几倍。这里有一个很迷惑人的点显存占用正常会让很多人觉得“GPU资源已经分配好了模型已经在跑了”但显存占用只能说明模型权重和中间激活值已经放到了显存里它和GPU核心是否在满负荷执行算子完全是两回事。就像一个餐厅把食材都买回来放进冷库了但这不代表后厨已经开始炒菜了。1.2 单卡训练里时间都去哪了那一张GPU卡在一个训练step里到底是怎么工作的我把这个过程拆开来看大家就明白为什么显存正常利用率却很低了。一个典型训练step的耗时由三部分构成数据准备与传输耗时CPU读数据、做解码与预处理、把tensor从内存拷贝进显存GPU算子执行耗时真正的前向、反向、权重更新计算同步与通信耗时多卡时涉及梯度同步的通信单卡则主要是同步等待与kernel launch开销。这三部分在时间线上是串行和交错混合的。GPU计算速度快是快但它的特点是“你不喂数据它就等着”。如果数据准备环节跟不上GPU核心就会在大部分时间里处于空闲等数据的状态表现出来就是利用率低、功耗低、时间却在飞逝。理解了这一点8卡7闲的问题就清晰了如果连单卡的数据供给链路都没理顺那多卡只会把这个问题乘以8。所以我把单机层面的排查和调优分成四个维度数据供给链路、GPU通信与拓扑、显存与计算效率、环境与监控验证。下面一个一个说。2. 数据供给链路GPU再快也怕“饿肚子”2.1 DataLoader的并行度不是越大越好数据加载是单机调优里最容易被忽视、也是收益最明显的环节。PyTorch的DataLoader默认num_workers0意思是数据加载和预处理全部在主进程里完成GPU每次都要等CPU处理完一个batch才继续这种情况在稍微像样点的数据集上几乎必然导致GPU饥饿。加大num_workers是常见的操作但很多人直接盲目调到CPU线程数上限结果反而更慢。原因是如果每个worker都在做高CPU开销的预处理如图像解码、归一化、随机增强大量worker会抢占CPU时间片导致整体处理吞吐反而下降。这里给一个相对稳妥的配置思路先按“物理CPU核心数除以GPU卡数”来估算每个GPU对应的可用核心数然后以此为基数设置num_workers。比如你有一台8核物理机、单卡训练那num_workers从4开始逐步观察step耗时找到拐点而不是直接拉到16。同时建议开启persistent_workersTrue这样每个epoch结束时worker进程不会被销毁重建省掉反复fork和初始化数据集的额外开销。搭配prefetch_factor默认2可以适当调大比如设为4或8相当于在内存里多缓存几个batch给GPU一个更大的缓冲池。2.2 解码和预处理是隐藏的CPU炸弹如果数据集是大量小文件比如几十万张图片那么数据链路里最大的瓶颈往往不在DataLoader本身而在文件读取和解码。小文件随机读取对磁盘IOPS的要求很高普通机械盘和云硬盘在小文件场景下会非常吃亏而CPU做JPEG/PNG解码同样耗时巨大。我遇到过一个实际案例一个图像分类训练任务GPU利用率始终在10%以下。排查发现DataLoader每个step都要从网络存储上一张一张拉取图片文件单次读取延迟高达几十毫秒数据准备耗时是GPU计算耗时的几十倍GPU成了彻底的闲置方。这类问题的常见解法有几种把数据集打包成TFRecord、WebDataset或LMDB格式把随机小文件读取变成顺序大块读取磁盘IO效率能提升一个数量级在预处理阶段就尽可能把解码成内存对象后缓存住比如用torchdata的缓存机制避免每个epoch都重复解码对于非常大的数据集考虑用WebDataset预取策略它在每个step可以边读边解流水线化程度更高。当然如果业务场景不允许改数据格式还有一个性价比很高的方案把数据源尽量放到本地NVMe盘并且用内存文件系统做二级缓存。数据链路调优的本质就是让数据准备时间小于等于GPU的单step计算时间形成流水线。2.3 从CPU到GPU的搬运也有学问数据从CPU内存搬运到显存这一环同样有讲究。PyTorch里有个pin_memoryTrue参数很多人设置了之后发现收益不大原因往往是没有配合使用.to(device, non_blockingTrue)。pin_memoryTrue的含义是把CPU侧数据放进页锁定内存pinned memory这样GPU进行DMA拷贝时就不需要先经过一个“可分页内存到固定内存”的临时中转。但如果你仍然用.to(device)这种同步拷贝方式那页锁定的收益会被同步等待抵消大半。正确的做法是DataLoader设置pin_memoryTrue然后在模型forward之前用batch[input] batch[input].to(device, non_blockingTrue)让拷贝操作与前面的CPU侧计算重叠这样流水线才真正建立起来。这一步属于典型的“设置简单、收益不小、但大部分人没做全”的操作建议在代码审查时重点关注。3. GPU通信与拓扑8卡之间怎么握手决定训练上限3.1 你以为的8卡互联和实际拓扑可能完全不同很多人会把“8卡机器”默认理解成8张卡之间高速互联其实这是一个非常大的误区。市面上常见的8卡服务器根据主板、CPU型号、PCIe Switch和GPU型号的不同卡间拓扑可能相差巨大。在单机调优之前我建议先跑一条命令nvidia-smi topo -m这条命令会输出GPU之间以及GPU与CPU/NIC之间的连接类型。连接类型从高到低大致是NV#NVLink/InfiniBand级带宽最高PIX走同一PCIe Switch带宽次之PXB走多个PCIe Switch桥接SYS走CPU Root Complex跨NUMA带宽最低。很多2路CPU服务器上GPU0-3挂在CPU0侧、GPU4-7挂在CPU1侧跨CPU访问的GPU之间带宽会明显低于同侧。如果NCCL在通信时使用的是低带宽路径多卡训练的效率就会大打折扣表现就是GPU利用率不低但吞吐上不去钱花了不少性能没涨。3.2 NCCL的默认行为有时并不聪明NCCLNVIDIA Collective Communications Library是PyTorch多卡训练默认使用的通信库它启动时会检测一次拓扑并尝试选择最优通信路径但“尝试”不等于“总能选对”。在多机场景下我遇到过NCCL默认选择走错网卡接口的案例。比如服务器上有10G业务网卡和25G训练网卡NCCL用了业务网卡做梯度同步通信耗时直接翻了倍。这种问题通过设置NCCL_SOCKET_IFNAME环境变量指向高速网卡接口就能解决export NCCL_SOCKET_IFNAMEeth1单机8卡场景里同样值得关注的是NCCL_P2P_LEVEL。默认情况下NCCL会在支持P2PPeer-to-Peer的链路上启用GPU直接通信不走CPU内存中转。如果PCIe拓扑比较复杂P2P通信可能会因为路径问题反而更慢此时可以通过设置NCCL_P2P_LEVELPXB允许PCIe Switch桥接级别的P2P或NCCL_P2P_DISABLE1完全禁用P2P来观察实际效果。另外如果使用多机训练NCCL_NET_GDR_LEVEL也是个需要注意的选项它控制GPUDirect RDMA的级别。这个值设得太高在某些网络拓扑下会导致通信初始化失败或性能回退。我的经验是多机环境里先用默认值跑通再用性能测试逐步调整而不是上来就全开或全关。需要强调的是这些环境变量的作用不是拍脑袋就能确定的。我一般会在调参前后跑一个NCCL带宽测试可以使用nccl-tests里的all_reduce_bench直接量出通信吞吐用数据说话。3.3 CPU亲和性与NUMA一个被低估的隐形瓶颈如果你的8卡机器是双路CPU2个物理CPU socket那么NUMA架构的影响就不能忽略。NUMA架构下每个CPU访问自己直接连接的内存更快访问远端CPU的内存则更慢这种“跨NUMA访问”的代价在数据搬运和通信上会被放大。一个常见的坑是训练进程可能被调度在CPU0上执行但它通过PCIe直接管理的GPU位于CPU1侧每次拷贝数据都要跨NUMA访问延迟显著增大。训练脚本里加上这段启动逻辑可以显著改善这种局面numactl --cpunodebind0 --membind0 python train.py或者用taskset把进程绑定到与GPU同侧的CPU核心上。PyTorch内部通常可以通过torch.cuda设置每个进程看到的GPU编号和CPU亲和性但框架之外的numactl往往是更直接的手段。需要说明的是绑核操作在很多“小而精”的服务器上收益并不明显但在双路CPU加上PCIe交换机复杂拓扑的机器上收益可能非常可观。这类优化属于“做对了不一定看得见做错了一定多花时间”的典型。4. 显存与计算效率把每张卡的算力真正压榨干净4.1 batch size、显存上限和计算效率的三角博弈当数据链路和通信拓扑都理顺之后GPU利用率依然上不去的情况也很常见这时候需要看计算侧的配置是否合理。最核心的三角关系是batch size、显存上限和计算效率。GPU是典型的“批处理”架构每个算子并行处理的数据量越大单位数据上的计算效率越高这从硬件设计上就决定了。如果batch size过小一个训练step里每个算子的数据量不足以填满GPU的所有计算单元SM的利用率自然上不去。我一般建议先跑一个batch size梯度探测从机器显存能装下的最大值开始逐步减小同时记录每个step的耗时找到“每样本耗时最少”的点。这里有个诀窍GPU利用率不高时优先把batch size拉到显存允许的最大值然后再看计算时间是否随之缩短。如果batch size增大会导致OOM优先考虑这几个手段开启torch.backends.cudnn.benchmark True让cuDNN自动搜索最适合当前输入尺寸的卷积算法通常能省一些显存并加速使用梯度累积gradient_accumulation_steps来等效放大batch size但要注意它并不能直接提升单步的计算密度它更多的是一种绕过显存上限的折中方案开启梯度检查点torch.utils.checkpoint用“重计算”换显存。4.2 混合精度不只是省显存更是在省时间混合精度是AI Infra里“性价比最高的单机优化”之一但很多人只把它理解为“省显存”其实它的价值还包括大幅提升计算速度。在Ampere架构及以上的GPU上FP16/BF16的Tensor Core算力通常是FP32的数倍。以A100为例FP16的深度学习算力是FP32的约2倍甚至更高取决于具体规格。这意味着如果训练任务能从FP32切到FP16/BF16混合精度理论峰值算力会大幅提升。实际使用中要注意一个关键点PyTorch默认torch.backends.cuda.matmul.allow_tf32 False这意味着即使你用的是RTX 30系/40系或A100以上的卡默认情况下某些矩阵乘法并不会自动走Tensor Core的TF32加速路径。我见过不少人用着支持TF32的卡却跑在纯FP32的算力水平上这是最可惜的一种资源浪费。在单机训练脚本里可以这样开启需要基于PyTorch 1.7且NVIDIA驱动和CUDA版本支持torch.backends.cuda.matmul.allow_tf32 True torch.backends.cudnn.allow_tf32 True如果是用torch.cuda.amp的自动混合精度AMP那么梯度缩放、loss scaling这些由API自动处理但在自定义训练循环时需要确保GradScaler正确使用。BF16相比FP16的优点是动态范围更大不容易溢出对训练的稳定性更友好在支持BF16的卡上如A100、H100我通常优先选BF16。混合精度调优过程中最需要注意的是观察loss的收敛曲线防止因精度降低导致训练不收敛。如果发现收敛异常优先检查是否某个模块的输入范围过大考虑对特定层单独保持FP32。4.3 梯度累积和梯度检查点用时间换空间但要换得聪明当显存不足以支撑大batch时梯度累积是一个非常有效的方案。它的原理是前向-反向算多个batch累加梯度每隔accumulation_steps次才做一次权重更新等效于把batch size放大了N倍。这个方案看着简单但坑在于如果处理不当它不等于大batch训练且会增加N倍反向和梯度计算的时间。另外在分布式场景下梯度累积与AllReduce的组合时机需要特别小心——如果每次累积都做AllReduce通信开销会成倍增加。正确的做法是本地累积一定步数后再触发一次全局梯度同步。梯度检查点Gradient Checkpointing也是常见的显存优化手段核心思想是不保存所有中间激活值反向传播时重新计算从而大幅压降显存峰值。以GPT类大模型为例开启梯度检查点后显存峰值可以降低50%以上但代价是反向传播时间增加20%-30%。我的实际经验是梯度检查点更适合大模型训练因为大模型的中间激活值占用极其夸张而中小模型优先用混合精度和调整batch size解决没必要为省显存牺牲额外计算时间。4.4 显存碎片化一个容易被OOM掩盖的元凶有人说“我显存明明够但一训练就OOM”这种情况除了batch size设置问题另一个常见原因是显存碎片化。PyTorch的显存分配器会预申请一大块显存再按需切分如果训练过程中反复创建和释放不同大小的张量显存被切得七零八落即便总量充足也可能找不到连续的大块显存来放入一个大tensor最终OOM。PyTorch 2.0提供了PYTORCH_CUDA_ALLOC_CONFexpandable_segments:True环境变量可以让显存分配器尽量使用可扩展的显存段显著缓解碎片化问题。如果你在用PyTorch 2.x训练重复崩溃在OOM且显存占用率并不高值得试一下。此外定期用torch.cuda.empty_cache()并不能解决碎片化问题——它只是把缓存显存返还给CUDA驱动真正需要优化的是分配策略。考虑cudaMallocAsync作为分配后端PYTORCH_CUDA_ALLOC_CONFbackend:cudaMallocAsync也是一个方向效果因场景而异。5. 调优效果怎么量化别拿nvidia-smi当唯一标准5.1 一套简单的分钟级评估方案调优不能靠感觉必须有一套可重复的评估方案。我的做法是固定训练模型和数据跑固定的50个step同时记录以下指标平均step耗时最关键直接反映吞吐GPU平均利用率通过nvidia-smi dmon采样数据加载阶段耗时占比通过torch.profiler的trace标注通信耗时占比多卡时观察AllReduce的耗时显存峰值防止OOM风险。跑完之后看step耗时有没有下降、GPU利用率有没有提升。如果step耗时下降而利用率反而下降往往是batch太小、等待时间变多需要结合前面提到的batch size梯度探测来调整。5.2 用profile工具找到真正的瓶颈nvidia-smi只能看到结果看不到原因。真正定位瓶颈要靠profiling工具。我使用频率最高的是PyTorch自带的torch.profiler它可以直接从训练脚本里导出每个算子的耗时、CUDA kernel launch耗时、Host侧等待时间和设备空闲时间等关键数据比外部工具方便很多。一个比较典型的profile流程是from torch.profiler import profile, ProfilerActivity with profile(activities[ProfilerActivity.CPU, ProfilerActivity.CUDA]) as prof: for step in range(5): train_one_step() print(prof.key_averages().table(sort_bycuda_time_total, row_limit20))输出表格里重点看几个字段Self CUDA Time Total算子自身在GPU上执行的真实时间、CUDA Time Total包含子操作的完整耗时、CPU Time TotalCPU侧发起该算子到完成的时间差。如果CPU Time Total远大于CUDA Time Total说明CPU侧在等待或调度上消耗了大量时间。如果是多卡场景我还会用NVIDIA的Nsight Systems做一次全局时间线trace直接看每个rank在通信上的等待时间占比。如果等待的时间片多于实际计算时间那么问题出在通信/拓扑/调度而不是数据加载。5.3 一张调优前后的资源透视表为了让大家对调优收益有个直观认识我根据近半年实际经手的优化项目整理了一张典型的单机8卡训练调优前后对照表数值取自公开测试模型训练场景具体数据会因模型、数据、卡型不同而波动但趋势具有参考价值指标调优前典型初始状态调优后数据链路通信混合精度均开启单卡GPU平均利用率15%-30%80%-95%平均step耗时完整step相对基线1.0x0.3x-0.5x多卡AllReduce通信占比30%-50%通信瓶颈严重5%-15%已趋于健康显存峰值占用因碎片化虚高甚至偶发OOM占用平稳OOM基本消除每样本有效耗时基准线降低约50%-70%这里想强调一点GPU利用率不是越高越好。如果利用率长期贴着99%同时step耗时没有继续下降那说明GPU可能已经被过度占用存在kernel launch过密、小算子过多等问题这种状态下通信和调度等待反而会增加。合理的利用率区间是80%-95%其余时间留给数据准备和通信流水线交错。6. 单机调优的兜底排查清单6.1 最容易忽视的环境层问题在排查数据、拓扑、计算配置之前环境层的问题反而应该先排除干净。我遇到过不少“GPU利用率低”的case最后发现根本原因极简单显卡驱动版本过老CUDA计算能力没跑满CUDA版本与PyTorch版本不匹配算子没有走最优路径GPU处于锁定功耗状态比如nvidia-smi -pl 200被设了低功耗墙核心频率上不去服务器被BIOS设置成“静音模式”散热限制导致GPU热降频机器上同时跑着其他任务CPU和显存被抢占。快速排查环境问题的方式很简单先nvidia-smi查看驱动版本、Persistence Mode是否为ON、显存和GPU占用是否有别的进程再确认nvidia-smi dmon里的功耗和频率是否异常。建议在BIOS里关闭无关的集显、开启Resizable BAR如果主板和GPU支持这些是很多调优教程不会提的“免费午餐”。6.2 遇到利用率波动我通常会按顺序排查的六件事最后分享一个我处理单机训练性能问题时比较顺手的排查顺序按这个顺序走大多数“GPU闲”的case都能定位到具体环节确认环境干净nvidia-smi里是否只有当前任务机器CPU是否被其他进程占满驱动有没有异常报错dmesg里的NVRM报错跑一次纯计算基准用固定的batch size跑一步不读数据看看GPU纯计算能达到的耗时上限。如果纯计算都很慢问题在算子/算法层面设置正确的数据加载参数num_workers、prefetch_factor、pin_memory配合non_blockingTrue观察step耗时变化检查存储IO和数据集格式用strace -f -e traceread采样看是否存在大量文件打开/随机读操作必要时改为顺序读取格式检查通信拓扑和NCCL配置跑nvidia-smi topo -m确认拓扑再通过nccl-tests量出真实通信带宽与理论带宽对比开启profiler做定向优化用torch.profiler定位CPU侧等待时间最长的算子针对性优化如融合小算子、重排预处理逻辑。这套流程走下来通常不需要太高深的手段就能把单机训练的性能拉到一个健康区间。回到开头那个“8张卡7张闲”的case——我在把那台机器的数据加载改成本地缓存、开启混合精度、把NCCL的通信通道固定到最优路径、并确认CUDA MPS和GPU核心频率没有异常之后同样一个训练任务单卡利用率从不到20%拉到了90%左右多卡训练的整体吞吐提升了近4倍。整个过程没有碰任何调度系统的配置纯粹是单机层面的调优账补上了。所以我的建议是不管是准备上8卡还是32卡先把单机的资源跑明白再谈分布式。这笔账欠得越久后面要还的利息就越高。
返回列表