做分布式训练绕不开流水线并行。大模型参数一多,单卡放不下,数据并行又解决不了显存问题,流水线并行就成了必须掌握的手段。这篇是“图解分布式训练”系列的第一篇,只聚焦一件事:流水线并行的核心逻辑,以及真正落地时的实践要点。
我尽量用图和表把调度过程讲清楚,让两类人都能读进去。一类是刚接触分布式训练,想搞明白流水线并行到底是什么、和别的并行方式有什么区别;另一类是已经在跑训练,但发现显存、效率、收敛总出问题,想弄明白背后的原理。读完你应该能建立起一个完整的认知框架,并且知道实际配置里该动哪些参数、按什么顺序排查问题。
1. 流水线并行到底在解决什么问题
1.1 单卡放不下的模型:先算一笔显存账
训练大模型最先撞上的墙是显存。以百亿参数(10B)模型为例,用半精度(bf16)存权重,每个参数占 2 字节,100 亿参数光权重就要 20GB。但训练时远不止权重本身:前向反向要存梯度和激活值,优化器还要维护额外的状态。一个常用的粗略估算口径是:用 Adam 这类自适应优化器训练时,每个参数大约需要 12~16 字节的显存预算,包括权重、梯度、动量和方差。按 12 字节算,10B 模型的参数相关开销就是 120GB,单卡 80GB 的 H 系列显卡根本塞不下。
如果你觉得“那我把模型切小一点跑不就行了”,还是绕不开同一个问题。模型的层数翻倍,显存需求不是线性增长,而是被激活值缓存和临时计算空间成倍放大。所以只要模型规模上去,单卡放不下是必然的,这是流水线并行出现的第一个理由:把模型拆开,让每张卡只承担一部分。
1.2 三种并行各管一段:数据并行、张量并行、流水线并行
面对显存不够,直觉上就是把多块 GPU 拼起来一起干活,这其实就是并行策略的出发点。目前主流的思路大体分三类,我习惯用一个表格把它们的分工说清楚:
| 并行方式 | 切的是什么 | 通信特点 | 解决什么问题 |
|---|---|---|---|
| 数据并行 | 把 batch 切成多份,每卡一份 | 每步做梯度全归约,通信随卡数增长 | 吞吐量上不去,卡多算得快 |
| 张量并行 | 把一层的矩阵运算切开,多卡协作算同一层 | 每层都要做全归约,通信量大 | 单层放不下,或者想压缩单卡显存 |
| 流水线并行 | 把模型按层切成多段,每卡负责一段 | 只传递层间激活值和梯度,通信量小 | 模型整体放不下,需要跨卡部署 |
用一句话概括:数据并行把数据切开,张量并行把层切开,流水线并行把模型按层切开。三者不互斥,大模型训练里经常组合使用。流水线并行的定位是:用相对较低的通信成本,先解决显存放不下的问题。它和另外两种并行最明显的区别在于,GPU 之间不需要每层都同步一次,只需要在段与段之间交换数据,这让它可以跨机器部署也不至于被通信拖垮。
2. 核心逻辑:切层、微批与气泡
2.1 按层切开:装配线上的工位
流水线并行的基本操作是把模型从中间切开。假设一个 Transformer 模型有 48 层,平均分成 4 段,每段 12 层,放到 4 张卡上,这就是 4 个 stage(流水线段)。数据流动的方向是从前往后的:第一张卡读入输入 batch,完成第 1~12 层的前向计算,把输出的 hidden state 发给第二张卡;第二张卡接着算第 13~24 层,再往后传;最后一张卡算完最后一段并计算 loss。反向传播时顺序反过来,从最后一张卡逐段往前传梯度。
这个过程非常像汽车装配线:每个工位只负责自己那一段工序,做完就把半成品交给下一个工位。为什么能这样切?因为 Transformer 层之间天然存在清晰边界——上一层的输出 hidden state 就是下一层的输入,除此之外没有别的依赖。这个边界是流水线并行成立的前提。如果你用的模型结构里层与层之间有跨层连接、共享权重之类的复杂依赖,切分就没这么简单了,得先处理依赖关系。
2.2 朴素切分的致命浪费:气泡
如果你真的按上面方式直接训练,效率会非常难看。假设整批数据一次性传进去,第一张卡要算完一整批的所有层,第二张卡才开始干活;第一张卡算完就没事干了。后续阶段依次接力,任意时刻都只有 1 张卡在计算,其他卡都在等着。4 张卡的时候利用率是 25%,切得越多浪费越严重,这显然不能接受。
我管这个空闲等待叫“气泡”(bubble)。气泡的本质是:流水线里下游工位必须等上游工位产出才能开工。就像装配线一次只造一辆车,下一道工序永远在等上一道,大部分工位闲置。气泡是流水线并行最核心的效率敌人,后面讲的所有调度方案,本质上都是在压缩它。
2.3 微批处理:拆小件才能不断料
解决办法说穿了也不复杂:不要把 batch 当成一个整体,而是把一个 batch 拆成多个更小的 micro-batch(微批),一个接一个地送进流水线。
还是拿 4 个 stage 举例。假设一个 batch 有 64 条样本,拆成 8 个 micro-batch,每个 8 条。第一张卡算完第 1 个 micro-batch 后,立刻传给第二张卡,自己接着算第 2 个 micro-batch。于是第二张卡在第一张卡算第 2 个 micro-batch 的同时,开始算第 1 个 micro-batch。流水线很快就被填满,所有卡都在连续工作。
这就是“微批”的精髓:把装配线从一次只产一辆车改成连续下料,产线上始终有车在流动,每个工位都能忙起来。微批数量越多,流水线越不容易断档,气泡占比就越小。但微批数量不是越大越好:它直接影响显存里的激活值数量,也影响全局 batch size 的语义,后面实操部分会展开讲。
3. 调度策略演进:从填满排空到 1F1B 再到交错式
3.1 经典填满排空调度:简单但吃显存
微批解决了“填满流水线”的问题,但前向和反向怎么排列,不同方案差别很大,直接决定显存占用。最早的经典做法是两阶段式:先把所有 micro-batch 的前向全部算完(填满),再统一做反向(排空)。以 P=4、M=4 为例,调度长这样(F 表示一个 micro-batch 的前向,B 表示反向,点表示空闲):
时间: t1 t2 t3 t4 t5 t6 t7 t8 t9 t10 t11 t12 t13 t14 S1 F1 F2 F3 F4 . . . . . B1 B2 B3 B4 . S2 . F1 F2 F3 F4 . . . . B1 B2 B3 B4 . S3 . . F1 F2 F3 F4 . . B1 B2 B3 B4 . . S4 . . . F1 F2 F3 F4 B1 B2 B3 B4 . . .这个方案的好处是简单、容易实现,坏处是前向过程中产出的每个 micro-batch 的激活值都得存着,等反向阶段用。micro-batch 一多,显存立刻爆炸。它是理解流水线并行最好的入门模型,但实际工程里很少直接用。
3.2 1F1B 稳态调度:把显存省下来
后来业界普遍改用一种更聪明的调度方式:一个前向接一个反向,工程里常叫 1F1B(one forward one backward)。前面先做一段预热,等流水线进入稳态后,每张卡交替执行前向和反向,算完一个 micro-batch 的反向就释放它对应的激活值,不必把全部 micro-batch 的激活值一直保留到排空阶段。相比填满排空,同等配置下显存占用大幅下降,这也是它在实践中能支持更大 M 的原因。
代价是实现的复杂度上去了。因为反向依赖的前向结果是交错的,调度器必须仔细安排每个 micro-batch 的先后顺序。另一个隐藏问题是:不同 stage 可能基于不同版本的权重做计算,梯度和权重版本对不上,会带来收敛层面的偏差。工程上通常通过延迟更新、版本对齐等手段处理,这也是流水线并行“跑通容易、跑稳难”的原因之一。
3.3 交错式切分:用通信换气泡
1F1B 缓解了显存问题,但气泡还在。于是又出现了交错式流水线:每个 stage 不再只负责一段,而是把整个模型切成更细的多段,让每张卡负责多段。比如 4 张卡,把 48 层切成 8 段,每张卡拿 2 段。这样流水线里“在途”的干活单元变多了,装配线上同时流动的车更多,气泡被进一步压缩。
好处是效率更高,坏处是切段越多,GPU 之间来回传递激活值的次数越多,通信开销上升。而且切得太碎会让每个计算块变小,GPU 的算力不一定能充分用起来。所以交错式的切分数是一个折中,并不是越多越好。我的建议是:先用常规切分跑通,再根据实测利用率决定要不要上交错式。
3.4 气泡占比的估算公式
气泡占比可以用一个简单的公式做预估。假设 stage 数量为 P,micro-batch 数量为 M,经典调度下:
气泡占比 ≈ (P - 1) / (M + P - 1)
手动算一遍就清楚了:
| P(stage 数) | M(微批数) | 理论气泡占比 |
|---|---|---|
| 2 | 4 | 20% |
| 4 | 8 | 27.3% |
| 4 | 32 | 8.6% |
| 8 | 32 | 17.9% |
可以看到,M 越大气泡越小,但收益递减。交错式调度相当于给分母乘上一个约等于每卡段数的因子,气泡进一步压低,同时通信成本上升。这个公式最大的用处不是精确定量,而是建立直觉:当你发现 GPU 利用率上不去时,先算算当前 M 对应的气泡理论值。如果理论值已经很低,那瓶颈就不在气泡,而在通信或者负载不均,排查方向完全不一样。
4. 实操配置要点与参数选择
4.1 切多少个 stage:先按显存缺口算
实践中第一个要定的参数是 stage 数量 P,它基本等于你打算用多少张卡来跑纯流水线。P 不是越大越好:stage 越多,单卡显存压力越小,但气泡变大、通信次数变多。而且每个 stage 至少要有几层才有意义,层太少,GPU 之间传激活值的开销会吃掉计算收益。
我的建议是从显存缺口算起:先统计单卡能放下多少层,再按“总层数 ÷ 单卡可容纳层数”向上取整得到 P。比如每张卡能装 8 层,模型有 32 层,那 P 至少取 4。之后用监控看实际利用率和显存余量,再决定要不要增加。直接上十几二十个 stage 通常不是最优解,除非模型真的巨大且通信条件极好。
4.2 微批数量 M:经验起点与调整方向
M 的经验起点是至少 4 倍于 P,再根据显存余量往上加。M 太小,气泡占比高,GPU 闲着;M 太大,激活显存暴涨。如果你用的是 1F1B 这类省显存的调度,M 的上限会比经典方案宽松不少,所以选调度模式要结合 M 一起想。
不管用什么方案,记住一个关键的恒等式:
全局 batch size = M × micro_batch_size × 数据并行度
也就是说,M 不只是调度参数,它直接影响你的有效 batch size 和学习率设置。改 M 之后要重新考虑学习率和 warmup 步数,否则收敛节奏会漂。很多人在流水线并行的坑里栽跟头,不是并行逻辑没搞懂,而是把这个恒等式忘了。
4.3 负载均衡的坑:头尾层不按常规出牌
很多人切模型时按层数均分,这是最容易踩的坑。一个 Transformer 模型并不是每层参数量都一样:embedding 层和最后的输出头往往特别大。如果简单地把 48 层平均切成 4 段,很可能第 1 段和第 4 段显存明显高于中间段,结果中间卡算完等着,两头还在忙,整个流水线被最慢的 stage 拖住。
因为整个流水线的速度等于最慢 stage 的速度,负载不均的损失是全局性的。我建议切分前先做一次简单的统计,用下面的伪代码把每层参数量打出来:
# 统计每个 Transformer 层的参数量,辅助负载均衡 for idx, layer in enumerate(model.layers): n = sum(p.numel() for p in layer.parameters()) print(f"layer {idx}: {n / 1e6:.1f}M params")统计完再分配层数:embedding 层和输出头所在的 stage 少放几层,中间层多的 stage 多放几层。宁可多花十分钟统计,也别让某一个 GPU 成为瓶颈。
4.4 与数据并行、张量并行怎么组合
流水线并行一般不单独使用。常见的组合是:先把 GPU 分组,组内各卡承担不同 stage,形成一条流水线;然后让多条流水线复制,每条流水线拿不同的 batch 做数据并行。这样显存问题和吞吐问题同时解决。
张量并行可以放进每个 stage 内部:当某一段的单个层还是放不下时,在 stage 内再用张量并行把矩阵切开。三个维度各有分工:数据并行管吞吐,流水线并行管层间显存,张量并行管层内显存。配置时先决定组的大小和数量,再决定每段层数和微批数,顺序别搞反。还要留意通信拓扑:尽量让流水线相邻 stage 落在同一机架内,跨机架的段数越少,通信受带宽抖动的影响越小。
5. 常见问题与排查实录
5.1 利用率低:先分清气泡、负载和通信
症状是直接观察到的:用监控工具看 GPU 利用率,出现周期性的大段空闲,或者整体利用率普遍不到 50%。先按公式估算气泡理论值。如果理论气泡就很高,优先提高 M。如果 M 已经比较大但实际利用率还是低,那要考虑负载不均,或者通信在等带宽。
我实际调参时的顺序是:先确认 M ≥ 4P;再用性能剖析工具看每个 stage 的实际计算时长;若某 stage 明显偏长,调整层分配;一切正常但还是闲,才怀疑通信问题。不要一上来就盲目加大 M,防止激活显存先炸。
5.2 显存 OOM:三个高频原因
OOM 常见原因有三个。一是 M 太大,激活值缓存超出预算,改用 1F1B 或者降低 M。二是头尾 stage 因 embedding 和输出头占满显存,调整层分配。三是激活值参数和调度方式不匹配,前向溢出导致某个 stage 临时缓存偏多。
处理激活值还有一招:开启激活检查点(activation checkpointing),用重计算换显存,通常能让显存占用下降一大截。但要注意它会让前向变慢,所以只在 OOM 压力大时开,并配合调整 M 一起做。开了之后一定要重新测一次端到端吞吐,别只盯显存。
5.3 训练卡死:从最小复现开始排查
卡死是分布式训练里最让人头疼的问题。常见原因有两个:收发不匹配——某个 stage 在等一批数据,但上游发送的微批数量、顺序和你配置的对不上;通信组配置错误——数据发到了错误的 rank。另一个原因是前向反向交错时,某一步的条件分支没走对,导致一个 stage 永远在等一个不会来的 tensor。
排查思路:先用最小配置(2 个 stage、1 个 micro-batch)跑通;在阶段入口打日志,核对每个 rank 实际收到的 tensor shape 和顺序;再逐步加大 M。分布式调试的黄金法则是缩小复现:单机能复现的不要上多机,单卡能复现的不要上多卡。
5.4 收敛异常:有效 batch 与权重版本
流水线并行改变了权重更新和梯度的时间关系,收敛问题常在。常见表现是 loss 在某个点突然暴涨,或者训练曲线比单卡更抖。原因往往和两个因素有关:一是 M 变大导致有效 batch 变大,学习率没有相应调整;二是调度带来的权重版本滞后问题,更新时用的梯度和当前权重的匹配关系变差。
解决手段:提高前期的 warmup,把学习率峰值适度降低;优先使用带版本对齐的同步更新;必要时在同一组参数下多跑几步看趋势,不要因为一两步的抖动就改参数。重点是把流水线并行的“等效 batch 变化”这个因素纳入调参考虑。
5.5 一套实用的排查顺序
最后总结一套我自己惯用的排查顺序:
- 先跑单卡小模型,确认模型本身的正确性。
- 用 2 个 stage、1 个 micro-batch 跑通流水线链路,确认收发和切层正确。
- 逐步增加 M,观察利用率和显存变化,找到拐点。
- 再考虑交错式切分和激活检查点,把效率和显存调到可接受范围。
- 最后才动数据并行度、学习率这类外层参数。
这套顺序的原则是:每次只改一个变量,确保每一步的异常都能定位到具体原因。常见问题可以先对照下面的速查表定位:
| 症状 | 常见原因 | 处理方向 |
|---|---|---|
| GPU 周期性空闲、利用率低 | 微批数太少,气泡占比高 | 提高 M,确认 M ≥ 4P |
| 训练慢但利用率看着还行 | 某 stage 负载过重,成为瓶颈 | 重新分配层数,平衡每段参数量 |
| 显存 OOM 频繁 | M 过大 / 调度模式吃显存 / 头尾层太多 | 降低 M,换 1F1B,开激活检查点 |
| 训练中途卡死 | 收发不匹配、通信组配置错 | 缩小复现规模,打日志核对收发 |
| loss 异常抖动或暴涨 | 有效 batch 变化、权重版本滞后 | 调整学习率与 warmup,使用同步更新 |
6. 一点个人经验
把流水线并行真正跑顺,我发现最值钱的经验反而是“别把它想得太高级”。它本质上就是装配线。装配线要顺畅,依赖的是工件大小均匀、每道工序耗时接近、物料供给不断档。对应到训练里就是负载均衡、M 够大、通信不堵。很多优化方案其实都是从这个朴素道理推出来的。
我个人的习惯是:新模型结构先在单卡上反复验证前向反向一致,再用两条流水线做冒烟测试。这个流程多花不了多少时间,却能把后面排查问题的周期大幅缩短。另一个习惯是动手配置前,先手动画一遍调度图,标清楚每个 stage 在哪个时间点收谁的数据、发谁的数据。这个系列叫“图解”,就是因为我觉得把调度图画出来,很多模棱两可的设计选择会立刻变得清晰。分布式训练的水很深,如果你也有流水线并行的踩坑经历,欢迎一起交流。