生产级分布式训练实战 - 第4节

一、模型并行的动机:显存不够,怎么办?

做分布式训练,第一个绕不开的问题就是:模型太大,单卡装不下。

我刚开始接触大模型时,也天真地以为“多买几张卡就行了”。直到有一次,我把一个 10B 参数的模型往 A100 上塞,结果 OOM 了。嗯,那时候我才真正意识到——显存不是万能的,但没有显存是万万不能的。

你想想看,一个 175B 的 GPT-3,光参数就要 700GB 的显存(FP32)。就算你用混合精度,也得 350GB。而一张 A100 才 80GB。怎么办?

答案就是:把模型拆开,分到多张卡上。这就是模型并行(Model Parallelism)的核心思想。

核心动机:单卡显存不足,无法容纳整个模型。通过将模型切分到多张 GPU 上,每张卡只负责一部分计算。

二、朴素模型并行:Layer-wise 切分

最直观的做法是什么?按层切。

比如一个 12 层的 Transformer,我把前 4 层放到 GPU0,中间 4 层放到 GPU1,最后 4 层放到 GPU2。数据从 GPU0 开始,一层层往后传。这就是朴素模型并行,也叫 Layer-wise 切分。

# 伪代码示例:朴素模型并行
class ModelParallelTransformer(nn.Module):
    def __init__(self, num_layers=12, num_gpus=3):
        super().__init__()
        # 每张卡负责 4 层
        self.layers_0 = nn.Sequential(*[TransformerLayer() for _ in range(4)])
        self.layers_1 = nn.Sequential(*[TransformerLayer() for _ in range(4)])
        self.layers_2 = nn.Sequential(*[TransformerLayer() for _ in range(4)])
        
        # 分配到不同 GPU
        self.layers_0 = self.layers_0.to('cuda:0')
        self.layers_1 = self.layers_1.to('cuda:1')
        self.layers_2 = self.layers_2.to('cuda:2')
    
    def forward(self, x):
        # 数据串行经过每张卡
        x = x.to('cuda:0')
        x = self.layers_0(x)
        
        x = x.to('cuda:1')
        x = self.layers_1(x)
        
        x = x.to('cuda:2')
        x = self.layers_2(x)
        return x

这个方法简单吧?但问题也很明显——同一时刻只有一张卡在工作。其他卡都在闲着等数据。GPU 利用率极低。

我曾经踩过的坑:第一次用朴素模型并行跑 40 层 BERT,发现 8 张卡的总吞吐量还不如单卡。原因就是通信开销太大,而且 GPU 利用率不到 20%。说白了,这就是“伪并行”。

三、流水线并行:让所有卡都忙起来

既然朴素模型并行有“空闲等待”的问题,那怎么解决?把数据切成微批次(micro-batch),让流水线转起来。

这就是流水线并行(Pipeline Parallelism)的核心思想。我习惯把它比作工厂流水线——每个工位(GPU)处理完一个零件(micro-batch),就传给下一个工位,同时开始处理下一个零件。

3.1 GPipe:Google 的方案

GPipe 的思路很直接:把一个大 batch 切成多个 micro-batch,依次送入流水线。

举个例子:假设 batch size = 8,我切成 4 个 micro-batch,每个大小为 2。GPU0 处理完 micro-batch 0 后,立刻传给 GPU1,同时开始处理 micro-batch 1。这样所有 GPU 都能同时工作。

# GPipe 流水线调度示意
# 假设 4 个 micro-batch,3 个 GPU 阶段
# 时间步 1: GPU0 处理 mb0
# 时间步 2: GPU0 处理 mb1, GPU1 处理 mb0
# 时间步 3: GPU0 处理 mb2, GPU1 处理 mb1, GPU2 处理 mb0
# 时间步 4: GPU0 处理 mb3, GPU1 处理 mb2, GPU2 处理 mb1
# ... 以此类推

GPipe 的优点是实现简单,但有个问题:流水线气泡(pipeline bubble)。在流水线启动和排空阶段,部分 GPU 还是空闲的。

我的经验:micro-batch 数量越多,气泡占比越小。但 micro-batch 太多会增加通信次数。我一般建议 micro-batch 数量是 GPU 数量的 2-4 倍,这样能在利用率和通信开销之间取得平衡。

3.2 PipeDream:更激进的方案

PipeDream 和 GPipe 最大的区别是什么?它不等待所有 micro-batch 完成前向,就开始反向传播。

说白了,GPipe 是“同步流水线”——所有 micro-batch 的前向跑完,再统一跑反向。而 PipeDream 是“异步流水线”——每个 micro-batch 的前向一结束,立刻开始反向。

这样做的好处是:减少了内存占用。因为不需要保存所有 micro-batch 的中间激活值。

但代价也很明显:权重更新不一致。因为不同 micro-batch 可能基于不同版本的权重计算梯度。PipeDream 用了 1F1B(One-Forward-One-Backward)调度策略来解决这个问题。

# PipeDream 1F1B 调度示意
# 每个 GPU 交替执行前向和反向
# GPU0: F(mb0) -> F(mb1) -> B(mb0) -> F(mb2) -> B(mb1) -> ...
# GPU1: 等待 -> F(mb0) -> B(mb0) -> F(mb1) -> B(mb1) -> ...
# 注意:反向传播的梯度会立即用于更新权重

我曾经踩过的坑:用 PipeDream 时,发现模型收敛变慢了。原因是异步更新导致梯度噪声增大。后来我加了梯度累积和 warmup 策略,才稳定下来。所以,PipeDream 虽然吞吐高,但对调参要求也更高。

四、GPipe vs PipeDream:怎么选?

对比维度 GPipe PipeDream
调度策略 同步流水线 异步 1F1B
内存占用 高(需保存所有 micro-batch 激活值) 低(及时释放)
吞吐量 中等(气泡较大) 高(气泡小)
收敛稳定性 好(同步更新) 一般(异步更新)
实现复杂度 简单 复杂
适用场景 对收敛要求高的场景 对吞吐要求高的场景

我个人习惯:刚开始做流水线并行时,先用 GPipe。等把整个训练流程跑通、调稳了,再考虑切换到 PipeDream 来压榨性能。毕竟,稳定收敛比那点吞吐提升更重要。

五、核心知识体系:一张图看懂

下面这张图总结了本章的核心逻辑。我把它画成了流程图,方便你理解模型并行的演进路径。

模型并行核心知识体系 动机:单卡显存不足 朴素模型并行(Layer-wise) 缺点:GPU利用率低,串行等待 流水线并行(Pipeline Parallelism) GPipe:同步流水线 PipeDream:异步1F1B 内存高 · 收敛稳 · 实现简单 内存低 · 吞吐高 · 调参复杂

从这张图你可以看到:模型并行的演进路径很清晰。从朴素模型并行到 GPipe,再到 PipeDream,每一步都在解决前一步的问题。但每个方案也都有自己的 trade-off。

一句话总结:模型并行解决的是“放不下”的问题。朴素模型并行是基础但效率低,GPipe 用同步流水线提升利用率,PipeDream 用异步调度进一步压榨性能。选哪个,取决于你对收敛稳定性和吞吐量的权衡。

嗯,这一章的内容就到这里。下一章我们会聊张量并行——另一种更细粒度的模型切分方式。到时候你会发现,流水线并行和张量并行经常搭配使用,效果更好。

可可精品资料库