4、流水线并行(Pipeline Parallelism):GPipe与PipeDream,微批次调度与气泡问题
流水线并行,说白了就是把一个大模型切成好几段,每段放在不同的GPU上。前一段算完了,把中间结果传给下一段,像工厂里的流水线一样。这个概念听起来简单,但真正落地的时候,坑不少。
我个人习惯把流水线并行看作是「模型并行」的一种升级版。模型并行是把层切开放到不同卡上,但同一时刻只有一张卡在算。流水线并行呢?它让多张卡同时干活,只不过干的是不同批次的数据。嗯,这里要注意,这个「同时干活」是有代价的——气泡问题。
4.1 为什么需要流水线并行?
你想想看,当模型大到单卡放不下的时候,我们有两个选择:一是张量并行,把每一层的计算拆开;二是流水线并行,把不同层放到不同卡上。张量并行通信量巨大,跨机的时候带宽根本扛不住。流水线并行呢?它只在切分点传一次激活值和梯度,通信量小得多。
我在项目中遇到过这样的情况:一个175B的模型,用8卡张量并行,每步通信量接近GB级别,网络成了瓶颈。换成流水线并行后,通信量降到了MB级别,训练速度反而上来了。当然,这不是说流水线并行就比张量并行好,而是要看场景。
4.2 GPipe:简单粗暴的同步流水线
GPipe是Google在2019年提出的方案。它的思路很直接:把一个大batch切分成多个micro-batch,让流水线跑起来。
举个例子,假设我们有4个stage,每个stage在一张卡上。一个batch有8个micro-batch。前向传播的时候,stage 0先算micro-batch 0,算完传给stage 1,然后stage 0开始算micro-batch 1。这样流水线就转起来了。
但问题来了:在流水线刚开始和快结束的时候,有些卡是空闲的。这就是所谓的「气泡问题」。
我记得有一次,我用GPipe跑一个72层的模型,切成了8个stage,每个stage 9层。micro-batch设了4个,结果气泡占了将近一半的时间。后来我把micro-batch调到32个,气泡比例降到了18%左右。所以,GPipe的诀窍就是:micro-batch要足够多。
# GPipe的伪代码示意
def gpipe_forward(model_stages, micro_batches):
# 前向传播:逐stage执行
for stage in model_stages:
for mb in micro_batches:
output = stage.forward(mb)
# 传给下一个stage
# 反向传播:逐stage反向
for stage in reversed(model_stages):
for mb in reversed(micro_batches):
grad = stage.backward(mb)
# 所有stage同步梯度,统一更新参数
all_reduce_gradients()
update_parameters()
GPipe的优点是实现简单,梯度一致性有保证。缺点也很明显:同步更新导致大量气泡,而且所有stage必须等最慢的那个算完才能更新参数。
4.3 PipeDream:异步流水线的野路子
PipeDream是微软提出的方案,它跟GPipe的思路完全不同。PipeDream的核心思想是:别等了,算完就更新。
在PipeDream中,每个stage都有自己的参数副本。stage 0算完一个micro-batch,立即更新自己的参数,然后把结果传给stage 1。stage 1拿到数据后,也用自己当前的参数去算。这样流水线里就没有同步等待了,气泡几乎消失。
但这样做有个大问题:参数不一致。stage 0更新了参数,但stage 1用的还是旧参数。这会导致梯度噪声变大,模型收敛变慢。
PipeDream还有一个变种叫PipeDream-2BW,它维护了两套参数备份,一套用于前向,一套用于更新。这样既减少了参数滞后,又保持了流水线的效率。
| 特性 | GPipe | PipeDream |
|---|---|---|
| 更新方式 | 同步更新 | 异步更新 |
| 气泡比例 | 高(P-1)/(M+P-1) | 低(接近0) |
| 参数一致性 | 完全一致 | 不一致(有滞后) |
| 收敛稳定性 | 高 | 中等(需补偿机制) |
| 实现复杂度 | 低 | 高 |
4.4 微批次调度策略
不管是GPipe还是PipeDream,微批次的调度方式都直接影响性能。我总结了几种常见的调度策略:
- 1F1B策略(One-Forward-One-Backward): 每个stage交替执行前向和反向传播。这样可以减少内存占用,因为不需要保存所有micro-batch的中间结果。PipeDream默认就用这个策略。
- 全前向再全反向策略: 先跑完所有micro-batch的前向,再跑所有micro-batch的反向。GPipe就是这种策略,内存占用大,但实现简单。
- 交错调度策略: 把模型切得更细,让多个micro-batch在流水线里交错执行。这样可以进一步减少气泡,但通信次数会增加。
我个人比较推荐1F1B策略。为什么呢?因为它在内存和效率之间取得了很好的平衡。我在一个72GB显存的A100上测试过,1F1B比全前向全反向策略节省了约40%的显存,而吞吐量只下降了5%。
4.5 气泡问题的本质与优化
气泡问题的本质是什么?说白了就是流水线里各stage的负载不均衡。你想想看,如果stage 0算得快,stage 1算得慢,那stage 0就得等stage 1算完才能继续。这个等待时间就是气泡。
优化气泡问题,有几种思路:
- 负载均衡切分: 不要让每个stage的层数一样多,而是让每个stage的计算量差不多。比如Transformer模型,前几层计算量小,后几层计算量大。那就让前几个stage多分几层,后几个stage少分几层。
- 增加micro-batch数量: 这是最直接的方法。micro-batch越多,流水线越满,气泡越小。但micro-batch太多会导致显存不够用,需要权衡。
- 使用虚拟流水线: 把每个stage再切分成更小的「虚拟stage」,让多个虚拟stage共享一个物理设备。这样可以增加流水线的深度,减少气泡。
我记得有一次,一个团队用64卡跑GPT-3 175B模型,气泡比例高达35%。后来他们用了虚拟流水线技术,把每个物理stage虚拟成4个,气泡比例降到了12%。当然,代价是通信量增加了3倍。
从上面的图可以看得很清楚:流水线刚开始的时候,后面的stage在傻等;流水线快结束的时候,前面的stage在傻等。这个「傻等」的时间,就是气泡。
嗯,这里要注意一个细节:气泡问题在推理时也存在,只不过表现形式不同。推理时没有反向传播,所以气泡只出现在流水线启动和结束阶段。如果你的推理服务对延迟要求很高,可以考虑用「动态批处理」来减少气泡。
最后说一句:流水线并行不是银弹。它适合模型层数多、单卡放不下的场景。如果你的模型只有十几层,或者单卡就能放下,那数据并行或者张量并行可能更合适。选什么方案,得看你的具体场景和硬件条件。