
1. 从单卡到双卡为什么“结果不变”才是真本事做过大模型训练的人都有一个共识把单卡脚本改成多卡跑起来不难难的是跑出来的 loss 曲线和单卡几乎一模一样。我见过太多团队单卡训到 loss 1.8换成双卡 DDP 之后 loss 直接飘到 2.3然后开始怀疑人生——是学习率要调是 batch size 变了还是数据 shuffle 出了问题这个项目的核心命题就一句话同一份训练任务改成双卡 DDP结果不能变。注意这里的“结果不变”不是指 loss 数值完全逐位相等那是不可能的浮点累加顺序变了而是指训练语义等价——同样的全局 batch size、同样的数据顺序、同样的梯度更新逻辑、同样的随机种子行为。做到这一点你才敢说“我这次扩容是干净的”。适合谁看如果你已经能跑通单卡训练脚本手里有 2 张卡哪怕是 2 张消费级卡想搞清楚 DDP 到底在背后做了什么、为什么有些改法会悄悄改变训练结果那这篇就是写给你的。我会用 PyTorch 的torchrun NCCL 这套最主流的组合把每一步的“为什么”讲透而不是甩一段代码让你抄。先说结论性的判断DDP 改变结果90% 的情况不是 DDP 本身的锅而是你在改造过程中动了这三样东西之一——全局 batch size、数据采样顺序、随机种子。剩下的 10% 是 BatchNorm 这类跨卡统计量的问题。把这三样锁死双卡和单卡就能对齐。2. 改造前的方案设计与思路拆解2.1 单卡脚本里哪些东西是“隐式全局”的单卡训练脚本里有很多变量你根本没意识到它们是“全局”的因为只有一张卡全局就等于本地。一旦上双卡这些隐式假设全部暴露batch size单卡时batch_size32就是全局 32。双卡如果每张卡还是 32全局就变成 64 了梯度更新的尺度直接翻倍。数据顺序单卡时 DataLoader 的 shuffle 就是全局顺序。双卡时每张卡各有一个 DataLoader如果不管两张卡会各自 shuffle同一份数据可能被两张卡重复采样。随机种子单卡时torch.manual_seed(42)管住一切。双卡时每个进程都要设种子而且如果种子相同两张卡的 dropout mask 会一模一样反而引入相关性。梯度同步单卡时梯度就是本地算的。双卡时 DDP 会在 backward 时做 all-reduce把两张卡的梯度求平均。理解这四点是理解后面所有操作的基础。我习惯把 DDP 改造拆成两个层面语义层保证训练逻辑等价和通信层保证梯度正确同步。语义层是大多数人踩坑的地方通信层反而是 PyTorch 帮你兜底的。2.2 为什么选 torchrun NCCL 这套组合PyTorch 的多卡启动方式经历过几代演进最早是torch.nn.DataParallel单进程多线程后来是torch.multiprocessing.spawn手写启动现在是torchrun旧称torch.distributed.launch官方推荐。DataParallel为什么不用它是单进程多线程Python 的 GIL 会让多卡并行效率大打折扣而且它把整个模型复制到每张卡、在卡 0 上聚合梯度卡 0 显存和计算压力都更大扩展性很差。做正经训练直接上 DDP别碰 DataParallel。torchrun相比手写spawn的好处是它帮你处理了进程启动、环境变量注入RANK、WORLD_SIZE、LOCAL_RANK、MASTER_ADDR、MASTER_PORT你只需要在脚本里读这些环境变量就行。NCCL 则是 NVIDIA 卡上的集合通信后端做 all-reduce 比 Gloo 快一个数量级双卡场景下基本是默认选择。注意如果你用的是 AMD 卡或者某些特殊环境NCCL 可能不可用需要换 Gloo 或 RCCL。但本文以最常见的 NVIDIA NCCL 为主线其他后端思路一致。2.3 “结果不变”的等价性到底指什么这里必须把话说清楚否则后面没法验证。浮点运算不满足结合律(ab)c和a(bc)的结果可能差最后几位。DDP 的 all-reduce 会改变梯度求和的顺序所以逐位相等是不可能的也不该追求。我们要保证的等价性是全局 batch size 不变单卡 32双卡就是每卡 16全局还是 32。每个 step 见到的数据集合不变用 DistributedSampler 保证两张卡合起来正好覆盖一个 epoch 的数据不重不漏。梯度更新语义不变DDP 默认对梯度求平均除以 world_size配合每卡 batch 减半等价于单卡全局 batch 的梯度。随机性行为一致dropout、数据增强等随机操作的统计分布一致。只要这四条满足你跑出来的 loss 曲线应该和单卡在数值上高度接近差异在浮点误差量级收敛行为一致。我实测下来双卡和单卡的 loss 差异通常在 1e-4 到 1e-3 量级属于正常浮点误差。3. 核心细节解析与实操要点3.1 全局 batch size 的换算最容易翻车的地方这是 DDP 改造的第一大坑。假设你单卡脚本里写的是batch_size 32改成双卡如果你什么都不动每张卡还是 32那全局 batch 就是 64。梯度更新的有效学习率实际上翻倍了loss 曲线会明显不同。正确做法是引入一个“每卡 batch size”的概念per_device_batch_size 16 # 每张卡 16 world_size int(os.environ[WORLD_SIZE]) # 2 global_batch_size per_device_batch_size * world_size # 32和单卡一致然后 DataLoader 用per_device_batch_size。这样全局 batch 保持 32梯度尺度不变。那学习率要不要动如果全局 batch 不变学习率就不用动。很多人一上多卡就条件反射地调学习率其实没必要——你只是把同样的全局 batch 拆到两张卡上算数学上等价。只有当你想利用多卡去扩大全局 batch比如从 32 扩到 64时才需要按线性缩放或平方根缩放规则调学习率。实操心得我习惯在脚本里显式打印global_batch_size每次启动都确认一遍。这个数字对不上后面全白搭。3.2 DistributedSampler让两张卡“合起来”看完整数据单卡时 DataLoader 的 shuffle 是全局的。双卡时如果每张卡各自 shuffle会出现两个问题一是同一份样本可能被两张卡在同一 step 都采到重复二是某些样本可能整个 epoch 都没被采到遗漏。PyTorch 提供的DistributedSampler就是解决这个的。它的逻辑是把整个数据集按 rank 切分rank 0 拿一部分rank 1 拿另一部分合起来正好是全集。而且每个 epoch 它会用 epoch 号作为种子重新 shuffle保证不同 epoch 的划分不同。from torch.utils.data.distributed import DistributedSampler sampler DistributedSampler( dataset, num_replicasworld_size, rankrank, shuffleTrue, seed42, drop_lastFalse, ) loader DataLoader(dataset, batch_sizeper_device_batch_size, samplersampler)关键点用了 sampler 就不能再设shuffleTrue否则 PyTorch 会报错。另外每个 epoch 开始前要调用sampler.set_epoch(epoch)否则每个 epoch 的 shuffle 结果都一样等于没 shuffle。for epoch in range(num_epochs): sampler.set_epoch(epoch) for batch in loader: ...set_epoch这个调用特别容易被忘。忘了的后果是每个 epoch 的数据划分完全相同模型见过的东西顺序固定泛化会受影响。我踩过这个坑loss 曲线看着正常但验证集指标就是比单卡差一点查了半天才发现是set_epoch没调。3.3 随机种子的设置每个 rank 要不一样单卡时torch.manual_seed(42)就够了。双卡时如果两个进程都设 42会发生什么dropout 的 mask 在两张卡上完全一样数据增强的随机变换也一样。这会让两张卡的计算产生人为相关性虽然梯度还是对的但随机性的统计性质变了。正确做法是让每个 rank 的种子不同但整体可复现def set_seed(seed, rank): torch.manual_seed(seed rank) torch.cuda.manual_seed_all(seed rank) np.random.seed(seed rank) random.seed(seed rank)这样 rank 0 用 42rank 1 用 43各自独立。同时因为种子是确定的整个训练仍然可复现。注意如果你追求和单卡“逐位对齐”那种子设置反而要更小心。但如前所述逐位对齐不现实我们追求的是统计等价。用seed rank是业界通行做法。3.4 DDP 包装模型device_ids 和 output_device模型要用DistributedDataParallel包一层model model.to(local_rank) model nn.parallel.DistributedDataParallel( model, device_ids[local_rank], output_devicelocal_rank, )local_rank是当前进程在本机上的卡号0 或 1从环境变量LOCAL_RANK读。device_ids指定这个进程用哪张卡。注意 DDP 要求模型先.to(device)再包装顺序反了会报错。DDP 在 backward 时会自动触发梯度 all-reduce。默认行为是对梯度求平均sum 之后除以 world_size。这一点很关键因为每张卡的 batch 是全局的一半两张卡的梯度平均后正好等价于全局 batch 的梯度。所以你不需要手动在 loss 上除以 world_sizeDDP 帮你做了。如果你看到有人写loss loss / world_size再 backward那是 DataParallel 时代的遗留写法在 DDP 下会导致梯度被多除一次训练变慢。这是个经典误区。4. 完整实操流程与关键环节实现4.1 环境准备与依赖确认先把环境理清楚。PyTorch 版本建议 1.9 以上torchrun在 1.9 之后成为正式命令之前叫torch.distributed.run。NCCL 一般随 PyTorch 的 CUDA 版本一起装好可以用下面这段确认import torch import torch.distributed as dist print(PyTorch:, torch.__version__) print(CUDA available:, torch.cuda.is_available()) print(GPU count:, torch.cuda.device_count()) print(NCCL available:, dist.is_nccl_available())如果NCCL available是 False检查你的 PyTorch 是不是 CPU 版本或者 CUDA 版本和驱动是否匹配。双卡训练前先用nvidia-smi确认两张卡都能被识别且没有其他进程占着显存。实操心得我习惯在训练脚本开头加一段环境打印把 rank、local_rank、world_size、当前卡号、全局 batch 全打出来。多卡调试时这些信息能帮你快速定位是哪个进程出了问题。4.2 训练脚本的 DDP 化改造下面是一个最小可运行的改造骨架我把它拆成几个关键块。初始化分布式环境import os import torch import torch.distributed as dist def init_distributed(): dist.init_process_group(backendnccl) local_rank int(os.environ[LOCAL_RANK]) torch.cuda.set_device(local_rank) return local_rankinit_process_group会自动读环境变量里的MASTER_ADDR、MASTER_PORT、RANK、WORLD_SIZE这些是torchrun注入的。backendnccl指定用 NCCL。构建模型、优化器、数据local_rank init_distributed() rank dist.get_rank() world_size dist.get_world_size() set_seed(42, rank) model MyModel().to(local_rank) model nn.parallel.DistributedDataParallel(model, device_ids[local_rank]) optimizer torch.optim.AdamW(model.parameters(), lr1e-4) dataset MyDataset() sampler DistributedSampler(dataset, num_replicasworld_size, rankrank, shuffleTrue, seed42) loader DataLoader(dataset, batch_sizeper_device_batch_size, samplersampler)训练循环for epoch in range(num_epochs): sampler.set_epoch(epoch) model.train() for step, batch in enumerate(loader): inputs, labels batch inputs inputs.to(local_rank) labels labels.to(local_rank) optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, labels) loss.backward() # DDP 在这里自动 all-reduce 梯度 optimizer.step() if rank 0 and step % 50 0: print(fepoch {epoch} step {step} loss {loss.item():.4f})注意打印日志只在 rank 0 上做否则两张卡会各打一遍日志乱成一团。收尾dist.destroy_process_group()训练结束要销毁进程组否则可能残留通信资源。4.3 启动命令与参数说明用torchrun启动双卡就是--nproc_per_node2torchrun \ --nproc_per_node2 \ --master_port29500 \ train_ddp.py \ --config config.yaml几个参数的含义--nproc_per_node2本机启动 2 个进程对应 2 张卡。--master_port29500主进程通信端口如果被占用换一个。--nnodes1单机默认就是 1多机才需要改。--node_rank0本机编号单机默认 0。torchrun会自动给每个进程设好RANK、LOCAL_RANK、WORLD_SIZE、MASTER_ADDR、MASTER_PORT脚本里直接读就行。注意--master_port冲突是多卡启动最常见的报错之一。如果看到Address already in use换个端口或者先lsof -i:29500看看谁占着。4.4 验证“结果不变”的对照实验改造完怎么证明结果没变我的做法是跑一个对照实验单卡跑 N 个 step记录 loss 序列。双卡跑同样 N 个 step全局 batch 保持一致记录 loss 序列。对比两条曲线。为了排除数据顺序的干扰我会把数据集设得很小比如 256 条shuffleFalse这样单卡和双卡的样本顺序完全确定。然后对比前 20 个 step 的 loss。实测下来两条曲线的差异应该在 1e-3 以内。如果差异很大按下面的顺序排查现象可能原因排查方法双卡 loss 明显偏高全局 batch 翻倍了打印 global_batch_size双卡 loss 波动大数据重复采样检查 DistributedSampler 是否生效双卡 loss 不下降梯度被多除了检查有没有手动除以 world_size每个 epoch 结果一样忘了 set_epoch检查训练循环启动就报错端口冲突或 NCCL 问题换端口检查 NCCL 可用性这个对照实验是我每次 DDP 改造后的必做项。花 10 分钟跑一遍能省掉后面几天的调试。5. 常见问题与排查技巧实录5.1 启动阶段的典型报错报错一Address already in use端口被占。换--master_port或者杀掉占用进程。多卡调试时经常反复启动上一个进程没退干净就会撞端口。报错二NCCL error: unhandled system errorNCCL 通信出问题。常见原因是两张卡之间没有 P2P 通道比如某些主板 PCIe 拓扑不支持或者驱动版本不匹配。可以先设export NCCL_DEBUGINFO看详细日志再设export NCCL_P2P_DISABLE1试试禁用 P2P。禁用 P2P 会慢一点但能跑通。报错三RuntimeError: Expected to have finished reduction in the prior iteration这个报错的意思是模型里有参数在 forward 中参与了计算但 backward 时没收到梯度。典型原因是模型里有分支逻辑某些参数在某些 step 不参与计算。DDP 默认要求所有参数每步都有梯度。解决办法是给 DDP 加find_unused_parametersTruemodel nn.parallel.DistributedDataParallel( model, device_ids[local_rank], find_unused_parametersTrue )但这个参数会带来额外开销能不用就不用。更好的做法是检查模型逻辑让所有参数每步都参与。5.2 训练过程中的隐蔽问题问题一loss 曲线看着正常但验证指标比单卡差这种最阴险因为 loss 不报错。我遇到过一次查了两天才发现是set_epoch没调导致每个 epoch 数据划分相同。加上sampler.set_epoch(epoch)后验证指标立刻对齐。问题二显存占用比预期高双卡时每张卡的显存应该和单卡差不多因为 batch 减半了。如果明显更高检查是不是模型被复制了两份、或者中间变量没释放。DDP 本身不会让显存翻倍它只同步梯度。问题三训练速度没有提升双卡理论上接近 2 倍加速但实际受通信开销影响。如果加速比很低比如只有 1.2 倍检查一是模型是不是太小通信开销占比过高二是数据加载是不是瓶颈可以调num_workers三是 NCCL 是不是走了慢速通道。实操心得小模型参数量小于 100M做 DDP加速比往往不理想因为 all-reduce 的通信时间占比大。这时候可以考虑梯度累积来模拟更大 batch而不是盲目加卡。5.3 一个容易被忽略的细节BatchNorm如果你的模型里有 BatchNormDDP 默认是每张卡各自算自己的统计量不做跨卡同步。这意味着双卡时每张卡看到的 batch 是全局的一半BN 的统计量是基于半个 batch 算的和单卡全局 batch 的统计量不同。对于大多数 Transformer 类 LLM用的是 LayerNorm没有这个问题。但如果你的模型有 BN要么换成 SyncBatchNormmodel nn.SyncBatchNorm.convert_sync_batchnorm(model)要么接受统计量的差异。SyncBatchNorm 会跨卡同步 BN 统计量代价是额外的通信开销。5.4 常见问题速查表问题根因解决端口冲突上次进程未退出换端口或杀进程NCCL 报错P2P 或驱动问题NCCL_DEBUGINFO 排查试禁用 P2P未使用参数报错模型有分支find_unused_parametersTrue验证指标变差set_epoch 未调每 epoch 调 sampler.set_epoch梯度异常手动除了 world_size去掉手动除法BN 统计量不一致未同步 BN换 SyncBatchNorm加速比低通信开销大增大 batch 或梯度累积6. 我个人在 DDP 改造中的几条经验最后分享几条踩坑踩出来的经验都是文档里不会写的。第一条先保证单卡可复现再改双卡。如果你的单卡脚本本身每次跑结果都不一样种子没设全、数据加载有随机性那改双卡后你根本分不清差异是 DDP 带来的还是本来就有的。改造前先把单卡的复现性做扎实。第二条全局 batch size 是锚点其他都围着它转。我习惯在配置文件里只写global_batch_size然后脚本自动算per_device_batch_size global_batch_size // world_size。这样无论用几张卡全局 batch 永远不变学习率也不用动。这个习惯帮我省了无数次调试。第三条日志只在 rank 0 打但关键指标可以 all-reduce 后打。训练时 rank 0 打日志就够了。但如果你想看全局的 loss 均值可以用dist.all_reduce把各卡的 loss 汇总再打这样看到的才是真正的全局 loss。第四条小步验证别一上来就全量跑。改完 DDP 后先用一个极小的数据集几百条跑几十个 step确认 loss 曲线和单卡对齐再上全量数据。全量跑一次几小时小步验证只要几分钟性价比极高。第五条torchrun的--standalone模式适合单机调试。单机双卡时torchrun --standalone --nproc_per_node2会自动处理 master 地址和端口省去手动配置。多机才需要显式指定--master_addr和--node_rank。这套流程我用了很多次从 2 卡到 8 卡都是同样的思路锁死全局 batch、用 DistributedSampler 管数据、每 rank 独立种子、DDP 自动同步梯度。把这四点做到位双卡和单卡的结果对齐就是水到渠成的事。真正花时间的从来不是 DDP 本身而是那些被单卡掩盖的隐式全局假设。