4. 流水线调度:1F1B调度、交错式1F1B调度、流水线气泡分析、调度优化
好,咱们进入流水线调度的核心环节。
前面我们聊了怎么切分模型、怎么分配显存。但说实话,光分好还不够。你想想看,GPU之间怎么协作?谁先算?谁等谁?这就像工厂里的流水线,调度不好,一半工人在闲着,气泡就出来了。
我个人习惯把流水线调度分成两个维度来看:调度策略和气泡优化。调度策略决定了GPU干活儿的节奏,气泡分析则帮我们找到哪里在浪费算力。
4.1 1F1B调度:最朴素的流水线
1F1B,全称是 One Forward One Backward。说白了就是一个前向计算,紧跟一个反向计算。这是最基础的流水线调度方式。
它的核心逻辑很简单:每个GPU拿到一个micro-batch,先做前向(Forward),存下中间激活值;然后做反向(Backward),计算梯度。做完一个,再接下一个。
为什么叫1F1B?
因为每个GPU在任何时刻,最多只维护一个前向和一个反向的计算状态。显存占用相对可控。
我在项目中遇到过一个问题:刚开始用1F1B时,总觉得显存占用比预期高。后来一查,发现是前向计算完的激活值没及时释放。嗯,这里要注意——反向计算开始前,前向的激活值必须保留,但反向一启动,就可以逐步释放了。
1F1B的调度时序大概长这样(我画了个简图):
看到没?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结果逐步优化。