为什么单卡训不动:显存、算力与带宽的三重墙
在 2019 年之前,把一个模型塞进一张卡通常只是调一调 batch size 的工程问题。到了大模型时代,这个命题被彻底改写了。参数量从亿级跨到千亿乃至万亿量级,单张加速卡上几十 GB 的显存,扣掉框架开销、算子工作区和通信缓冲区之后,能容纳的往往只剩下模型的四分之一不到。剩下的部分去哪了?被优化器状态、梯度、激活值和碎片化的临时张量吃掉了。
具体拆开看,训练一份模型要同时存放四样东西:前向传播产生的激活值(用于反向计算梯度)、每个参数的梯度、同量级的优化器状态(动量、方差等),以及参数本身。前两项在长序列训练下会随着序列长度平方增长,后两项则随着参数量线性增长。所以你会看到一个反直觉的现象:把序列长度砍半、batch size 缩小,显存压力往往比参数更多时缓解得更多。
第二重墙是算力。大模型训练的浮点运算量大致与参数量、序列长度、数据量三者相乘。单卡算力即使是最新的旗舰卡,面对千亿级模型的完整预训练也需要以月为单位,这个时间尺度对迭代式的研究工作是不可接受的。多卡不是为了「跑得动」,而是为了「跑得快到能形成迭代闭环」。
第三重墙最隐蔽,也最常被低估,就是带宽墙。分布式训练把计算拆到了很多张卡上,但每一步计算之间都可能需要交换张量。一旦通信量超过了互联带宽所能承载的吞吐,计算单元就会大面积空转,硬件利用率断崖式下跌。这时候多卡不但不加速,反而比少卡更慢。整篇文章讨论的四种并行策略,本质上都是在回答同一个问题:在给定的集群形状和带宽条件下,怎么把通信量压到算力之下。
并行策略不是「越大越好」,而是一道带宽与显存的配平题。选错的代价不是「慢一点」,而是让新加的卡完全闲置。
四种并行切的是不同的维度
初学者最容易混淆的一点,是把这四种并行当成同一层级上的四个可选项。其实它们切分的是模型中完全不同的维度,通信代价的数量级也差着好几个数量级。下面这张表先建立一个总的直觉,后面每一节再展开。
| 策略 | 切分对象 | 通信原语 | 通信频率 | 通信量量级 | 硬约束 |
|---|---|---|---|---|---|
| 数据并行(DP) | 批维度 | 梯度 all-reduce | 每步一次 | 与模型参数量成正比 | 参与卡必须能存放完整模型 |
| 张量并行(TP) | 权重矩阵的行/列 | all-reduce 或 all-gather | 每个 Transformer 层至少两次 | 与序列长度、隐层维度成正比 | 必须绑定在高带宽域内 |
| 流水线并行(PP) | 层 | 点对点 send/recv | 每个微批一次 | 与微批激活值大小成正比 | 存在流水线气泡 |
| 专家并行(EP) | MoE 专家 | all-to-all 分发 token | 每个 MoE 层 | 与 token 数、隐层维度成正比 | 需配合容量因子与负载均衡 |
把这张表从上往下读,会看到一个清晰的递进关系:数据并行的通信量最「重」但频率最低,张量并行的频率最高但每次最「轻」,流水线并行几乎不传梯度只传激活值,专家并行则同时具备高频与大数据量两个特点,是最难调的一类。这个特征直接决定了后面所有的工程取舍。
还有一个更重要的组织原则:通信代价越高的并行维度,越应该被「关小」,也就是把它的并行度控制在高带宽互联所能支撑的范围内;而通信代价随并行度增长平缓的维度(典型是流水线),则适合用来把模型铺开到大量相对低带宽的节点上。后面 3D 并行那一节会把这个原则量化。
数据并行:通信量小但同步贵
数据并行的思路简单到近乎显然:每张卡拿一份完整模型,把这一批数据切成若干份分给各卡,各自前向反向算一遍自己的梯度,然后在反向结束之后把所有梯度同步一次,让每张卡的模型参数保持一致。这样一来,模型容量不受单卡显存限制,而计算量被线性摊薄到所有卡上。
但「简单」只停留在概念层面。数据并行的真实困难有两个:一是梯度同步的单次通信量太大,二是这个同步是一个硬屏障,无法被计算掩盖。
梯度累积:等效批量与通信次数的分离
先说梯度累积这件事,因为它和数据并行的开销直接耦合。梯度累积的逻辑是:把一个大批次拆成若干个微批次,前向反向若干次,把梯度累加到缓冲区而不是每步都同步,累积到设定次数之后再做一次 all-reduce。数学上它等价于一个更大的有效批量。
关键判断是:梯度累积减少了 all-reduce 的次数,但没有减少单次 all-reduce 的通信量。如果梯度缓冲区里是 fp32 的全量梯度,那么累积 32 次之后同步一次,通信量仍然是全量梯度大小,只是不用发 32 次了。很多人在这里会算错账,以为累积能把通信成本压到 1/32,实际上只是把 32 次传输压成了 1 次大传输。
那么梯度累积到底省了什么?它省的是通信启动开销、缓冲区峰值显存,以及在带宽受限时对网络的占用时长。更重要的是,它给了你一个控制手段:在不改变全局等效批量的前提下,调整每次同步的频率,从而在「同步次数少」和「单次通信时占用带宽集中」之间做权衡。
| 策略 | 等效批量 | all-reduce 次数 | 单次通信量 | 适用倾向 |
|---|---|---|---|---|
| 不累积,每微批同步 | 小 | 多 | 小 | 带宽极紧、模型小 |
| 高累积倍数 | 大 | 少 | 大 | 带宽尚可、想省通信启动开销 |
| 参数冻结 / 梯度稀疏 | 不变 | 少 | 小 | 微调场景,天然适合 |
最后一行值得单独说一句:一旦进入微调场景,如果只训练一小部分参数,需要同步的梯度就天然变小了,此时数据并行的性价比会发生质变。这也是低秩微调类方法在大规模多卡上依然好用的原因之一——它们削弱的正是数据并行最痛的那部分成本。
all-reduce 的代价到底在哪
梯度同步的经典实现是环形 all-reduce:每个 rank 持有梯度分片,按环形依次把自己的分片发给下一个节点,同时接收上一个节点来的分片,一轮之后每个 rank 都拿到了完整梯度,代价是 2(N-1)/N 倍的单卡数据量。另一种是两阶段式 reduce-scatter 加 all-gather,总量相同,但延迟特性不同:环形对大消息带宽效率高,reduce-scatter + all-gather 的两阶段结构在超大消息上更容易吃满链路。
真正的性能损耗来自三处,每一处都值得在 profile 里单独看:
- 带宽上限。实际通信吞吐永远达不到标称链路带宽,消息分段、协议栈开销、尾部效应都会打折。节点间走以太网 infiniband 时,实际有效带宽与峰值之间常有明显差距,而 NVLink 域内的差距小得多。
- 小消息延迟。当模型不大或者梯度被分片后单片很小时,通信时间由延迟而非带宽支配,环形轮数越多,延迟累积越明显。此时把「带宽算法」换成「延迟算法」才有意义。
- 硬屏障语义。all-reduce 必须等所有 rank 到达才能完成,任何一张卡慢,整个集群就一起等。这就是所谓「同步尾延迟」,它的放大效应在千卡规模下非常显著:单卡抖动 100 毫秒,乘上一个深度很深的通信序列,损失会被层层放大。
把通信藏进计算:分桶与重叠
数据并行并非必须「算完再同步」。工程上常用的做法是梯度分桶:反向传播过程中,参数是按计算图顺序陆续产生梯度的。当某个桶内的梯度凑够固定大小(比如几十兆字节)就立刻异步发起 all-reduce,让它和后续层的反向计算重叠。由于反向是从后往前推进的,先算出来的梯度属于网络后部,通信正好可以与剩余层的反向重叠。
这解释了为什么 contiguous gradient buckets 不是一个可有可无的调优项:它决定了有多少通信能被真正藏起来。分桶太粗,重叠窗口小,通信时间裸露;分桶太细,小消息的延迟开销上来。实际调优时更值得关注的指标是「通信被掩盖的比例」,而不是「通信总时长」。
# 典型数据并行启动方式:每节点 8 卡,梯度分桶 25MB,开启重叠
torchrun --nnodes=8 --nproc-per-node=8 \
--max-restarts=3 train.py \
--parallelism.data_parallel_size=64 \
--parallelism.tensor_parallel_size=1 \
--data.batch_size=1 \
--data.seq_length=4096 \
--optim.grad_reduce_in_fp32=true \
--optim.overlap_grad_reduce=true \
--optim.bucket_size_mb=25 \
--distributed.timeout_minutes=30
# 说明:
# bucket_size_mb 决定重叠窗口的粗细,与 overlap_grad_reduce 配套才有意义
# grad_reduce_in_fp32 决定通信负载,代价是缓冲区显存翻倍
# max-restarts 用于容忍单 rank 崩溃后重建进程,是基础容错手段
另一个容易被忽略的方向是通信量的压缩。低精度通信(例如 bf16 梯度参与 reduce、把通信量相对 fp32 砍半)在数值上通常可接受,因为梯度本身有噪声容忍度。还有梯度稀疏化、误差反馈、二阶统计量的低秩近似等手段,它们都是在通信瓶颈压过算力瓶颈时才值得引入——这是一个明确的适用边界:先用好计算硬件,再考虑通信压缩,顺序反了会平白损失收敛质量。
张量并行:为什么每层需要两次 all-reduce
数据并行的前提是「每张卡能放下整个模型」。当模型本身就把单卡显存吃满时,这条路就走不通了,于是需要把单个矩阵乘法横向切开——这就是张量并行。
经典做法把权重矩阵按行或按列切分成若干份,每张卡只算分片,必要时再把结果聚合起来。为了让每个分片之间能正确配合,一个 Transformer 层的内部被设计成成对的列切分与行切分交替出现。这个交替结构,正是「两次 all-reduce」存在的原因。
一对共轭算子:为什么不能只做一次
理解这一点最干净的方式是看注意力模块里的两个算子。假设把注意力的隐藏维度或头数切到 N 张卡上:
- 第一个算子(QKV 投影 / 前馈网络第一层)按列切分。权重矩阵 W 的列被切成 N 块,每张卡拿一块,输入 X 是复制(all-gather 或者保持复制状态)的,于是每张卡算出的是完整输出的一部分分片。此时每张卡手上是「不完整的结果」,必须先聚合才能进入下一步。
- 第二个算子(注意力输出投影 / 前馈网络第二层)按行切分。权重矩阵按行切分后,每张卡需要的是输入的对应分片,正好是上一步各卡算出的那部分。行切分的好处是:把上一阶段各卡算出的分片求和,恰好就等于完整矩阵乘法的结果,不需要再做任何重排。
于是关键事实出现了:列切分之后需要一次规约,行切分完成之后需要另一次规约。一次变换的「进」和「出」各需要一次通信,所以每个这样的模块至少触发两次集合通信。这不是实现上的浪费,而是数学上两次规约合并不了——前向阶段需要在「分片态」和「完整态」之间来回切换,边界不可能省掉。
如果一个方案声称张量并行「每层只做一次 all-reduce」,那它一定在某个地方偷偷多付了代价:要么把规约挪到了层外导致激活值不能释放,要么引入了额外的重排通信,要么是用了另一套切分约定而把成本转移到了别处。通信总量守恒,只是被挪了位置。
为什么 TP 的并行度不能开大
TP 的通信频率是所有策略里最高的:每个 Transformer 层至少两次,层数一多就是几百次、上千次集合通信。这些通信都发生在最关键的前向与反向路径上,属于强同步,无法与同层的矩阵计算重叠——因为后续计算必须等规约结果。这就是为什么 TP 对互联带宽极度敏感,也是为什么 TP 度通常被限制在单节点内部(8 卡或 16 卡)而不跨节点。
更严格地说,TP 度有一个由算力带宽比决定的上界。当单个节点的算力增长速度快于互联带宽时,把 TP 度开大反而会让每张卡分到的矩阵变小、计算时间按比例缩短,而通信时间几乎不变甚至变差,于是利用率断崖。可以把它理解成:矩阵被切得越碎,通信的相对开销越高,直到通信完全吃掉计算。
# 张量并行配置:单节点内 8 卡张量并行,跨节点做数据并行
# 这是一种把 TP 关在高带宽域内的典型拓扑
model:
tensor_model_parallel_size: 8
pipeline_model_parallel_size: 1
expert_model_parallel_size: 1
sequence_parallel: true # 同步归约/归约替代 scatter,进一步压通信
context_parallel_size: 1
data:
micro_batch_size: 1
global_batch_size: 512
seq_length: 8192
optim:
overlap_grad_reduce: true
overlap_param_gather: true
distributed:
backend: nccl
comm_hook: none
# 关键:TP=8 已经吃满节点内 NVLink 域,跨节点不要再叠加 TP
sequence_parallel 值得单独提一句。它的做法是把那些本来在 TP 下被复制在各卡的激活值和 dropout 掩码也切分开来,让原本的 all-gather 直接退化成更省的 all-reduce,同时把这些张量的显存占用降到 1/N。这是一种「不加通信次数反而减少通信字节数」的优化,和减少通信次数是两回事。
反向阶段的通信量
很多人只算了前向的两次 all-reduce,就以为算清楚了。总账应该是前向加反向:反向时每个模块同样需要把梯度在切分态与完整态之间来回搬运,因此通信次数基本是前向的两倍,通信总量也在同一量级。做容量规划时,把前向的两次当成全部,是最常见的低估来源,会让并行方案在真实集群上跑不出预期吞吐。
通信模式与硬件拓扑的匹配
并行策略选到一定程度,就不是软件问题了,而是拓扑问题。集群的互联通常分两层:节点内部的高速域(多卡 NVLink 或 NVSwitch)和节点之间的网络域(InfiniBand 或以太网)。这两层的带宽、延迟和拓扑都差着一到两个数量级,并行策略的并行度上限基本由它们决定。
| 维度 | 节点内(NVLink 域) | 节点间(IB / RoCE 域) |
|---|---|---|
| 带宽 | 极高,单向数百 GB/s 量级 | 中等,单向几十 GB/s 量级 |
| 延迟 | 极低 | 明显更高 |
| 推荐承载的并行 | 张量并行、专家并行、序列/上下文并行 | 数据并行、流水线并行 |
| 典型限制 | 卡数受限于插槽与供电 | 受限于网络拓扑与超额订阅比 |
这张表就是并行度划分的直接依据:把通信最频繁的维度塞进节点内,把通信最稀疏的维度铺到节点间。在只有 8 卡节点、64 节点集群的常见配置下,张量并行取 8,专家并行取 8,流水线并行取 8(跨节点),数据并行取 8(跨节点),就是把这 512 张卡凑成一组 3D 并行网格。
拓扑感知不是自动的
真实集群的链路质量并不均匀:同一机柜内的节点走顶层交换,跨机柜要走叶子交换机,跨可用区甚至要走更长的路径。如果通信组没有对齐到物理拓扑,集合通信就会跨越慢链路,规模一大会成为系统性瓶颈。
所以在多维并行里,并行组之间的映射关系是必须显式声明的:数据并行组放在最外层,通信走最慢也最「耐粗」的链路;张量并行组放在节点内,走最快的链路。这件事在现代框架里通常由设备网格(device mesh)与进程组的显式构造来表达,而不是靠自动推导,因为自动推导读不懂机柜图。
另一个实践要点是集合通信的实现质量直接决定上限。同一份代码,换一个通信后端或者更合适的算法,可能有一倍以上的差距。选择取决于消息大小:消息大时偏向环形等带宽算法,消息小时偏向树形等低延迟算法。很多框架的集合通信实现内置了自适应选择,如果你的模型结构变化剧烈(张量并行和流水线并行混排),值得实测一下是不是走到了你以为的那条路径。
流水线并行:气泡、调度与两种经典方案
当单个节点装不下模型,且张量并行度又被带宽卡住时,剩下的自然选项就是流水线并行:把模型按层切成若干段,每一段完整地放在一张卡上。前向传播时,数据像流水线一样从第一段流到最后一段;反向传播时再倒着流回来。
流水线并行的通信非常轻——只传边界的激活值和梯度,点对点发送即可,不需要集合通信。这正是它适合跨节点的原因。但它有一个结构性的效率损失:气泡。
气泡从哪里来,以及它的量级
理想情况下,所有卡在任一时刻都在做有用计算。但现实中,第一张卡在前向的一开始是满载的(它要连续算多个微批),最后一张卡在后段的反向之前是闲着的。填充和排空这两段无法避免的时间,就是气泡。
气泡占比随微批数量和流水线段数的比值变化:微批数量越多、流水线段数越少,气泡越薄。极限情况是段数等于微批数,此时完全没有气泡,但每张卡都极不均衡。实践中大家关心的量是「每张卡的有效利用率」,也就是忙碌时间占总时间的比例,它直接决定了你能用多少张卡换多少倍加速。
要提升利用率,方向有两个:一是增加微批数把气泡摊薄,二是改进调度让填充期更短。前者会推高峰值显存,后者要看调度算法。
GPipe 与 1F1B:两种截然不同的权衡
朴素流水线调度(常被称为 GPipe 风格)的做法是:先跑完所有微批的前向,把中间激活值全部存下来,再统一反向,然后做一次梯度同步。优点是调度逻辑极其简单,通信被整段批量发送,效率高;缺点是所有中间激活值必须同时驻留,峰值显存随微批数线性增长,等于用显存换吞吐。这在大模型时代往往不可接受。
1F1B 调度(一个前向接一个反向)把顺序改成交替推进:每个流水线段在完成一个微批的前向之后,立刻找一个可以做的反向去做。这样激活值在产生后很快就被消费并释放,峰值显存从「微批数 × 单微批激活值」降到大约「流水线段数 × 单微批激活值」,与微批数基本解耦。这是目前超大模型训练的主流选择,代价是通信变得细碎,边界张量要频繁点对点收发。
| 维度 | 朴素调度(全前向后全反向) | 1F1B 调度 |
|---|---|---|
| 峰值激活显存 | 随微批数线性增长 | 随段数增长,与微批数解耦 |
| 通信形态 | 集中批量,点对点效率高 | 细碎交错,需注意延迟敏感 |
| 调度复杂度 | 低 | 中高,涉及空闲与再分配 |
| 典型用途 | 中等规模、显存有余量 | 超大模型、显存紧张 |
交错式调度与更细的切分
还有两条进一步压气泡的路子。一是交错调度:让一张物理卡按顺序承担多个不连续的模型段,本质上是把虚拟段数变成段数的整数倍,气泡比例按这个倍数下降,而段的顺序是精心排列的。代价是显存碎片与调度复杂度的上升,以及需要更精确的资源分配。
二是非均匀切分。不同类型的层显存和算力开销差别很大:注意力层通常比前馈层更吃激活值,前几层和后几层还要承担 embedding 与输出的额外开销。把每张卡分到的层数按显存占用而不是按数量均分,往往能直接削掉一大块峰值显存。代价是流水线不同段之间计算时间不再一致,气泡的形状变复杂了。
# 流水线并行 + 1F1B 的配置片段(示意)
# 目标是:段数 × 每节点卡数 × 数据并行度 = 总卡数
model:
tensor_model_parallel_size: 8 # 节点内
pipeline_model_parallel_size: 8 # 跨节点,1F1B
expert_model_parallel_size: 1
pipeline_model_parallel_schedule: 1F1B
num_layers_per_virtual_pipeline_stage: 2 # 交错调度:每卡承担 2 段
data:
micro_batch_size: 1
global_batch_size: 1024 # = 微批数 × 段数 × DP 度 × 微批大小
seq_length: 8192
# 注意:global_batch_size 必须能被 微批数 × DP 度 整除,否则会静默产生不均匀的尾部
# 气泡率约等于 段数/(微批数+段数-1),交错调度按虚拟段数计算
最后提一个容易被忽略的点:流水线并行的理论加速比受限于气泡,跨节点时边界激活值的传输延迟会直接计入流水线节拍。所以把段边界放在网络较慢的链路上是常见但有害的做法,段边界的划分应该和拓扑一起设计。
专家并行:MoE 带来的特殊约束
稠密模型的所有参数每次前向都参与计算,这让参数量的扩张直接等价于算力成本。混合专家结构改变了这一点:每个 token 只会路由到少数几个专家,未被选中的专家完全不参与计算。于是「扩容」可以主要靠加专家来做,而每次前向的算力只按激活专家数增长。
但这个红利有严格条件。专家必须分布在不同设备上,否则就没有并行可言;而一旦分散到不同设备,「把 token 发给对应专家」这件事就成了每层都要做的全局通信。这就是专家并行的核心矛盾。
all-to-all 是主通信,而不是 all-reduce
与前三种并行不同,专家并行的通信原语是 all-to-all。每张卡手上有一批 token,每个 token 有一个路由目标(落在哪些专家上),专家分布在各卡上。于是必须做一次全局交换:每张卡把自己的 token 按照目标专家打包,发给持有该专家的卡;对方收到后用本地专家做计算,再把结果原路发回。
这个 all-to-all 的通信量与 token 数量 × 隐藏维度成正比,而且每个 MoE 层要做两次(一次分发输入,一次回传输出)。如果 MoE 层很密集,通信总量可以和整条计算路径上的其他通信相加,成为新的瓶颈。
三个工程上的重要判断:
- all-to-all 对拓扑极度敏感。它同时涉及跨节点和节点内,且需要匹配的进程组划分。放在节点内的专家并行,通信走 NVLink,效果好;跨节点的专家并行需要极高带宽和低超额订阅的网络,否则会因为「跨节点 token 交换」把整个 MoE 层的延迟拉高。
- all-to-all 的实现质量差异很大。一些实现会做分块流水化,让一部分 token 的交换和计算重叠,另一些则更接近阻塞式。这属于通信与计算重叠能力上的实质性差异,值得实测。
- token 复制与专家冗余会影响实际负载。路由器给热门专家分配的 token 数可能远超平均,这就是负载不均。
容量因子:不均与丢弃之间的开关
all-to-all 的通信量直接由实际交换的 token 数决定,而实际交换的 token 数是 token 数 × 路由到的专家数。这带来一个可以用来自我调节的旋钮:容量因子,也就是每个专家预留能处理多少个 token。
容量因子被触发上限时,超出的 token 会被丢弃(或者走一个兜底路径)。它形成了一个清晰的取舍:容量因子调小,显存占用和通信量下降、吞吐上升,但丢弃率上升、质量受损;容量因子调大,质量更稳,但显存和通信都变贵。同时还要考虑辅助负载均衡损失——训练中通常会加入一个惩罚项,把不同专家的 token 负载拉近,防止训练早期就退化成少数几个专家独揽流量。没有这个约束,专家并行会在训练过程中被路由器不均衡反噬。
EP 与 TP 的冲突
这是专家并行最需要小心的地方。当专家权重本身也被张量并行切分时,一个 token 的目标「专家」就变成了「某个专家在某张卡上的分片」,分发目标从「发到一个专家」变成「发到多个卡上的同一专家的不同分片」。通信模式随之变得复杂:要么在分片维度上再做一次切分并复用 all-to-all,要么在某些实现里退化为更贵的通信序列。
实践中的判断很简单:EP 和 TP 是两种切分维度的竞争关系,不是简单叠加。选择一条,要么用 EP 让不同专家分布在不同卡(并以负载均衡和容量因子为约束),要么用 TP 切分单个专家的权重矩阵。很少有方案能让两者都开到很大值。一旦两个一起开大,通信复杂度上升的速度会超过收益。
# 专家并行配置:专家放在节点内 8 卡,模型其余部分用流水线铺开
model:
expert_model_parallel_size: 8 # 8 个专家分散到节点内 8 卡
tensor_model_parallel_size: 1 # 不与 EP 叠加,避开 all-to-all 复杂化
pipeline_model_parallel_size: 16 # 靠 PP 铺开到 16 节点
expert_tensor_parallel_size: 1 # 单个专家内部不再切分
# MoE 层的关键旋钮
expert_capacity_factor: 1.25 # 容量因子:显存/通信 ↔ 丢弃率 的旋钮
moe_router_load_balancing_type: aux_loss # 辅助负载均衡损失
moe_aux_loss_coeff: 0.01 # 太小易退化,太大伤质量,需按步数调
moe_router_topk: 2 # 每 token 路由到几个专家:直接乘在 all-to-all 量上
# 诊断要点:看 all-to-all 字节数是否 ≈ token 数 × topk × 隐维 × 数据类型大小
# 若明显偏大,多半是容量因子过大或 token 复制策略不对
3D 并行:切分维度该怎么选
当单一并行方式都撑不住时,就要组合。这被称为 3D(或更高维)并行:把数据、张量、流水线、专家等维度组合起来,构成一张并行网格,每个维度负责消化一种资源压力。
组合的难点不是「能不能组合」,而是「先切哪个维度」。顺序错了,你会付出高带宽域里最稀缺的资源。
切分顺序的判据:谁最怕慢链路
核心判据只有一条,但非常锋利:先切那些通信最密集、最不能忍受慢链路的维度,让它们落在节点内;后切通信稀疏的维度,让它们跨节点。
- 第一步:张量并行(以及专家并行)在节点内取满。这两者的通信发生在每个模块的前后向路径上,是最不能被慢链路容忍的。让节点内 8 卡或 16 卡全部用于 TP/EP,把模型「变薄」。
- 第二步:专家并行与张量并行二选一或谨慎叠加。如果模型是 MoE 专家密集型,EP 会自然争抢节点内的槽位;此时常常把 TP 调小甚至置 1,把节点内带宽让给 EP。这不是「EP 更好」,而是「这个模型里 EP 是主要并行收益来源」。
- 第三步:流水线并行把薄模型铺到节点间。PP 只传边界激活值,跨节点完全承受得起。它的作用是消化「节点数不够 / 内存不够装全部」的压力,用气泡率换取可扩展性。
- 第四步:数据并行在剩余维度上复制。DP 度 = 总卡数 ÷ (TP × PP × EP)。它不改变单卡要装的模型大小,只影响显存和通信总量;它的作用是扩大全局批量、摊薄固定开销。
用一句口诀收束:TP / EP 吃满节点内 → PP 铺开节点间 → DP 撑起批量。
| 切分维度 | 落在哪一层 | 主要消化什么 | 切分顺序 | 调大它的代价 |
|---|---|---|---|---|
| 张量并行 | 节点内 | 单卡放不下模型 | 最先,节点内取满 | 通信次数多,怕慢链路 |
| 专家并行 | 节点内 | MoE 专家放不下 | 与 TP 竞争同层槽位 | all-to-all + 负载不均 |
| 流水线并行 | 跨节点 | 节点数不够 | 中间,用于铺开 | 气泡 + 边界激活值延迟 |
| 数据并行 | 跨节点 | 扩大全局批量 | 最后,在剩余维度 | 同步屏障 + 尾延迟 |
通信总量:并行度怎么影响账本
要判断组合方案是否合理,最实用的口径是把一次训练的通信总量按并行类型加起来,看各项占比:
- 数据并行的梯度同步:参数量级,一次反向之后发生一次。DP 度增大不改变单卡这部分的量,但同步次数按步数计;带宽是否够支撑,取决于参数量和步间时间。
- 张量并行的层内通信:隐维 × 序列长度 × 数据类型大小的量级,出现 N 层 × 前向加反向的次数。这里 TP 度不改变单次总量(切分后总字节数守恒,重复的部分是复制来的),但 TP 变大会让单卡计算时间变短,相对通信开销上升。
- 流水线并行的边界传输:只传一个边界的激活值与梯度,量小但频繁,跨节点时主要怕延迟。
- 专家并行的 all-to-all:token 数 × topk × 隐维 × 数据类型,且每层两次。EP 度增大同样不显著降低总量,但跨卡比例上升。
把四项加起来之后你会发现一个规律:对于稠密模型,TP 通信往往是主导项;对于 MoE 模型,TP 通信项会被 EP 的 all-to-all 顶上来,量级可能反超。这也是为什么不同模型类型要用不同的切分顺序——判据虽然只有一条,但落到不同模型上,最优解会完全不同。
ZeRO:1/2/3 真正省的是什么
并行策略解决的是「怎么切模型」,ZeRO 解决的是另一个正交问题:数据并行时,每张卡都存着完整的优化器状态、梯度和参数,这部分冗余能不能省掉。它不改模型的切分方式,而是把数据并行下原本每卡复制的状态分片到不同卡上。
要理解 ZeRO,先看数据并行下每张卡存的四样东西:参数、梯度、优化器状态、还有反向计算需要的激活值。ZeRO 三个级别就是按「切几样」来分的。
| 级别 | 切分对象 | 显存省在哪 | 通信代价 | 关键收益 |
|---|---|---|---|---|
| ZeRO-1 | 优化器状态分片 | 优化器状态(通常是显存大头) | 与 DP 相同量级(多一次 reduce-scatter/all-gather 组合) | 同等卡数能跑更大模型 |
| ZeRO-2 | 优化器状态 + 梯度分片 | 再加上梯度 | 与 ZeRO-1 同量级 | 激活值和临时缓冲有更多余量 |
| ZeRO-3 | 优化器状态 + 梯度 + 参数都分片 | 再加上参数,接近全部分片 | 明显增加(每层前向/反向都要取用参数) | 单卡可承载的模型尺寸大幅提升 |
最容易误解的一点是:ZeRO 各级别省的主要是「优化器状态和梯度、参数这类状态内存」,而不是「激活值」。激活值主要靠梯度累积、检查点、序列并行和上下文并行来压,和 ZeRO 是两套手段。把这两件事混在一起,就会出现「上了 ZeRO-3 但激活值还是爆」然后误判为 ZeRO 没用的情况。
ZeRO-3 的通信代价是真实的
ZeRO-1 和 ZeRO-2 的通信量与普通数据并行大致同量级,因为它们只切状态、不切前向要用到的参数,前向计算时每卡仍持有完整参数。ZeRO-3 则不同:参数也被切分了,每张卡在任何时刻只持有参数的一个分片,前向和反向计算需要参数时必须即取即用,用完立刻释放。这会在每一层引入额外的 all-gather 和 reduce-scatter。
所以 ZeRO-3 是典型的「用通信换显存」:它让你在同样的卡上跑更大的模型,但如果算力已经被通信打满,吞吐会掉。判断依据很直接——看你的训练里通信占比,如果本来就紧张,ZeRO-3 可能适得其反;如果显存是瓶颈、算力有富余,ZeRO-3 就能把模型规模换上来。经验上,从 ZeRO-2 到 ZeRO-3 的显存收益很大,但吞吐损失也明显;在通信已经饱和的集群上,先做流水线/张量并行的切分,再考虑 ZeRO-3,比一上来就 ZeRO-3 更稳。
把 ZeRO-3 当成免费的显存扩容是危险的。它是通信换显存,账要在 profile 里算,不能凭直觉。
ZeRO 的分片思路还和流水线、张量并行完全正交:ZeRO 在数据并行的维度上分片状态,TP/PP 在模型的维度上切分结构。所以「TP + PP + ZeRO」是可以同时用的,组合起来大致是「先用 TP/PP 把结构切到节点能吃下的规模,再用 ZeRO 把剩下的状态冗余压掉」。
异构与故障容错
到了数百上千卡,训练周期以周计,「能不能扛住一次故障」已经从锦上添花变成决定项目成败的前提。并行策略在这里和容错机制互相牵制。
故障类型与后果
- 单卡故障 / 进程崩溃。最常见。后果取决于策略:数据并行下崩一张卡,通常意味着整个通信组要重建(因为它持有分片状态),需要重跑当前步;流水线并行下崩一张卡,整条流水线的下游要等它。重启代价与「到下一个检查点的步数」成正比。
- 链路降级 / 超时。比崩溃更隐蔽:某条链路变慢会让整个集合通信拖尾,进而让所有卡陪着等。表现是吞吐悄悄下降而非报错,这往往比崩溃更难排查。
- 内存溢出与碎片。峰值显存是概率性事件,节点间微小的负载不均会导致部分卡溢出而其他卡正常。
检查点粒度:恢复代价与训练代价的平衡
容错的核心手段是周期性检查点。检查点太稀疏,一次故障就白跑很久;太密集,写盘和存储带宽成为新的瓶颈,还可能拖慢训练。合理做法是让「写检查点的开销」远小于「两次检查点之间能推进的收益」。
分布式检查点的复杂度在于:模型状态、并行策略、优化器状态、随机数状态、数据加载进度都要对齐保存,而且和并行配置绑定。改动并行配置后旧检查点往往不能直接用。把检查点内容和并行配置一起版本化,是让容错真正可用的前提。
另外两个容错相关的工程点:一是弹性伸缩——允许在训练中途增减节点并调整并行度,这需要在设计上就容忍重新分片;二是挂起与恢复的粒度——能在不重启进程的情况下跳过故障节点,比整进程重启的代价小得多,但实现要求更高。
长序列与低精度:和并行策略纠缠在一起的两个变量
前面所有讨论都默认序列长度和数值精度是给定的常量,但真实训练里这两个变量和并行策略是强耦合的:它们同时改变显存占用和通信体积。多数时候它们应该和并行度一起规划,而不是先固定、出问题再回头补救。
激活值:显存里最容易被低估的一块
参数、梯度、优化器状态这三样都是静态的,激活值却随着序列长度快速增长。注意力相关的中间张量在朴素实现下会随序列长度近似平方增长,这意味着序列长度翻倍带来的显存压力可能远超翻倍。在超长上下文训练中,激活值而不是参数,往往才是真正的显存瓶颈。
对付激活值有几套手段,它们与并行策略的接口各不相同。梯度累积通过缩小单次前向的规模压低峰值,代价是步数增加;激活值重算用计算换显存,反向时重跑前向,只保留边界张量,代价是额外算力;序列并行把那些在张量并行下被复制在各卡的激活值也切分开,把这类张量的显存降到原来的几分之一,同时还能减少通信字节数;上下文并行把整条序列按位置切开,注意力退化成跨卡的规约与合并,是应对超长序列最彻底的一种切法。
这里有个容易走偏的地方:选择激活值手段之前,要先证明瓶颈真的在激活值上。如果显存统计显示激活值只占很小一块,那么继续在激活值上做优化就是浪费,应该回到参数与状态那一侧用分片解决。这个判断值得在每次改动并行配置之前重做一遍,因为并行度一变,各部分的占比会跟着变,上一次的结论未必还成立。
另一个常见误区是把「显存够用」等同于「显存充足」。峰值显存是随步数波动的,采集时要以峰值为准;叠加碎片之后,统计值与实际可分配值之间还隔着一层。把两者混为一谈,很容易在长跑几万步后才遇到溢出,而且很难复现。
低精度:同时压算力与通信量
低精度训练的价值容易被窄化成「只是省显存」。实际上它同时作用于三处:矩阵乘法的算力开销、激活值的体积、以及走网络的那部分张量字节数。通信量按数据类型位宽近似线性下降,所以在通信已经接近饱和的集群上,把通信张量换成低精度格式,收益可能比单纯降低计算精度更大——它把一段本来会阻塞后续计算的时间直接缩短了。
但低精度不是免费的。归约时的累加顺序、梯度累积的步数、以及哪些张量保留高精度,都会影响数值稳定性。判断是否值得换精度,同样应该看 profile:如果通信在单步里占比明显,那么先动通信侧的数据类型通常是性价比最高的一处改动;如果计算才是瓶颈,那么重点应放在让矩阵乘法走更低精度的执行路径。两者可以分开决策,不必一次全换,也不必同时换。
还有一类改动的性质类似:把通信切得更碎或更整。前者降低单次通信量但增加次数,后者相反。在延迟主导和带宽主导之间移动,有时收益远超换精度,因为延迟那一侧几乎不受位宽影响。
三者一起规划:一张校验顺序
把序列长度、精度、并行度放在一起看时,可以按下面的顺序逐层校验,每一层只解决自己那一类问题。
- 先看单卡能否独立放下参数与状态。放得下就直接上数据并行加梯度累积,这是最省心的情形。
- 放不下就决定走结构切分还是状态分片。结构切分改变模型在卡上的形状,状态分片只改变冗余的存放方式。两者的通信特征不同,需要一起算总账。
- 再看激活值在当前序列长度下占多少。占比高就用梯度累积或重算压峰值,占比低就别在这上面花力气。
- 然后估算单步通信总量,看它在当前链路带宽下是否可接受。不可接受就优先降通信侧的数据类型或减少通信次数,而不是加卡。
- 最后才决定是否为了收敛质量而保留高精度。这一步是取舍而不是优化,应该在前面四项都稳定之后再动。
任何一步的结论发生变化,后面几步都要重算——这正是很多人低估并行配置工作量的原因:它不是一个能一次调好的参数,而是一组互相牵制的约束在给定集群形状下的解。这套顺序的价值,在于把「并行度」从一个孤立的旋钮,还原成受显存、序列长度、精度和拓扑共同约束的结果。把四者绑在一起规划,才能解释为什么同一份模型代码在不同集群上需要不同的并行配置——变的不是模型,是这四个约束的相对松紧。
并行策略选错导致的性能塌陷:真实训练里长什么样
这是全文最实用的一节。并行配置错了不会立刻报错,它会「跑起来、但很慢」,而慢的原因往往被误判成「GPU 不够」「模型太大」。下面按症状归类。
症状一:加了卡反而更慢
表现:把 DP 度从 8 加到 16,墙钟时间不降反升;或者把 TP 从 1 调到 4 之后,吞吐掉了而不是涨。
原因:通信已经饱和。DP 度翻倍不改变单次的梯度同步总量,却把同步的参与者翻倍、尾延迟暴露面翻倍;TP 度调大让单卡矩阵变小、计算时间按比例缩短,而层内两次 all-reduce 的时间几乎不变甚至变差,相对开销陡增。
诊断:在 profile 里看单步时间里集合通信的占比。如果通信已经占据大部分时间,再加卡只是在加开销;如果计算占比高,说明还有并行空间。
症状二:吞吐随训练步数缓慢下滑
表现:早期正常,几个千步后每步变慢。
原因:多与策略或状态有关。其一是显存碎片随时间累积,导致本该充裕的显存逐渐逼近峰值;其二是负载不均——如果是 MoE,热门专家导致 all-to-all 字节数与通信不均衡随训练加剧;其三是专家并行下路由器塌缩,token 大量涌向少数专家,分布越来越偏。
诊断:看显存占用是否呈上升趋势,看 all-to-all 字节数是否随步数上升,看专家负载分布图是否失衡。
症状三:有效加速比远低于理论值
表现:理论上 N 倍卡该有近 N 倍加速,实际只有零头,甚至负收益。
原因:气泡(流水线段数相对微批数太深/太多)、跨节点边界延迟、并行组没有对齐物理拓扑(数据并行组跨了机柜)、集合通信走了次优算法、或者负载在流水线各段之间不均导致尾部空转。
诊断:把各卡的忙碌时间拉出来做直方图,一眼就能看出气泡和各段是否均衡。如果某些卡明显空转多,就是流水线或负载不均;如果所有卡都均匀地忙但总吞吐低,那问题在通信或者算子实现。
症状四:MoE 训练质量不上去或莫名掉点
表现:损失下不去、专家几乎只用少数几个、或者训练后期无故劣化。
原因:容量因子太小导致丢弃率高、辅助负载均衡损失系数不当(太小压不住不均,太大伤害路由)、EP 与 TP 叠加导致路由到的分片不一致、all-to-all 通信覆盖不全让部分 token 路由失败。
诊断:直接看丢弃率与专家负载分布,而不是从损失曲线猜。通信层面用通信字节数反推:理论值 = token 数 × topk × 隐维 × 数据类型大小,实测明显偏大就说明有额外通信或 token 复制。
症状五:显存明明够却还是 OOM
表现:按 ZeRO 估算的优化器+梯度+参数占用远小于实际显存,却仍然溢出。
原因:大头其实是激活值和通信缓冲区,而 ZeRO 不管激活值。长序列下激活值随序列长度超线性增长;集合通信的预分配缓冲区可能很大;碎片让「够用」变成「不够用」。
诊断:按「参数 / 梯度 / 优化器 / 激活值 / 通信缓冲 / 碎片」六项分别打点,别用总显存去反推。缓解手段是梯度累积压峰值、序列或上下文并行压单序列激活值、给通信缓冲设上限。
把塌陷归因到「维度错误」
上面五类塌陷,追根溯底几乎都是切分维度选错:把怕慢链路的维度放到了慢链路(TP/EP 跨节点),把气泡预算排得太紧(PP 太深而微批太少),把该用状态分片的问题当成结构问题(只用 DP 不上 ZeRO),或者把结构切分和状态切分的职责搞混。判断并行配置对不对,可以问自己三个问题:
- 最怕慢链路的并行维度(TP、EP),是不是都关在节点内了?如果跨了节点,先把它收回节点内。
- 流水线的气泡率,在当前微批数下是不是一个可接受的数?如果气泡吃掉大半,考虑加微批、降段数,或改用交错调度。
- 显存紧张的部分,到底是状态还是激活值?状态紧就上 ZeRO,激活值紧就上梯度累积和序列/上下文并行——这两者用错工具都会白费力气。
选型建议:一张可执行的决策表
最后把全文收敛成可直接照做的选型逻辑。以下假设集群是标准的「多节点、节点内 NVLink、节点间高速网络」。
| 你的处境 | 主策略 | 辅助策略 | 要盯的指标 |
|---|---|---|---|
| 模型单卡放得下,卡不够算得快 | 数据并行 | ZeRO-1/2 + 梯度累积 | 集合通信占单步时间比例 |
| 模型单卡放不下,但节点数够 | 节点内张量并行取满 | 跨节点数据并行 + ZeRO | 单卡矩阵尺寸、通信相对开销 |
| 单节点也放不下,要动用多节点 | TP 在节点内 + 流水线跨节点 | 1F1B 调度 + ZeRO-2/3 | 气泡率、各段计算均衡、峰值激活显存 |
| MoE 专家密集,参数多但每 token 计算少 | 节点内专家并行 | TP 谨慎叠加;PP 跨节点铺开 | all-to-all 字节数、丢弃率、专家负载分布 |
| 激活值是显存瓶颈(长序列) | 上下文/序列并行 | 梯度累积 + 检查点 | 单序列激活值随长度的增长 |
| 状态是显存瓶颈(参数多、状态大) | ZeRO-2/3 | 与 TP/PP 正交组合 | 分片后的状态占用 vs 通信新增 |
还有几条经验性提醒值得单独强调:
- 先算账再上卡。把通信总量按四项加起来,和单步时间对比。通信占比高就先优化通信占比,而不是加卡。
- 一次只动一个维度。同时调并行度和超参,出了问题无法归因。每次改一项,跑够步数看曲线。
- 拓扑与并行组一起设计。数据并行组跨机柜、张量并行组跨节点这类问题,在配置文件里一分钟能改,在运行时是持续的性能损失。
- 不要把通信压缩当默认解。先榨干计算硬件和调度(分桶、重叠、选对算法),再考虑低精度通信或稀疏化去换带宽。
- 给容错留预算。检查点间隔、训练配置、并行配置要作为一个整体版本化,确保故障恢复时能对齐续跑。
小结
四种并行的差别,本质上是对「显存、算力、带宽」三种压力在「模型不同维度」上的不同落点。数据并行用批量换算力,代价是每步一次重通信和硬同步;张量并行用带宽换模型容量,代价是每层至少两次无法隐藏的集合通信,因此必须关在节点内;流水线并行用气泡换跨节点可扩展性,代价是效率损失,靠微批数与 1F1B 调度摊薄;专家并行用 all-to-all 换参数量,代价是路由与负载均衡这一整套额外约束。ZeRO 则在正交的方向上——它不动结构,只在数据并行的维度上切分状态,用通信换状态显存。
把这几条线索放在一起,3D 并行的切分顺序就是顺理成章的:最怕慢链路的最先切、切在节点内;通信稀疏的后切、切到节点间;状态冗余用 ZeRO 在数据并行的维度上压掉。而所有塌陷——加了卡更慢、吞吐下滑、加速比打折、MoE 掉点、显存明明够却溢出——本质上都是偏离这条原则的某种表现。学会用 profile 里的「单步时间里通信占多少、各卡忙碌时间均不均、all-to-all 字节数对不对得上」这三组指标去定位,就把「性能塌陷」从玄学变成了可排查的工程问题。
并行策略的演进方向很清楚:让「最怕慢链路的计算」尽可能待在最快的那一小块硬件里,把「不怕慢的」铺开去换规模,而把状态冗余用分片压掉。理解并把这三条原则落到具体训练配置上,比记住任何单一框架的参数名都重要。