ARTICLE DETAIL

资讯详情

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

8卡7闲?从GPU利用率到DataLoader,单机多卡训练调优实战

8卡7闲?从GPU利用率到DataLoader,单机多卡训练调优实战 1. 8张卡7张闲这不是段子是AI Infra排查链路的起点这章聊聊训练和调度。上一轮帮朋友排查训练效率问题他给的诊断截图让我印象深刻nvidia-smi里一台 8 卡 A800GPU-Util 只有 1 号卡在 95% 上下跳剩下 7 张卡全部趴在 0%~3% 的区间。问他怎么跑的任务答曰就是正常的分布式训练DDP 启动的再往下问 batch size、数据加载线程、NCCL 环境变量一概没动过。这个现象说穿了太普遍了。很多人默认卡多就等于快8 卡一插torchrun --nproc_per_node8一执行loss 在降、日志在滚就以为并行训练在正常工作。实际上你看到的有日志在滚只能说明进程活着压根证明不了 8 张卡都在干活。想确认到底有没有并行就得让每张 GPU 自己报利用率然后你才会面对这个行业里最扎心的事实之一分布式训练第一步要还的债不是配集群调度器也不是上 Kubernetes而是把单机的并行效率先调明白。标题里8 张卡 7 张闲这句话我建议所有做 AI Infra、做模型训练、做推理优化的朋友都把它贴在工位上。这不是段子是一个很真实的故障画像。它背后往往藏着一串问题DataLoader 慢到把 GPU 饿死、DDP 梯度同步把时间吃光、数据 padding 不齐导致卡间负载严重倾斜、甚至干脆就是进程启动参数写错变成单卡训练。这些问题如果不花时间系统性排查后面跑到多机多卡阶段问题会被放大十倍不止因为你不具备判断到底是调度系统分配不给力还是我这任务在单机上就没跑对的能力。本篇文章是训练与调度的上半部分焦点就一个单机调优。我会把这台 8 卡机器从7 张闲调成8 张忙的完整思路、工具链和避坑过程写清楚。调度器的账咱们留在下一章算。2. 看起来的忙和真正意义的忙先搞懂 GPU 利用率到底在测什么2.1 nvidia-smi 的 GPU-Util 为什么经常骗人要聊单机调优第一件事是把指标搞清楚。很多人一看到nvidia-smi里显示 GPU-Util 99%就觉得这张卡已经满负荷了。这个认知说实话只对了一半在某些场景下甚至是完全错误的。nvidia-smi里那个volatile GPU-Util表示的是采样时间窗口内 GPU 上某个引擎有活动的时间占比。注意它不是算力利用率更不是 Tensor Core 的利用率。只要 GPU 在跑 kernel——哪怕是打个很小的矩阵乘法、做个访存操作——采样器都会把它判定为在忙。也就是说一个跑得稀烂、每个 step 之间有大量停顿的进程完全可能让 GPU-Util 显示 90% 以上但实际 FLOPs 远远没打满。我见过最典型的场景某训练脚本里 DataLoader 的num_workers0CPU 侧读图 预处理占用几十毫秒GPU 每算一个 batch 就要干等这么久nvidia-smi因为采样粒度粗看起来 GPU 一直处于有活干的状态于是利用率高得发光但整机吞吐就是上不去。你看指标和数据对不上你拿到第一手情报就是错的。2.2 测真正的利用率哪些指标才靠谱真正判断 GPU 有没有在好好干活我建议至少看三个维度的数据指标来源指标名说明nvidia-smi dmonsmSM流处理器实际繁忙率比 GPU-Util 更接近真实计算占用nvidia-smi dmonfb显存带宽利用率访存密集型任务重点关注nvidia-smi dmontensorTensor Core 活跃度吃混合精度的训练任务重点关注DCGMDCGM_FI_PROF_SM_ACTIVESM 实际处于 active 状态的时间比例DCGMDCGM_FI_PROF_PIPE_TENSOR_ACTIVETensor Pipe 活跃率判断是否真的在走 Tensor CoreNsight Systemkernel 时间线直接看每一步里 kernel 之间有没有大段空白nvidia-smi dmon是个好工具采样频率比nvidia-smi高而且能把 SM、显存、Tensor Core、NVLINK 带宽分开列出来。比如你跑一个 FP16 的大模型训练如果sm到 95% 但tensor只有 20%说明你的算子根本没把 Tensor Core 喂饱。这属于假忙的另一种形态后面我会细讲怎么修正。DCGM 是 NVIDIA 官方的数据中心 GPU 管理组件能采集细粒度的硬件计数器和性能指标。在单机排查阶段没必要一上来就上全套 Prometheus Grafana跑dcgmproftester或者直接查/usr/local/dcgm/bin/下的工具也行。但有一点得先想清楚没有指标就没有判断没有判断就没有调优。光靠肉眼盯nvidia-smi的百分比来优化训练和开盲盒差不多。2.3 7 张闲最容易被忽略的真相进程模型错了排查8 卡 7 闲第一步别急着调参先确认你的进程模型到底对不对。很多人对 DDP 的启动方式理解有误以为只要torch.distributed.init_process_group写了nccl就算分布式了实际上还要配合正确的进程启动器和环境变量比如WORLD_SIZE、RANK、LOCAL_RANK或者直接用torchrun来管。torchrun --nproc_per_node8 train.py这套方式相对稳妥因为每个进程会被绑定到独立的LOCAL_RANKDDP 会按这个 rank 把数据切片给对应卡。反例我也见过有人手动set_device(cuda:0)写了死然后DataLoader里压根没上DistributedSampler结果 8 个进程全挤在 0 号卡上算7 张卡当然闲。这种情况不是调优能解决的是代码写错了。所以单机调优的第一条经验先确认数据加载和 device 分配是真正按卡拆分的再谈性能和效率的事。怎么确认三步走nvidia-smi看显存8 个进程是否各占一张卡的显存而不是 8 个进程全挤在一张卡上。看日志的local_rank输出每个进程打印自己的torch.cuda.current_device()。算 loss 曲线DDP 模式下所有 rank 的 loss 应该收敛方向一致因为梯度已经跨卡同步。这三步走完确认进程模型没问题再往下排查有活干但效率低的问题才有意义。3. 单机并行效率的三座大山数据投喂、计算密度、通信开销3.1 第一座山DataLoader 把 GPU 饿死在半路进程模型没问题但 GPU 还是闲那就要看数据投喂管线的健康度了。PyTorch 的 DataLoader 有几个关键参数堪称单机调优第一课num_workers数据加载和预处理的工作进程数。设成 0 意味着所有数据处理都在主进程同步做GPU 每算完一个 batch 都得干等 CPU 把下一个 batch 准备好这时候 GPU 利用率再高也是空转式的忙整机吞吐量惨不忍睹。pin_memory设为 True 可以让 DataLoader 把张量放在锁页内存Pinned Memory里这样 CPU 到 GPU 的拷贝可以走更快的数据通路不走普通可分页内存的慢速通道。prefetch_factor控制每个 worker 在内存里预取的 batch 数量经验值是 2 或 4能让数据流水线提前把后面几个 batch 准备好。persistent_workers设为 True 后worker 进程不会每个 epoch 都重新创建省掉反复 fork 进程的开销。我自己的习惯是单卡训练时先按num_workers8、pin_memoryTrue、prefetch_factor4起步然后观测 CPU 内核占用和 GPU 的sm占用。如果sm已经到 90% 以上而 CPU 侧没有明显的等待就先不管数据管线了。如果sm起不来且用perf top或py-spy能看到明显的dataloader相关热点那就要继续加大num_workers。有一个常被忽略的细节如果你的数据预处理包含大量 CPU 算子比如图像随机裁剪、MixUp、CutMix 等等这些算子本身也会吃满 CPU 内核。num_workers开得太多反而会引发操作系统的进程调度开销和内存带宽竞争最后 CPU 本身成了瓶颈。所以越大越好是个误区要拿数据说话。3.2 第二座山batch size 和梯度累积的平衡点没找对很多人在单机上把 batch size 开得特别小美其名曰显存不够所以用小 batch。但小 batch 在多卡场景下有另一个致命副作用梯度同步通信次数不变但每次同步摊到的计算量变少了通信占比会急剧上升。拿 8 卡 DDP 来算每个 step 结束所有 rank 要把自己的梯度广播出去做 allreduce通信量大约等于模型参数量 x2算上反向里要同步的梯度张量实际是接近两倍模型字节数。假设一个 7B 参数的模型用 BF16 混合精度训练每个 rank 的梯度大小大概就是 7B x 2 字节 x 2 28GB。这 28GB 要在每张卡之间通过 NVLink 或 PCIe 同步一次。如果你的全局 batch size 很小比如 8x864那么每步计算时间可能只有几百毫秒而 allreduce 通信可能占掉其中 100~200 毫秒甚至更多。你看着每张卡的sm都接近 100%但整机每秒能跑多少 step 就是上不去因为时间全耗在梯度同步上了。怎么破两个办法增大单卡 batch size让每张卡在更长的计算时间里产生一批梯度再做同步通信在整步里的占比就小了。如果显存放不下更大的 batch可以考虑开启梯度检查点gradient checkpointing来换取更大的 batch。梯度累积gradient accumulation把每张卡上的 batch 拆成 micro-batch连续算多个 micro-batch攒够一次梯度后再统一做 allreduce。注意梯度累积和 DDP 的no_sync()配合好否则每个 micro-batch 都会触发一次通信等于白攒。我自己常用的试法先用纯单卡、单进程模式跑一个理想 batch size测出单卡每步耗时。然后开 8 卡 DDP同样 batch size如果发现 8 卡每步耗时是单卡的 3~5 倍以上先检查通信是不是炸了。这时候可以把torch.distributed.barrier()包在 step 前后做计时分别量出计算段和通信段的时长。3.3 第三座山混合精度没做好Tensor Core 在打盹现代 GPU 之所以训练得快很大程度靠 Tensor Core而 Tensor Core 对精度的要求是能用 FP16/BF16 就别用 FP32。很多人的训练脚本虽然开了 AMP但代码写得不够彻底导致某些关键算子还是回落到 FP32。典型表现就是用nvidia-smi dmon看tensor指标低得可怜。排查思路是先确认 padder 有没有生效PyTorch 的torch.cuda.amp.autocast()会把支持的算子自动转成 FP16/BF16 执行但如果你在模型里手工写了torch.Tensor.float()或者强转精度就可能绕过 autocast让部分计算退回 FP32。还有个更隐蔽的坑torch.compile或 JIT 脚本里如果用了大量自定义算子又没有对应的autocast兼容规则也容易退化成 FP32。训练吞吐上不去的时候别光怀疑网络和存储先用torch.profiler把每个 op 的精度和执行时间拉出来看看到底哪些算子在拖后腿。我在实际项目里拿到过一份代表性 profile模型里有个自定义的 GroupNorm 实现作者图省事直接在 forward 里写死了float()结果这个 op 的耗时比预期高了 6 倍整卡的有效算力利用率掉了 15% 还不止。改成autocast环境变量下支持的类型后单机吞吐直接回升。4. 通信调优NVLink、PCIe 环和 NCCL 环境变量这些坑你绕不开4.1 单机多卡通信的物理底子多卡训练的通信单机环境下主要两条路GPU 之间走 NVLink或 NVSwitch跨主机时才会走网卡。NVLink 的带宽用好了allreduce 的开销可以压得很低用不好就会出现8 卡跑得比 4 卡还慢的怪象常见原因是通信的拓扑没对齐。单机 8 卡的 GPU 拓扑一般有几种8 卡全互联、4 卡一组环形、或者直连 NVSwitch。你可以用nvidia-smi topo -m看一张表直观显示 GPU 之间的互联方式。如果是 4 卡环形/网状结构那么跨组的 allreduce 通信往往要绕道这时候你需要控制 NCCL 的通信方式比如设置NCCL_P2P_DISABLE或NCCL_SHM_DISABLE来进行组合验证。生产环境里我一般先默认全开量过一轮带宽之后再决定要不要做参数调整。4.2 实测NCCL allreduce 带宽测试做单机多卡调优一个必备动作是跑通官方的 NCCL allreduce 带宽测试把当前环境的通信天花板量出来。工具路径通常在# 如果安装的是 NVIDIA 官方 nccl-tests /usr/local/bin/all_reduce_perf -b 128M -e 4G -f 2 -g 8这条命令会在 8 张卡上做消息大小从 128MB 到 4GB 的 allreduce 压测。重点看AlgoBandwidth算法带宽和BusBandwidth总线带宽。如果 BusBandwidth 明显低于 NVLink 标称值比如 A800 的 NVLink 大约能跑到 400GB/s 级别实际只有几十 GB/s那说明通信路径没有对齐或者 NCCL 的拓扑感知没生效。跑完这套压测你心里就有底了如果压测本身带宽正常那训练慢的问题基本出在计算/通信重叠不好上如果压测本身就慢那得先解决物理/驱动层问题再谈训练脚本优化。4.3 计算和通信重叠DDP 默认帮你做但没做干净现代 DDP 的实现里梯度 allreduce 是分桶bucket进行的而不是等整个反向传播算完再一次性同步。它会一边计算反向、一边把已经算好的梯度桶做通信这样计算和通信在同一时刻重叠能藏掉不少延迟。但你如果自己实现分布式训练比如手写 allreduce 或者用 Ring-AllReduce 的简化版本就要注意黄金法则先切桶再同步别等全部反向算完再通信。通信是异步发出去的计算不依赖这个梯度桶的话就能继续往下跑。这属于老掉牙但又特别好用的性能优化思路。我在一个 13B 模型单机 8 卡训练项目里遇到过 DDP 默认 bucket 大小25MB不够理想的情况导致通信频次太高。把bucket_cap_mb调大到 200~300通信次数变少整体吞吐能提升 8%~10%。这种细节如果你不做 profiler 级别的观测根本发现不了。4.4 绕不开的 NCCL 环境变量虽然我不主张遇到问题就调 NCCL 参数但有几个关键变量做训练的人一定要知道环境变量作用建议NCCL_DEBUGINFO打印通信初始化信息排查通信拓扑、报错时打开NCCL_DEBUGWARN只打印警告性能巡检时开着NCCL_P2P_DISABLE1禁用 GPU 间 P2P 传输虚拟化或某些云主机上 P2P 不稳定时用NCCL_SHM_DISABLE1禁用共享内存通信跨机场景或共享内存异常时用NCCL_IB_DISABLE1禁用 InfiniBand 通信没有 IB 网络时避免误用注意用这些变量写的优化未必是真的优化多数时候反而降低性能。我见过有人为了调试在 8 卡单机上把NCCL_P2P_DISABLE1设上后忘了去掉结果每步通信都走 PCIe 绕一大圈训练慢了将近 40%。调参之前先记录 baseline调完立刻测对比这是基本功。5. 定位瓶颈的过程一次完整的7 闲排查链路5.1 第一步看进程模型和 GPU 显存分配上文说过先确认 8 个进程各占各的卡。实际操作时用nvidia-smi --query-compute-appspid,used_memory,device_uuid --formatcsv可以列出每个进程占的显存和所在卡。如果发现所有进程都挤在一张卡上先查启动脚本里的CUDA_VISIBLE_DEVICES和torch.cuda.set_device逻辑。特别提醒一下torchrun --nproc_per_node8会自动设置LOCAL_RANK你在代码里应该在初始化 DDP 时用torch.cuda.set_device(local_rank)而不是硬编码cuda:0。如果各进程显存分配正常但训练速度依然慢进入第二步。5.2 第二步跑两步训练抓 kernel 时间线在训练脚本里临时加一个 profilerfrom torch.profiler import profile, ProfilerActivity with profile(activities[ProfilerActivity.CPU, ProfilerActivity.CUDA]) as prof: for i, batch in enumerate(train_loader): loss model(batch) loss.backward() optimizer.step() if i 2: break print(prof.key_averages().table(sort_bycuda_time_total, row_limit20))重点看两部分CPU 一侧耗时最长的函数如果dataloader或DataLoader下的__getitem__占了很大比例说明 CPU 预处理是瓶颈。GPU 一侧的cuda_time_total如果每个 step 中 kernel 之间有大段空白可以用prof.export_chrome_trace()导出到 Chrome 的chrome://tracing看可视化时间线说明 GPU 在空等数据或同步。我看到过一个真实案例某模型 forward 里有大量小算子逐个调用每次 kernel 启动都有固定的 CPU launch 开销GPU 在大量小 kernel 之间来回切换导致sm很低但cuda_time_total并不低。解决方案是把多个小算子 merge 进一个自定义 CUDA kernel或者调整 PyTorch 的算子融合策略。5.3 第三步量化通信占比在训练主循环里包一层计时import torch.distributed as dist t_start torch.cuda.Event(enable_timingTrue) t_end torch.cuda.Event(enable_timingTrue) # 模拟一个 step t_start.record() loss model(batch) loss.backward() dist.barrier() # 等所有 rank 梯度同步完成 t_end.record() torch.cuda.synchronize() print(fstep total: {t_start.elapsed_time(t_end):.2f} ms)如果你对计算时间做两次采样——一次带 allreduce一次不带——能粗算出通信占比。其实更简单的办法是单卡跑同样 batch size 的 step 时间做对比如果单卡 200ms8 卡却要 800ms说明扩展效率只有 25%通信/负载不均衡基本可以确定是罪魁祸首。5.4 第四步检查负载不均衡负载不均衡最典型的原因是 padding。NLP 任务里句子长度差异大如果你的 tokenizer 不做动态 padding而是把所有 sequence 都 pad 到同一个 max_length那么短句子占多数时GPU 的有效计算密度很低看起来sm挺高实际大部分算力浪费在 padding token 上。CV 任务里也一样输入图片尺寸参差不齐如果你不做 aspect-ratio-bucketing按宽高比分桶而是全图 resize 到统一尺寸很多卡上的计算其实在空转。排查方式是把每个 batch 的真实有效 token 数或有效面积打出来看看是否远低于理论最大值。如果差很多就该考虑动态 padding bucket sampler或者按比例混合长短样本。我处理的 7B 模型训练任务里序列长度从 128 到 2048 不等改成按长度分桶后8 卡 DDP 的端到端吞吐提升了 34%。同一个模型、同样的卡只是把 padding 和 sampler 改合理了而已。6. 单机都调不通上集群调度只会放大问题6.1 调度器分配的是资源不是性能把单机调优说清楚了再回头看标题里的训练与调度就很有意思。很多人搞 AI Infra上来就想搞大集群调度把 Slurm、Kubernetes、Volcano 这些调度系统搭得漂漂亮亮一跑任务发现 8 张卡 7 张闲。这时候你很难判断是调度器分配的资源不对还是你代码本身就写得很低效调度器能做的只是保证每个任务拿到它申请的资源至于任务拿到这 8 张卡后能不能充分利用那是任务自己的事。资源请求是有没有利用效率是好不好这完全是两回事。我见过太多团队调度平台建得挺完善告警也接得挺全但告警信息永远是GPU 利用率低于 20%。然后大家去查调度策略、查队列排队、查抢占逻辑查了半天发现把num_workers从 0 改成 8 就解决了一半问题。调度层关注的是作业之间怎么排队、怎么分时复用资源单机层关注的是单个作业拿到资源后怎么把资源压干。两层之间有一条明显的分界线但很多人把这层边界模糊了。6.2 单机调优的产出一份可靠的扩展性基线单机调优的另一层价值是给你一个扩展性基线。什么叫扩展性基线就是单卡跑一个 step 多少毫秒2 卡跑同样 batch size 多少毫秒4 卡、8 卡分别是多少如果 1 卡 100ms2 卡 105ms4 卡 110ms8 卡 120ms这属于接近完美的线性扩展说明通信开销被藏得很好下一步上多机才有意义。反之如果 1 卡 100ms2 卡 150ms4 卡 300ms8 卡 800ms那说明通信或者负载均衡有严重问题。留着这个问题上集群调度无非就是把1 台机器慢变成100 台机器都慢再配上漂亮的监控面板于性能一点帮助没有。我一般建议做一个傻瓜式脚本torchrun --nproc_per_nodeN bench.py分别跑 N1、2、4、8把每 step 耗时和端到端吞吐自动打出来画成加速比曲线。这张曲线是判断单机调优是否达标的硬指标比任何人的口头分析都靠谱。6.3 调度层的第一笔账其实是单机调优所以回到标题这句话单机调优才是你欠下的第一笔账说的是个优先级问题。AI Infra 的建设和优化应该遵守严格的顺序先把单机性能调到扩展性曲线的天花板再去做多机通信调优最后才轮到集群调度策略设计。跳过前面两步直接做调度本质上是在沙滩上盖楼。这个系列后续的篇幅会聚焦调度层任务排队、优先级抢占、资源配额、拓扑感知调度这些内容。但写这些东西之前我希望能先把单机调优的理讲透。原因很简单调度这事儿是有明确的系统边界和接口的而单机调优的坑是散落在训练框架、CUDA 驱动、数据管线和代码实现里的它更琐碎更考验一线经验也更容易被团队里的架构师惯性忽略。一条 8 卡机器把数据装载、batch size 策略、混合精度、allreduce 通信、负载均衡这五件事逐一验证完再去看调度系统你的判断力会完全不一样。我在多个项目里反复验证过这个顺序每次都能把复杂问题拆回源头。这里我还有个实操建议把单机调优的结论固化成一个标准作业卡比如每台新机器上线前先跑一遍 NCCL allreduce 压测、跑一遍 DataLoader 吞吐测试、跑一遍单卡和 8 卡的 step 耗时对比三项数据达标才允许接入训练任务队列。这样调度系统的队列里跑的都是合格作业而不是各种来历不明的慢任务。说白了8 张卡 7 张闲不可怕可怕的是你把原因归到调度上而问题明明在更底层的地方等着你。先还完单机调优这笔账后面的路会好走很多。
返回列表