深蓝黑背景封面。左侧上下两张对照机制图:上图标注「DP:单进程调度,主卡中转一切」,一张紫色描边的 GPU0 主卡卡片通过双向箭头连接三张普通 GPU 卡片,说明梯度全部回传主卡、新参数再由主卡广播,下方小字注「主卡的通信与显存成为瓶颈」;下图标注「DDP:每卡一个进程,环形 AllReduce」,四张 GPU 卡片各自标注「进程 i · 本地梯度」,卡片之间用环形箭头首尾相连,说明相邻进程互传梯度分片、两圈后每卡都拿到平均梯度,下方小字注「无主卡,通信均摊,吞吐随卡数近线性扩展」。右侧大标题两行「PyTorch 的 DP 和 DDP」「到底有什么区别」,副标题「同是数据并行:一个主卡中转,一个环形同步」,小注「为什么生产训练清一色选 DDP」。左上角标注「面试经典 · 分布式训练」,右上角圆角胶囊标注「Day 04 · 12-01」。 图 1:本文封面,两种数据并行 API 的机制对照。

先说明属性:这是一篇经典题解析,题目出自本地历史面试资料整理,与任何公司的当前招聘无关。DP 和 DDP 都是 PyTorch 的数据并行 API,干的活一样——把一批数据切到多张卡上算、汇总梯度、更新参数——但面试官要的不是「都是数据并行」这句废话,而是进程模型、梯度同步路径和性能特性这三层差异,以及由此推出的结论:为什么生产训练清一色用 DDP。

一句话结论:DP 是单进程多线程、一切绕主卡中转;DDP 是每卡一个进程、梯度走环形 AllReduce 均摊通信。

先看 DP:每一步都绕不开 GPU0。

按 PyTorch 官方文档对 nn.DataParallel 的描述,它的工作流程是:每次前向都把整个模型复制(replicate)到各张卡上,输入按 batch 维度切分(scatter)发下去,各副本独立前向、反向,算出的梯度全部回传到 device 0 上的原始模块求和,优化器只在主卡上更新参数,再把新参数广播回各卡——下一轮前向再重复这一整套。这个设计有一个结构性的弱点:每一步迭代,所有梯度都要流经主卡,主卡的通信量和显存占用都随卡数线性上涨,卡越多,GPU0 越成为瓶颈。DP 由单进程内的多线程驱动,还要吃 Python GIL 的亏;而且它只支持单机,多机场景根本没有入口。

再看 DDP:把通信摊到一个环上。

nn.parallel.DistributedDataParallel 换了一套架构:N 张卡就起 N 个独立进程,每个进程独占一张卡、持有一份完整的模型副本。初始化时由 rank 0 把初始参数广播给所有进程,保证起点一致;训练数据用 DistributedSampler 切分,每个进程只读自己的分片;前向和反向完全在本地进行,不需要把输出 gather 回谁。关键的差别在反向阶段:梯度不是回主卡汇总,而是在所有进程之间做 AllReduce——环形实现把每份梯度切成 N 片,相邻进程之间先做 N-1 步的 scatter-reduce(边传边累加),再做 N-1 步的 all-gather(转发已聚合的片),两圈走完,每卡手里都是完整的平均梯度。之后每个进程用同一个梯度在本地独立更新参数,起点一致、梯度一致,参数自然始终一致——DP 每轮必做的参数广播在 DDP 里被彻底消掉了,只剩 BatchNorm 的统计量这类 buffer 每轮从 rank 0 广播一次。官方文档还提到梯度会按 bucket(默认 25 MiB 一桶)切分,让通信与反向计算重叠。

用一段模拟把通信量差距算出来。

没有多卡环境也能把这笔账算清楚:模拟 4 个进程的环形 AllReduce,验证结果正确性的同时统计每条链路的传输量,再对比 DP 主卡的负载(Node.js 22.15.0,node ring.js,梯度全量记为 M):

环形AllReduce后每卡梯度 = [15,16,17,18,19,20,21,22]
与理论平均一致: true
环形每条链路传输总量: 48 个元素 (= 2(N-1)/N × 全量梯度)
N= 2  DP主卡 2M  DP普通卡 2M  环形每链路 1.00M
N= 4  DP主卡 6M  DP普通卡 2M  环形每链路 1.50M
N= 8  DP主卡 14M  DP普通卡 2M  环形每链路 1.75M
N=16  DP主卡 30M  DP普通卡 2M  环形每链路 1.88M

数字很直观:DP 主卡的通信量是 2(N-1)M,16 卡时主卡要扛 30 倍于全量梯度的收发;而环形 AllReduce 每条链路的负载是 2(N-1)/N × M,无论多少卡都收敛在 2M 附近,通信被均匀摊到环上的每一条边。模拟同时验证了正确性:两圈之后四个进程拿到的梯度数组与直接求平均的理论值逐元素一致。

所以生产训练为什么清一色选 DDP?

三条理由环环相扣。一是通信:上面的账已经算了,DP 的瓶颈长在主卡上,DDP 无主卡、可扩展。二是进程模型:DDP 的多进程天然绕过 Python GIL,各卡的前向反向真正并行;DP 的多线程在 CPU 侧互相抢锁。三是能力边界:DP 只支持单机,DDP 单机多机通吃。PyTorch 官方文档的结论也很直接——即便在单机多卡场景,DDP 也被证明显著快于 DP,官方建议直接用 DDP 取代 DP。代价是写法稍复杂:要初始化进程组、用 DistributedSampler、用 torch.distributed.run 启动,但这套模板写一次就定型了。

深蓝黑背景概念图,顶部小字「概念图 · DP 与 DDP 的梯度同步」,主标题「一个主卡中转,一个环形同步」。上半部分是 DP 的五步横向流程:① 主卡 scatter 输入分片到各卡;② 各卡上的模型副本独立前向反向;③ 梯度全部回传 GPU0 求和;④ GPU0 上优化器更新参数;⑤ 新参数从主卡广播回各卡,下方小字注「每一步都绕不开 GPU0,主卡通信量随卡数线性上涨」。下半部分左侧是 DDP 环形 AllReduce 图:四个进程卡片 P0 至 P3 按顺时针箭头连成环,环心标注「梯度切 N 片 · scatter-reduce 收、all-gather 发,两圈后每卡都有完整平均梯度」。下半部分右侧是四行对照表:进程模型(单进程多线程 对 每卡独立进程)、梯度通信(全部汇聚主卡 对 环形均摊)、Python GIL(受 GIL 限制 对 多进程天然绕过)、适用规模(单机且官方已不推荐 对 单机多机皆宜的生产标配)。底部总结条:「DP 的瓶颈长在主卡上,DDP 把通信摊到环上 —— 卡数越多,差距越大」。 图 2:DP 的主卡中转流程、DDP 的环形同步与四维对照。

面试现场的答题结构。

先给结论「同是数据并行,DP 主卡中转、DDP 环形同步」,再分三层展开:进程模型(单进程多线程对每卡一进程,顺带提 GIL)、梯度路径(梯度回 GPU0 求和、主卡更新再广播 对 AllReduce 后各进程本地更新、参数广播被消掉)、性能与规模(主卡通信随卡数线性涨对每链路收敛 2M,单机对多机)。追问「Ring-AllReduce 怎么工作」就答切片、scatter-reduce、all-gather 两圈;追问「DDP 里参数怎么保持一致」就答初始广播加同源梯度。最后落到选型收尾:官方已建议用 DDP 替代 DP。你在多卡训练里踩过主卡显存爆掉的坑吗?用的 DP 还是 DDP?欢迎聊聊。