函数入口与核心变量初始化
整个 VPP 调度器的入口是 forward_backward_pipelining_with_interleaving。
它是一个"批导体":接收用户的 forward_step_func、数据和模型,
将 V×M 个计算事件按三阶段(warmup → 1F1B → cooldown)安排在所有 GPU 上有序执行,
同时通过 P2P 通信在 stage 间传递激活值和梯度。
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.py 的 forward_step。
它的职责是:从 data_iterator 取出一个 micro-batch,
调用 model.forward,并返回一个 (output_tensor, loss_func) 元组。
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=True 或
post_process=True 的 chunk 才有真实 iterator:
| GPU / Chunk | pre_process | post_process | data_iterator[c] | 用途 |
|---|---|---|---|---|
| GPU0 · model[0] chunk 0,layers 0–(N/2-1) |
True | False | real_iterator |
提供 input_ids / position_ids(Embedding 需要) |
| GPU0 · model[1] chunk 1,layers N/2–(N-1) |
False | False | None |
中间段,input 来自 P2P,不读数据 |
| GPU1 · model[0] | False | False | None |
中间段 |
| GPU1 · model[1] chunk 1,last stage |
False | True | real_iterator |
提供 labels / loss_mask(计算 cross-entropy loss 需要) |
中间 chunk 的 input 由 P2P 通信接收——前一个 stage 的 hidden_states 直接注入
TransformerBlock。设为 None 可以在 get_batch 里
做快速判断,避免数据加载操作(数据加载是 CPU 上的重量级操作,不应在无关的 rank 上触发)。
③ 三个共享缓冲区:调度器的"传送带"
这三个 list-of-list 贯穿整个调度器生命周期,是 warmup / steady / cooldown 各阶段之间传递中间结果的核心数据结构:
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 的反向梯度输入队列;反向传播的"起点" |
在 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] 计数器:
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 的形状:
# 默认形状:[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),两者可叠加,并不互斥
两者都对 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
同时开启的情形,说明两者可以叠加使用。
在 _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 的参数将永远收不到正确梯度。
virtual_microbatch_id 与主循环三阶段
VPP 调度器面临的核心问题是:如何把 V 个 chunk × M 个 micro-batch 的所有计算事件 线性化成一个可遍历的序列?答案是 virtual_microbatch_id(vid)。
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。
这样可以确保流水线中的数据依赖关系被满足。
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)。
# 前向 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 都"装满"数据:
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 有足够的输入数据才能开始执行
| 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 |
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 反向消费,保持不变。
################################################################################
# 阶段一: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)
| 阶段 | k(循环变量) | fid → get_model_chunk_id(fid, True) → (chunk, mb) | bid → get_model_chunk_id(bid, False) → B(chunk↑, mb) |
|---|---|---|---|
| Warmup 纯前向 |
k=0 | fid=0 → (c0, m0) | — |
| k=1 | fid=1 → (c0, m1) | — | |
| k=2 | fid=2 → (c1, m0) | — | |
| k=3 | fid=3 → (c1, m1) | — | |
| Steady 1F1B |
k=0 | fid=4 → (c0, m2) | bid=0 → B(c1, m0) |
| k=1 | fid=5 → (c0, m3) | bid=1 → B(c1, m1) | |
| k=2 | fid=6 → (c1, m2) | bid=2 → B(c0, m0) | |
| k=3 | fid=7 → (c1, m3) | bid=3 → B(c0, m1) | |
| Cooldown 纯反向 |
k=0 | — | bid=4 → B(c1, m2) |
| k=1 | — | bid=5 → B(c1, m3) | |
| k=2 | — | bid=6 → B(c0, m2) | |
| k=3 | — | bid=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 倍耗时)
c0,m0
c0,m1
c1,m0
c1,m1
c0,m2
c1,m0
c0,m3
c1,m1
c1,m2
c0,m0
c1,m3
c0,m1
c1,m2
c1,m3
c0,m2
c0,m3
c0,m0
c0,m1
c1,m0
c1,m0
c1,m1
c1,m1
c0,m2
c0,m0
c0,m3
c0,m1
c1,m2
c1,m2
c1,m3
c1,m3
c0,m2
c0,m3
GPU0 warmup=4(稳态第1个B + cooldown前2个B各有1个pipeline bubble,共3个bubble) · GPU1 warmup=2 + 1等待泡沫
total_forward_steps = V × M = 8(每个 GPU 需完成的前向总数)。
steady_steps = total_forward_steps − warmup = 8 − warmup。
cooldown_steps = warmup(对称:warmup 积累了多少前向,cooldown 就有多少纯反向)。
| GPU | Warmup | Steady (1F1B) | Cooldown | 总计 |
|---|---|---|---|---|
| GPU 0 | 4 | 4 对 (4F+4B) | 4 | 8F + 8B = 16 事件 |
| GPU 1 | 2 | 6 对 (6F+6B) | 2 | 8F + 8B = 16 事件 |
input_tensors / output_tensors 队列的 append / index / pop 节奏核心机制
调度器用两组列表(每个 chunk 一个槽)管理前向激活值和梯度的生命周期。 理解何时 append、何时 index 读取、何时 pop 消费, 是看懂整个 VPP 流水线的钥匙。
# ── 调用时机:每次 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) 步。
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
# 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)
###############################################################################
# ① 前向 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 激活值)
# ─── 启动前(主循环开始前的单次 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 得到当前实际下标 ──
- 前向只读不消费:
input_tensors[c][idx]用 INDEX 取,不 pop, 因为同一个input_tensor在反向时还要用(它的.grad就是 input_tensor_grad)。 - 反向一次消费三个:
input_tensors.pop(0)/output_tensors.pop(0)/output_tensor_grads.pop(0)三合一,对应一次完整的 backward_step。 - recv 存到 next_chunk 而非当前 chunk:当前步的 input 在 preprocess 时已 index 到了,
P2P recv 拿到的是下一步前向的 input,所以存入
input_tensors[next_chunk_id]。
forward_backward_helper_wrapper 的角色
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_backward 的 wait() 才同步到 CPU,
此时通信通常已经完成,实际等待时间趋近于零。
forward_step_helper 内部调用链
每次前向事件最终都会走到 forward_step_helper(vid),
它将 vid 解码为具体的 chunk/microbatch,然后按下面的链路驱动计算:
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 调用链反向对称逻辑
P2P 通信层四层架构
P2P 通信被封装成清晰的四层结构,从底层 PyTorch 原语到高层语义 API, 每一层都有明确职责:
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)— 异步发送,立即返回一个Workhandle,不阻塞调用方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, 打破潜在的循环等待。
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_size=2 时,两个 rank 互为邻居(prev=next)。
Megatron 使用两个不同的 communicator group
(even_send_odd_recv_group 和 even_recv_odd_send_group)
来允许两个通信方向(forward 和 backward)在不同 group 上并发,
避免 group 内部的顺序约束。UCC backend 例外,需使用同一 communicator。
Layer 3:_communicate() — 核心中间层
_communicate() 是真正的核心:它分配 recv buffer、
组装 ops、控制同步策略。
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
返回的 reqs 是 List[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 - 1,next_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 = GPU0,next_rank = GPU0(循环后继)
这意味着 GPU1 在 chunk 0 完成 forward 后,通过 send_next → GPU0
把激活值送给 GPU0 的 chunk 1 forward,这正是 VPP 交错调度的核心 P2P 路径。
# 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: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_forward 和 recv_forward 本身不知道 VPP——
它们只管"发给 next_rank / 从 prev_rank 收"。VPP 的绕回完全由 __init__ 中
(rank + 1) % world_size 的环形拓扑决定。上层的 recv_tensor_from_previous_stage()
负责判断是否要 recv以及收到的张量存入哪个 chunk 的队列。
Warmup 阶段逐步解析
以 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 放入队列。
计算
- 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)
计算
- 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)
计算
- 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)
计算
- 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)
input_tensors = [[], []] ← 全部消费完
output_tensors = [[c0m0,c0m1], [c1m0,c1m1]] ← 等待 backward 消费
output_tensor_grads = [[], [recv_grad_c1m0]] ← 第一个 grad 已就绪
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:
Steady State(1F1B)阶段
Steady state 是整个调度器效率最高的阶段:每个时间槽同时完成一次前向和一次反向,
GPU 始终处于忙碌状态。以 overlap_p2p_comm=True 模式为例,
一个稳态步骤(steady step)的完整时序如下:
CUDA 有独立的 compute stream 和 communication 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)
# ── 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:
1F1B 的关键性质:每个 forward 产生的 output_tensor 恰好在下一批 backward 时被消费,
内存峰值被严格控制——缓冲区中至多同时存在 warmup 个 output_tensor,
这正是 VPP 比朴素调度显存效率高的原因。
Cooldown 阶段
Cooldown 阶段是 warmup 的镜像:warmup 积累了多少个前向(warmup 步),
cooldown 就需要消化多少个纯反向。此阶段没有任何新的前向计算,
只是把 output_tensors 中剩余的 warmup 缓存全部做完反向,
并把梯度通过 P2P 回传给上游 stage。
# 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
- 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 | 4 | 2 | ✓ | ✗ | send_forward_recv_forward结束时 send_forward_recv_backward |
| Steady | 4 对 | 6 对 | ✓ | ✓ | send_forward_recv_forward/backwardsend_backward_recv_forward/backward |
| Cooldown | 4 | 2 | ✗ | ✓ | send_backward_recv_backward末步 send_backward |
当 forward_only=True(推理/评估模式)时,
跳过全部 backward 逻辑:无 backward_step_helper、无梯度 recv、
无 send_backward。output_tensor_grads 和
output_tensors 也无需保留(可提前释放显存)。
Warmup 和 Cooldown 都只做纯前向,steady state 退化为纯 forward 流水线。
完整数据流全景图
以 pp=2,V=2,M=4 为参数,展示 GPU0 和 GPU1 的完整时间线。 每个格子代表一个计算时间槽(≈ 一次前向或反向的时间),P2P 传输在相邻格子间并发进行。
c0,m0
c0,m1
c1,m0
c1,m1
c0,m2
c1,m0
c0,m3
c1,m1
c1,m2
c0,m0
c1,m3
c0,m1
c1,m2
c1,m3
c0,m2
c0,m3
c0,m0
c0,m1
c1,m0
c1,m0
c1,m1
c1,m1
c0,m2
c0,m0
c0,m3
c0,m1
c1,m2
c1,m2
c1,m3
c1,m3
c0,m2
c0,m3
关键时刻的缓冲区快照(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}\)。