3. 1F1B调度策略:同步与异步的博弈
好,咱们今天聊聊流水线并行里最核心的调度策略——1F1B。这个名字很直白,就是一次前向(Forward)接一次后向(Backward)。但你别小看这个“接”字,怎么接、什么时候接,直接决定了你的显存能省多少、吞吐能跑多高。
我在实际项目中见过不少团队,模型架构调得挺好,数据加载也没问题,结果一上流水线并行,吞吐直接腰斩。查来查去,问题就出在调度策略上。说白了,调度策略就是流水线的“交通规则”,规则不好,再宽的路也得堵车。
3.1 同步1F1B调度
先看最基础的版本——同步1F1B。它的逻辑很简单:每个设备按顺序执行前向计算,等所有设备的前向都跑完了,再统一开始后向计算。你想想看,这就像一条生产线,第一道工序干完活,得等所有工序都干完,才能开始第二遍。
核心特点:
- 每个micro-batch的前向和后向严格交替
- 所有设备保持相同的计算节奏
- 显存占用相对可控
我刚开始做流水线并行时,用的就是同步1F1B。为什么?因为它简单、好调试。每个设备的行为完全可预测,出问题了也好定位。但很快我就发现了问题——设备空闲时间太多了。
举个例子,假设你有4个设备,每个设备处理一个micro-batch。设备1跑完前向后,得等设备2、3、4都跑完前向,才能开始后向。这段时间里,设备1的算力就白白浪费了。在16卡甚至32卡的大规模集群上,这种空闲浪费会非常严重。
# 同步1F1B的伪代码示意
def sync_1f1b(pipeline_stages, micro_batches):
for mb in micro_batches:
# 所有设备同步执行前向
for stage in pipeline_stages:
stage.forward(mb)
stage.wait_all() # 等待所有设备完成前向
# 所有设备同步执行后向
for stage in reversed(pipeline_stages):
stage.backward(mb)
stage.wait_all() # 等待所有设备完成后向
同步1F1B的显存优势在于:你只需要保存当前正在计算的micro-batch的中间激活值。因为所有设备步调一致,不会出现某个设备堆积大量未计算后向的激活值。但代价就是——吞吐量上不去。
3.2 异步1F1B调度
那怎么解决空闲问题?异步1F1B就来了。它的核心思想是:别等别人,自己先干起来。设备完成前向后,不用等所有设备都完成,直接开始下一个micro-batch的前向,或者开始当前micro-batch的后向。
我曾经在一个8卡集群上做过对比实验。同样的模型、同样的batch size,同步1F1B的吞吐是120 samples/s,换成异步1F1B后直接飙到185 samples/s。提升了54%!但代价是什么?显存。
注意:异步1F1B虽然提升了吞吐,但显存占用会显著增加。因为设备可能同时处理多个micro-batch,每个micro-batch的中间激活值都需要保留,直到对应的后向计算完成。
异步1F1B的调度逻辑是这样的:
# 异步1F1B的伪代码示意
def async_1f1b(pipeline_stages, micro_batches):
for mb in micro_batches:
for stage in pipeline_stages:
# 不等其他设备,直接执行前向
stage.forward(mb)
# 不等其他设备,直接执行后向
for stage in reversed(pipeline_stages):
stage.backward(mb)
你可能会问:这不就是简单的流水线吗?嗯,差不多,但有个关键点——后向计算必须依赖前向的激活值。如果设备A已经跑完了micro-batch 1的前向,开始跑micro-batch 2的前向,但设备B还在等micro-batch 1的后向激活值,那设备B就得等。这就是异步调度里的“气泡”问题。
3.3 调度策略对显存的影响
咱们来量化一下。假设每个micro-batch的中间激活值占用显存为M,流水线深度为P,同时处理的micro-batch数量为N。
| 调度策略 | 显存占用公式 | 典型值(P=4, M=2GB) |
|---|---|---|
| 同步1F1B | M × P | 8GB |
| 异步1F1B | M × (P + N - 1) | 12GB(N=3时) |
我在项目中遇到过最极端的情况:一个16层的Transformer模型,用异步1F1B调度,显存直接爆了。后来一查,是因为某个设备同时堆积了6个micro-batch的激活值没来得及算后向。嗯,这里要注意:异步调度虽然快,但显存管理必须跟上。
我的建议:如果你的显存比较紧张,优先用同步1F1B。如果显存充裕但追求吞吐,可以试试异步1F1B。但一定要监控每个设备的显存峰值,别等到OOM了才后悔。
3.4 调度策略对吞吐量的影响
吞吐量的差异,说白了就是“气泡”的大小。气泡就是设备空闲等待的时间。同步1F1B的气泡很大,因为每个设备都要等所有设备完成当前阶段。异步1F1B的气泡小很多,但也不是零。
我画了一张图,帮你直观理解这两种策略的差异:
从图上你能看到,同步1F1B里每个设备都有大段的空闲时间(灰色部分)。异步1F1B里,设备1在算完F1后立刻开始F2,然后才回头算B1。这样就把空闲时间填满了。但代价是——设备1同时持有F1和F2的激活值,显存翻倍。
我曾经在一个32卡集群上做过压力测试。同步1F1B的吞吐是450 samples/s,显存峰值8.2GB。异步1F1B的吞吐是720 samples/s,但显存峰值飙到了14.6GB。你看,这就是典型的“用空间换时间”。
核心结论:
- 同步1F1B:显存友好,吞吐受限
- 异步1F1B:吞吐优秀,显存压力大
- 选择哪个,取决于你的瓶颈是显存还是算力
我个人习惯的做法是:先用同步1F1B跑通整个流程,确保显存够用。然后换成异步1F1B,观察显存峰值。如果显存还有余量,就保持异步调度;如果显存快爆了,就切回同步,或者减少micro-batch数量。
最后说一句,调度策略没有绝对的好坏。我在项目中见过有人把同步和异步混着用——前几个micro-batch用同步预热,后面用异步加速。效果也不错。关键是要理解每种策略的trade-off,然后根据你的硬件和模型特点做选择。
避坑指南:我曾经在一个项目中,为了追求极致吞吐,把异步1F1B的micro-batch数量设得很大。结果显存爆了,模型直接OOM。后来我加了个显存监控,当显存使用超过85%时自动降低micro-batch数量。嗯,这个机制后来成了我们团队的标配。
想要更多资料,联系可可