1. 单卡跑不动之后,DDP到底解决了什么问题
第一次把训练脚本从单卡挪到多卡的人,多半有过这种体验:代码改完了,torchrun一敲,八张卡的风扇都转起来了,日志也哗哗地打,但算一下吞吐,只比单卡快了 1.6 倍。这时候你会开始怀疑人生——说好的线性加速呢?我在几个项目里反复踩过这个坑,最后发现问题往往不在硬件,而在于对 PyTorch分布式训练 里 DDP(DistributedDataParallel)这套机制的理解只停留在"套个壳就能跑"的层面。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 里有三种常见的并行路子:DataParallel(DP)、DistributedDataParallel(DDP)、以及各种模型并行/流水并行。DP 是最早的写法,一行nn.DataParallel(model)就能跑,但它的问题很致命——它是单进程多线程的。主进程负责分发输入、收集输出、汇总梯度,所有 GPU 的梯度都要回到主卡的显存里做 reduce,主卡显存凭空多出一份模型大小的开销,而且 Python 的 GIL 让多线程调度效率很一般。实测下来,DP 在 4 卡以上的加速比经常掉到 2 倍以下,8 卡基本没什么收益,还容易出现主卡 OOM。
DDP 换了个思路:多进程。每个进程就是一个独立的 Python 解释器,独占一张卡,拥有自己的模型副本、自己的优化器状态、自己的数据加载器。进程之间不共享 Python 对象,只通过 NCCL(NVIDIA 卡上的集合通信库)交换梯度张量。这样一来,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 rank
torchrun启动时会给每个进程注入几个环境变量,这是整套体系的基石:
RANK:全局进程序号,从 0 到 world_size-1,全局唯一。LOCAL_RANK:节点内的进程序号,从 0 到 每节点卡数-1。这个值直接决定cuda:${LOCAL_RANK},用错就会所有进程抢同一张卡。WORLD_SIZE:总进程数,通常等于总卡数。MASTER_ADDR/MASTER_PORT:rank 0 的地址和端口,所有进程靠它完成初次握手。
我见过最常见的翻车现场是:代码里硬编码cuda:0,torchrun --nproc_per_node=8一跑,八个进程全挤在 0 号卡上,显存瞬间爆掉,报错信息还是一堆 CUDA 相关的噪音。正确写法一定是local_rank = int(os.environ["LOCAL_RANK"]); torch.cuda.set_device(local_rank)。
init_process_group这一步是在建立"所有人互相认识"的通道。backend的选择有讲究:单机或多机的 NVIDIA 卡,无脑选nccl;CPU 或者调试环境用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( backend="nccl", timeout=torch.timedelta(minutes=10), ) # 让 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_IFNAME=eth0这种形式)能避开这个问题。我一般会在启动脚本里显式设置,不指望它自己猜对。
第二是NCCL_IB_DISABLE和NCCL_DEBUG=INFO。当你怀疑通信没走高速路径时,把 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_mb=25,即每桶 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.6GB,8 卡时每卡通信量约2 × 7/8 × 2.6GB ≈ 4.55GB。如果节点内 NVLink 带宽按单向 300GB/s 算,理想情况十几毫秒就能完成;但如果走的是 PCIe 4.0 x16(双向约 64GB/s),或者更糟的 TCP 网络,这个时间会拉到几百毫秒甚至秒级。对比一下单步计算时间——如果你的模型单步计算只要 200ms,而通信要 800ms,那不管加多少卡,加速比都上不去。这就是为什么调 DDP 的第一步永远是测通信开销,而不是调学习率。
有两个参数跟这个机制直接相关,值得记住:
gradient_as_bucket_view=True:让梯度直接指向 bucket 的视图,省掉一次内存拷贝。开启后显存占用会小幅下降,实测在 7B 以下模型上大概省 1-2GB,同时吞吐有 1-3% 的提升。基本可以无脑开。static_graph=True:如果你在多个迭代之间用的是完全相同的计算图(绝大多数字节级正常的训练脚本都是这样),开启它会带来额外的优化,官方给的数字是 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 挂了能自动重启补位),而且对信号处理更友好(Ctrl+C 能优雅退出,不会留下一堆僵尸进程)。
单机 8 卡的启动命令:
torchrun \ --nproc_per_node=8 \ --master_port=29500 \ train_ddp.py \ --config conf/base.yaml多机 2 节点、每节点 8 卡的启动命令(两个节点上各执行一次,只有node_rank不同):
torchrun \ --nnodes=2 \ --nproc_per_node=8 \ --node_rank=0 \ --master_addr=10.0.0.11 \ --master_port=29500 \ train_ddp.py \ --config conf/base.yaml注意:
--master_port一定要避开已被占用的端口。同一个节点上跑多个实验时,不同实验必须用不同端口,否则第二个实验会在init_process_group阶段直接报 address already in use。我一般会在脚本里用随机端口或者按实验编号递增。
有个小技巧:如果你习惯用 shell 脚本封装,可以把启动参数写成变量,本地调试时nproc_per_node=1也能跑通全流程,代码路径完全一致,这样就不用维护"单卡版"和"多卡版"两份脚本了。代码路径统一这件事在排查问题时的价值极高,因为单卡能复现的问题,多半不是 DDP 本身的锅。
3.2 模型包装顺序与数据切分
DDP 的包装顺序有严格的要求,写反了会静默出错。正确的顺序是:
- 建模型 → 2. 放到正确的 device 上 → 3. 包成 DDP → 4. 建优化器。
为什么优化器必须在 DDP 包装之后建?因为 DDP 在构造函数里会对参数做一次性处理(比如提升参数成连续的内存块、注册梯度 hook),如果先建优化器再包 DDP,优化器里持有的参数引用可能和 DDP 内部的参数不是同一批对象,导致梯度更新错乱。这个坑非常隐蔽,症状是训练不收敛但没有任何报错。
还有一条:DDP 的构造函数内部会做一次参数广播(broadcast),把 rank 0 的参数同步给所有进程。所以模型初始化时的随机性不重要——各 rank 即使初始权重不同,也会在包装那一刻被统一。但这点不能当成"随机种子随便设"的借口,因为数据和数据增强的随机性还是需要控制的,调试期我习惯固定种子。
数据切分的正确姿势是DistributedSampler:
from torch.utils.data import DataLoader, DistributedSampler train_sampler = DistributedSampler( train_dataset, num_replicas=dist.get_world_size(), rank=dist.get_rank(), shuffle=True, drop_last=True, # 关键:保证各 rank 样本数一致,避免 All-Reduce 错位 ) train_loader = DataLoader( train_dataset, batch_size=per_gpu_bs, sampler=train_sampler, num_workers=8, pin_memory=True, drop_last=True, ) for epoch in range(num_epochs): train_sampler.set_epoch(epoch) # 每个 epoch 必须调用,否则 shuffle 顺序永远一样 for batch in train_loader: ...set_epoch这一行是无数人漏掉的。不调用它,每个 epoch 的数据切分和打乱顺序完全相同,等于每个 epoch 都在看同一批样本的同一个排列,训练效果会明显变差。drop_last=True也很关键,如果各 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(backend="nccl", timeout=torch.timedelta(minutes=10)) 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_device=local_rank, gradient_as_bucket_view=True, static_graph=True, broadcast_buffers=True, ) optimizer = torch.optim.AdamW(model.parameters(), lr=base_lr * world_size) scaler = torch.amp.GradScaler("cuda") for step, batch in enumerate(loader): with torch.amp.autocast("cuda", dtype=torch.bfloat16): loss = model(**batch) optimizer.zero_grad(set_to_none=True) 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(f"step={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_buffers=True是默认值,作用是每次前向之前把 rank 0 的 buffer(比如 BN 的 running_mean)广播给其他 rank。这在用 SyncBatchNorm 时其实是多余的(SyncBN 自己做同步),但保留无害;如果你有自己维护的 buffer 且不希望被同步,才需要关掉。
zero_grad(set_to_none=True)比默认的置零更快,因为它是把.grad置为 None 而不是写一遍零,省一次内存写。这个优化不小,大模型上能省几毫秒一步。
3.4 batch size 与学习率的换算关系
DDP 天然把 global batch 放大了 N 倍。如果你的单卡 batch 是 8,8 卡就是 64。这时候学习率怎么调?
业界常用的经验法则是线性缩放:lr_new = lr_base × (global_bs_new / global_bs_base)。单卡 bs=8 时 lr=1e-4 训得好,8 卡 bs=64 就试 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 变得很小(比如 1),BN 统计会非常不准,而且 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_none=True)这个no_sync的技巧在实际项目里收益很大。8 卡、累积 4 步的情况下,通信次数从每步一次降到每 4 步一次,通信开销直接砍掉 75%。我做过对比,同样的有效 batch,加no_sync后吞吐提升了将近 40%。
4. 显存、吞吐与精度的三角平衡
DDP 调优的本质是在显存、吞吐、精度之间找平衡点。这三者互相牵制:想省显存就得牺牲 batch 或精度,想提吞吐就得多占显存。这一节讲几个实操中真正有效的调节手段。
4.1 显存到底被谁吃掉了
单卡显存占用可以粗略拆成四块:模型权重、优化器状态、梯度、激活值。以 AdamW + fp16 混合精度为例,一个 P 参数的模型:
- fp16 权重:2P 字节
- fp32 主权重(Adam 需要):4P 字节
- 一阶动量 + 二阶动量(fp32):8P 字节
- 梯度(fp16):2P 字节
- 激活值:跟 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 是 32,8 卡之后每卡变 4,BN 的统计量噪声陡然增大,相当于给模型注入了一个额外的扰动。解决办法就是前面说的convert_sync_batchnorm,把统计量跨卡同步成等效的大 batch 统计。
第二嫌疑人是随机种子与数据增强。每个进程都要设置不同的数据增强种子(否则所有卡做完全相同的增强,等于削弱了数据多样性),但模型初始化的种子应该一致(虽然 DDP 会广播参数,但如果你在 forward 里用了 dropout,dropout 的随机数生成器也要注意——实际上 dropout 各卡独立是正确行为)。我的做法是seed = base_seed + rank用于数据侧,模型侧统一用base_seed。
第三嫌疑人是损失函数的归约方式。如果你的 loss 是按样本求平均,而不同 rank 上的样本数不同(最后一批没 drop 干净),那么各 rank 的 loss 尺度不一致,梯度平均之后就不等于全局平均。这就是为什么drop_last=True不只是为了防死锁,也是为了数值正确。
第四嫌疑人是梯度裁剪。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:0 | nvidia-smi看显存分布 | 用LOCAL_RANK设置 device |
| 跑到某步卡死,最后超时 | 各 rank 通信序列不一致 | 在可疑分支前后加 barrier | 统一分支、设drop_last=True |
| 多卡 loss 明显差于单卡 | BN 统计量或随机性不同步 | 关掉 BN 或换 LayerNorm 对比 | 开 SyncBatchNorm、分开设种子 |
| 加速比只有 1.5 倍 | 通信带宽不足或 bucket 太小 | 看NCCL_DEBUG=INFO的链路信息 | 检查网卡、调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_graph=True的适用边界。它能生效的前提是多轮迭代的计算图完全一致,绝大多数标准训练满足。但如果你的模型里有基于输入长度的动态 padding、有随机的专家路由、或者用了某些会改变 graph 的检查点技术,开了之后要么报错要么行为异常。我的判断方法是:先不开,跑 200 步确认稳定,再开着跑 200 步,对比 loss 曲线是否逐位一致。不一致就说明你的图确实在变,别硬开。
还有一个关于调试的思路,我觉得比任何具体参数都重要:先让单进程多卡能跑通,再上多进程。具体做法是在代码里把 world_size 强制设成 1、不启动任何 DDP 包装,用一个纯粹的普通训练循环跑 100 步,记录 loss 曲线作为基线。然后开 2 卡跑同样的有效 batch,曲线应该能对上(允许微小数值差异)。这一步过了,再扩到 8 卡。我见过太多人一上来直接 8 卡,出问题后完全不知道是代码问题还是环境问题,一天就没了。分级排查看着慢,其实是快。
最后说个我自己的教训。早期有次多机训练,加速比一直卡在 4 倍左右上不去(16 卡),我花了半天调 bucket 大小、调 no_sync,效果都不明显。后来把NCCL_DEBUG=INFO打开才发现,节点间通信根本没走高速网络,在用 TCP 跑,带宽只有理论值的十分之一。改完网卡配置,加速比立刻到 12 倍。这件事让我养成了一个习惯:任何关于分布式性能的判断,先看通信链路,再看算法层面的调优。链路是地基,地基歪了,上面怎么调都是徒劳。