流水线并行原理:模型切分策略与调度机制

大家好,我是你们的老朋友。今天我们来聊聊流水线并行中最核心的几个概念。说实话,我刚开始接触分布式训练时,觉得流水线并行不就是把模型切成几段嘛,有什么难的?结果第一次上手就踩了不少坑。嗯,今天我把这些经验都整理出来,希望能帮你少走弯路。

模型切分策略:按层切还是按算子切?

模型切分,说白了就是决定「从哪里下刀」。我个人习惯把切分策略分成两种:按层切分和按算子切分。

按层切分(Layer-wise Partitioning)

这是最直观的方式。比如一个12层的Transformer,切成3段,每段4层,分别放到3张GPU上。我在项目中遇到过一个大模型,就是按层切分的。优点是实现简单,代码改动少。但缺点也很明显——负载不均衡。

为什么会这样?你想想看,有些层计算量大(比如注意力层),有些层计算量小(比如LayerNorm)。如果切得不均匀,就会出现「有的GPU忙死,有的GPU闲死」的情况。

核心要点:按层切分时,建议先做一次profiling,摸清每层的计算耗时和显存占用,再决定切分点。

按算子切分(Operator-wise Partitioning)

这种策略更精细。不是按层切,而是按算子切。比如把一个Transformer层里的Self-Attention和FFN拆开,放到不同的GPU上。我在做GPT-3级别的模型时试过这种方式,负载均衡效果更好,但通信开销也更大。

举个例子:

# 按层切分示意
stage_0: layers[0:4]  → GPU 0
stage_1: layers[4:8]  → GPU 1
stage_2: layers[8:12] → GPU 2

# 按算子切分示意
stage_0: embedding + self_attention → GPU 0
stage_1: ffn + layer_norm          → GPU 1
stage_2: output_projection         → GPU 2
我的建议:对于新手,先从按层切分入手。等你对模型的计算图有了感觉,再尝试按算子切分。我曾经在项目里直接上按算子切分,结果调试通信逻辑花了一周...

微批次(Micro-batch)调度:让流水线转起来

模型切好了,接下来就是怎么调度数据。这里引入一个关键概念——微批次(Micro-batch)。

假设你的全局batch size是64,你可以把它拆成4个micro-batch,每个16条数据。为什么要这么做?说白了就是为了减少气泡。

我画了一张图,帮你理解微批次调度的核心逻辑:

微批次调度示意图(4个micro-batch,3个stage) 时间 → GPU 0 (Stage 0) GPU 1 (Stage 1) GPU 2 (Stage 2) F1 F2 F3 F4 F1 F2 F3 F4 F1 F2 F3 F4 B1 B2 B3 B4 B1 B2 B3 B4 B1 B2 B3 B4 空闲 空闲 空闲(气泡) 空闲 前向传播 反向传播 气泡(空闲时间)

看到图里的灰色区域了吗?那就是气泡。在流水线启动和收尾阶段,总有一些GPU在等待。微批次越多,气泡占比就越小,但也不是越多越好——微批次太多会增加通信开销。

经验值:我一般把micro-batch数量设为GPU数量的2-4倍。比如4张GPU,用8-16个micro-batch,效果通常不错。

前向与反向传播的流水线气泡

气泡(Bubble)是流水线并行中绕不开的话题。说白了,就是GPU在空转等待。我刚开始做流水线并行时,看到GPU利用率只有60%,急得不行。后来才明白,这是流水线固有的问题。

气泡产生的原因很简单:

  • 启动阶段:前面的GPU在计算,后面的GPU在等待
  • 收尾阶段:后面的GPU在计算,前面的GPU在等待
  • 反向传播依赖:反向传播必须等前向传播完成后才能开始

怎么减少气泡?我总结了三个方向:

  1. 增加微批次数量——让流水线更「满」
  2. 均衡各stage的计算量——避免某个stage成为瓶颈
  3. 使用1F1B调度策略——这个下面会讲

GPipe vs PipeDream:两种主流方案对比

说到流水线并行,就绕不开两个经典方案:GPipe和PipeDream。我在实际项目中都用过,各有千秋。

对比维度 GPipe PipeDream
调度策略 同步流水线:所有micro-batch前向完成后,统一开始反向 异步流水线:前向和反向可以交错执行(1F1B策略)
气泡大小 较大(O(P-1) / O(M) 比例,P=stage数,M=micro-batch数) 较小(通过1F1B策略减少气泡)
显存占用 较高(需要保存所有micro-batch的中间激活值) 较低(只保存当前正在计算的激活值)
实现复杂度 简单,容易上手 较复杂,需要处理权重版本问题
适用场景 小规模流水线(2-4个stage) 大规模流水线(8+个stage)
避坑指南:我曾经在一个8卡的项目里用了GPipe,结果显存直接爆了。后来换成PipeDream的1F1B策略,显存占用降了40%。所以如果你的stage数比较多,建议优先考虑PipeDream。

GPipe的同步调度

GPipe的思路很直接:先把所有micro-batch的前向传播跑完,再统一跑反向传播。优点是实现简单,梯度更新是同步的,模型收敛稳定。缺点就是气泡大,显存占用高。

# GPipe调度示意(伪代码)
for micro_batch in micro_batches:
    forward(micro_batch)  # 所有stage依次前向

for micro_batch in micro_batches:
    backward(micro_batch) # 所有stage依次反向

optimizer.step()          # 统一更新参数

PipeDream的1F1B调度

PipeDream用了更聪明的策略——1F1B(一个前向,一个反向)。什么意思呢?就是每个GPU在完成一个micro-batch的前向后,立刻开始另一个micro-batch的反向。这样流水线更紧凑,气泡更小。

# PipeDream 1F1B调度示意(伪代码)
# 假设有4个micro-batch,3个stage
for i in range(num_micro_batches):
    forward(i)  # 启动阶段

# 1F1B核心循环
for i in range(num_micro_batches):
    backward(i)  # 反向一个
    forward(num_micro_batches + i)  # 前向一个(如果有剩余)

# 收尾阶段
for i in range(num_micro_batches):
    backward(num_micro_batches + i)
核心区别:GPipe是「先全部前向,再全部反向」;PipeDream是「前向和反向交错进行」。PipeDream的显存效率更高,但需要处理权重版本问题——因为前向和反向可能使用不同版本的权重。

我的选择建议

说了这么多,到底该用哪个?我个人习惯这样选:

  • stage数 ≤ 4:用GPipe,简单稳定,调试方便
  • stage数 ≥ 8:用PipeDream,显存效率高,气泡小
  • 显存紧张:优先PipeDream,能省不少显存
  • 追求收敛稳定性:GPipe更稳妥,毕竟是同步更新

嗯,今天的内容就到这里。流水线并行看起来简单,但真正调优起来,细节还是挺多的。希望这些经验能帮你少踩几个坑。

最后提醒一句:不管用哪种方案,一定要先做profiling。我曾经跳过这一步直接上生产,结果发现某个stage的计算量是其他stage的3倍...那叫一个惨。

想要更多资料,联系可可