
1. 单卡跑不动之后DDP到底解决了什么问题第一次把训练脚本从单卡挪到多卡的人多半有过这种体验代码改完了torchrun一敲八张卡的风扇都转起来了日志也哗哗地打但算一下吞吐只比单卡快了 1.6 倍。这时候你会开始怀疑人生——说好的线性加速呢我在几个项目里反复踩过这个坑最后发现问题往往不在硬件而在于对 PyTorch分布式训练 里 DDPDistributedDataParallel这套机制的理解只停留在套个壳就能跑的层面。DDP 不是魔法它是一套有明确代价、明确边界的数据并行方案你省下的每一秒算力都是用通信换来的。先把话说清楚这篇内容讲的是 PyTorch 的 DDP也就是多进程数据并行。它能做的事情很具体——把一个大的全局 batch 切成 N 份分给 N 个进程通常一进程一卡每个进程独立跑前向和反向然后在反向传播的过程中把梯度对齐最后每个进程用完全相同的梯度更新出完全相同的参数。解决的问题就一个单卡显存放不下、单卡算得太慢。它适合谁适合已经能跑通单机单卡训练、现在想扩到单机多卡或多机多卡的人也适合那些正在被多卡比单卡还慢训练半天不收敛跑到第 300 步就卡死折磨的人。如果你连单卡脚本都还没跑通先回去把单卡跑顺DDP 只会把原有的 bug 放大 N 倍。我见过太多团队在这个阶段的决策失误模型参数量 1B 出头单卡 80GB 显存能塞下 batch size 8他们上来就搞 8 卡 DDP结果 global batch 变成 64学习率没调训练曲线直接变成一条心电图。也见过反过来的明明通信是瓶颈还在死磕 DDP 的调参其实换成梯度累积 更小的通信频率就解决了。所以这一节我想先讲清楚 DDP 的定位再往下拆机制。1.1 为什么是 DDP而不是 DP 或者模型并行PyTorch 里有三种常见的并行路子DataParallelDP、DistributedDataParallelDDP、以及各种模型并行/流水并行。DP 是最早的写法一行nn.DataParallel(model)就能跑但它的问题很致命——它是单进程多线程的。主进程负责分发输入、收集输出、汇总梯度所有 GPU 的梯度都要回到主卡的显存里做 reduce主卡显存凭空多出一份模型大小的开销而且 Python 的 GIL 让多线程调度效率很一般。实测下来DP 在 4 卡以上的加速比经常掉到 2 倍以下8 卡基本没什么收益还容易出现主卡 OOM。DDP 换了个思路多进程。每个进程就是一个独立的 Python 解释器独占一张卡拥有自己的模型副本、自己的优化器状态、自己的数据加载器。进程之间不共享 Python 对象只通过 NCCLNVIDIA 卡上的集合通信库交换梯度张量。这样一来GIL 的问题没了主卡显存倾斜的问题也没了通信走的是 GPU 之间的高速互联节点内走 NVLink 或 PCIe节点间走网卡比 DP 那种挤在主卡显存里做加法高效得多。至于模型并行那是另一个维度的事情——当你单层参数量就大到一张卡放不下比如某些超大宽度的 FFN 层数据并行帮不了你必须切模型。但绝大多数中等规模场景模型能塞进单卡只是 batch 太小导致训练慢、梯度噪声大这时候 DDP 就是性价比最高的选择。我的经验判断标准很简单模型 fp16 权重 优化器状态 激活值 能塞进单卡显存的 70%就优先 DDP塞不进去再考虑 ZeRO 式的分片或者模型并行。方案进程模型显存开销通信实现适用场景DataParallel单进程多线程主卡多一份梯度缓冲主卡汇聚已淘汰仅小模型调试DDP多进程一进程一卡各卡均摊无额外倾斜NCCL All-Reduce模型可放入单卡的主流场景模型并行 / 流水并行多进程层切分按层切分点对点收发单层就放不下的超大模型1.2 DDP 的运行模型一份代码N 个副本理解 DDP 最快的方式是把它想成同一份剧本N 个剧组同时开拍。你写的训练脚本就是剧本torchrun负责同时拉起 N 个剧组进程每个剧组拿到同样的模型初始权重通过rank 0广播保证一致但拿到不同的素材数据的不同切片。每个剧组各自拍各自的镜头前向 反向拍完一个镜头后所有人开个短会把各自的意见平均一下梯度 All-Reduce然后各自按照这个统一意见修改剧本。因为每次修改都基于完全相同的平均梯度所以 N 个剧组的剧本永远保持一模一样。这个永远保持一模一样是 DDP 最重要的不变量也是后面所有坑的根源。只要有一个进程的模型参数和别的进程不一致训练就会以一种极其隐蔽的方式崩掉——loss 看起来在降但效果就是比单卡差。常见的破坏这个不变量的操作包括在模型里用了随机性但没同步随机种子、手动给某个 rank 单独加载了权重、用了find_unused_parameters却没处理好分支、以及最经典的 BatchNorm 在每张卡上各算各的统计量。所以在写代码之前脑子里要有一个清晰的图N 个进程N 份完全相同的参数N 份不同的数据一次全局同步。所有设计决策——batch size 怎么定、学习率怎么调、数据怎么切、checkpoint 怎么存——都从这个图里推导出来。2. 核心机制拆解通信后端、进程组与梯度同步很多人用 DDP 用了很久但对底层发生了什么一无所知于是遇到问题时只能靠猜。我花过一整个下午排查一个多机训练跑到一半hang住的问题最后定位到是某个节点的网卡名没指定对NCCL 走了慢速路径。这类问题如果不懂机制根本无从下手。这一节把 DDP 的三个核心机制拆开讲进程组怎么建、通信后端怎么选、梯度怎么同步。2.1 进程组、全局 rank 与 local ranktorchrun启动时会给每个进程注入几个环境变量这是整套体系的基石RANK全局进程序号从 0 到 world_size-1全局唯一。LOCAL_RANK节点内的进程序号从 0 到 每节点卡数-1。这个值直接决定cuda:${LOCAL_RANK}用错就会所有进程抢同一张卡。WORLD_SIZE总进程数通常等于总卡数。MASTER_ADDR/MASTER_PORTrank 0 的地址和端口所有进程靠它完成初次握手。我见过最常见的翻车现场是代码里硬编码cuda:0torchrun --nproc_per_node8一跑八个进程全挤在 0 号卡上显存瞬间爆掉报错信息还是一堆 CUDA 相关的噪音。正确写法一定是local_rank int(os.environ[LOCAL_RANK]); torch.cuda.set_device(local_rank)。init_process_group这一步是在建立所有人互相认识的通道。backend的选择有讲究单机或多机的 NVIDIA 卡无脑选ncclCPU 或者调试环境用gloo。有个细节值得说如果你要做 CPU 上的集合通信比如某些评测阶段而训练用的是 NCCL那就得维护两个进程组不能混用。另一个细节是timeout默认 30 分钟很多跑到一半卡死的案例其实是某个 rank 在等一个永远不会到来的集合通信然后被超时机制干掉。把 timeout 调小比如 10 分钟能让问题暴露得更快调试期非常有用。import os import torch import torch.distributed as dist def setup_dist(): local_rank int(os.environ[LOCAL_RANK]) torch.cuda.set_device(local_rank) dist.init_process_group( backendnccl, timeouttorch.timedelta(minutes10), ) # 让 NCCL 的报错信息更详细调试期强烈建议打开 os.environ.setdefault(NCCL_DEBUG, WARN) return local_rank def cleanup_dist(): dist.barrier() dist.destroy_process_group()注意dist.barrier()这个收尾动作。如果 rank 0 提前跑完开始写文件、其他 rank 还在训练不加 barrier 有时候会触发一些诡异的销毁顺序问题。这不是必需的但在多机场景下加上更保险。2.2 通信后端与网络路径的实际影响nccl这个名字背后是一整套针对 GPU 互联拓扑的优化。节点内它优先走 NVLink节点间走 RDMA 网卡。这里有两个参数经常被忽略但影响巨大第一是NCCL_SOCKET_IFNAME。多机训练时如果机器上有多张网卡管理网、存储网、计算网NCCL 可能自己选错走到一条慢速或者根本不通的路径上。表现就是进程都能起来握手也成功但第一次 All-Reduce 就超时。明确指定计算网卡名比如export NCCL_SOCKET_IFNAMEeth0这种形式能避开这个问题。我一般会在启动脚本里显式设置不指望它自己猜对。第二是NCCL_IB_DISABLE和NCCL_DEBUGINFO。当你怀疑通信没走高速路径时把 DEBUG 打到 INFO日志里会明确打印它选用了哪条链路、带宽多少。这比盲猜有效一百倍。日志里如果看到走的是 via NET/Socket 而不是 RDMA/IB 相关字样那基本可以确认在跑 TCP速度会差一个数量级。还有一个常被问到的点我不装 NCCL 行不行不行NCCL 是 GPU 通信的必选项PyTorch 的预编译包里通常自带一份不需要你单独安装但版本要跟驱动匹配。如果你在容器里跑宿主机的驱动和容器里的 NCCL 版本不匹配症状是init_process_group直接报错或者卡在握手阶段。2.3 梯度同步All-Reduce、分桶与计算通信重叠DDP 最精妙的设计在于梯度同步的实现方式。朴素想法是反向传播全部结束把每个参数的梯度收集起来一次性做 All-Reduce。这样能work但通信期间 GPU 完全空闲浪费严重。DDP 的实际做法是分桶 重叠。它在构建时把所有需要梯度的参数按反向传播的执行顺序逆序排列然后切成若干个 bucket默认bucket_cap_mb25即每桶 25MB。反向传播是逐层往前的所以最后一层的梯度最先算完。一旦某个 bucket 里的所有梯度都就绪DDP 立刻对这个 bucket 发起异步的 All-Reduce同时 GPU 继续算前面那些层的梯度。这样一来通信时间被计算时间掩盖掉了大部分这也是 DDP 比 DP 快得多的核心原因之一。All-Reduce 本身的通信量是可以算出来的。经典的 ring all-reduce 算法每个进程需要发送和接收的数据量约为2 × (N-1)/N × S其中 N 是进程数S 是梯度总字节数。举个具体例子一个 1.3B 参数的模型fp16 梯度S ≈ 2.6GB8 卡时每卡通信量约2 × 7/8 × 2.6GB ≈ 4.55GB。如果节点内 NVLink 带宽按单向 300GB/s 算理想情况十几毫秒就能完成但如果走的是 PCIe 4.0 x16双向约 64GB/s或者更糟的 TCP 网络这个时间会拉到几百毫秒甚至秒级。对比一下单步计算时间——如果你的模型单步计算只要 200ms而通信要 800ms那不管加多少卡加速比都上不去。这就是为什么调 DDP 的第一步永远是测通信开销而不是调学习率。有两个参数跟这个机制直接相关值得记住gradient_as_bucket_viewTrue让梯度直接指向 bucket 的视图省掉一次内存拷贝。开启后显存占用会小幅下降实测在 7B 以下模型上大概省 1-2GB同时吞吐有 1-3% 的提升。基本可以无脑开。static_graphTrue如果你在多个迭代之间用的是完全相同的计算图绝大多数字节级正常的训练脚本都是这样开启它会带来额外的优化官方给的数字是 10% 左右。但注意它跟find_unused_parameters不能同时用而且如果你的图确实会变比如某些动态路由的 MoE 实现开了会报错。find_unused_parameters这个参数我要单独说一句因为它被滥用了。这个参数的语义是允许某些参数在本轮迭代中没有参与计算跳过后面的同步。它确实能解决模型里有分支没走到导致的报错代价是每轮反向都要遍历整个计算图去标记哪些参数未使用这是一次额外的图遍历开销在大模型上可能吃掉 10% 以上的速度。正确的做法是先想办法让所有参数都参与计算比如检查你的分支逻辑实在不行再用它。我的经验是find_unused_parameters报错这件事八成是因为模型里有 if/else 分支且各 rank 走了不同路径——而这本身就是个严重的设计问题因为它会导致不同 rank 的通信序列不一致最终hang住。3. 从零搭一套能跑的训练脚本理论讲完落到代码。这一节我会给出一个完整可复现的 DDP 训练骨架并把每一步为什么这么写讲清楚。这套结构我在好几个项目里都直接复用改动量很小。3.1 启动方式从 torchrun 到环境变量早期大家用python -m torch.distributed.launch现在官方推荐torchrun它支持弹性训练某个 worker 挂了能自动重启补位而且对信号处理更友好CtrlC 能优雅退出不会留下一堆僵尸进程。单机 8 卡的启动命令torchrun \ --nproc_per_node8 \ --master_port29500 \ train_ddp.py \ --config conf/base.yaml多机 2 节点、每节点 8 卡的启动命令两个节点上各执行一次只有node_rank不同torchrun \ --nnodes2 \ --nproc_per_node8 \ --node_rank0 \ --master_addr10.0.0.11 \ --master_port29500 \ train_ddp.py \ --config conf/base.yaml注意--master_port一定要避开已被占用的端口。同一个节点上跑多个实验时不同实验必须用不同端口否则第二个实验会在init_process_group阶段直接报 address already in use。我一般会在脚本里用随机端口或者按实验编号递增。有个小技巧如果你习惯用 shell 脚本封装可以把启动参数写成变量本地调试时nproc_per_node1也能跑通全流程代码路径完全一致这样就不用维护单卡版和多卡版两份脚本了。代码路径统一这件事在排查问题时的价值极高因为单卡能复现的问题多半不是 DDP 本身的锅。3.2 模型包装顺序与数据切分DDP 的包装顺序有严格的要求写反了会静默出错。正确的顺序是建模型 → 2. 放到正确的 device 上 → 3. 包成 DDP → 4. 建优化器。为什么优化器必须在 DDP 包装之后建因为 DDP 在构造函数里会对参数做一次性处理比如提升参数成连续的内存块、注册梯度 hook如果先建优化器再包 DDP优化器里持有的参数引用可能和 DDP 内部的参数不是同一批对象导致梯度更新错乱。这个坑非常隐蔽症状是训练不收敛但没有任何报错。还有一条DDP 的构造函数内部会做一次参数广播broadcast把 rank 0 的参数同步给所有进程。所以模型初始化时的随机性不重要——各 rank 即使初始权重不同也会在包装那一刻被统一。但这点不能当成随机种子随便设的借口因为数据和数据增强的随机性还是需要控制的调试期我习惯固定种子。数据切分的正确姿势是DistributedSamplerfrom torch.utils.data import DataLoader, DistributedSampler train_sampler DistributedSampler( train_dataset, num_replicasdist.get_world_size(), rankdist.get_rank(), shuffleTrue, drop_lastTrue, # 关键保证各 rank 样本数一致避免 All-Reduce 错位 ) train_loader DataLoader( train_dataset, batch_sizeper_gpu_bs, samplertrain_sampler, num_workers8, pin_memoryTrue, drop_lastTrue, ) for epoch in range(num_epochs): train_sampler.set_epoch(epoch) # 每个 epoch 必须调用否则 shuffle 顺序永远一样 for batch in train_loader: ...set_epoch这一行是无数人漏掉的。不调用它每个 epoch 的数据切分和打乱顺序完全相同等于每个 epoch 都在看同一批样本的同一个排列训练效果会明显变差。drop_lastTrue也很关键如果各 rank 的 batch 数不一致最后一个 batch 只有部分 rank 参与 All-Reduce其他 rank 在那里等着直接死锁。3.3 完整训练骨架与几个必须写的细节下面这个骨架可以直接当模板用import os, argparse, torch import torch.nn as nn import torch.distributed as dist from torch.nn.parallel import DistributedDataParallel as DDP def main(): local_rank int(os.environ[LOCAL_RANK]) torch.cuda.set_device(local_rank) dist.init_process_group(backendnccl, timeouttorch.timedelta(minutes10)) rank, world_size dist.get_rank(), dist.get_world_size() model build_model().to(local_rank) # 同步 BatchNorm多卡各算各的统计量会破坏一致性小 batch 时必开 model nn.SyncBatchNorm.convert_sync_batchnorm(model) model DDP( model, device_ids[local_rank], output_devicelocal_rank, gradient_as_bucket_viewTrue, static_graphTrue, broadcast_buffersTrue, ) optimizer torch.optim.AdamW(model.parameters(), lrbase_lr * world_size) scaler torch.amp.GradScaler(cuda) for step, batch in enumerate(loader): with torch.amp.autocast(cuda, dtypetorch.bfloat16): loss model(**batch) optimizer.zero_grad(set_to_noneTrue) scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) scaler.step(optimizer) scaler.update() if rank 0 and step % 50 0: print(fstep{step} loss{loss.item():.4f}) if rank 0: torch.save({model: model.module.state_dict()}, ckpt.pt) dist.barrier() dist.destroy_process_group()几个细节值得展开model.module.state_dict()里的.module不能少。DDP 包装后的对象真正的模型挂在.module上直接存model.state_dict()会把 DDP 内部的前缀module.一起存进去下次加载到非 DDP 模型上就对不上键名了。这个坑我在迁移推理脚本时踩过排查了两个小时才发现键名前缀不对。broadcast_buffersTrue是默认值作用是每次前向之前把 rank 0 的 buffer比如 BN 的 running_mean广播给其他 rank。这在用 SyncBatchNorm 时其实是多余的SyncBN 自己做同步但保留无害如果你有自己维护的 buffer 且不希望被同步才需要关掉。zero_grad(set_to_noneTrue)比默认的置零更快因为它是把.grad置为 None 而不是写一遍零省一次内存写。这个优化不小大模型上能省几毫秒一步。3.4 batch size 与学习率的换算关系DDP 天然把 global batch 放大了 N 倍。如果你的单卡 batch 是 88 卡就是 64。这时候学习率怎么调业界常用的经验法则是线性缩放lr_new lr_base × (global_bs_new / global_bs_base)。单卡 bs8 时 lr1e-4 训得好8 卡 bs64 就试 8e-4。但这个规则在 batch 大到一定程度后失效超过某个临界 batch size再增大只会降低样本效率而且它假设你用同样的 epoch 数训练——实际上大 batch 需要更多 warmup 来避免早期发散。我自己的做法是线性缩放作为起点然后加 1%-3% 总步数的 warmup再用余弦退火衰减到峰值的 10%。如果放大了 batch 之后 loss 前 500 步抖得厉害就把峰值学习率往下压 30% 再试。另外一个容易被忽略的点是权重衰减有些实现里 weight decay 是按参数绝对大小作用的batch 变大后等效正则强度会变通常需要同步放大 weight decay 或者改用解耦的形式。还有一个反向思路不放大 global batch保持和单卡一致。做法是把单卡的 batch 除以卡数每卡只跑 1/8 的 batch。这样学习率完全不用调但每卡的 batch 变得很小比如 1BN 统计会非常不准而且 GPU 利用率会下降小 batch 的 kernel 效率低。所以一般不是首选除非你的显存实在卡得死死的。如果是显存限制导致单卡 batch 上不去那就上梯度累积累积 K 步再更新一次等效 global batch 变成per_gpu_bs × N × K。这里有个优化必须提——前 K-1 步不需要做梯度同步用no_sync包起来能省掉大量通信for i, batch in enumerate(loader): ctx model.no_sync() if (i 1) % accum_steps ! 0 else nullcontext() with ctx: loss model(**batch) / accum_steps scaler.scale(loss).backward() if (i 1) % accum_steps 0: scaler.step(optimizer); scaler.update(); optimizer.zero_grad(set_to_noneTrue)这个no_sync的技巧在实际项目里收益很大。8 卡、累积 4 步的情况下通信次数从每步一次降到每 4 步一次通信开销直接砍掉 75%。我做过对比同样的有效 batch加no_sync后吞吐提升了将近 40%。4. 显存、吞吐与精度的三角平衡DDP 调优的本质是在显存、吞吐、精度之间找平衡点。这三者互相牵制想省显存就得牺牲 batch 或精度想提吞吐就得多占显存。这一节讲几个实操中真正有效的调节手段。4.1 显存到底被谁吃掉了单卡显存占用可以粗略拆成四块模型权重、优化器状态、梯度、激活值。以 AdamW fp16 混合精度为例一个 P 参数的模型fp16 权重2P 字节fp32 主权重Adam 需要4P 字节一阶动量 二阶动量fp328P 字节梯度fp162P 字节激活值跟 batch size 和序列长度成正比通常是大头算一下 7B 模型权重 14GB 主权重 28GB 动量 56GB 梯度 14GB 112GB。这已经超过单张 80GB 卡了还没算激活值。所以 7B 全参数训练在单卡上根本跑不了必须上分片方案ZeRO-2/3或者 LoRA 这类参数高效微调。而一个 1.3B 的模型2.6 5.2 10.4 2.6 20.8GB剩 60GB 给激活值batch 开大一点完全够用。这个账要提前算别等 OOM 了再猜。DDP 本身不减少任何单卡的显存占用——它只是把数据切开了模型、优化器、梯度在每个进程里都是完整的一份。这一点是 DDP 和 ZeRO 的根本区别很多人误以为多卡就能跑更大的模型其实多卡只是让你能跑更大的 batch。4.2 混合精度与 DDP 的配合陷阱torch.amp.autocast配合 GradScaler 是标配。但在 DDP 场景下有两个坑。第一个坑GradScaler 的状态也要在各 rank 间保持一致。PyTorch 的实现里如果某个 rank 出现 inf/nan 导致 scale 回退而其他 rank 没有那么各 rank 的 scale 因子就会分叉最终导致参数更新不一致。好消息是 DDP 的文档里明确说了只要各 rank 的模型和损失计算逻辑一致数据不同没关系但计算图要一致GradScaler 的行为就是确定性的不会分叉。所以关键还是那句老话——别让不同 rank 走不同的分支。第二个坑如果你用了 bf16其实不需要 GradScaler因为 bf16 的动态范围跟 fp32 一样不存在下溢问题。这种情况下省掉 scaler 能少一次 unscale 的遍历速度略快。但要注意 bf16 的尾数精度低小梯度的舍入误差会累积某些对精度敏感的任务比如小学习率的微调用 bf16 可能收敛略慢。我的经验是预训练用 bf16 没问题微调阶段如果发现收敛变差退回 fp16 scaler。第三个容易被忽视的点是SyncBatchNorm 的成本。常规的 SyncBN 在前向时要额外做一次跨卡统计量同步反向时还要再同步一次梯度。这会引入额外的通信开销在 8 卡场景下大概会吃掉 5%-15% 的吞吐。如果模型里 BN 层很少收益不大如果模型里 BN 很多而 batch 又小那就必须开否则精度损失更严重。近两年的模型大多用 LayerNorm/RMSNorm这种情况直接用convert_sync_batchnorm反而不划算我一般会先看模型结构再决定。5. 踩坑实录那些日志里看不出来的问题这一节是我最想写的部分因为前面所有理论最终都体现在出事的时候怎么定位。DDP 的问题有个共同特点报错信息要么极其模糊要么根本不报错。不报错的那种最难受——训练在跑loss 在降但就是比单卡差一截。5.1 训练卡死先看通信序列是否对齐卡死是 DDP 最高频的问题典型症状是loss 打印到某一步之后就没动静了GPU 利用率掉到 0日志里什么错都没有过了十分钟或者你设的 timeout才抛出一堆 NCCL timeout 的堆栈。根本原因几乎总是同一个某个 rank 在执行集合通信时其他 rank 没在执行同一个通信。这属于地址对不上一部分人到了集合点另一部分人还在别处。具体诱因有数据加载器的长度不一致某个 rank 少了一个 batch导致它提前退出了循环而其他 rank 还在等 All-Reduce。这是drop_last没设的经典后果。模型里有条件分支某些 rank 走了if分支、另一些走了else。比如if loss.isnan(): continue这种聪明的写法直接让这个 rank 跳过了后续的 backward其他 rank 就永远等不到它的梯度。多个进程同时写同一个日志文件或 checkpoint 文件文件锁竞争导致某个进程被阻塞。多机场景下某个节点的网络瞬断或者NCCL_SOCKET_IFNAME指错了网卡。排查顺序我总结成这样先看是不是数据长度不一致把各 rank 的len(loader)打出来对比再看是不是条件分支在可疑的 if 前后加dist.barrier()哪个 barrier 卡住就是那里的问题最后看网络和文件 IO。加 barrier 这个技巧非常土但极其有效等于给通信序列加了一个断言。还有一个特别隐蔽的变种某个 rank 上出现了 CUDA OOM。如果你用 try/except 捕获了 OOM 做了降级处理那个 rank 就脱离了正常流程其他 rank 在 All-Reduce 处死等。所以OOM 千万不要吞掉让它抛出来整个训练一起挂掉问题反而暴露得干净。5.2 loss 对不上单卡从 BatchNorm 和随机性查起多卡能跑但效果比单卡差这个问题比卡死更难查因为它没有明显的断点。第一嫌疑人永远是BatchNorm。DDP 默认下每张卡上的 BN 用的是自己那张卡上那点样本算出来的均值方差。如果单卡 batch 是 328 卡之后每卡变 4BN 的统计量噪声陡然增大相当于给模型注入了一个额外的扰动。解决办法就是前面说的convert_sync_batchnorm把统计量跨卡同步成等效的大 batch 统计。第二嫌疑人是随机种子与数据增强。每个进程都要设置不同的数据增强种子否则所有卡做完全相同的增强等于削弱了数据多样性但模型初始化的种子应该一致虽然 DDP 会广播参数但如果你在 forward 里用了 dropoutdropout 的随机数生成器也要注意——实际上 dropout 各卡独立是正确行为。我的做法是seed base_seed rank用于数据侧模型侧统一用base_seed。第三嫌疑人是损失函数的归约方式。如果你的 loss 是按样本求平均而不同 rank 上的样本数不同最后一批没 drop 干净那么各 rank 的 loss 尺度不一致梯度平均之后就不等于全局平均。这就是为什么drop_lastTrue不只是为了防死锁也是为了数值正确。第四嫌疑人是梯度裁剪。clip_grad_norm_要在 All-Reduce 之后做DDP 保证了 backward 结束时梯度已经同步完成所以调用时机是对的但如果你在no_sync的累积步里调用了它那用的是局部梯度范数跟全局范数对不上。梯度累积场景下裁剪要么放在最后一次 backward 之后要么就用累积后的范数别在中间步骤瞎裁。5.3 checkpoint 与断点续训的正确姿势保存和加载在 DDP 里也有讲究尤其是只让 rank 0 写文件这条。如果所有 rank 都往同一个路径写轻则文件内容错乱重则卡死文件系统锁。def save_ckpt(path, model, optimizer, scaler, step): if dist.get_rank() ! 0: return state { model: model.module.state_dict(), optimizer: optimizer.state_dict(), scaler: scaler.state_dict(), step: step, } tmp path .tmp torch.save(state, tmp) os.replace(tmp, path) # 原子替换防止写到一半崩溃留下坏文件os.replace这个原子操作值得加我吃过一次断电的亏checkpoint 写了一半进程被杀文件损坏只能从头训。另外多机场景下如果共享文件系统很慢torch.save可能会阻塞几十秒其他 rank 在 barrier 处干等。可以考虑让 rank 0 异步写单独开一个写线程其他 rank 继续训练。加载的时候optimizer.state_dict()里的状态是按参数顺序存的只要模型结构一致就能对上。但如果你在 DDP 包装前加载、包装后保存或者反过来.module前缀的处理要一致否则load_state_dict会报 missing keys。我的统一约定是所有 checkpoint 一律存model.module.state_dict()加载时一律先建裸模型、load、再包 DDP。这样 checkpoint 跟 DDP 解耦推理脚本也能直接用。5.4 常见问题速查表现象最可能的原因快速验证方式处理办法所有进程挤在一张卡上硬编码了cuda:0nvidia-smi看显存分布用LOCAL_RANK设置 device跑到某步卡死最后超时各 rank 通信序列不一致在可疑分支前后加 barrier统一分支、设drop_lastTrue多卡 loss 明显差于单卡BN 统计量或随机性不同步关掉 BN 或换 LayerNorm 对比开 SyncBatchNorm、分开设种子加速比只有 1.5 倍通信带宽不足或 bucket 太小看NCCL_DEBUGINFO的链路信息检查网卡、调bucket_cap_mb第一个 All-Reduce 就超时网卡选错 / 端口不通ping 端口连通性检查指定NCCL_SOCKET_IFNAME显存比单卡涨了误以为 DDP 能省显存算单卡显存账DDP 不省显存需分片方案每个 epoch 数据顺序一样漏了set_epoch打印 sampler 的 epoch 属性每轮调用set_epoch(epoch)存下来的模型加载不上存了带module.前缀的权重检查 state_dict 的 key统一用.module.state_dict()6. 最后分享几个我在实操中摸出来的经验关于bucket_cap_mb这个参数网上很少有具体的建议。默认 25MB 在中等模型上挺好用但它其实可以调。逻辑是这样的bucket 越大通信次数越少、单次效率越高但等待 bucket 填满的时间也越长计算与通信重叠的颗粒度越粗。我在这几个场景下试下来小于 1B 的模型调到 50-100MB 通常有 2-5% 的提升超过 10B 的模型反而建议略降到 10-25MB因为梯度太多大 bucket 会让尾部的同步延迟变得明显。这个得实测不同模型结构差异很大千万别照搬别人的数字。另一个是static_graphTrue的适用边界。它能生效的前提是多轮迭代的计算图完全一致绝大多数标准训练满足。但如果你的模型里有基于输入长度的动态 padding、有随机的专家路由、或者用了某些会改变 graph 的检查点技术开了之后要么报错要么行为异常。我的判断方法是先不开跑 200 步确认稳定再开着跑 200 步对比 loss 曲线是否逐位一致。不一致就说明你的图确实在变别硬开。还有一个关于调试的思路我觉得比任何具体参数都重要先让单进程多卡能跑通再上多进程。具体做法是在代码里把 world_size 强制设成 1、不启动任何 DDP 包装用一个纯粹的普通训练循环跑 100 步记录 loss 曲线作为基线。然后开 2 卡跑同样的有效 batch曲线应该能对上允许微小数值差异。这一步过了再扩到 8 卡。我见过太多人一上来直接 8 卡出问题后完全不知道是代码问题还是环境问题一天就没了。分级排查看着慢其实是快。最后说个我自己的教训。早期有次多机训练加速比一直卡在 4 倍左右上不去16 卡我花了半天调 bucket 大小、调 no_sync效果都不明显。后来把NCCL_DEBUGINFO打开才发现节点间通信根本没走高速网络在用 TCP 跑带宽只有理论值的十分之一。改完网卡配置加速比立刻到 12 倍。这件事让我养成了一个习惯任何关于分布式性能的判断先看通信链路再看算法层面的调优。链路是地基地基歪了上面怎么调都是徒劳。