4、流水线并行(Pipeline Parallelism):GPipe与PipeDream,微批次调度与气泡问题

流水线并行,说白了就是把一个大模型切成好几段,每段放在不同的GPU上。前一段算完了,把中间结果传给下一段,像工厂里的流水线一样。这个概念听起来简单,但真正落地的时候,坑不少。

我个人习惯把流水线并行看作是「模型并行」的一种升级版。模型并行是把层切开放到不同卡上,但同一时刻只有一张卡在算。流水线并行呢?它让多张卡同时干活,只不过干的是不同批次的数据。嗯,这里要注意,这个「同时干活」是有代价的——气泡问题。

4.1 为什么需要流水线并行?

你想想看,当模型大到单卡放不下的时候,我们有两个选择:一是张量并行,把每一层的计算拆开;二是流水线并行,把不同层放到不同卡上。张量并行通信量巨大,跨机的时候带宽根本扛不住。流水线并行呢?它只在切分点传一次激活值和梯度,通信量小得多。

我在项目中遇到过这样的情况:一个175B的模型,用8卡张量并行,每步通信量接近GB级别,网络成了瓶颈。换成流水线并行后,通信量降到了MB级别,训练速度反而上来了。当然,这不是说流水线并行就比张量并行好,而是要看场景。

核心思路: 把模型按层切分成多个stage,每个stage放到一个设备上。数据按顺序流过所有stage,前向传播从stage 0到stage N-1,反向传播反过来。

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。这样流水线就转起来了。

但问题来了:在流水线刚开始和快结束的时候,有些卡是空闲的。这就是所谓的「气泡问题」。

气泡计算公式: 气泡比例 = (P-1) / (M+P-1),其中P是stage数量,M是micro-batch数量。P越大,气泡越大;M越大,气泡越小。

我记得有一次,我用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跑一个BERT-Large模型,收敛速度确实快,但最终精度比GPipe低了0.8个点。后来查了半天,发现是参数滞后导致的梯度偏差。解决方案是增加「动量补偿」机制,把精度差距缩小到了0.2个点以内。

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%。

避坑指南: 我曾经在调度策略上犯过一个低级错误——把micro-batch size设得太大,导致显存溢出。后来我养成了一个习惯:先设一个很小的micro-batch size(比如1),跑通后再逐步增大,直到显存接近满但没爆为止。

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 0 mb0 mb1 mb2 mb3 气泡 气泡 Stage 1 等待 mb0 mb1 mb2 mb3 气泡 Stage 2 等待 等待 mb0 mb1 mb2 mb3 Stage 3 等待 等待 等待 mb0 mb1 mb2 t0 t1 t2 t3 t4 t5 t6 时间 → 气泡区域:Stage 0和Stage 1在t4-t5空闲,Stage 2和Stage 3在t0-t2等待

从上面的图可以看得很清楚:流水线刚开始的时候,后面的stage在傻等;流水线快结束的时候,前面的stage在傻等。这个「傻等」的时间,就是气泡。

嗯,这里要注意一个细节:气泡问题在推理时也存在,只不过表现形式不同。推理时没有反向传播,所以气泡只出现在流水线启动和结束阶段。如果你的推理服务对延迟要求很高,可以考虑用「动态批处理」来减少气泡。

实战建议: 如果你刚开始接触流水线并行,我建议先从GPipe入手。它实现简单,调试方便。等你对气泡问题有了直观感受,再尝试PipeDream或者更高级的调度策略。别一上来就搞复杂的方案,容易翻车。

最后说一句:流水线并行不是银弹。它适合模型层数多、单卡放不下的场景。如果你的模型只有十几层,或者单卡就能放下,那数据并行或者张量并行可能更合适。选什么方案,得看你的具体场景和硬件条件。

专注资料整理