4. 流水线调度:1F1B调度、交错式1F1B调度、流水线气泡分析、调度优化

好,咱们进入流水线调度的核心环节。

前面我们聊了怎么切分模型、怎么分配显存。但说实话,光分好还不够。你想想看,GPU之间怎么协作?谁先算?谁等谁?这就像工厂里的流水线,调度不好,一半工人在闲着,气泡就出来了。

我个人习惯把流水线调度分成两个维度来看:调度策略和气泡优化。调度策略决定了GPU干活儿的节奏,气泡分析则帮我们找到哪里在浪费算力。

4.1 1F1B调度:最朴素的流水线

1F1B,全称是 One Forward One Backward。说白了就是一个前向计算,紧跟一个反向计算。这是最基础的流水线调度方式。

它的核心逻辑很简单:每个GPU拿到一个micro-batch,先做前向(Forward),存下中间激活值;然后做反向(Backward),计算梯度。做完一个,再接下一个。

为什么叫1F1B?

因为每个GPU在任何时刻,最多只维护一个前向和一个反向的计算状态。显存占用相对可控。

我在项目中遇到过一个问题:刚开始用1F1B时,总觉得显存占用比预期高。后来一查,发现是前向计算完的激活值没及时释放。嗯,这里要注意——反向计算开始前,前向的激活值必须保留,但反向一启动,就可以逐步释放了。

1F1B的调度时序大概长这样(我画了个简图):

1F1B 调度时序图(4个GPU,16个micro-batch) 时间→ GPU 0 F1 B1 F2 B2 F3 B3 F4 B4 GPU 1 F1 B1 F2 B2 F3 B3 F4 B4 GPU 2 F1 B1 F2 B2 F3 B3 F4 B4 GPU 3 F1 B1 F2 B2 F3 B3 F4 B4 前向 (F) 反向 (B) 气泡 气泡 气泡

看到没?GPU 0最先启动,GPU 3最后启动。前面几个时间步里,后面的GPU一直在等——这就是流水线气泡。

4.2 交错式1F1B调度

1F1B有个明显的问题:气泡太大。尤其是GPU数量多的时候,前几个GPU都算完一轮了,后面的还没开始。

交错式1F1B(Interleaved 1F1B)就是为了解决这个问题的。它的思路是:把每个micro-batch再切小,让多个micro-batch交错执行。

我的经验:交错式1F1B在8卡以上的场景效果特别明显。我曾经在一个16卡集群上测试,气泡从原来的30%降到了12%左右。

具体怎么做呢?假设我们有4个GPU,每个GPU负责2个stage。传统1F1B是GPU 0算完F1才给GPU 1。交错式则是:GPU 0算完F1的一小部分,就传给GPU 1,然后GPU 0接着算F2的一小部分。

伪代码大概长这样:

# 交错式1F1B调度伪代码
for step in range(total_steps):
    for gpu_id in range(num_gpus):
        # 每个GPU交错执行多个micro-batch
        for mb in range(chunks_per_gpu):
            if can_forward(gpu_id, mb):
                forward(gpu_id, mb)
            if can_backward(gpu_id, mb):
                backward(gpu_id, mb)

说白了,就是把一个大任务拆成更小的片,让流水线更细粒度地并行。代价是什么?通信次数增加了。每个小片都要传一次激活值和梯度。

4.3 流水线气泡分析

气泡(Bubble)是流水线并行里最让人头疼的东西。我刚开始做分布式训练时,总觉得GPU利用率上不去,一查全是气泡在作怪。

气泡的产生原因很简单:依赖关系导致的等待。后面的GPU必须等前面的GPU算完才能开始。

气泡大小的计算公式:

气泡比例 = (p - 1) / (m + p - 1)

其中:
  p = 流水线深度(GPU数量)
  m = micro-batch数量

举个例子:

GPU数量 (p) micro-batch数量 (m) 气泡比例
4 8 27.3%
4 16 15.8%
8 16 30.4%
8 32 17.9%

看到规律了吗?micro-batch越多,气泡越小。GPU越多,气泡越大。所以实践中,我们通常会尽量增大micro-batch数量来压制气泡。

注意:micro-batch不是越大越好。受显存限制,每个GPU能同时处理的micro-batch数量是有限的。我曾经为了压气泡,把micro-batch设得太大,结果OOM了……

4.4 调度优化

好,聊完了问题,咱们看看怎么优化。我总结了几个实战中比较有效的优化手段:

4.4.1 梯度累积 + 流水线并行

这个组合很常见。梯度累积本身就能增加有效的batch size,同时也能增加micro-batch数量,间接减小气泡。

# 梯度累积 + 流水线并行
accumulation_steps = 4
for step in range(total_steps):
    for acc_step in range(accumulation_steps):
        # 正常执行1F1B调度
        forward_and_backward()
    # 累积完梯度再更新参数
    optimizer.step()

4.4.2 异步通信与计算重叠

通信是流水线并行的一大瓶颈。我建议把通信和计算重叠起来。比如,在反向计算的同时,把上一层的梯度异步发送出去。

具体做法:

  • 使用CUDA的异步流(Stream)
  • 把通信操作放到单独的流里
  • 计算和通信用不同流,让它们并行执行

我的经验:在NVIDIA A100集群上,通信和计算重叠能带来15%-20%的性能提升。但要注意,重叠的程度取决于模型的计算密度和通信带宽。

4.4.3 动态micro-batch调度

这个比较高级。传统1F1B是固定顺序的,但实际训练中,不同micro-batch的计算时间可能不一样(比如变长输入)。

动态调度就是根据每个micro-batch的实际计算时间,动态调整执行顺序。谁先算完谁先传,减少空闲等待。

实现起来比较复杂,我建议只在以下场景使用:

  • 输入长度变化很大(如NLP中的变长句子)
  • GPU数量超过16卡
  • 对吞吐量有极致要求

4.4.4 虚拟流水线(Virtual Pipeline)

这个思路很有意思。把每个GPU上的计算再切分成更小的虚拟stage,让流水线更细粒度。

比如,一个GPU负责2个stage,虚拟流水线可以把它切成4个虚拟stage。这样流水线深度从4变成8,但每个stage的计算量减半。

好处是气泡更小,坏处是通信次数翻倍。适合计算密集型模型,不适合通信密集型模型。

避坑指南:我曾经在一个小模型上试虚拟流水线,结果通信开销比计算还大,反而变慢了。所以一定要先做profiling,看看你的模型是计算密集还是通信密集。

好了,关于流水线调度,核心就是这些。记住一句话:调度策略决定了上限,气泡优化决定了你能多接近这个上限。实际项目中,我建议先用1F1B跑通,再根据profiling结果逐步优化。