4. 通信与计算流水线:流水线并行原理、微批次调度、气泡(Bubble)分析与优化
各位同学,今天我们来聊聊多卡训练里最核心的一个话题——通信与计算的流水线。说白了,就是怎么让GPU别闲着,一边算一边传数据。
我刚开始做分布式训练那会儿,经常遇到一个尴尬的情况:8张卡跑起来,一看GPU利用率,好家伙,平均不到30%。钱花了不少,性能没提上去。后来我才意识到,问题就出在通信和计算没有好好“配合”。
4.1 流水线并行:把模型切成几段
流水线并行,思路其实很简单。你想想看,一个大模型如果放不进一张卡,那就把它切成几段,每张卡负责一段。
比如一个12层的Transformer,我把它切成3段,每段4层。卡0负责第1-4层,卡1负责第5-8层,卡2负责第9-12层。数据从卡0流到卡1,再到卡2,最后输出。
这样做的好处很明显:每张卡只存一部分参数,显存压力小了很多。但坏处也来了——卡与卡之间有依赖关系。卡1必须等卡0算完才能开始,卡2必须等卡1算完。这就产生了所谓的“气泡”。
核心概念:流水线并行中,每个设备只负责模型的一部分层。前向传播时数据依次流过各设备,反向传播时梯度依次流回。
4.2 微批次调度:把数据再切一刀
怎么减少气泡?一个经典的做法就是微批次调度。
我不把整个batch一次性塞进去,而是把batch拆成更小的micro-batch。比如一个batch有32个样本,我拆成4个micro-batch,每个8个样本。
这样,卡0算完第一个micro-batch后,立刻传给卡1,同时自己开始算第二个micro-batch。卡1收到第一个micro-batch后开始算,同时卡0在算第二个。你看,重叠就产生了。
我个人习惯用4-8个micro-batch。太少的话,气泡还是大;太多的话,调度开销会上去。这个需要根据你的模型大小和卡间带宽来调。
经验之谈:微批次数量一般设为流水线深度的2-4倍。比如3卡流水线,用6-12个micro-batch效果不错。我在项目中试过,这样能把气泡从40%压到15%左右。
4.3 气泡分析:到底浪费了多少算力?
气泡,英文叫Bubble。说白了就是GPU在干等的时间。
为什么会这样?我们来算一笔账。
假设有3张卡,流水线深度为3。没有微批次的情况下,整个过程是这样的:
- 卡0算前向,卡1、卡2闲着
- 卡1算前向,卡0、卡2闲着
- 卡2算前向,卡0、卡1闲着
- 然后反向传播,又是类似的等待
你算算,理想情况下利用率只有1/3。这就是最原始的气泡。
引入微批次后,情况好多了。但气泡依然存在,只是变小了。我给大家一个公式:
气泡率 ≈ (流水线深度 - 1) / (微批次数量 + 流水线深度 - 1)
举个例子:3卡流水线,4个micro-batch,气泡率 ≈ (3-1)/(4+3-1) = 2/6 ≈ 33%。如果micro-batch增加到8个,气泡率 ≈ 2/(8+2) = 20%。
嗯,这里要注意:micro-batch不是越多越好。每个micro-batch都有调度开销,而且梯度累积也会增加显存压力。
避坑指南:我曾经在一个项目里把micro-batch设成了32个,结果调度开销占了总时间的15%,得不偿失。后来我改成8个,性能反而提升了。所以,别盲目追求数量。
4.4 优化策略:让气泡再小一点
除了微批次调度,还有几个优化手段,我给大家总结一下:
4.4.1 1F1B调度
1F1B,就是One Forward One Backward。前向算一个micro-batch,立刻反向算一个。这样能更早地释放显存,减少峰值显存占用。
我比较推荐这个策略。它比传统的全前向再全反向要高效,显存能省下20%-30%。
4.4.2 通信与计算重叠
这个技巧很实用。在卡与卡之间传输数据时,让GPU同时做计算。
比如卡0算完第5层,要把结果传给卡1。这时候卡0可以继续算第6层,同时后台把第5层的结果发出去。只要通信时间小于计算时间,就能完全隐藏通信开销。
我建议用CUDA的异步传输API,配合stream来实现。代码大概长这样:
// 创建两个stream
cudaStream_t compute_stream, comm_stream;
cudaStreamCreate(&compute_stream);
cudaStreamCreate(&comm_stream);
// 在comm_stream上异步传输
cudaMemcpyAsync(dst, src, size, cudaMemcpyDeviceToDevice, comm_stream);
// 同时在compute_stream上继续计算
kernel_forward<<<grid, block, 0, compute_stream>>>(...);
// 同步等待
cudaStreamSynchronize(comm_stream);
cudaStreamSynchronize(compute_stream);
4.4.3 梯度累积与流水线深度匹配
这个点容易被忽略。梯度累积步数最好和流水线深度匹配。比如3卡流水线,梯度累积步数设为3的倍数,这样能减少同步次数。
| 流水线深度 | 推荐微批次数量 | 预期气泡率 |
|---|---|---|
| 2 | 4-8 | 10%-20% |
| 4 | 8-16 | 15%-25% |
| 8 | 16-32 | 20%-30% |
这张表是我自己总结的,不同模型可能略有差异,但大致范围是准的。
4.5 核心逻辑图
下面我用一张图来展示流水线并行的核心逻辑,包括微批次调度和气泡的产生:
这张图里,绿色是前向计算,红色是反向计算,灰色就是气泡。你看,卡1和卡2在刚开始时都有气泡,但随着调度进行,气泡逐渐被填满。这就是微批次调度的效果。
4.6 实战建议
最后,我给大家几个实战建议:
- 先测通信带宽:用nccl-tests跑一下,看看卡间带宽到底多少。我遇到过有人用PCIe 3.0 x16跑大模型,结果通信成了瓶颈。
- 微批次数量从4开始试:别一上来就设32,先试4、8、16,看哪个效果最好。
- 监控GPU利用率:用nvidia-smi或者Nsight Systems看看GPU是不是一直在干活。如果利用率低于60%,说明气泡太大了。
- 考虑梯度压缩:如果通信确实是瓶颈,可以试试梯度压缩,比如FP16压缩或者TopK稀疏化。我有个项目用了FP16压缩,通信量直接减半。
总结一下:流水线并行的核心就是让通信和计算重叠,减少气泡。微批次调度是最常用的手段,配合1F1B和异步传输,能把气泡压到20%以下。记住,没有银弹,每个项目都需要调参。
好了,这一章就到这里。希望大家在实际项目中能少踩坑,多出活。