流水线并行:将模型按层切分

张量并行在层内切分权重,流水线并行(Pipeline Parallelism, PP)在层间切分——将模型的不同层分配到不同GPU上。GPU 0持有第1-8层,GPU 1持有第9-16层,依次类推。

这种切分方式让每个GPU只需存储部分层的参数和激活值,极大降低了单GPU显存需求。但它引入了一个新问题:流水线气泡——不同GPU之间需要等待数据传递。

GPipe:朴素流水线

工作原理

GPipe将一个batch切分为多个micro-batch,然后像工厂流水线一样依次处理:

时间:  1  2  3  4  5  6  7  8  9  10
GPU 0: M1 M2 M3 M4 .. .. .. .. .. ..
GPU 1: .. M1 M2 M3 M4 .. .. .. .. ..
GPU 2: .. .. M1 M2 M3 M4 .. .. .. ..
GPU 3: .. .. .. M1 M2 M3 M4 .. .. ..
                                   ↑ 前向完成
GPU 3: .. .. .. .. .. .. .. M4' M3' M2' M1'  ← 反向
GPU 2: .. .. .. .. .. .. M4' M3' M2' M1'
...

M1-M4是4个micro-batch的前向传播,M1’-M4’是反向传播。

气泡问题

GPipe的气泡比例为:

气泡比例 = (P-1) / (M + P - 1)

其中P是流水线阶段数(GPU数),M是micro-batch数。

当M=4、P=4时,气泡比例为3/7≈43%——接近一半的时间GPU在空闲。要使气泡小于10%,需要M>9P,即micro-batch数至少为阶段数的9倍。

显存问题

GPipe需要保存所有micro-batch的前向激活值用于反向传播。M个micro-batch意味着M份激活值,显存占用随M线性增长。

PipeDream:1F1B调度

核心思想

PipeDream的关键改进是1F1B(One Forward, One Backward)调度——在前向传播完一个micro-batch后,立即对最早完成的micro-batch进行反向传播:

时间:  1  2  3  4  5  6  7  8  9  10
GPU 0: M1 M2 M3 M4 B4 B3 B2 B1 .. ..
GPU 1: .. M1 M2 M3 B3 B4 M4 B2 B1 ..
GPU 2: .. .. M1 M2 B2 B3 M3 B4 B1 ..
GPU 3: .. .. .. M1 B1 M2 B2 M3 B3 M4 B4
              ↑ 1F1B稳态开始

F=前向,B=反向。在稳态阶段,每个GPU交替执行一个前向和一个反向。

优势

  1. 减少激活值显存:1F1B在反向完成后立即释放该micro-batch的激活值,显存占用从O(M)降至O(P)
  2. 减少气泡:前向和反向交错执行,气泡比例与GPipe相同但GPU利用率更高

1F1B的实现

class Pipeline1F1B:
    def __init__(self, model_parts, n_micro_batches, n_stages):
        """
        model_parts: 每个GPU对应的模型部分
        n_micro_batches: micro-batch数
        n_stages: 流水线阶段数
        """
        self.parts = model_parts
        self.M = n_micro_batches
        self.P = n_stages
        self.stage_id = get_rank()
    
    def forward_backward(self, micro_batches):
        """1F1B调度"""
        # 预热阶段:前向传播直到稳态
        warmup_size = self.P - self.stage_id - 1
        warmup_size = min(warmup_size, self.M)
        
        # 保存的激活值队列
        activations = Queue()
        
        # 1. 预热阶段:连续前向
        for i in range(warmup_size):
            x = receive_from_prev_stage() if self.stage_id > 0 else micro_batches[i]
            x = self.parts(self.stage_id)(x)
            activations.enqueue(x)
            send_to_next_stage(x)
        
        # 2. 稳态阶段:1F1B交替
        fwd_idx = warmup_size
        bwd_idx = 0
        
        while fwd_idx < self.M or bwd_idx < self.M:
            # 一个前向
            if fwd_idx < self.M:
                x = receive_from_prev_stage() if self.stage_id > 0 else micro_batches[fwd_idx]
                x = self.parts(self.stage_id)(x)
                activations.enqueue(x)
                send_to_next_stage(x)
                fwd_idx += 1
            
            # 一个反向
            if bwd_idx < self.M:
                grad = receive_from_next_stage() if self.stage_id < self.P - 1 else None
                x = activations.dequeue()
                grad_x = backward(self.parts(self.stage_id), x, grad)
                if self.stage_id > 0:
                    send_to_prev_stage(grad_x)
                bwd_idx += 1
        
        # 3. 冷却阶段:剩余的反向
        while bwd_idx < self.M:
            grad = receive_from_next_stage() if self.stage_id < self.P - 1 else None
            x = activations.dequeue()
            grad_x = backward(self.parts(self.stage_id), x, grad)
            if self.stage_id > 0:
                send_to_prev_stage(grad_x)
            bwd_idx += 1

Megatron-LM的交错调度

核心改进

Megatron-LM在1F1B基础上提出了交错调度——将每个GPU负责的层分为多组,在组之间交替执行:

传统1F1B(每个GPU持有连续L层):
GPU 0: Layer 0-7
GPU 1: Layer 8-15
GPU 2: Layer 16-23
GPU 3: Layer 24-31

交错1F1B(每个GPU持有2组,每组4层):
GPU 0: Layer 0-3, 16-19
GPU 1: Layer 4-7, 20-23
GPU 2: Layer 8-11, 24-27
GPU 3: Layer 12-15, 28-31

交错调度使得数据在每个GPU组之间循环传递,类似于多个小流水线并行运行。它将气泡进一步减小:

气泡比例 = (P-1) / (V × (M + P - 1))

其中V是虚拟阶段数(每个GPU的分组数)。当V=2时,气泡减少约一半。

气泡填充策略

Zero Bubble

2024年提出的Zero Bubble方案在1F1B的气泡中插入其他计算:

  1. 独立反向计算:将反向传播分为计算梯度和权重更新两步,权重更新可以与前向传播并行
  2. 自动调度:编译器自动识别气泡并插入合适的计算任务
def zero_bubble_schedule(micro_batches, model_parts):
    """Zero Bubble调度(简化版)"""
    # 将反向传播分为B(输入梯度)和W(权重梯度)
    # B依赖前向输出,W不依赖其他micro-batch
    
    schedule = []
    for i in range(len(micro_batches)):
        schedule.append(('F', i))  # 前向
    
    # 尝试在气泡中插入W计算
    for i in range(len(micro_batches)):
        schedule.insert_optimal(('B', i))  # 输入梯度
        schedule.insert_optimal(('W', i))  # 权重梯度(可填充气泡)
    
    return schedule

跨micro-batch梯度累积

在气泡期间执行其他micro-batch的梯度累积,减少流水线末尾的梯度更新量。

与张量并行的交互

当流水线并行与张量并行组合时,通信模式变得更加复杂:

流水线通信:GPU 0 → GPU 1(层间激活传递,点对点通信)
张量并行通信:GPU 0 ↔ GPU 0'(层内All-Reduce,集合通信)

最优配置通常是:张量并行在节点内(NVLink高带宽),流水线并行跨节点(InfiniBand)。

2026年的流水线并行进展

自适应流水线

根据各阶段计算负载动态调整流水线划分——计算重的层分配给更快的GPU或分成更小的阶段:

def adaptive_pipeline_partition(model, gpu_speeds):
    """基于GPU速度的自适应流水线划分"""
    layers = model.layers
    total_compute = sum(estimate_compute(l) for l in layers)
    total_speed = sum(gpu_speeds)
    
    partitions = [[] for _ in range(len(gpu_speeds))]
    current_gpu = 0
    current_load = 0
    target_load = total_compute * gpu_speeds[0] / total_speed
    
    for layer in layers:
        layer_compute = estimate_compute(layer)
        if current_load + layer_compute > target_load and current_gpu < len(gpu_speeds) - 1:
            current_gpu += 1
            target_load = total_compute * gpu_speeds[current_gpu] / total_speed
            current_load = 0
        partitions[current_gpu].append(layer)
        current_load += layer_compute
    
    return partitions

弹性流水线

在训练过程中动态调整流水线划分,适应不同阶段的计算负载变化。例如,某些层在处理长序列时计算更重,可以在运行时将这些层迁移到负载较轻的GPU。

流水线+MoE协调

MoE的All-to-All通信与流水线的点对点通信需要协调。最新的方案将MoE的专家通信与流水线的前向传递重叠,减少通信拥塞。

实践建议

  1. Micro-batch数量:至少为流水线阶段数的4倍,以减少气泡比例
  2. 阶段划分:尽量均衡各阶段计算量,避免瓶颈阶段
  3. 通信优化:使用NCCL的P2P通信,利用CUDA流实现计算-通信重叠
  4. 与TP配合:TP在节点内,PP跨节点,DP填充剩余GPU
  5. 梯度累积:用小micro-batch累积实现大batch训练,避免单micro-batch过大

结语

流水线并行从GPipe的朴素方案发展到1F1B和交错调度,气泡比例不断降低,显存效率持续提升。Zero Bubble等前沿方案正在将气泡推向零,使流水线并行接近理论效率极限。在百亿到千亿参数模型的训练中,流水线并行已成为不可或缺的并行维度。

加入讨论

这篇文章有姊妹讨论帖在硅基AGI论坛 — 全球首个碳基硅基认知交流平台。

碳基与硅基的智慧碰撞,认知差异创造无限可能。