1. 从一张卡跑不动说起:DDP到底替你干了什么
第一次把模型和数据规模同时往上抬的时候,多数人都会经历同一个场景:单卡显存直接爆掉,改小 batch size 又掉精度,于是开始找多卡方案。搜到的第一篇文章往往教你DataParallel,写起来确实简单,模型外面包一层就完事,但真正跑起来你会发现主卡显存比副卡高一大截,GPU 利用率曲线像心电图,训练速度提升远低于卡数。这不是你代码写错了,而是DataParallel的运行模型决定的:它只开一个进程,靠 Python 多线程把 batch 切到各张卡上,前向之后梯度全部汇集到主卡做归约,主卡既要算自己那份,又要负责汇总,Python 的全局解释器锁还会让线程调度卡顿。卡越多,主卡越像瓶颈。
DistributedDataParallel(后面统一叫 DDP)换了一套思路:一张卡一个进程。每个进程持有完整的模型副本、完整的优化器状态,各自读自己那份数据做前向和反向,只在反向计算完梯度之后做一次跨进程的梯度归约,让所有副本的梯度保持一致,再由各自的优化器独立更新参数。因为初始参数相同、每步梯度相同、更新规则相同,各副本在训练全程都保持同步,逻辑上等价于用了一个超大 batch 的单卡训练。这个"等价"是理解 DDP 所有行为的基础,后面遇到的 batch size 换算、学习率调整、BatchNorm 统计量偏差,根子都在这里。
它解决的不只是显存问题。多进程意味着每个进程可以独立设置 CUDA 上下文、独立开 DataLoader worker、独立做混合精度,通信走的是针对 GPU 互联优化的集合通信库,卡间走高速互联时归约开销可以压到很低。适合谁来上手?只要你的训练脚本已经能在单卡跑通,并且手上有两台以上 GPU(单机多卡也算),DDP 就是当前最值得投入时间掌握的并行方案。下面这些内容,是我在实际项目里反复改脚本、反复排障之后沉淀下来的东西,不是官方文档的复述。
1.1 单卡脚本到多卡的三种路线,为什么最后都收敛到 DDP
把单卡训练改成多卡,市面上常见三条路。第一条是手写torch.multiprocessing,自己起进程、自己写通信逻辑,灵活但极易写出死锁;第二条是DataParallel,改动最小但性能天花板很低;第三条就是 DDP。三者放在一起对比,差距非常直观:
| 对比项 | 单进程多线程(DataParallel) | 多进程 DDP | 手写多进程 |
|---|---|---|---|
| 进程模型 | 1 进程 N 线程 | N 进程,一卡一进程 | N 进程,自行管理 |
| 梯度同步方式 | 汇集到主卡做归约 | 环形/树形集合通信,去中心化 | 自己实现 |
| 主卡显存偏斜 | 明显,主卡常多出 1~2GB | 基本均衡 | 取决于实现 |
| Python GIL 影响 | 有,线程调度受限 | 无 | 无 |
| 代码改动量 | 最小 | 中等,需改数据加载与启动方式 | 大 |
| 多机扩展 | 不支持 | 原生支持 | 需自行处理 |
DataParallel的问题不在 API 层面,而在架构层面:它把"分发"和"归约"都压在主卡所在进程里,卡数一多,主卡既要承担通信中转又要承担计算,扩展性天然受限。我实测过 4 卡场景,同样的模型和 batch,DataParallel的吞吐只有 DDP 的六成左右,而且卡数越多差距越大。所以哪怕只是单机 2 卡,我也建议直接上 DDP,省得后面重构。
1.2 一次 all-reduce 的账:梯度到底是怎么"对齐"的
DDP 在每个进程里注册了反向传播的钩子。当某一层参数的梯度算完,钩子会把这个梯度标记为待同步,等一个 bucket(默认 25MB)攒满或者反向结束,就触发一次集合通信。通信的语义不是简单广播,而是对所有进程的梯度做求和再除以进程数,也就是求平均。之所以用平均而不是求和,是为了让梯度的量级与单卡训练保持一致,学习率不用因为卡数变化而重调(当然全局 batch 变了,学习率还是要按规则缩放,这个后面单独讲)。
这里有个常被忽略的细节:DDP 的归约是分 bucket 异步进行的,反向计算和通信可以重叠。靠近输出层的梯度先算完,先开始通信,此时更浅层的反向还在算,两者并行。这个设计是 DDP 吞吐能接近线性的关键之一。bucket 太小,通信次数多、启动开销占比高;bucket 太大,通信要等更久才能发起,重叠效果变差。默认 25MB 对大多数模型是合理值,但如果你的模型单层特别大(比如超大 embedding),或者层数极多但每层很小,就值得手动调bucket_cap_mb。
还有一点:DDP 只同步参与反向计算的参数的梯度。如果你在模型里写了条件分支,某些参数这一步没参与计算,它们的梯度是None,DDP 默认会报错,需要显式告诉它find_unused_parameters=True。这个参数不是免费的,它会让 DDP 在每次反向时额外遍历计算图去判断哪些参数没被用到,开销随模型复杂度上升。所以正确做法是尽量把分支写清楚,只在确实需要时开这个开关,而不是图省事一直开着。
1.3 进程组、rank、world_size:三个词先掰扯明白
DDP 的所有协调都建立在"进程组"这个概念上。world_size是参与训练的总进程数,单机 4 卡就是 4,双机各 4 卡就是 8。rank是全局进程编号,从 0 到world_size - 1,全局唯一,用来做"只有 rank 0 打印日志""只有 rank 0 存模型"这类判断。local_rank是进程在本机内的编号,决定这个进程该用哪张卡,torch.cuda.set_device(local_rank)就是靠它绑定的。
这三个值的关系必须刻在脑子里,因为绝大部分 DDP 报错都能追溯到它们没对齐:world_size设错会让进程互相等待直到超时;rank重复会让两个进程抢同一份数据、写同一个文件;local_rank和实际使用的卡不一致,会出现"明明 4 卡,nvidia-smi 上只有 2 张在跑"的诡异现象。用torchrun启动时,这三个值会通过环境变量自动注入,所以你不需要手动传;如果自己用mp.spawn起进程,就得自己算好再传进去。这也是我推荐优先用torchrun的原因:少写一层容易出错的封装。
提示:调试期在
init_process_group之后立刻打印一次rank / local_rank / world_size和当前绑定的卡号,这行日志能省掉后面大量猜测。
2. 把单卡脚本改成DDP:改动量比想象中小,但顺序不能错
一个写得规整的单卡训练脚本,改成 DDP 通常只需要动五个地方:进程组初始化、设备绑定、DataLoader 的 sampler、模型包一层 DDP、启动命令。听起来不多,但每一步都有顺序依赖,顺序错了就会卡住或者静默算错。我见过最常见的情况是:脚本改完能跑,loss 也在降,但最后评估指标比单卡差一截,回头查了半天才发现是验证集数据被重复采样了。
动手之前先明确一件事:DDP 下每个进程都是"完整的训练程序",它有自己的优化器、自己的学习率调度器、自己的日志。你要做的是让这些"完整的程序"看到不同的数据、在梯度上达成一致,而不是把程序拆成几段分给不同进程。理解这一点,改代码时就不会纠结"这段该放在 rank 0 还是所有 rank"——除了写文件、打日志、上传指标这类副作用操作,其余全部放在所有 rank 上执行。
2.1 进程组初始化:backend、超时和设备绑定
初始化的核心就三行,但每行都有讲究:
import os import torch import torch.distributed as dist def setup_dist(): # torchrun 会注入 RANK / WORLD_SIZE / LOCAL_RANK local_rank = int(os.environ["LOCAL_RANK"]) torch.cuda.set_device(local_rank) dist.init_process_group( backend="nccl", timeout=torch.timedelta(minutes=30), ) return local_rankbackend选nccl是 GPU 训练的默认答案,它针对卡间通信做了深度优化,单机内走 NVLink 或 PCIe,跨机走网卡。CPU 训练才考虑gloo。set_device必须放在init_process_group之前或紧随其后,原因是 NCCL 在初始化时会读取当前设备,如果此时还没绑定,所有进程可能都默认用 0 号卡,结果就是显存全压在一张卡上,其余卡空转。这个问题在日志里完全看不出来,只有在nvidia-smi里才能发现。
超时设置值得单独说。默认超时是 30 分钟(不同版本略有差异),对于大模型单步耗时长的场景可能不够,一旦某个 rank 卡住,其他 rank 会等满超时然后一起崩,报出一堆NCCL timeout或者Watchdog caught collective operation timeout。把超时显式设大一点,配合后面要讲的日志手段,能让你在进程被杀之前有机会看清楚是谁卡住了。另外,init_process_group是阻塞的,它会等所有 rank 都到齐,所以如果你看到程序停在初始化不动,八成是某个 rank 根本没起来,或者world_size和实际进程数对不上。
2.2 DistributedSampler:数据切分与那几个必须改的细节
单卡时DataLoader的shuffle=True就能工作,DDP 下必须换成DistributedSampler,否则每个进程都会读到全量数据,等于把 batch 放大了world_size倍还做了重复计算:
from torch.utils.data import DataLoader from torch.utils.data.distributed import DistributedSampler sampler = DistributedSampler( dataset, num_replicas=dist.get_world_size(), rank=dist.get_rank(), shuffle=True, drop_last=True, # 样本数不能被 world_size 整除时,丢弃尾部保证各 rank 步数一致 ) loader = DataLoader( dataset, batch_size=per_gpu_bs, sampler=sampler, num_workers=8, pin_memory=True, drop_last=True, )drop_last=True在这里几乎是必须的。如果不丢,最后一个 batch 在每个 rank 上的大小可能不同,反向时各进程的通信节奏就对不上,轻则报形状不匹配,重则直接 hang。代价是每个 epoch 丢掉少量样本,对训练影响可以忽略,但换来的稳定性很值。
还有一个极易漏掉的调用:每个 epoch 开始前要sampler.set_epoch(epoch)。DistributedSampler的 shuffle 依赖一个内部的随机种子,这个种子由epoch决定。如果你不调用set_epoch,每个 epoch 的切分方式完全一样,模型看到的样本顺序恒定,数据增强的随机性也被削弱,表现为训练前期 loss 降得还行,后期明显乏力。这个问题极其隐蔽,因为代码不报错,指标也不崩,只是慢一点、差一点,很多人会误以为是模型容量不够。
提示:把
sampler.set_epoch(epoch)写在 epoch 循环的第一行,和自己约定"这行不能删"。我在一个项目里因为它漏写,白跑了两天实验才从对比曲线上看出来。
另外,num_workers的总量要算总账。单进程 8 个 worker,8 卡就是 64 个进程,如果机器只有 32 核,worker 之间会互相抢 CPU,数据加载反而变慢。经验值是num_workers * world_size <= CPU 核数 * 1.5,超了就往下调。容器环境还要注意共享内存,/dev/shm给小了会导致 DataLoader 卡死,表现为进程在跑但 GPU 利用率长期为 0。
2.3 模型包DDP之前,这三件事必须先做完
模型不是随便包一层就行的,包之前有三件事必须完成,顺序不能颠倒:
第一,模型要先搬到对应的卡上。model.to(local_rank)要在构造 DDP 之前执行,DDP 不接受跨设备的模型。
第二,优化器要在包 DDP 之后创建,或者用model.parameters()时注意引用。严格说优化器在包之前创建也能工作,因为 DDP 包装的是同一个 module 的参数(不复制参数),但为了可读性我习惯先包再用ddp_model.parameters()建优化器,避免后面加载 checkpoint 时 key 名前缀不一致。
第三,确认没有未参与计算的参数。如果模型里有只在特定条件下才走的分支,要么重构掉,要么在构造时传find_unused_parameters=True。判断方法很简单:先不加这个参数跑一个 step,报错了再加。
from torch.nn.parallel import DistributedDataParallel as DDP model = MyModel().to(local_rank) ddp_model = DDP( model, device_ids=[local_rank], output_device=local_rank, broadcast_buffers=True, # 默认 True,每次 forward 前广播 buffer,保证 BN 统计量一致 find_unused_parameters=False, # 确认没有未使用参数后再关掉,省开销 gradient_as_bucket_view=True, # 梯度直接写入 bucket,省一次内存拷贝 ) optimizer = torch.optim.AdamW(ddp_model.parameters(), lr=lr)broadcast_buffers这个开关值得多解释一句。模型里的 BatchNorm 有 running_mean、running_var 这类 buffer,它们不参与梯度计算,但会影响前向结果。DDP 默认在每次 forward 前把 rank 0 的 buffer 广播给所有进程,保证各副本的前向行为一致。这在训练时是必要的,但推理阶段会带来额外开销,可以在 eval 前把ddp_model.broadcast_buffers = False(或构造时就设 False,只在训练时注意),代价是各卡的 BN 统计量可能不同步。我的习惯是训练保持默认,纯推理场景改掉。
2.4 启动方式:torchrun 与 mp.spawn 的取舍
启动命令看着简单,但它决定了环境变量怎么注入,进而决定了你的脚本能不能读到正确的 rank:
# 单机 8 卡 torchrun --nnodes=1 --nproc_per_node=8 --master_port=29500 train.py --config cfg.yaml # 双机,每机 8 卡(两台机器上分别执行,node_rank 不同) torchrun --nnodes=2 --nproc_per_node=8 \ --node_rank=0 \ --master_addr=10.0.0.11 \ --master_port=29500 \ train.py --config cfg.yamltorchrun会自动设置RANK、WORLD_SIZE、LOCAL_RANK、MASTER_ADDR、MASTER_PORT,还会在某个进程挂掉时把整组进程一起收掉,避免留下僵尸进程占着显存。mp.spawn需要你自己算rank = node_rank * gpus_per_node + local_rank,并且异常处理要自己写,一旦某个子进程崩了,其他进程可能还在傻等,最后靠超时才退出。新项目没有历史包袱的话,直接用torchrun。
--master_port冲突是新手常踩的坑:同一台机器上跑多个训练任务,端口不换就会互相抢占,症状是第二个任务起不来或者起一半就断。我的做法是每次起任务前随机挑一个 20000 到 30000 之间的端口,或者在脚本里用--master_port=$((20000 + RANDOM % 10000))。
3. 第一次跑通不等于跑对:loss曲线背后的问题
脚本能起来、loss 在下降,只能说明"流程通了",不能说明"算对了"。DDP 的错误有一大类是静默的:不报错、不崩溃,但结果就是偏差。我见过最典型的一次是,4 卡训练跑完,验证集指标比单卡低了三个点,团队排查了两天,最后定位到验证集用了DistributedSampler但没有做跨进程收集,每个 rank 各自算自己那部分样本的指标然后简单平均,而各 rank 的样本数不一样(尾数没对齐),平均值被拉偏了。
这类问题的共同特征是:数值层面的错误不会触发任何异常,只能靠对照实验发现。所以养成一个习惯——改完 DDP 之后,先用小数据、小步数做一次单卡与多卡的对照。同样的种子、同样的数据子集,跑 50 步,看 loss 曲线是否一致(允许多卡因为有 BN 和随机性带来的微小差异,但量级应该吻合)。这一步花十分钟,能省掉后面几天的返工。
3.1 全局batch、学习率与"等价batch"的换算
DDP 最容易被误解的一点是:DataLoader里写的batch_size是单卡 batch,全局 batch 等于它乘以world_size。单卡 batch 8、8 卡训练,全局就是 64。如果你从单卡 batch 64 的实验迁过来,只改了启动方式没改 batch_size 参数,那实际全局 batch 变成了 512,学习率却还是原来那个,收敛行为会明显不同。
线性缩放规则是业界常用的起点:全局 batch 扩大 k 倍,学习率也扩大 k 倍。但这只是起点,不是定律。batch 很大时线性缩放会让训练前期不稳定,通常配合 warmup 使用,前若干步把学习率从一个小值线性升到目标值。
| 单卡 batch | 卡数 | 全局 batch | 建议初始 lr(以单卡 lr=1e-4 为基准) | 是否建议 warmup |
|---|---|---|---|---|
| 16 | 1 | 16 | 1e-4 | 否 |
| 16 | 4 | 64 | 4e-4 | 建议,500 步 |
| 16 | 8 | 128 | 8e-4 | 建议,1000 步 |
| 32 | 8 | 256 | 8e-4 ~ 1.6e-3 | 必须,1000~2000 步 |
表里的数字是经验起点,实际还要看优化器类型。AdamW 对学习率的敏感度比 SGD 高,大 batch 下更保守一些;SGD 配 momentum 时线性缩放的效果更稳定。我的做法是先用线性缩放跑 500 步看 loss 是否平稳,如果前期抖动明显就降一半再试,别一上来就上 cosine 调度,那样出了问题分不清是调度策略的锅还是初始学习率的锅。
还有一个隐性坑:学习率调度器的步数要按全局步数算。单卡时一个 epoch 有 N 个 step,8 卡时每个 rank 一个 epoch 只有 N/8 个 step(因为数据被切了)。如果你的 scheduler 是按 step 数配置的(比如 cosine 的T_max),迁移时忘了改,训练会在计划外的位置结束衰减,后段学习率过低,模型欠拟合。
3.2 日志、打印与副作用:只留rank 0说话
DDP 下所有 rank 都会执行同一份代码,如果你在训练循环里直接print(loss),8 卡就会打出 8 行几乎一样的日志,交错在一起根本没法看。正确做法是用 rank 判断包一层:
def log_info(msg, rank=0): if dist.get_rank() == rank: print(f"[rank{rank}] {msg}", flush=True) # 训练循环里 if step % 50 == 0: log_info(f"step={step} loss={loss.item():.4f} lr={scheduler.get_last_lr()[0]:.2e}")flush=True不要省。多进程环境下标准输出有缓冲,不 flush 的话日志可能攒着一起吐,或者进程被杀时直接丢失,排障时你会怀疑自己是不是没打这行日志。写文件同理,只让 rank 0 写,其他 rank 跳过,否则多个进程同时写同一个文件,内容会交错甚至损坏。
指标上报(无论是写本地文件还是推到监控系统)也要做单 rank 处理。但要注意,如果你要在日志里记录"全局平均 loss",直接拿 rank 0 的 loss 是不准确的(它只是那一份数据的 loss)。严谨做法是用dist.all_reduce把各 rank 的 loss 求和再除以 world_size,代价是一次很小的通信。日常调试用 rank 0 的值足够,正式实验建议做全局归约。
3.3 验证集上的重复样本与指标对不上
前面提到的那个坑,展开说一下正确写法。评估阶段有两种常见做法:
第一种,每个 rank 用DistributedSampler(shuffle=False, drop_last=False)分到不同子集,各自前向,把预测结果和标签all_gather到 rank 0 上拼起来,再统一算指标。这种做法效率最高,但要处理"样本总数不能被 world_size 整除"导致的 padding,拼接时要把 padding 部分裁掉。
第二种,只让 rank 0 跑完整验证集,其他 rank 用dist.barrier()等待。实现简单,但浪费了其他卡的算力,验证集大时会明显拖慢。
# 方式一的核心片段:跨进程收集预测 def gather_predictions(preds, labels): world_size = dist.get_world_size() gathered_preds = [torch.zeros_like(preds) for _ in range(world_size)] gathered_labels = [torch.zeros_like(labels) for _ in range(world_size)] dist.all_gather(gathered_preds, preds) dist.all_gather(gathered_labels, labels) return torch.cat(gathered_preds, dim=0), torch.cat(gathered_labels, dim=0)注意all_gather要求各进程的张量形状一致,所以验证集的 batch 要保证每个 rank 拿到的数量相同(用 padding 补齐或者drop_last),否则会在通信阶段报形状错误。这类错误信息通常不直观,会提示某个维度不匹配,得回头查数据切分。
3.4 checkpoint:存一份还是每卡都存
保存模型只让 rank 0 做,这个大家都知道。但有两个细节容易出问题。
第一,保存前要确保其他 rank 不会继续往前跑。如果 rank 0 在写几百 MB 的 checkpoint,其他 rank 已经进入下一轮训练并开始集合通信,rank 0 还没到,它们就会阻塞等待。短时间没问题,但如果写盘很慢(网络存储),可能触发通信超时。稳妥写法是在保存前后加 barrier:
if dist.get_rank() == 0: torch.save({ "model": ddp_model.module.state_dict(), # 注意 .module "optimizer": optimizer.state_dict(), "scheduler": scheduler.state_dict(), "epoch": epoch, }, ckpt_path) dist.barrier()第二,保存的 state_dict 要去掉 DDP 包装。ddp_model.state_dict()和ddp_model.module.state_dict()在多数版本里 key 是一致的(DDP 不额外加前缀),但为了保险和可移植,统一用.module.state_dict(),这样存出来的文件能直接被单卡脚本加载,不依赖 DDP 环境。
断点续训时,模型权重和优化器状态加载完后,别忘了scheduler的步数也要恢复,否则学习率会从初始值重新开始,续训效果和预期完全不符。这个坑我在两个项目里都见过,因为 scheduler 的状态往往被当成"不重要"而忽略。
4. 卡住不动、报NCCL错误、速度不升反降:排障链条
DDP 的故障排查和单卡完全不是一个路数。单卡报错,堆栈直接指到出问题的代码行;DDP 出问题,你看到的往往是"某个进程报错,其他进程在等",或者"所有进程都在等,谁也没报错"。这时候需要的不是读堆栈,而是建立一套从外到内的排查顺序:先确认进程是否都起来了,再确认集合通信是否对齐,最后才看具体是哪个 rank 的计算出了问题。
我把这套顺序总结成一句话:先看有几个进程活着,再看它们在等谁,最后看谁先掉队。下面按这个顺序展开。
4.1 进程hang住的几种典型形态与定位顺序
第一种形态:程序启动后没有任何输出,GPU 占用为 0。这通常是init_process_group没通过,原因多是world_size和实际进程数不一致,或者MASTER_ADDR/PORT配错导致进程连不上。排查方法是先看nvidia-smi上有几个进程,再对照你期望的卡数。如果进程数就少,问题在启动命令;如果进程数够但都卡在初始化,问题在网络地址或端口。
第二种形态:训练跑了几十步突然全体卡住,GPU 利用率掉到 0。这多半是集合通信次数不对齐——某个 rank 多跑了一次all_reduce,或者某个 rank 提前退出了循环。典型诱因是数据量不能被整除导致各 rank 步数不同,或者代码里有if rank == 0包住的集合通信调用(集合通信必须所有 rank 都参与,包在条件里必然死锁)。
第三种形态:跑着跑着报NCCL timeout或Watchdog caught collective operation timeout,然后整组退出。这种情况通常是某个 rank 在计算上花了异常长的时间(比如遇到了超大样本、显存不足触发换页、磁盘 IO 卡顿),其他 rank 等超时。定位的关键是看报错前最后一条日志来自哪个 rank、卡在第几步。
# 排查时常用的两个命令 # 1. 看每张卡上的进程和显存 nvidia-smi --query-compute-apps=pid,used_memory --format=csv # 2. 设置通信库日志级别,拿到更详细的握手信息 export NCCL_DEBUG=INFO export NCCL_DEBUG_SUBSYS=INIT,COLLNCCL_DEBUG=INFO会在启动时打印每张卡用的网卡、互联类型、拓扑结构,很多"跨机通信慢"的问题从这段日志就能看出来——比如本该走高速互联却走了普通网卡。
4.2 NCCL相关环境变量:先确认互联拓扑
NCCL 的默认行为是自动探测拓扑并选择最优路径,多数情况下不需要手动干预。但在容器、多网卡、混合互联的环境里,自动探测经常选错,表现为跨机通信极慢或者初始化失败。这时候需要手动指定网卡:
# 指定用于跨机通信的网卡名,多网卡环境必须显式指定 export NCCL_SOCKET_IFNAME=eth0 # 关闭 IB,纯以太网环境可以试 export NCCL_IB_DISABLE=1 # 打开 P2P,单机内 NVLink/PCIe 直连场景有用 export NCCL_P2P_LEVEL=NVL这些变量不是"开了就快",而是"环境不匹配时用来纠偏"。我的建议是:默认什么都不设,先跑一次基准,记录下单步耗时;如果跨机明显慢于单机,再逐个加变量对比。盲目照搬网上的环境变量组合,很可能把本来正常的自动探测覆盖掉。
单机多卡还有一个常见误区:以为所有卡之间都是高速互联。实际上很多机器的卡间是 PCIe 分组的,跨组的卡通信要经过 CPU 中转,速度差好几倍。如果你的模型对通信敏感(参数多、层数少、计算量小),卡的分组方式会直接影响扩展效率。这一点在租用机器时要问清楚,或者在NCCL_DEBUG=INFO的拓扑输出里自己确认。
4.3 慢节点拖累全局:找出摸鱼的那个rank
分布式训练的吞吐由最慢的那个 rank 决定。8 个进程里 7 个都算得快,1 个因为磁盘读得慢或者数据预处理重,整体速度就会被它拖住。这种问题在总吞吐上看是"扩展效率不高",但单看每张卡的利用率都是 100%(因为快的在等慢的,等待时也显示占用),非常难定位。
有效手段是给每个 rank 的关键阶段打时间戳,训练若干步后汇总对比:
import time t0 = time.time() batch = next(iter(loader)) t1 = time.time() loss = ddp_model(**batch).loss loss.backward() optimizer.step() t2 = time.time() stats = torch.tensor([t1 - t0, t2 - t1], device=local_rank) gathered = [torch.zeros_like(stats) for _ in range(dist.get_world_size())] dist.all_gather(gathered, stats) if dist.get_rank() == 0: for r, s in enumerate(gathered): print(f"rank{r}: data={s[0]:.3f}s compute={s[1]:.3f}s")跑十几步取平均,哪个 rank 的 data 时间明显偏高,问题就在它的数据加载上。常见原因包括:该 rank 分到的样本恰好都是大文件、num_workers不够、该进程所在 NUMA 节点内存带宽被抢占。定位到具体 rank 之后,再去看它绑定的卡和 CPU 亲和性,问题通常就清楚了。
4.4 find_unused_parameters 的代价与显存不均衡
find_unused_parameters=True是很多人在遇到"Expected to have finished reduction in the prior iteration"报错后随手加上的开关。它确实能让代码跑起来,但代价有两个:一是每次反向多一次计算图遍历,模型层数多时开销可达 10% 以上;二是它会改变 DDP 对 bucket 的划分逻辑,可能让通信与计算的重叠效果变差。
更麻烦的是,这个开关会掩盖真正的设计问题。模型里出现未参与计算的参数,通常意味着有分支逻辑或者有参数压根没被用到。前者应该通过调整前向路径让所有参数每步都参与(或者用no_sync配合),后者说明模型定义有问题,白占显存。我处理过的案例里,有一半以上在清理掉无用参数之后,find_unused_parameters就可以关掉,单步耗时直接降了一成。
显存不均衡是另一个隐蔽问题。理论上各 rank 的模型副本大小相同,显存占用应该接近,但实际经常看到 rank 0 多出几百 MB。原因通常是 rank 0 额外承担了日志张量、指标缓存、checkpoint 缓冲区。如果卡本来就快满了,rank 0 会先 OOM,而报错信息看起来像是"模型太大",误导排查方向。解决办法是把这些额外操作尽量放到 CPU 内存里做,或者在保存前先释放缓存。
5. 调优与工程化:让DDP真正吃满机器
跑通和跑好之间隔着一段距离。同样的硬件、同样的模型,两个人写出来的 DDP 脚本吞吐可能差 30% 以上。差距主要来自几个地方:通信与计算的重叠程度、数据加载是否成为瓶颈、混合精度与梯度累积的配合方式、以及一些容易被忽略的显存与带宽细节。这一节讲的是把 DDP 从"能用"推到"用满"的具体手段。
5.1 bucket调优与梯度视图:减少无谓的内存搬运
前面提过 bucket 的作用,这里给出调优方法。判断 bucket 是否合理的指标是"通信时间占单步时间的比例":如果通信占比很低(比如 5% 以下),说明重叠做得好,不用动;如果占比超过 20%,就值得试几个不同的bucket_cap_mb值做对比。
ddp_model = DDP( model, device_ids=[local_rank], bucket_cap_mb=50, # 默认 25,模型层大时可以调大 gradient_as_bucket_view=True, # 梯度直接写进 bucket,省一次拷贝 static_graph=True, # 计算图结构固定的模型可以开,进一步优化 )gradient_as_bucket_view=True的效果是把归约后的梯度直接以 bucket 的视图形式暴露给优化器,避免"梯度先拷到 bucket、归约后再拷回来"的来回搬运。这个开关在大模型上能省下可观的显存和时间,但要求你的代码不去原地修改梯度(比如手动做梯度裁剪时要小心)。static_graph=True适用于每步计算图完全一致的模型,它让 DDP 可以提前规划好通信顺序,进一步减少协调开销;但如果模型有动态分支,开了会报错或者算错,用之前要确认。
我用一组 8 卡、约 3 亿参数模型的实测做过对比,只改这两个开关,单步耗时从 1.42 秒降到 1.28 秒,大约 10% 的提升,改动成本几乎为零,属于性价比最高的调优项。
5.2 混合精度、梯度累积与no_sync的配合
混合精度和 DDP 的组合有一个真实的坑:GradScaler是每个进程独立的,它会根据本进程是否出现 inf/nan 动态调整 scale 值。如果某个 rank 因为数值问题把 scale 降低了,而其他 rank 没降,那么各进程的梯度经过了不同的缩放,再做平均就失去了数学意义,训练会悄悄跑偏。
规避方式有三种。第一种,用 bf16 代替 fp16,bf16 的动态范围足够大,通常不需要 GradScaler,从根上绕开这个问题。第二种,如果必须用 fp16,在optimizer.step()之前用集合通信把各进程的 scale 同步成一致的值(取最小值)。第三种,关掉动态调整,用固定 scale,代价是需要提前试出合适的值。现在硬件支持 bf16 的话,我优先选第一种,省事且稳。
梯度累积的目的是在小显存下模拟大 batch。DDP 下有一个专门的优化:累积的中间步骤不需要通信,只在最后一次反向时同步:
for i, batch in enumerate(loader): with ddp_model.no_sync() if (i + 1) % accum_steps != 0 else contextlib.nullcontext(): loss = ddp_model(**batch).loss / accum_steps loss.backward() if (i + 1) % accum_steps == 0: optimizer.step() optimizer.zero_grad(set_to_none=True)no_sync()的作用是临时关闭梯度同步,让累积的前几步只在本进程内计算。如果不加,每一步都会触发一次全量归约,通信量变成accum_steps倍,累积就完全失去了意义。这里要注意 loss 要除以accum_steps,否则梯度量级会放大,等效学习率变了。
提示:
no_sync()必须覆盖除了最后一次之外的所有累积步,条件判断写反了不会报错,只是通信量翻倍,从监控上看是"训练变慢了但不知道为什么"。
5.3 多机启动参数与容错的实际处理
跨机训练比单机多卡复杂的地方在于网络。启动参数里--master_addr要填一个所有节点都能访问的地址,--node_rank从 0 开始编号且不能重复。两台机器上都要执行torchrun,参数除了node_rank之外完全一致。
容错方面,PyTorch 提供torchrun的弹性训练能力(配合--rdzv_backend、--rdzv_endpoint、--max_restarts),某个节点挂掉后可以自动拉起重新加入。但弹性训练要求你的脚本能在启动时从 checkpoint 恢复,否则重启等于从头开始。我的建议是:先把 checkpoint 的保存与恢复做扎实,再考虑弹性。很多团队上了弹性训练却发现恢复逻辑有 bug,重启后模型状态不对,反而比直接失败更糟。
跨机还要注意时钟同步。日志里各节点的时间戳如果差几分钟,排障时根本对不上事件顺序。容器环境下时钟漂移更常见,部署前确认一下各节点时间是同步的。
5.4 项目里沉淀下来的一些习惯
最后分享几个我在多个项目里固定下来的做法,都是踩过坑之后形成的条件反射。
第一,所有 DDP 相关代码集中在一个dist_utils.py里,包括初始化、清理、日志封装、指标归约、checkpoint 保存。训练脚本只调用封装好的函数,不在业务代码里散落dist.get_rank()判断。这样换项目时可以整包拷过去,也不会出现"某处忘了判断 rank 导致 8 个进程同时写文件"的问题。
第二,启动脚本里固定打印一次环境摘要:world_size、每台机器的卡数、NCCL 版本、是否启用了 IB、各 rank 的 hostname。这份摘要存在日志开头,出问题时第一时间能确认环境是否符合预期,省掉大量来回询问。
第三,用小规模数据做冒烟测试。改完 DDP 相关代码,先用 100 条样本、跑 20 步、4 卡验证一遍,确认 loss 有下降、checkpoint 能存能读、验证集指标计算正常,再上全量数据。全量跑一次几小时,冒烟测试两分钟,这个投入产出比没什么好犹豫的。
第四,显存预留留出余量。DDP 的通信 bucket、NCCL 的内部缓冲、日志张量都会占显存,按单卡能跑的最大 batch 去配置,很容易在多卡场景下 OOM。我的经验是留 10% 左右的显存余量,并且用torch.cuda.memory_summary()定期看一眼峰值。
第五,也是我认为最重要的一条:不要迷信"扩展效率"这个数字。8 卡跑到 7 倍吞吐是很好的结果,但如果你为了追这个数字把单卡 batch 调得极小、通信占比极高,实际训练效果可能还不如 4 卡配大 batch。分布式训练的目标是缩短达到目标精度的时间,不是把 GPU 利用率数字做漂亮。我见过为了追求线性扩展把 batch 压到 1 的实验,最后收敛慢得离谱,白白浪费了机器。
真要把 DDP 用顺手,关键不在于记住多少个 API,而在于脑子里始终有一张图:几个进程各自持有一份完整模型,吃不同的数据,在梯度上对齐,独立更新。所有诡异现象——hang、慢、指标不对、显存不均——都能从这张图里推出可能的原因,然后再用日志和对照实验去验证。这套思路比任何一份配置模板都管用。