01

函数入口与核心变量初始化

forward_backward_pipelining_with_interleaving input_tensors tensor_shape data_iterator

整个 VPP 调度器的入口是 forward_backward_pipelining_with_interleaving。 它是一个"批导体":接收用户的 forward_step_func、数据和模型, 将 V×M 个计算事件按三阶段(warmup → 1F1B → cooldown)安排在所有 GPU 上有序执行, 同时通过 P2P 通信在 stage 间传递激活值和梯度。

schedules.py · forward_backward_pipelining_with_interleaving 签名 schedules.py:1044
def forward_backward_pipelining_with_interleaving(
    forward_step_func,          # 用户前向函数(来自 pretrain_gpt.py)
    data_iterator,              # VPP 下为 List[Iterator],长度 = V
    model,                      # List[GPTModel],长度 = V
    num_microbatches,           # M:本次 train_step 的 micro-batch 总数
    seq_length,                 # 序列长度,用于推算 P2P tensor 形状
    micro_batch_size,           # 单个 micro-batch 的 batch size
    decoder_seq_length=None,    # Encoder-Decoder 架构中 decoder 序列长度
    forward_only=False,         # True = 只做推理,跳过 backward
    collect_non_loss_data=False,# True = 收集非 loss 输出(推理 logits)
    first_val_step=None,        # 评估第一步的特殊标记
    config=None,                # ModelParallelConfig
) -> List[Tensor]:              # 返回所有 micro-batch 的 loss 列表

① forward_step_func:用户提供的前向回调

forward_step_func 来自 pretrain_gpt.pyforward_step。 它的职责是:从 data_iterator 取出一个 micro-batch, 调用 model.forward,并返回一个 (output_tensor, loss_func) 元组。

pretrain_gpt.py · forward_step(简化)
def forward_step(data_iterator, model):
    # 1. 从 data_iterator 取 batch(first stage 或 last stage 用到)
    tokens, labels, loss_mask, attention_mask, position_ids = get_batch(data_iterator)
    # get_batch 对 None iterator 返回全零的 dummy tensors(中间 stage 不用)

    # 2. 调用 model.forward,输出是 hidden_states(非 last stage)或 loss(last stage)
    output_tensor = model(input_ids=tokens, position_ids=position_ids, ...)

    # 3. 返回 (output_tensor, loss_func) 二元组
    #    调度器拿到 output_tensor 后发给下游 GPU
    #    loss_func 稍后在 last stage 被 backward 前调用
    return output_tensor, partial(loss_func, labels=labels, loss_mask=loss_mask)

② data_iterator:VPP 下的 List[Iterator]

在 VPP 模式下,data_iterator 是一个长度为 V(virtual stage 数)的列表。 绝大多数 slot 是 None——只有在本 GPU 上 pre_process=Truepost_process=True 的 chunk 才有真实 iterator:

GPU / Chunkpre_processpost_processdata_iterator[c]用途
GPU0 · model[0]
chunk 0,layers 0–(N/2-1)
TrueFalse real_iterator 提供 input_ids / position_ids(Embedding 需要)
GPU0 · model[1]
chunk 1,layers N/2–(N-1)
FalseFalse None 中间段,input 来自 P2P,不读数据
GPU1 · model[0] FalseFalse None 中间段
GPU1 · model[1]
chunk 1,last stage
FalseTrue real_iterator 提供 labels / loss_mask(计算 cross-entropy loss 需要)
为什么大多数 slot 是 None

中间 chunk 的 input 由 P2P 通信接收——前一个 stage 的 hidden_states 直接注入 TransformerBlock。设为 None 可以在 get_batch 里 做快速判断,避免数据加载操作(数据加载是 CPU 上的重量级操作,不应在无关的 rank 上触发)。

③ 三个共享缓冲区:调度器的"传送带"

这三个 list-of-list 贯穿整个调度器生命周期,是 warmup / steady / cooldown 各阶段之间传递中间结果的核心数据结构:

schedules.py · 三个共享缓冲区初始化schedules.py:1120
num_model_chunks = len(model)   # = V(virtual stage 数)

# 每个 list 有 V 个 slot,每个 slot 是该 chunk 的独立队列
input_tensors       = [[] for _ in range(num_model_chunks)]
output_tensors      = [[] for _ in range(num_model_chunks)]
output_tensor_grads = [[] for _ in range(num_model_chunks)]
缓冲区谁往里写谁从里读语义
input_tensors[c] P2P recv(前一 stage 发来的 hidden_states)
None(first stage chunk)
forward_step_helper_preprocess,通过 offset 索引取出 chunk c 的前向输入队列(FIFO);warmup 期间会积累多个待消费的 tensor
output_tensors[c] forward_step_helper_postprocess 调用 append backward_step_helper,取出对应的 output 传给 autograd chunk c 的前向输出缓存;backward 时需要 output_tensor 来重建梯度图
output_tensor_grads[c] P2P recv(后一 stage 发来的梯度)
或 last stage 由 loss 直接产生
backward_step_helper 作为 grad_output 传入 chunk c 的反向梯度输入队列;反向传播的"起点"
为什么设计成 list-of-list 而不是单一队列

在 warmup 阶段,不同 chunk 的填充速度不同。 例如:chunk 0 可能已有 2 个输入排队(mb=0、mb=1),而 chunk 1 还没有任何输入。 用 list[chunk_id] 可以给每个 chunk 独立维护队列, 避免跨 chunk 的读写干扰。如果用单一队列,就必须同时记录 chunk_id,引入额外复杂度。

offset 变量在 forward_step_helper_preprocess 里的作用实现细节

input_tensors[c]append 方式写入(O(1)), 以 索引方式读出,而不是 pop(0)(O(n))。 调度器为每个 chunk 维护一个 input_tensors_offset[c] 计数器:

forward_step_helper_preprocess(逻辑等价代码)
def forward_step_helper_preprocess(chunk_id):
    # input_tensors[chunk_id] 中,offset 指向下一个待消费的 tensor
    input_tensor = input_tensors[chunk_id][input_tensors_offset[chunk_id]]
    input_tensors_offset[chunk_id] += 1
    # 用索引而非 pop(0) 的原因:
    #   pop(0) 对 list 是 O(n),每次消费都要移动所有后续元素
    #   索引 + counter 是 O(1),避免 warmup 阶段的性能退化
    return input_tensor

类似地,output_tensors[chunk_id] 也用 output_tensors_offset[c] 控制 backward 时从哪里取 output_tensor。

④ tensor_shape:P2P recv buffer 的预分配依据

_communicate() 发起 recv 之前,需要 预先分配一个空 buffer 接收来自相邻 stage 的 tensor。tensor_shape 决定了这个 buffer 的形状:

schedules.py · tensor_shape 计算schedules.py:1100
# 默认形状:[seq_length, micro_batch_size, hidden_size]
tensor_shape = [seq_length, micro_batch_size, config.hidden_size]

# CP 先切:序列维度按 CP size 整除
tensor_shape[0] = tensor_shape[0] // cp_group.size()

# SP 再切:若开启 Sequence Parallelism,再按 TP size 整除
if config.sequence_parallel:
    tensor_shape[0] = tensor_shape[0] // tp_group.size()

# 最终 seq 维度 = seq_len / (CP × TP),两者可叠加,并不互斥
SP 与 CP 可以同时开启

两者都对 seq_length 做切分,但作用层次不同,可以叠加:

SP(Sequence Parallelism) CP(Context Parallelism)
绑定维度 与 TP 紧耦合 独立并行维度
切分范围 LN、Dropout 等非 attention 层 整条序列(含 attention)
通信原语 all-reduce → reduce-scatter + all-gather(TP 组内) ring P2P(attention 中跨 CP rank 共享 KV)

两者同时开启时,tensor_shape[0] 先被 CP size 整除、再被 TP size 整除, 最终 pipeline P2P recv buffer 的 seq 维度为 seq_len / (CP × TP),这正是设计预期。 LLaVA 模型代码(llava_model.py:693)也显式处理了 context_parallel and sequence_parallel 同时开启的情形,说明两者可以叠加使用。

requires_grad=True 的必要性

_communicate() 内,recv buffer 以 torch.empty(tensor_shape, requires_grad=True, device=...) 分配。这不是可选的

  • 收到的 tensor 是本 stage 前向计算图的起点, PyTorch autograd 必须能从这里向前追溯梯度。
  • 反向传播时,tensor.grad 会被填入梯度值, 作为 input_tensor_grad 通过 P2P 发回上游 stage。
  • 若设为 requires_grad=False,梯度无法跨 stage 传递, 上游 stage 的参数将永远收不到正确梯度。
02

virtual_microbatch_id 与主循环三阶段

virtual_microbatch_id (vid) fid / bid get_schedule_table get_model_chunk_id warmup / steady / cooldown

VPP 调度器面临的核心问题是:如何把 V 个 chunk × M 个 micro-batch 的所有计算事件 线性化成一个可遍历的序列?答案是 virtual_microbatch_id(vid)

virtual_microbatch_id 是什么

vid 是把所有 (chunk_id, microbatch_id) 组合 展平成的线性下标,范围 0 … V×M−1,代表"第几次前向计算事件"。 调度器的所有循环都以 vid 为索引,通过查表获知当前应执行哪个 chunk 的哪个 micro-batch。

get_schedule_table 的构造逻辑

schedule_table 的核心设计思想是按组交替:将 M 个 micro-batch 按 N = pp_size 个一组分批,每批依次分配给 chunk 0、chunk 1、…、chunk V-1。 这样可以确保流水线中的数据依赖关系被满足。

schedules.py · get_schedule_table(逻辑等价代码)schedules.py:820
def get_schedule_table(num_microbatches, num_model_chunks, pp_size):
    """
    返回长度为 V*M 的列表,每个元素是 (microbatch_id, chunk_id)。
    microbatch_group_size (N) = pp_size:每组 N 个 micro-batch,循环分配给各 chunk。
    """
    N = pp_size   # microbatch_group_size_per_vp_stage
    schedule = []
    for mb_group in range(num_microbatches // N):    # 每 N 个 mb 一组
        for chunk in range(num_model_chunks):         # 循环各 chunk
            for mb in range(N):                       # 组内 N 个 mb
                schedule.append((mb_group * N + mb, chunk))
    return schedule
    # 注:实际代码处理 num_microbatches % N != 0 的情况

具体示例:pp=2,V=2,M=4

参数:pp_size=2, num_model_chunks=2, num_microbatches=4, 则 N=2,共 V×M=8 个 vid。

vid 0 1 2 3 4 5 6 7
microbatch_id 0 1 0 1 2 3 2 3 ↑ 每 N=2 个 mb 切换一次 chunk
chunk_id 0 0 1 1 0 0 1 1

蓝色列(vid 0,1,4,5)属于 chunk 0;紫色列(vid 2,3,6,7)属于 chunk 1。 每 N=2 个连续 vid 处理同一 chunk,然后切换,循环往复。

get_model_chunk_id(vid, forward)

根据 vid 查表得到 chunk_id,但前向和反向使用不同的 chunk_id

  • 前向forward=True):直接返回 schedule_table[vid].chunk_id
  • 反向forward=False):返回 V - 1 - schedule_table[vid].chunk_id

反向必须从最后一个 chunk 开始往回传——这是链式法则的要求。 例如当 V=2 时,反向 chunk 0 → 原来的 chunk 1(last chunk), 反向 chunk 1 → 原来的 chunk 0(first chunk)。

反向 chunk_id 映射(pp=2,V=2,M=4 示例)
# 前向 vid → (chunk, mb)
# vid:   0→(c0,m0)  1→(c0,m1)  2→(c1,m0)  3→(c1,m1)
#        4→(c0,m2)  5→(c0,m3)  6→(c1,m2)  7→(c1,m3)

# 反向 vid → (chunk, mb)  [chunk 翻转为 V-1-chunk]
# vid:   0→(c1,m0)  1→(c1,m1)  2→(c0,m0)  3→(c0,m1)
#        4→(c1,m2)  5→(c1,m3)  6→(c0,m2)  7→(c0,m3)
#
# 反向顺序:先 B(c1,m0), B(c1,m1), B(c0,m0), B(c0,m1)...
# 即先反向 last chunk(c1),再反向 first chunk(c0),符合梯度反传方向。

Warmup 步数公式

每个 GPU(rank)需要在进入 1F1B 稳态之前预先完成若干纯前向步骤(warmup), 使流水线各 stage 都"装满"数据:

schedules.py · warmup 步数计算schedules.py:1150
N = microbatch_group_size_per_vp_stage   # = pp_size(基本情况下)

num_warmup_microbatches = (
    (pipeline_parallel_size - pipeline_parallel_rank - 1) * 2
    + (num_model_chunks - 1) * N
)
# 第一项:物理流水线中,靠前的 stage 要给后面的 stage 预留更多 warmup
#   rank=0(first stage)需要最多 warmup
#   rank=pp-1(last stage)需要最少 warmup
#
# 第二项:VPP 的额外开销——切换到下一个 chunk 时,需要 N 个额外的 warmup
#   确保 chunk 1 有足够的输入数据才能开始执行
pp=2,V=2,N=2 时各 rank 的 warmup 步数
GPU pp_rank 第一项 (pp-rank-1)×2 第二项 (V-1)×N 总 warmup
GPU 0 (first stage) 0 (2-0-1)×2 = 2 (2-1)×2 = 2 4
GPU 1 (last stage) 1 (2-1-1)×2 = 0 (2-1)×2 = 2 2
GPU0 做完 4 步纯 forward 才进入 1F1B;GPU1 只需 2 步 warmup,更早进入稳态

fid / bid:前向与反向各自的 virtual id

vid 是统一的"计算事件序列号",调度器在主循环中把它拆成两个具体变量分别驱动前向和反向:

  • fid(forward_k / f_virtual_microbatch_id):当前步要执行前向的 vid, 通过 get_model_chunk_id(fid, forward=True) 得到正序 chunk_id。
  • bid(backward_k / b_virtual_microbatch_id):当前步要执行反向的 vid, 通过 get_model_chunk_id(bid, forward=False) 得到翻转后的 chunk_id (V-1-chunk),确保反向从 last chunk 开始向前传梯度。

关键关系:在稳态阶段,bid = fid − num_warmup。 反向始终比前向"落后" num_warmup 步,这正是 warmup 积累的 未消费 activation 缓存数量——稳态每轮 +1 前向缓存、−1 反向消费,保持不变。

schedules.py · 主循环三阶段(逻辑等价代码)schedules.py:1495
################################################################################
# 阶段一:Warmup ── 纯前向,积累 num_warmup 个 activation 缓存
################################################################################
for k in range(num_warmup_microbatches):
    fid = k                  # fid: 0 … num_warmup-1(bid 不存在,反向还未开始)
    forward_backward_helper_wrapper(f_virtual_microbatch_id=fid, b_virtual_microbatch_id=None)
    # 效果:激活值存入 output_tensors[chunk_id],等待 cooldown/steady 阶段的 backward 消费

################################################################################
# 阶段二:Steady 1F1B ── 每轮做 1 次前向 + 1 次反向(流水线配对)
################################################################################
num_microbatches_remaining = total_num_microbatches - num_warmup_microbatches
for k in range(num_microbatches_remaining):
    fid = k + num_warmup_microbatches   # fid 从 warmup 结束处继续往后推
    bid = k                             # bid 从 0 开始,消费 warmup 积累的缓存
    forward_backward_helper_wrapper(
        f_virtual_microbatch_id=fid,
        b_virtual_microbatch_id=bid,
    )
    # 循环不变量:output_tensors 中未消费的缓存数始终 = num_warmup

################################################################################
# 阶段三:Cooldown ── 纯反向,消耗 warmup 积累的剩余 activation 缓存
################################################################################
for k in range(num_warmup_microbatches):
    bid = k + num_microbatches_remaining   # bid 从 remaining 处继续往后推
    # fid 不存在,所有前向已完成
    forward_backward_helper_wrapper(f_virtual_microbatch_id=None, b_virtual_microbatch_id=bid)
pp=2, V=2, M=4 的 fid / bid 完整对照(GPU0 视角,warmup=4)
阶段k(循环变量) fid → get_model_chunk_id(fid, True) → (chunk, mb) bid → get_model_chunk_id(bid, False) → B(chunk↑, mb)
Warmup
纯前向
k=0fid=0 → (c0, m0)
k=1fid=1 → (c0, m1)
k=2fid=2 → (c1, m0)
k=3fid=3 → (c1, m1)
Steady
1F1B
k=0fid=4 → (c0, m2)bid=0 → B(c1, m0)
k=1fid=5 → (c0, m3)bid=1 → B(c1, m1)
k=2fid=6 → (c1, m2)bid=2 → B(c0, m0)
k=3fid=7 → (c1, m3)bid=3 → B(c0, m1)
Cooldown
纯反向
k=0bid=4 → B(c1, m2)
k=1bid=5 → B(c1, m3)
k=2bid=6 → B(c0, m2)
k=3bid=7 → B(c0, m3)

注:反向 chunk 翻转——bid=0 在前向表对应 (c0,m0),反向时 chunk_id 翻转为 c1, 即反向从 last chunk(c1)开始,符合梯度从 loss 向上游反传的方向。 bid=2 对应反向 (c0,m0),即对 chunk1-m0 forward 之前的 chunk0-m0 做梯度。

VPP Interleaved 调度时间线(pp=2, V=2, M=4 micro-batches)

格子宽度正比于实际耗时:F = 1×B = 2×(反向含梯度,约为前向 2 倍耗时)

← Warmup (4步) →
← Steady State 1F1B(含1个pipeline bubble)→
← Cooldown(含2个pipeline bubble)→
GPU 0 (first)
F
c0,m0
F
c0,m1
F
c1,m0
F
c1,m1
F
c0,m2
·
B
c1,m0
F
c0,m3
B
c1,m1
F
c1,m2
B
c0,m0
F
c1,m3
B
c0,m1
·
B
c1,m2
·
B
c1,m3
B
c0,m2
B
c0,m3
等待
Warmup (2步)
← Steady State 1F1B (6 对) →
Cooldown
GPU 1 (last)
·
F
c0,m0
F
c0,m1
F
c1,m0
B
c1,m0
F
c1,m1
B
c1,m1
F
c0,m2
B
c0,m0
F
c0,m3
B
c0,m1
F
c1,m2
B
c1,m2
F
c1,m3
B
c1,m3
B
c0,m2
B
c0,m3
F chunk 0 F chunk 1 B chunk 0(宽=2×F) B chunk 1(宽=2×F) 泡沫(等待)

GPU0 warmup=4(稳态第1个B + cooldown前2个B各有1个pipeline bubble,共3个bubble) · GPU1 warmup=2 + 1等待泡沫

Steady State 步数推导

total_forward_steps = V × M = 8(每个 GPU 需完成的前向总数)。 steady_steps = total_forward_steps − warmup = 8 − warmup。
cooldown_steps = warmup(对称:warmup 积累了多少前向,cooldown 就有多少纯反向)。

GPUWarmupSteady (1F1B)Cooldown总计
GPU 044 对 (4F+4B)48F + 8B = 16 事件
GPU 126 对 (6F+6B)28F + 8B = 16 事件
input_tensors / output_tensors 队列的 append / index / pop 节奏核心机制

调度器用两组列表(每个 chunk 一个槽)管理前向激活值和梯度的生命周期。 理解何时 append、何时 index 读取、何时 pop 消费, 是看懂整个 VPP 流水线的钥匙。

schedules.py · 谁在哪里调用 recv_tensor_from_previous_stageschedules.py:1513, 1860, 1929
# ── 调用时机:每次 forward / backward 计算"完成之后",决定本次 P2P 是否 recv ──
# 函数从不在"计算之前"调用,它回答的是:
#   "我刚算完 vid=X,对端(邻居 stage)现在有没有东西要发给我?
#    如果有,应该放进 input_tensors[哪个 chunk] 的队列?"

# warmup 阶段(对每个 k 调用一次,决定 k→k+1 的 recv)
for k in range(num_warmup_microbatches):
    output_tensor = forward_step(...)                                # 先算
    recv_prev, next_chunk_id = recv_tensor_from_previous_stage(k, forward=True)  # 再问
    input_tensor = send_forward_recv_forward(output_tensor, recv_prev)
    if recv_prev:
        input_tensors[next_chunk_id].append(input_tensor)           # 存进下一步的 chunk 队列

# steady 阶段(每轮两次:forward 后问一次,backward 后问一次)
for k in range(num_microbatches_remaining):
    fid, bid = k + num_warmup, k
    output_tensor    = forward_step(fid)                            # forward 算完
    input_tensor_grad = backward_step(bid)                          # backward 算完
    recv_prev, next_fwd_chunk = recv_tensor_from_previous_stage(fid, forward=True)
    recv_next, next_bwd_chunk = recv_tensor_from_previous_stage(bid, forward=False)
    (input_tensor, output_tensor_grad) = send_fwd_bwd_recv_fwd_bwd(
        output_tensor, input_tensor_grad, recv_prev, recv_next)
    if recv_prev: input_tensors[next_fwd_chunk].append(input_tensor)
    if recv_next: output_tensor_grads[next_bwd_chunk].append(output_tensor_grad)

为什么 leading stage 的逻辑不一样?

函数内部区分了两种 GPU:

  • 非 leading stage(GPU1 in forward):逻辑极简——
    next_chunk = get_model_chunk_id(vid + 1, forward)
    "我刚做完 vid=X,下一步做 vid=X+1,所以我要 recv 的是 X+1 对应 chunk 的 input,来自 GPU0。"
  • leading stage(GPU0 in forward):复杂。原因是 GPU0 的 c1 chunk input 不来自 GPU0 自身之前的步骤,而来自 GPU1 的 c0 输出(VPP 循环绕回), 而 GPU0 比 GPU1 快了 (pp_size-1) 步。
schedules.py · recv_tensor_from_previous_stage:逐行注释(pp=2, V=2 视角)schedules.py:1201
def recv_tensor_from_previous_stage(vid, forward):
    # 前向时:leading = GPU0(第一个处理数据的)
    # 反向时:leading = GPU1(第一个做 backward 的,因为它是 last stage)
    is_leading = is_pp_first_stage() if forward else is_pp_last_stage()

    # 前向时 last_chunk = c1(最后一个 chunk 的激活值不发给任何人,只产生 loss)
    # 反向时 last_chunk = c0(最后一个反向 chunk 不 recv 梯度,只 recv None)
    last_chunk = (num_model_chunks - 1) if forward else 0

    if is_leading:   # ── GPU0 视角(pp=2, V=2) ────────────────────────────────
        # 关键约束:GPU0 比 GPU1 早 (pp_size-1)=1 步
        # 当 GPU0 做完 vid=X 时,GPU1 刚做完 vid = X - 1
        # GPU0 想 recv 的是 GPU1 的 c0 输出(循环绕回给 GPU0 的 c1)
        # 但如果 X < pp_size-1=1,即 X=0,GPU1 还没开始,没有任何输出
        if vid < (pipeline_parallel_size - 1):   # X=0 时
            recv = False
            # next_chunk_id 设为 vid+1 对应的 chunk(仅用于边界判断,recv=False 后不实际使用)
            next_chunk_id = get_model_chunk_id(vid + 1, forward)
        else:
            # GPU1 刚做完的是 vid = X-(pp_size-1) = X-1,对应 chunk:
            next_chunk_id = get_model_chunk_id(vid - (pipeline_parallel_size - 1), forward)
            # 例:vid=1 → GPU1 刚做完 vid=0 (c0,m0) → next_chunk_id = c0
            #     vid=2 → GPU1 刚做完 vid=1 (c0,m1) → next_chunk_id = c0
            #     vid=3 → GPU1 刚做完 vid=2 (c1,m0) → next_chunk_id = c1

        # 如果 GPU1 刚做完的是 last_chunk (c1),说明这是最后一个前向 chunk,
        # 它只产生 loss,不向 GPU0 发激活值 → 不 recv
        if next_chunk_id == last_chunk:   # vid=3 时 next_chunk_id=c1=last_chunk → recv=False
            recv = False

        # GPU1 把 c0 的输出发给 GPU0 的 c1(即下一个 chunk)
        # 所以存储目标队列是 next_chunk_id + 1
        # 前向时 chunk_id 单调递增;反向时单调递减
        next_chunk_id = next_chunk_id + 1 if forward else next_chunk_id - 1

    else:   # ── GPU1 视角(非 leading)────────────────────────────────────────
        # 极简:vid=X 做完,下一步是 vid=X+1,recv 它对应 chunk 的 input
        next_chunk_id = get_model_chunk_id(vid + 1, forward)

    return recv, next_chunk_id
pp=2, V=2, M=4 —— GPU0 每步 recv 决策全表(warmup 阶段)
# GPU0 完成 fid=X 后调用 recv_tensor_from_previous_stage(X, forward=True)
#
# fid=0 (c0,m0 做完):X=0 < pp_size-1=1 → recv=False
#   GPU1 此时才刚开始处理 vid=0,还没任何输出
#
# fid=1 (c0,m1 做完):X=1 >= 1 → 看 GPU1 刚做完的 vid=1-1=0 → schedule[0]=(m0,c0) → c0
#   c0 != last_chunk(c1) → recv=True
#   next_chunk_id = c0 + 1 = c1
#   → recv GPU1 发来的 c0,m0 激活值,存入 input_tensors[c1]  ← 这是 fid=2(c1,m0) 的 input
#
# fid=2 (c1,m0 做完):X=2 → GPU1 刚做完 vid=1 → schedule[1]=(m1,c0) → c0
#   recv=True, next=c1
#   → recv GPU1 发来的 c0,m1 激活值,存入 input_tensors[c1]  ← 这是 fid=3(c1,m1) 的 input
#
# fid=3 (c1,m1 做完):X=3 → GPU1 刚做完 vid=2 → schedule[2]=(m0,c1) → c1
#   c1 == last_chunk → recv=False
#   GPU1 的 c1 chunk 是最后一层,产生 loss,不发激活值给 GPU0
#
# ── 结论:warmup 中 GPU0 只在 fid=1 和 fid=2 之后 recv,共 recv 2 次(c1,m0 和 c1,m1 的 input)
schedules.py · 四个关键队列操作(带源码行号)
###############################################################################
# ① 前向 preprocess:对 c0(pp_first_stage + vp_first_stage)APPEND None 占位,
#    然后 INDEX 读取(不 pop)
###############################################################################
# schedules.py:1271
if _is_vp_first_stage(chunk_id) and is_pp_first_stage():
    # c0 永远从 data_iterator 读,不走 P2P recv。
    # 但 input_tensors[c0] 里仍需留 None 占位,保持下面 index 运算的一致性。
    # 触发条件:len(input) == len(output),即"上一次 c0 forward 已经配对了一个 output"
    # → 要再做一次 c0 forward,需要再加一个 None 槽位
    if len(input_tensors[chunk_id]) == len(output_tensors[chunk_id]):
        input_tensors[chunk_id].append(None)   # ← 占位 None

# offset = 截止到本 fid,已经 pop 掉的 input_tensors[chunk_id] 条目数
# (等于"对应 chunk 已完成的反向次数")
# 公式(非 forward_only 模式):
#   if fid < num_warmup: offset = 0         ← warmup 阶段没有任何反向
#   else:
#     bid_so_far = fid - num_warmup          ← 到目前为止已完成的反向步数
#     offset = model_chunk_id_table[:bid_so_far].count(V-1-chunk_id)
#             ← 数前 bid_so_far 个反向步里,有几个处理了对应 chunk(反向时 chunk 翻转)
offset = num_released_microbatches(fid, chunk_id)

mb_id = microbatch_id_table[fid]   # 本 fid 处理的是第几个 microbatch
input_tensor = input_tensors[chunk_id][mb_id - offset]   # ← INDEX,不 pop!
# 为什么 mb_id - offset:
#   input_tensors[c] 原本按 mb_id 0,1,2,... 顺序 append
#   每次反向 pop(0) 会把队列头删掉,使列表缩短 1
#   缩短 offset 次后,原来下标 mb_id 的元素现在在下标 mb_id-offset

###############################################################################
# ② 前向 postprocess:APPEND output(激活值入队等反向消费)
###############################################################################
# schedules.py:1288
output_tensors[chunk_id].append(output_tensor)

###############################################################################
# ③ P2P recv 后:APPEND 到下一步前向对应的 chunk 队列(next_chunk_id,非当前)
###############################################################################
# schedules.py:1590
recv, next_chunk_id = recv_tensor_from_previous_stage(fid, forward=True)
if recv:
    input_tensor = send_forward_recv_forward(output_tensor, ...)
    input_tensors[next_chunk_id].append(input_tensor)

###############################################################################
# ④ 反向 preprocess:POP × 3(唯一真正释放内存的地方)
###############################################################################
# schedules.py:1348
if _is_vp_last_stage(chunk_id) and is_pp_last_stage():
    if len(output_tensor_grads[chunk_id]) == 0:
        output_tensor_grads[chunk_id].append(None)  # last stage 梯度来自 loss

input_tensor       = input_tensors[chunk_id].pop(0)        # ← 消费前向 input
output_tensor      = output_tensors[chunk_id].pop(0)        # ← 消费前向 output
output_tensor_grad = output_tensor_grads[chunk_id].pop(0)   # ← 消费下游梯度

队列状态逐步追踪:pp=2, V=2, M=4, GPU0(已验证)

schedule_table: mb_id_table=[0,1,0,1,2,3,2,3]chunk_table=[0,0,1,1,0,0,1,1]
c0 = GPU0 的 chunk0(pp_first + vp_first,input 全为 None,读 data_iterator)
c1 = GPU0 的 chunk1(从 GPU1 的 c0 输出 recv 激活值)

队列状态逐步追踪(GPU0,warmup 全程 + 第一个稳态步 + offset 生效演示)
# ─── 启动前(主循环开始前的单次 recv)────────────────────────────────────────
# p2p_communicator.recv_forward(is_pp_first=True) → 对 pp_first_stage 返回 None
input_tensors[0].append(None)
# 状态 ▶ input[0]=[None]  input[1]=[]  output[0]=[]  output[1]=[]

# ─── Warmup k=0 : fid=0 → (c0, m0) ─────────────────────────────────────────
# ① preprocess (c0 = pp_first + vp_first):
#     len(input[0])=1, len(output[0])=0  →  1 ≠ 0  →  不 append(初始 None 够用)
#     offset = 0(warmup 恒为 0)
#     mb_id = mb_id_table[0] = 0
#     input_tensor = input[0][0-0] = None  ← READ
# ② forward_step,以 None 驱动,从 data_iterator 读数据
# ③ output[0].append(out_c0m0)
# ④ recv_tensor_from_previous_stage(0, fwd) → recv=False(0 < pp-1=1)
# 状态 ▶ input[0]=[None]  input[1]=[]  output[0]=[out0]  output[1]=[]
#   ◀── len(input[0])=1 == len(output[0])=1,下次 c0 forward 前会触发 append ──▶

# ─── Warmup k=1 : fid=1 → (c0, m1) ─────────────────────────────────────────
# ① preprocess:
#     len(input[0])=1 == len(output[0])=1  →  条件成立  →  APPEND None!
#     input[0] = [None, None]              ← 现在有 2 个 None 槽位
#     offset=0, mb_id=mb_id_table[1]=1
#     input_tensor = input[0][1-0] = input[0][1] = None  ← READ
# ② forward_step(c0,m1),以 None 驱动,从 data_iterator 读第 2 条数据
# ③ output[0].append(out_c0m1)
# ④ recv_tensor_from_previous_stage(1, fwd) → recv=True, next_chunk=c1
#     GPU1 刚完成 vid=0(c0,m0),将激活值发回 GPU0(VPP 循环绕回)
#     input[1].append(recv_c1m0)
# 状态 ▶ input[0]=[None,None]  input[1]=[recv_c1m0]
#         output[0]=[out0,out1]  output[1]=[]

# ─── Warmup k=2 : fid=2 → (c1, m0) ─────────────────────────────────────────
# ① preprocess:c1 不是 vp_first_stage → 跳过 append 分支
#     offset=0, mb_id=mb_id_table[2]=0
#     input_tensor = input[1][0-0] = recv_c1m0  ← READ
# ② forward_step(c1,m0)
# ③ output[1].append(out_c1m0)
# ④ recv_tensor_from_previous_stage(2, fwd) → recv=True, next_chunk=c1
#     GPU1 完成 vid=1(c0,m1) → input[1].append(recv_c1m1)
# 状态 ▶ input[0]=[None,None]  input[1]=[recv_c1m0,recv_c1m1]
#         output[0]=[out0,out1]  output[1]=[out_c1m0]

# ─── Warmup k=3 : fid=3 → (c1, m1) ─────────────────────────────────────────
# ① offset=0, mb_id=1 → READ input[1][1] = recv_c1m1
# ③ output[1].append(out_c1m1)
# ④ recv_tensor_from_previous_stage(3, fwd) → recv=False
#     GPU1 完成 vid=2 → chunk_table[2]=c1=last_chunk → 不发激活值(last stage 产生 loss)
# 状态 ▶ input[0]=[None,None]  input[1]=[recv_c1m0,recv_c1m1]
#         output[0]=[out0,out1]  output[1]=[out_c1m0,out_c1m1]

# ─── Steady k=0 : fid=4(c0,m2) + bid=0(B_c1,m0) ────────────────────────────
# ==== forward fid=4 ====
# ① preprocess (c0):
#     len(input[0])=2 == len(output[0])=2  →  APPEND None
#     input[0] = [None, None, None]
#     offset = num_released(fid=4, c0):
#       backward_mid = 4-4 = 0,chunk_table[:0].count(V-1-c0=1) = 0  →  offset=0
#     mb_id=mb_id_table[4]=2
#     input_tensor = input[0][2-0] = None  ← READ
# ③ output[0].append(out_c0m2)
# ④ recv=True, next=c1 → input[1].append(recv_c1m2)
#    [input[1] = [recv_c1m0, recv_c1m1, recv_c1m2]]
#
# ==== backward bid=0 → B(c1,m0) ====
# output_tensor_grads[1].append(None)   ← last stage,loss 提供梯度
# input[1].pop(0) = recv_c1m0           ← 释放!input[1]=[recv_c1m1,recv_c1m2]
# output[1].pop(0) = out_c1m0           ← 释放!output[1]=[out_c1m1]
# output_tensor_grads[1].pop(0) = None
# 状态 ▶ input[0]=[None,None,None]  input[1]=[recv_c1m1,recv_c1m2]
#         output[0]=[out0,out1,out2]  output[1]=[out_c1m1]

# ─── 快进到 Steady k=2 : fid=6(c1,m2) ── offset 生效场景 ────────────────────
# 此时 bid=0,1(B_c1,m0 和 B_c1,m1)已完成,input[1] 已 pop 两次
# ① preprocess (c1):跳过 append 分支
#     offset = num_released(fid=6, c1):
#       backward_mid = 6-4 = 2
#       flipped_chunk = V-1-c1 = 0
#       chunk_table[:2].count(0) = [0,0].count(0) = 2  →  offset=2
#     mb_id = mb_id_table[6] = 2
#     index = 2 - 2 = 0
#     此时 input[1] = [recv_c1m2, recv_c1m3](前两项已被 pop 掉)
#     input[1][0] = recv_c1m2  ✓
# ── offset 的本质:pop(0) 每次将整个列表前移,mb_id 减 offset 得到当前实际下标 ──
三条关键规律
  1. 前向只读不消费input_tensors[c][idx] 用 INDEX 取,不 pop, 因为同一个 input_tensor 在反向时还要用(它的 .grad 就是 input_tensor_grad)。
  2. 反向一次消费三个input_tensors.pop(0) / output_tensors.pop(0) / output_tensor_grads.pop(0) 三合一,对应一次完整的 backward_step。
  3. recv 存到 next_chunk 而非当前 chunk:当前步的 input 在 preprocess 时已 index 到了, P2P recv 拿到的是下一步前向的 input,所以存入 input_tensors[next_chunk_id]
03

forward_backward_helper_wrapper 的角色

调度分发器 overlap_p2p_comm pre/post callback forward_step_helper

forward_backward_helper_wrapper 是整个调度器的"调度分发器"。 它根据传入的 f_vid(前向 vid)和 b_vid(反向 vid)的组合, 决定在当前时间槽里做什么:

Warmup 模式:只传 f_vid,b_vid=None

只做前向计算。input 来自 P2P recv(或 data_iterator), output 存入 output_tensors[chunk_id],然后 send 给下一 stage。

Steady 模式:同时传 f_vid 和 b_vid → 1F1B

先前向(f_vid),再反向(b_vid)。 overlap 模式下通信与计算并发:前向 send/recv 与反向计算重叠, 反向 send/recv 与下一步前向计算重叠。

Cooldown 模式:只传 b_vid,f_vid=None

只做反向计算。消费 output_tensors[chunk_id] 中 warmup 积累的缓存, grad 发回上一 stage。

overlap_p2p_comm=True 模式下的 4 个回调

config.overlap_p2p_comm=True 时,P2P 通信以异步方式提交, 调度器通过 4 个回调函数控制等待和发起时机,实现通信与计算的流水线并发:

回调调用时机做什么
pp_pre_forward(vid) 前向计算开始之前 调用 recv_prev_wait_handle.wait(),等待上一步异步 irecv 完成, 确保 input_tensor 已到位,可以安全读取
pp_post_forward(output_tensor, next_f_vid) 前向计算完成之后 发起异步 isend(output_tensor → next_rank) 把激活值推给下游; 同时发起异步 irecv(buf ← prev_rank) 预取下一个 microbatch 的 input; 把 irecv handle 存入 recv_prev_wait_handles[next_f_vid]
pp_pre_backward(b_vid) 反向计算开始之前 调用 recv_next_wait_handle.wait(),等待梯度 recv 完成, 确保 output_tensor_grad 已到位
pp_post_backward(input_tensor_grad, next_b_vid) 反向计算完成之后 发起异步 isend(input_tensor_grad → prev_rank) 把梯度推给上游; 同时发起异步 irecv(buf ← next_rank) 预取下一个梯度; 把 handle 存入 recv_next_wait_handles[next_b_vid]
"通信与计算并行"的具体含义

isend/irecv 在 CUDA stream 上提交通信 kernel 后立即返回, CPU 不阻塞。GPU 的 CUDA engine 并发执行:通信 kernel(NVLink / IB 数据搬运) 与 matmul kernel(Transformer 计算)在不同 CUDA stream 上同步推进。 直到下一次 pre_forward / pre_backwardwait() 才同步到 CPU, 此时通信通常已经完成,实际等待时间趋近于零。

forward_step_helper 内部调用链

每次前向事件最终都会走到 forward_step_helper(vid), 它将 vid 解码为具体的 chunk/microbatch,然后按下面的链路驱动计算:

forward_step_helper(vid) │ ├─ get_model_chunk_id(vid, forward=True) → chunk_id ├─ get_microbatch_id(vid) → microbatch_id │ ├─ forward_step_helper_preprocess(chunk_id) │ └─ input_tensor = input_tensors[chunk_id][offset_f] # O(1) 索引 │ offset_f[chunk_id] += 1 │ ├─ model[chunk_id].set_input_tensor(input_tensor) │ └─ 把 P2P 收到的 hidden_states 注入 TransformerBlock │ (first stage 的 chunk 此处 input_tensor=None, │ forward 时直接从 embedding 读取) │ ├─ forward_step_func(data_iterator[chunk_id], model[chunk_id]) │ │ ← pretrain_gpt.forward_step │ ├─ get_batch(data_iterator[chunk_id]) │ │ → tokens, labels, loss_mask, attention_mask, position_ids │ │ (若 data_iterator[chunk_id] is None,返回 dummy tensors) │ ├─ model[chunk_id].forward(input_ids=tokens, ...) │ │ → output_tensor (hidden_states 或 last stage 的 logits) │ └─ return (output_tensor, loss_func) │ └─ forward_step_helper_postprocess(output_tensor, chunk_id) └─ output_tensors[chunk_id].append(output_tensor) # 存入队列,等待对应的 backward 消费
loss_func 何时被调用

forward_step_func 返回的 loss_func 是一个 partial 函数, 它在 last stage(post_process=True) 的 forward 完成后、 backward 开始前被调用:loss = loss_func(output_tensor)。 loss scalar 作为反向传播的起点(output_tensor_grad = None), PyTorch 自动计算梯度并向上游 chunk 传递。 非 last stage 收到的 output_tensor_grad 来自下游 stage 的 P2P send。

backward_step_helper 调用链反向对称逻辑
backward_step_helper(b_vid) │ ├─ get_model_chunk_id(b_vid, forward=False) → chunk_id (已翻转) ├─ get_microbatch_id(b_vid) → microbatch_id │ ├─ output_tensor = output_tensors[chunk_id][offset_b] │ output_tensor_grad = output_tensor_grads[chunk_id][offset_bg] │ (last stage: output_tensor_grad=None,由 loss 自动提供) │ ├─ custom_backward(output_tensor, output_tensor_grad) │ └─ 调用 PyTorch autograd,沿计算图反向传播 │ 若 deallocate_pipeline_outputs=True,output_tensor.data │ 已在 post_forward 时被替换为空标量,此处通过 C++ engine │ 直接调用 grad_fn 跳过形状检查 │ └─ input_tensor_grad = input_tensor.grad └─ 即 forward 时 recv buffer 上的梯度 这个梯度通过 P2P send_backward 发给上一 stage
04

P2P 通信层四层架构

isend / irecv 奇偶交替防死锁 _communicate() P2PCommunicator

P2P 通信被封装成清晰的四层结构,从底层 PyTorch 原语到高层语义 API, 每一层都有明确职责:

flowchart TB L4["Layer 4:P2PCommunicator 语义方法
send_forward_recv_forward / send_backward_recv_backward
recv_forward / send_forward_recv_backward / ..."] L3["Layer 3:_communicate()
分配 recv buffer(requires_grad=True)
选择 batch 或 non-batch 实现 · 同步控制"] L2["Layer 2:_p2p_ops()
奇偶交替(even: send→recv,odd: recv→send)
构建 P2POpList"] L1["Layer 1:PyTorch 原语
torch.distributed.isend / irecv / batch_isend_irecv"] L4 --> L3 --> L2 --> L1 style L4 fill:#1a2740,stroke:#58a6ff,color:#e6edf3 style L3 fill:#1a2740,stroke:#d2a8ff,color:#e6edf3 style L2 fill:#1a2740,stroke:#f0883e,color:#e6edf3 style L1 fill:#1a2740,stroke:#3fb950,color:#e6edf3

Layer 1:PyTorch 原语

最底层是 PyTorch 分布式通信原语:

  • torch.distributed.isend(tensor, dst, group, tag) — 异步发送,立即返回一个 Work handle,不阻塞调用方
  • torch.distributed.irecv(tensor, src, group, tag) — 异步接收,tensor 必须预先分配好,handle.wait() 前数据未到
  • torch.distributed.batch_isend_irecv(p2p_op_list) — 批量提交多个 isend/irecv,减少通信轮次,提升吞吐

所有操作都是非阻塞的:提交后 GPU 通信 kernel 开始执行, CPU 继续运行。调用 handle.wait() 才真正等待完成。

Layer 2:_p2p_ops — 奇偶交替防死锁

当不使用 batch_isend_irecv 时(batch_p2p_comm=False), 独立的 isend/irecv 存在死锁风险:若所有 GPU 都先阻塞 send, send buffer 满了却没有人 recv,整个系统就会永久挂起。

解决方案:奇偶交替——偶数 rank 先发送后接收, 奇数 rank 先接收后发送。这样总有一方(奇数 rank)先 recv, 打破潜在的循环等待。

p2p_communication.py · _p2p_ops 奇偶逻辑p2p_communication.py:78
def _p2p_ops(tensor_send_next, tensor_send_prev,
             tensor_recv_prev, tensor_recv_next,
             group, ...):
    ops = []
    if group.rank() % 2 == 0:
        # ── 偶数 rank:先 send,再 recv ──────────────────────────
        if tensor_send_next is not None:
            ops.append(dist.P2POp(dist.isend, tensor_send_next,
                                  next_pipeline_rank, group))
        if tensor_recv_prev is not None:
            ops.append(dist.P2POp(dist.irecv, tensor_recv_prev,
                                  prev_pipeline_rank, group))
        if tensor_send_prev is not None:
            ops.append(dist.P2POp(dist.isend, tensor_send_prev,
                                  prev_pipeline_rank, group))
        if tensor_recv_next is not None:
            ops.append(dist.P2POp(dist.irecv, tensor_recv_next,
                                  next_pipeline_rank, group))
    else:
        # ── 奇数 rank:先 recv,再 send ──────────────────────────
        if tensor_recv_prev is not None:
            ops.append(dist.P2POp(dist.irecv, tensor_recv_prev,
                                  prev_pipeline_rank, group))
        if tensor_send_next is not None:
            ops.append(dist.P2POp(dist.isend, tensor_send_next,
                                  next_pipeline_rank, group))
        if tensor_recv_next is not None:
            ops.append(dist.P2POp(dist.irecv, tensor_recv_next,
                                  next_pipeline_rank, group))
        if tensor_send_prev is not None:
            ops.append(dist.P2POp(dist.isend, tensor_send_prev,
                                  prev_pipeline_rank, group))
    return ops
pp=2 时的特殊处理

pp_size=2 时,两个 rank 互为邻居(prev=next)。 Megatron 使用两个不同的 communicator groupeven_send_odd_recv_groupeven_recv_odd_send_group) 来允许两个通信方向(forward 和 backward)在不同 group 上并发, 避免 group 内部的顺序约束。UCC backend 例外,需使用同一 communicator。

Layer 3:_communicate() — 核心中间层

_communicate() 是真正的核心:它分配 recv buffer、 组装 ops、控制同步策略。

p2p_communication.py · _communicate()(简化)p2p_communication.py:148
def _communicate(tensor_send_next, tensor_send_prev,
                 recv_prev, recv_next,
                 tensor_shape, config,
                 wait_on_reqs=True, ...):
    # ── Step 1:预分配 recv buffer ──────────────────────────────
    tensor_recv_prev = tensor_recv_next = None
    if recv_prev:
        tensor_recv_prev = torch.empty(
            tensor_shape,
            requires_grad=True,        # ← 反向传播从这里起始,必须为 True
            device=torch.cuda.current_device(),
            dtype=config.params_dtype,
        )
    if recv_next:
        tensor_recv_next = torch.empty(tensor_shape, requires_grad=True, ...)

    # ── Step 2:构建 ops 列表(奇偶交替) ───────────────────────
    if config.use_ring_exchange_p2p:
        # ring_exchange 实现(同时和前后邻居交换)
        reqs = _ring_exchange_p2p(...)
    elif config.batch_p2p_comm:
        ops = _p2p_ops(tensor_send_next, tensor_send_prev,
                       tensor_recv_prev, tensor_recv_next, ...)
        reqs = dist.batch_isend_irecv(ops)  # 批量提交
    else:
        reqs = _p2p_ops_non_batch(...)      # 逐个提交

    # ── Step 3:同步控制 ─────────────────────────────────────────
    if wait_on_reqs:
        for req in reqs:
            req.wait()                      # 阻塞,等所有通信完成
        return tensor_recv_prev, tensor_recv_next, None
    else:
        # overlap 模式:不等待,把 reqs 列表返回给调用方
        # 调用方在下一次 pre_forward/pre_backward 时再 wait
        return tensor_recv_prev, tensor_recv_next, reqs
wait_on_reqs=False 时的 reqs 是什么

返回的 reqsList[torch.distributed.Work], 每个元素对应一个 isend 或 irecv 操作的句柄。 调用方(通常是 pp_post_forward / pp_post_backward)把它存进 recv_prev_wait_handles[next_vid],在下一次 pp_pre_forward(next_vid) 时调用 handle.wait()。这个异步 handle 机制是实现通信-计算 overlap 的关键。

Layer 4:P2PCommunicator 语义方法

最高层是面向调度器的语义 API,隐藏了底层参数细节。 每个方法名直接描述"做什么"(send/recv + 方向 + 对象):

方法 方向 调用时机 底层参数
recv_forward() ← prev warmup 第一步,接收 first input recv_prev=True
send_forward(out) → next warmup 中纯 send(leading stage 后) send_next=out
send_forward_recv_forward(out) → next & ← prev warmup 中间步骤:send 当前 output + recv 下个 input send_next=out, recv_prev=True
send_forward_recv_backward(out) → next & ← next warmup→steady 过渡:最后一个 warmup forward send_next=out, recv_next=True
recv_backward() ← next cooldown 纯 recv backward 的第一步 recv_next=True
send_backward_recv_backward(grad) → prev & ← next steady/cooldown:send 当前梯度 + recv 下个梯度 send_prev=grad, recv_next=True
send_backward_recv_forward(grad) → prev & ← prev steady:backward 完成后,发梯度同时 recv 下轮 forward 的 input send_prev=grad, recv_prev=True
send_backward(grad) → prev cooldown 最后一步 send_prev=grad
VPP 中 prev_rank / next_rank 的特殊含义重要

在普通 PP 中,prev_rank = pp_rank - 1next_rank = pp_rank + 1。 但 VPP 的流水线是循环的:数据经过 chunk 0 的所有 stage 后, 需要从 last stage 绕回 first stage 才能进入 chunk 1。

因此对于 pp=2(只有 GPU0 和 GPU1):

  • GPU0:prev_rank = GPU1(循环前驱),next_rank = GPU1
  • GPU1:prev_rank = GPU0next_rank = GPU0(循环后继)

这意味着 GPU1 在 chunk 0 完成 forward 后,通过 send_next → GPU0 把激活值送给 GPU0 的 chunk 1 forward,这正是 VPP 交错调度的核心 P2P 路径。

P2P 环形拓扑(pp=2, V=2)
GPU 0
chunk 0 chunk 1
rank 0
next_rank →
send_forward
← prev_rank
recv_forward
GPU 1
chunk 0 chunk 1
rank 1
(rank+1) % world_size → pp=2 时双方互为 next/prev,形成环
VPP 前向数据路径(pp=2, V=2,一个 microbatch)
第 1 跳
GPU0
chunk 0
send_next
GPU1
chunk 0
第 2 跳
GPU1
chunk 0
send_next绕回!
GPU0
chunk 1
第 3 跳
GPU0
chunk 1
send_next
GPU1
chunk 1
→ loss
GPU0:c0GPU1:c0GPU0:c1GPU1:c1 → loss
p2p_communication.py · 循环拓扑的建立(P2PCommunicator.__init__)
# p2p_communication.py:146-163
class P2PCommunicator:
    def __init__(self, pp_group, config):
        world_size = self.pp_group.size()          # pp=2 → world_size=2
        curr_rank_in_pg = self.pp_group.rank()     # GPU0→0, GPU1→1

        # 关键:模运算形成环形拓扑
        next_rank_pg = (curr_rank_in_pg + 1) % world_size  # GPU0→1, GPU1→0(绕回!)
        prev_rank_pg = (curr_rank_in_pg - 1) % world_size  # GPU0→1(绕回!), GPU1→0

        self.next_rank = dist.get_global_rank(self.pp_group, next_rank_pg)
        self.prev_rank = dist.get_global_rank(self.pp_group, prev_rank_pg)
p2p_communication.py · send_forward / recv_forward 使用 next_rank / prev_rank
# p2p_communication.py:465-483 — send_forward
def send_forward(self, output_tensors, is_last_stage):
    """Send tensor to next rank in pipeline (forward send)."""
    for output_tensor in output_tensors:
        if not is_last_stage:
            self._communicate(
                tensor_send_next=output_tensor,  # ← 发给 self.next_rank
                tensor_send_prev=None,
                recv_prev=False,
                recv_next=False,
            )

# p2p_communication.py:402-431 — recv_forward
def recv_forward(self, tensor_shapes, is_first_stage):
    """Receive tensor from previous rank in pipeline (forward receive)."""
    for tensor_shape in tensor_shapes:
        if is_first_stage:
            input_tensor = None          # ← first stage 不 recv,用 None
        else:
            input_tensor, _, _ = self._communicate(
                tensor_send_next=None,
                tensor_send_prev=None,
                recv_prev=True,          # ← 从 self.prev_rank 收
                recv_next=False,
            )

# p2p_communication.py:376-384 — _communicate 内部实际使用的 rank
p2p_reqs = p2p_func(
    tensor_send_prev=tensor_send_prev,
    tensor_recv_prev=tensor_recv_prev,
    tensor_send_next=tensor_send_next,       # → self.next_rank
    tensor_recv_next=tensor_recv_next,       # ← self.next_rank
    prev_pipeline_rank=self.prev_rank,       # 环形拓扑的 prev
    next_pipeline_rank=self.next_rank,       # 环形拓扑的 next
)

注意 send_forwardrecv_forward 本身不知道 VPP—— 它们只管"发给 next_rank / 从 prev_rank 收"。VPP 的绕回完全由 __init__(rank + 1) % world_size 的环形拓扑决定。上层的 recv_tensor_from_previous_stage() 负责判断是否要 recv以及收到的张量存入哪个 chunk 的队列

05

Warmup 阶段逐步解析

pp=2 V=2 M=4 GPU0 warmup=4 buffer 状态追踪 P2P 时序

pp=2,V=2,M=4 为例,逐步追踪 GPU0(rank=0,warmup=4)的每一个 warmup 步骤,包括 vid、chunk_id、micro-batch、input/output 缓冲区状态,以及 P2P 操作。

初始状态:所有缓冲区为空。第一步前调度器先触发 recv,判断 vid=0 对应 chunk=0, GPU0 的 model[0] 有 pre_process=True(leading stage), 不需要从 prev recv,将 None 放入队列。

Step 0 — f_vid=0 → F(chunk=0, mb=0)

计算

  • chunk_id=0,pre_process=True
  • input_tensor = None(从 data_iterator[0] 读入数据)
  • model[0].forward(tokens) → out(c0,m0)
  • output_tensors[0].append(out(c0,m0))

P2P

  • send_forward(out(c0,m0)) → GPU1
  • next_vid=1 → chunk=0 → leading → no recv
  • input_tensors[0].append(None)
缓冲区快照:input_tensors=[[], []] output_tensors=[[out_c0m0], []]
Step 1 — f_vid=1 → F(chunk=0, mb=1)

计算

  • chunk_id=0,仍是 leading stage
  • input_tensor = None(从 data_iterator[0])
  • model[0].forward(tokens) → out(c0,m1)
  • output_tensors[0].append(out(c0,m1))

P2P

  • send_forward(out(c0,m1)) → GPU1
  • next_vid=2 → chunk=1 → 非 leading → 需要 recv
  • 发起 send_forward_recv_forward
  • 等待 GPU1 完成 F(c0,m0) 并回传激活
  • input_tensors[1].append(recv_c1m0)
缓冲区快照:input_tensors=[[], [recv_c1m0]] output_tensors=[[out_c0m0, out_c0m1], []]
Step 2 — f_vid=2 → F(chunk=1, mb=0)

计算

  • chunk_id=1,pre_process=False
  • input_tensor = recv_c1m0(从 input_tensors[1] 取出)
  • model[1].set_input_tensor(recv_c1m0)
  • model[1].forward() → out(c1,m0)
  • output_tensors[1].append(out(c1,m0))

P2P

  • send_forward(out(c1,m0)) → GPU1
  • next_vid=3 → chunk=1 → 非 leading → recv
  • 等待 GPU1 完成 F(c0,m1) 并回传
  • input_tensors[1].append(recv_c1m1)
缓冲区快照:input_tensors=[[], [recv_c1m1]] output_tensors=[[c0m0,c0m1], [out_c1m0]]
Step 3 — f_vid=3 → F(chunk=1, mb=1) ← warmup 最后一步

计算

  • chunk_id=1,pre_process=False
  • input_tensor = recv_c1m1
  • model[1].forward() → out(c1,m1)
  • output_tensors[1].append(out(c1,m1))

P2P(warmup→steady 过渡)

  • send_forward_recv_backward(out(c1,m1))
  • send out(c1,m1) → GPU1(最后一个 warmup forward)
  • 同时 recv 来自 GPU1 的第一个 backward grad
  • output_tensor_grads[1].append(recv_grad_c1m0)
★ warmup 结束时缓冲区快照:
input_tensors = [[], []] ← 全部消费完
output_tensors = [[c0m0,c0m1], [c1m0,c1m1]] ← 等待 backward 消费
output_tensor_grads = [[], [recv_grad_c1m0]] ← 第一个 grad 已就绪
Warmup 结束时的关键不变式
  • input_tensors 所有队列已清空——warmup 期所有 forward 的 input 都已消费
  • output_tensors[c] 保存了 warmup 期 chunk c 所有 forward output, 留给 cooldown 阶段的 backward 使用
  • output_tensor_grads 中已有第一个梯度,steady state 可立即开始 backward
  • 进入 steady state 时,流水线中已有多个 microbatch 在不同 stage "飞行中"(in-flight), 这正是 1F1B 能保持 GPU 忙碌的原因
GPU1 warmup(仅 2 步)简要追踪对比

GPU1(rank=1,warmup=2),chunk 0 在 GPU1 上 pre_process=False, 每步都需要从 GPU0 recv:

Step 0 — f_vid=0 → F(c0, m0) recv_forward() ← GPU0 # recv GPU0 发出的 out(c0,m0) model[0].forward(recv_c0m0) → out_gpu1_c0m0 output_tensors[0] = [out_gpu1_c0m0] send_forward_recv_forward(out_gpu1_c0m0) # send 回 GPU0(chunk1 的 input),recv 下个 mb 的 input Step 1 — f_vid=1 → F(c0, m1) recv 已在上一步 post_forward 中预取 model[0].forward(recv_c0m1) → out_gpu1_c0m1 output_tensors[0] = [out_gpu1_c0m0, out_gpu1_c0m1] send_forward_recv_backward(out_gpu1_c0m1) # warmup→steady 过渡 output_tensor_grads[1] = [recv_grad_c1m0] # 收到第一个梯度 → GPU1 进入 steady state(比 GPU0 早 2 步)
06

Steady State(1F1B)阶段

1F1B overlap_p2p_comm 通信计算并行 CUDA stream

Steady state 是整个调度器效率最高的阶段:每个时间槽同时完成一次前向和一次反向, GPU 始终处于忙碌状态。以 overlap_p2p_comm=True 模式为例, 一个稳态步骤(steady step)的完整时序如下:

sequenceDiagram participant CPU as CPU 控制流 participant GPU as GPU 计算流 participant NET as NVLink/IB 通信流 Note over CPU,NET: Steady Step k(f_vid=k+warmup,b_vid=k) CPU->>GPU: pp_pre_forward():recv_prev_handle.wait() Note over NET: 前一步已提交的 irecv 此时应已完成 CPU->>GPU: forward_step_helper(f_vid) activate GPU GPU-->>GPU: TransformerBlock.forward()(matmul) deactivate GPU CPU->>NET: pp_post_forward(output_tensor):isend(out→next) + irecv(buf←prev) activate NET Note over GPU: 计算下一步 backward,同时 NET 搬运数据 CPU->>GPU: pp_pre_backward():recv_next_handle.wait() Note over NET: 前一步梯度 irecv 应已完成 deactivate NET CPU->>GPU: backward_step_helper(b_vid) activate GPU GPU-->>GPU: autograd.backward()(matmul + grad 累积) deactivate GPU CPU->>NET: pp_post_backward(grad):isend(grad→prev) + irecv(buf←next) activate NET Note over GPU: 进入下一轮 pre_forward,NET 继续传输 deactivate NET
"通信与计算并行"的具体含义

CUDA 有独立的 compute streamcommunication stream

  • isend/irecv 提交后,通信 kernel 在独立的 CUDA stream 上执行, 不阻塞 compute stream 上的 matmul。
  • CPU 在提交 isend/irecv立刻推进到下一步计算, CPU 也不阻塞。
  • 下一步的 pre_forward.wait() 通常在通信已经完成的情况下立刻返回, 实际等待时间趋近于零(通信时间已被计算时间掩盖)。
  • 当计算时间 ≥ 通信时间时,通信开销被完全隐藏, GPU 利用率达到最大。

GPU0 的 4 个 Steady 步骤(pp=2,V=2,M=4)

GPU0 Steady 步骤追踪(f_vid=4-7,b_vid=0-3)
# ── Steady Step 0:f_vid=4 → F(c0,m2);b_vid=0 → B(c1,m0) ──────────
# 前向:
pp_pre_forward()         # wait recv handle(vid=4,chunk=0,leading stage,无 recv,直接返回)
forward_step_helper(4)   # F(chunk=0, mb=2) → out(c0,m2)
pp_post_forward(out_c0m2)
  # isend(out_c0m2 → GPU1)       [异步,不等待]
  # irecv(buf ← GPU1)            [预取 f_vid=5 的 input,chunk=0,leading=True,无需 recv]
# 反向:
pp_pre_backward()        # wait recv handle for B(c1,m0) → output_tensor_grads[1][0] 已就绪
backward_step_helper(0)  # B(chunk=1, mb=0),消费 output_tensors[1][0] = out(c1,m0)
pp_post_backward(grad_c1m0)
  # isend(grad_c1m0 → GPU1)      [发梯度给 GPU1]
  # irecv(buf ← GPU1)            [预取 b_vid=1 的梯度]

# ── Steady Step 1:f_vid=5 → F(c0,m3);b_vid=1 → B(c1,m1) ──────────
forward_step_helper(5)   # F(chunk=0, mb=3) → out(c0,m3)
backward_step_helper(1)  # B(chunk=1, mb=1),消费 output_tensors[1][1] = out(c1,m1)

# ── Steady Step 2:f_vid=6 → F(c1,m2);b_vid=2 → B(c0,m0) ──────────
# 注意:vid=6 → chunk=1,需要 recv from GPU1
pp_pre_forward()         # wait irecv handle(recv chunk=1 的 input)
forward_step_helper(6)   # F(chunk=1, mb=2),set_input_tensor(recv) → out(c1,m2)
pp_post_forward(out_c1m2)
  # isend(out_c1m2 → GPU1),irecv 下个 input
backward_step_helper(2)  # B(chunk=0, mb=0),消费 output_tensors[0][0] = out(c0,m0)
  # 梯度传向 chunk=0 的上游(对 GPU0 来说,chunk=0 是 leading,grad=None,无需 P2P send)

# ── Steady Step 3:f_vid=7 → F(c1,m3);b_vid=3 → B(c0,m1) ──────────
forward_step_helper(7)   # F(chunk=1, mb=3) → out(c1,m3)
backward_step_helper(3)  # B(chunk=0, mb=1)
# → steady 结束,进入 cooldown
Steady state 中 output_tensors 的消费顺序详细追踪

backward_step_helper 按 b_vid 顺序依次消费 output_tensors 里积累的 tensor:

b_vid → (chunk, mb) [chunk已翻转] → 消费 output_tensors[chunk][offset] b_vid=0: B(c1,m0) → output_tensors[1][0] = out_c1m0 ✓ warmup 时存入 b_vid=1: B(c1,m1) → output_tensors[1][1] = out_c1m1 ✓ warmup 时存入 b_vid=2: B(c0,m0) → output_tensors[0][0] = out_c0m0 ✓ warmup 时存入 b_vid=3: B(c0,m1) → output_tensors[0][1] = out_c0m1 ✓ warmup 时存入 b_vid=4: B(c1,m2) → output_tensors[1][2] = out_c1m2 ← steady forward f_vid=6 刚产生 b_vid=5: B(c1,m3) → output_tensors[1][3] = out_c1m3 ← steady forward f_vid=7 刚产生 b_vid=6: B(c0,m2) → output_tensors[0][2] = out_c0m2 ← steady forward f_vid=4 刚产生 b_vid=7: B(c0,m3) → output_tensors[0][3] = out_c0m3 ← steady forward f_vid=5 刚产生

1F1B 的关键性质:每个 forward 产生的 output_tensor 恰好在下一批 backward 时被消费, 内存峰值被严格控制——缓冲区中至多同时存在 warmup 个 output_tensor, 这正是 VPP 比朴素调度显存效率高的原因。

07

Cooldown 阶段

纯反向 排空积压 grad send

Cooldown 阶段是 warmup 的镜像:warmup 积累了多少个前向(warmup 步), cooldown 就需要消化多少个纯反向。此阶段没有任何新的前向计算, 只是把 output_tensors 中剩余的 warmup 缓存全部做完反向, 并把梯度通过 P2P 回传给上游 stage。

GPU0 Cooldown 步骤(b_vid=4-7,共 4 步)
# Cooldown 只调用 backward_step_helper,无 forward_step_helper
for b_vid in range(steady_steps, total_backward_steps):
    # b_vid=4 → B(c1,m2);b_vid=5 → B(c1,m3)
    # b_vid=6 → B(c0,m2);b_vid=7 → B(c0,m3)

    pp_pre_backward()                   # wait for grad recv handle
    backward_step_helper(b_vid)         # autograd backward,消费 output_tensors

    if b_vid < total_backward_steps - 1:
        pp_post_backward(input_tensor_grad)
        # send 梯度给上游 + recv 下一个梯度(非最后一步)
    else:
        p2p_comm.send_backward(input_tensor_grad)
        # 最后一步:只 send,不再 recv
Cooldown 与 Steady Backward 的区别
  • Steady backward:做完 backward 后,在 pp_post_backward 中同时 send 梯度并 recv 下一个梯度,也可能 recv 下一步 forward 的 input (send_backward_recv_forward)。
  • Cooldown backward:不再有 forward,pp_post_backward 只 send 梯度 + recv 下一个梯度(send_backward_recv_backward)。
  • 最后一个 cooldown 步骤只做 send_backward,不再 recv。

三阶段全局小结

阶段 GPU0 步数 GPU1 步数 前向 反向 P2P 模式
Warmup 42 send_forward_recv_forward
结束时 send_forward_recv_backward
Steady 4 对6 对 send_forward_recv_forward/backward
send_backward_recv_forward/backward
Cooldown 42 send_backward_recv_backward
末步 send_backward
forward_only=True 时的简化

forward_only=True(推理/评估模式)时, 跳过全部 backward 逻辑:无 backward_step_helper、无梯度 recv、 无 send_backwardoutput_tensor_gradsoutput_tensors 也无需保留(可提前释放显存)。 Warmup 和 Cooldown 都只做纯前向,steady state 退化为纯 forward 流水线。

08

完整数据流全景图

pp=2 V=2 M=4 GPU0 / GPU1 时间线 P2P 路径 buffer 快照

pp=2,V=2,M=4 为参数,展示 GPU0 和 GPU1 的完整时间线。 每个格子代表一个计算时间槽(≈ 一次前向或反向的时间),P2P 传输在相邻格子间并发进行。

F(c0,·) chunk 0 前向 F(c1,·) chunk 1 前向 B(c0,·) chunk 0 反向 B(c1,·) chunk 1 反向 bubble / 等待
← Warmup (GPU0=4) →
← Steady State 1F1B(含1个pipeline bubble)→
← Cooldown(含2个pipeline bubble)→
GPU0 (rank=0)
F
c0,m0
F
c0,m1
F
c1,m0
F
c1,m1
F
c0,m2
·
B
c1,m0
F
c0,m3
B
c1,m1
F
c1,m2
B
c0,m0
F
c1,m3
B
c0,m1
·
B
c1,m2
·
B
c1,m3
B
c0,m2
B
c0,m3
P2P
↓↑
↓↑
↓⇑
·
⇑⇓
⇑⇓
↓↑
·
⇑⇓
·
⇑⇓
等待
← Warmup (GPU1=2)
← Steady State 1F1B (6 对) →
Cooldown
GPU1 (rank=1)
·
F
c0,m0
F
c0,m1
F
c1,m0
B
c1,m0
F
c1,m1
B
c1,m1
F
c0,m2
B
c0,m0
F
c0,m3
B
c0,m1
F
c1,m2
B
c1,m2
F
c1,m3
B
c1,m3
B
c0,m2
B
c0,m3
= send_forward → GPU1 (激活值) = recv_forward ← GPU1 (chunk1 input,GPU1→GPU0 绕回) ⇑⇓ = 梯度双向(send grad + recv grad)
VPP 前向数据路径(pp=2, V=2): 时间槽 0-1: GPU0 ─ F(c0,m0) ──→─ GPU1 ─ F(c0,m0) ──→─ GPU0 ↑ 绕回(GPU1.next = GPU0) GPU0 ─ F(c1,m0) ──→─ GPU1 ─ F(c1,m0) ─→─ loss/output VPP 反向梯度路径(pp=2, V=2): GPU1 ─ B(c1,m0) ──⇒─ GPU0 ─ B(c1,m0) ──⇒─ GPU1 ↑ 绕回(GPU0.prev = GPU1) GPU1 ─ B(c0,m0) ──⇒─ GPU0 ─ B(c0,m0) ─→─ param.grad 累积 注:─→ = 激活值 send_forward;─⇒ = 梯度 send_backward

关键时刻的缓冲区快照(GPU0)

时间点 input_tensors[0] input_tensors[1] output_tensors[0] output_tensors[1] output_tensor_grads
初始 [][] [][] [[], []]
Warmup 结束
时间槽 3 后
[][] [m0,m1] [m0,m1] [[], [grad_c1m0]]
Steady S0 后
F(c0,m2)+B(c1,m0)
[][] [m0,m1,m2] [m1] (m0 已消费) [[], [grad_c1m1]]
Steady 全部结束
时间槽 11 后
[][] [m0-m3] (0,1已消费) [m0-m3] (0,1已消费) grads 持续消费
Cooldown 结束
时间槽 15 后
[][] [] 全清[] 全清 [[], []] 全清
全景图关键结论
  • GPU1 比 GPU0 早 2 步进入 steady(warmup=2 vs 4), 因此 GPU1 的 steady 持续 6 对、cooldown 只有 2 步。总计算事件数相同(各 16 个)。
  • slot 0 时 GPU1 有一个 bubble(等 GPU0 完成第一个 F(c0,m0)), 这是 VPP 调度不可避免的启动开销,但仅 1 个 slot。
  • steady state 期间两个 GPU 几乎完全满负荷, 每个时间槽都有 F 或 B 在计算,P2P 通信在 overlap 模式下被完全隐藏。
  • output_tensors 的峰值大小 = warmup 步数(GPU0=4,GPU1=2), 这与标准 1F1B 相比增大了(VPP 代价),但 bubble 率从 \(\frac{p-1}{m}\) 降至 \(\frac{p-1}{m \cdot v}\)。