6.3 流水线并行推理
分析推理场景下 PP 段间传递的内容与通信量,解释 Decode 的步间依赖为什么需要多个 Batch 同时在飞,讨论层划分不均、单请求延迟与 KV 容量,以及 TP×PP 的组合选择
TP 让每张卡算每一层的一部分,PP 换了个方向:每张卡算完整的几层,算完把结果交给下一张卡。段与段之间只传一次激活,通信次数比 TP 少一到两个数量级,所以它是跨节点扩展的主要手段。
代价是”流水线”本身。只有一个 Batch 时,任意时刻只有一段在工作,其余都在等。训练用切 Micro-batch 的办法填满流水线,推理的 Decode 每一步依赖上一步的采样结果,填法不同。本节讲清这件事,再讨论层怎么分、延迟和容量怎么算,以及 TP 和 PP 怎么组合。
⏰ 信息时效:原理部分与框架无关;vLLM 相关描述基于 v0.32.0(2026-10-18 发布)。
📑 目录
- 1. 按层切:段之间传什么
- 2. 单个 Batch 填不满流水线
- 3. 推理怎么填:多个 Batch 交错
- 4. Prefill 与 Chunked Prefill
- 5. 层怎么分:最慢的一段决定速度
- 6. 延迟、容量与 TP×PP 组合
- 7. 动手算:PP 与 TP 的同卡数对比
- 总结
- 自我检验清单
- 参考资料
1. 按层切:段之间传什么
1.1 每段是一组连续的层
PP 把 层按顺序分成 段,每段(Stage)放在一张卡或一个 TP 组上。第一段还要负责 Embedding,最后一段负责最后的 Norm、LM Head 和采样。
一个 Batch 的前向就是依次走过各段:Stage 0 算完前几层,把输出发给 Stage 1,Stage 1 接着算,直到最后一段得到 Logits。
1.2 只传当前步的激活
段之间传的是当前这一步的隐藏状态,形状是 , 是本步的 Token 数。vLLM 的 Llama 实现里,非末段的输出打包成 IntermediateTensors,包含 hidden_states 和 residual 两个张量(残差连接要用到)。
KV Cache 不需要传。每段只为自己负责的那些层存 KV,Attention 在本段内就能完成。
以 Llama-3.1-70B、Decode 64 个请求为例,每个段边界每步传:
PP=2 时每步只有 1 次这样的传输,对比 TP 的 160 次 AllReduce,对网络的要求低得多。即使走 50 GB/s 的 InfiniBand,传输时间也只有约 40 µs。
📌 关键点:PP 的通信是点对点的单向发送,不需要所有卡同步。下一段只等上一段,和其他段无关。
2. 单个 Batch 填不满流水线
接着用第 6.1 节的后厨流水线:备料、炒菜、装盘各一个人。只有一桌客人时,炒菜的师傅要等备料完成,装盘的要等炒完。每个人只有三分之一的时间在干活。
推理的 Decode 比这还麻烦:第 步的输入是第 步采样出来的 Token。一个 Batch 必须走完全部 段、采样出新 Token,才能开始下一步。只有一个 Batch 时,任意时刻只有一段在工作,每段的利用率是 。
训练也有气泡,处理方法是把一个大 Batch 切成 个 Micro-batch,一个接一个塞进流水线。推理能借用这个思路,但有一条限制:同一个请求的相邻两步有严格的先后关系,所以同时在流水线里的必须是不同请求组成的 Batch。同一个请求的第 步,无论怎么切都不能在第 步走完之前开始。
3. 推理怎么填:多个 Batch 交错
3.1 让 个 Batch 同时在飞
办法是把当前正在运行的请求分成 组,组成 个互相独立的 Batch,轮流送进流水线。Batch A 在 Stage 1 时,Batch B 进入 Stage 0;A 到 Stage 2 时,B 到 Stage 1,C 进入 Stage 0……填满以后,每一段在每个时刻都在处理某个 Batch。
下图对比 PP=4 时只有 1 个 Batch 和 4 个 Batch 交错的情况,四种颜色是 Batch A–D,格子里的数字是该 Batch 第几步 Decode:
要点在下半张图的对角线:A1 走完 Stage 3、采样出 Token,正好轮到 Stage 0 空出来处理 A2。只要在飞的 Batch 数等于段数,并且各段耗时相同,这个节奏就能一直保持。
3.2 vLLM 的做法
vLLM V1 的 EngineCore 在 PP>1 时启用一个 Batch 队列,队列长度取自 max_concurrent_batches。VllmConfig.max_concurrent_batches 的源码注释写得很直接:“PP requires PP-size concurrent batches to fill the pipeline”,默认就是 ;开启异步调度并使用 V2 Model Runner 时是 ,多出的一个用来让调度和执行重叠。
调度器每次产出一个新 Batch 送进 Stage 0,不必等上一个 Batch 的结果回来。某个请求已经在一个在飞的 Batch 里时,它要等这一步走完整条流水线、采样出 Token 后才能进入下一步,这正是第 2 节说的步间依赖。
由此可以推出:PP 要发挥作用,并发请求数至少要能分成 组。
- 只有 1 个请求时,不管 PP 是多少,流水线里永远只有 1 个 Batch,利用率是 。
- 并发 64、PP=4 时,每个 Batch 约 16 个请求。和不开 PP 时 64 个请求一起算相比,每次前向的 Batch 变小了,但 4 段同时在工作。
3.3 不均匀会产生新的气泡
图里假设每格耗时相同。实际中几种情况会打破这个假设:
- Batch 之间工作量不同:一个 Batch 里有长上下文请求,Attention 更慢,后面的 Batch 就要等它。
- Prefill 和 Decode 混在一起:一个 Batch 里塞了几千个 Token 的 Prefill,这一格会比其他 Batch 长很多。
- 请求结束或新请求到达:组的大小在不断变化。
任何一格变长,后续所有 Batch 在那一段都要排队,空出来的时间就是气泡。所以 PP 下的吞吐对调度的均衡性更敏感。
4. Prefill 与 Chunked Prefill
Prefill 一次处理一整段 Prompt,没有步间依赖,只要有多个请求在排队就能交错进入流水线。
单个超长 Prompt 可以切成多个 Chunk。Chunk 2 在 Stage 0 只依赖 Chunk 1 在 Stage 0 写下的 KV,而这部分 KV 在 Chunk 1 离开 Stage 0 时就已经写好。所以从原理上讲,同一个请求的不同 Chunk 也可以像流水线一样一前一后走过各段,降低长 Prompt 的 TTFT。引擎是否这样调度取决于具体实现,以所用版本的行为为准。
和第 2 章 2.4 节讲过的一样,Chunked Prefill 的块大小还影响 Decode 的稳定性:块越大,含 Prefill 的那一格越长,上文 3.3 小节说的不均匀就越明显。
5. 层怎么分:最慢的一段决定速度
流水线的速度由最慢的一段决定。层的划分有几个容易忽略的地方:
- 层数不一定整除:Llama-3.1-405B 有 126 层,PP=4 时分不成整数,只能有的段 32 层、有的段 31 层。
- 首尾段有额外的工作:第一段有 Embedding,最后一段有最后的 Norm、LM Head 和采样。LM Head 要把隐藏状态乘到 128256 维的词表上,计算量和显存都不小。
- MoE 模型的层不一样重:DeepSeek-V3 的前 3 层是 Dense FFN,其余 58 层是 MoE。
vLLM 的默认划分(vllm/distributed/utils.py 的 get_pp_indices)已经考虑了首尾段:层数除不尽时,余下的层优先分给中间段,不分给首尾段;中间段不够分时,再分给除最后一段以外的段。所以 405B 在 PP=4 下默认就是 31,32,32,31,启动日志会打印实际的划分。
默认划分仍然只按层数算。实测发现某一段明显更慢时,可以用环境变量 VLLM_PP_LAYER_PARTITION 手动指定每段的层数,数字个数必须等于 PP 大小,总和必须等于模型层数,否则启动时报错。例如 Llama-3.1-70B 在 PP=2 下默认是 40,40,如果最后一段因为 LM Head 偏慢,可以试 VLLM_PP_LAYER_PARTITION="41,39"。
💡 提示:调整划分前先测:用 Nsight Systems 或日志看各段每步的耗时,找出最慢的那段,再决定挪动哪几层。
6. 延迟、容量与 TP×PP 组合
6.1 单请求延迟不会变短
一个 Token 要依次走过 段,每段读 的权重,加起来还是读了一整份,只是分在不同的卡上先后进行。再加上 次段间传输,单请求的 TPOT 比同样每段配置的单副本略长。
所以 PP 加卡换来的是容量和吞吐,不是延迟。要降低单请求延迟,只能让每段内部更快,也就是加大每段的 TP。
6.2 KV 容量随段数线性增长
每段只存自己那些层的 KV,每个 Token 在每张卡上的 KV 按 缩小。和 TP 不同,PP 没有”KV 头不够分”的问题,不会出现复制。
6.1 节的估算表里,16 张卡开 TP=16 只能放下约 43 个 32K 请求,TP=8 × PP=2 能放下 87 个。原因就在这里:TP=16 时 8 个 KV 头已经被复制了一遍,而 PP=2 让每张卡只负责 40 层的 KV。
6.3 什么时候选 PP
| 🎯 情况 | 选择 | 原因 |
|---|---|---|
| 单节点装得下 | 优先 TP,不用 PP | 同样卡数,TP 能降延迟,PP 不能 |
| 单节点装不下 | TP = 每节点卡数,PP = 节点数 | 只有段边界的少量流量跨过网卡 |
| 卡数不能整除头数(如 6 张卡) | PP,或 TP × PP 组合 | PP 按层切,各段层数可以不同 |
| GQA 模型想用超过 KV 头数的卡 | TP 取 KV 头数,其余用 PP | 避开 KV 头复制 |
| 机内没有 NVLink | 实测 PP 与小 TP 的组合 | PP 的同步次数少得多,对互联要求低 |
⚠️ 注意:PP 需要足够的并发才能填满。低并发的交互式服务开 PP,大部分卡在等;离线批处理、高并发在线服务才能吃到 PP 的吞吐。
7. 动手算:PP 与 TP 的同卡数对比
沿用 6.2 节的假设成本模型:只算读权重、读 KV、TP 的 AllReduce 和 PP 的段间传输,忽略计算和调度。PP 时把并发请求平均分成 个 Batch 轮流在飞。机内、跨机的固定开销 10 µs、30 µs 都是假设值。
# 假设成本模型:比较同卡数下 TP 与 TP×PP 的 Decode
# 只算读权重、读 KV、TP AllReduce 和 PP 段间传输,忽略计算、Kernel 启动与调度
HBM = 3.35e12 # H100 SXM 显存带宽,B/s
NVLINK, IB = 450e9, 50e9 # 机内 NVLink 4 单向、机间 400 Gb/s 网卡,B/s
A_IN, A_X = 10e-6, 30e-6 # 机内、跨机每次通信的固定开销,假设值
LAYERS, HIDDEN = 80, 8192
WEIGHT_BYTES = 70.6e9 * 2
KV_PER_TOKEN = 320 * 1024 # 整个模型每 Token 的 KV,B
CTX = 4096 # 每个请求的平均上下文长度
def stage_s(tp, pp, n, cross_node_tp):
"""一段处理 n 个请求、走一步 Decode 的时间(秒)"""
alpha, bw = (A_X, IB) if cross_node_tp else (A_IN, NVLINK)
weight = WEIGHT_BYTES / (tp * pp) / HBM
kv = n * CTX * KV_PER_TOKEN / (min(tp, 8) * pp) / HBM # TP>8 后 KV 不再缩小
msg = n * HIDDEN * 2
comm = 0 if tp == 1 else 2 * (LAYERS // pp) * (alpha + 2 * (tp - 1) / tp * msg / bw)
return weight + kv + comm
def decode(tp, pp, conc, cross_node_tp=False, pp_bw=NVLINK, pp_alpha=A_IN):
n = max(conc / pp, 1) # 请求分成 pp 组轮流在飞
p2p = pp_alpha + 2 * n * HIDDEN * 2 / pp_bw # hidden_states + residual
tpot = pp * stage_s(tp, pp, n, cross_node_tp) + (pp - 1) * p2p
return tpot * 1e3, conc / tpot
CASES = [ # 前三种在一台 8 卡机内;后两种横跨两台 8 卡机
dict(tp=8, pp=1),
dict(tp=4, pp=2),
dict(tp=2, pp=4),
dict(tp=16, pp=1, cross_node_tp=True),
dict(tp=8, pp=2, pp_bw=IB, pp_alpha=A_X),
]
print(f"{'TP×PP':>6} {'卡数':>4} {'并发':>4} {'TPOT ms':>8} {'Token/s':>8} {'Token/s/卡':>10}")
for conc in [1, 64]:
for kw in CASES:
t, thr = decode(conc=conc, **kw)
gpus = kw["tp"] * kw["pp"]
print(f"{kw['tp']:>3}×{kw['pp']:<2} {gpus:>5} {conc:>5} {t:>8.2f} {thr:>8.0f} {thr / gpus:>11.0f}")
输出:
TP×PP 卡数 并发 TPOT ms Token/s Token/s/卡
8×1 8 1 6.93 144 18
4×2 8 1 12.26 82 10
2×4 8 1 22.91 44 5
16×1 16 1 7.58 132 8
8×2 16 1 6.96 144 9
8×1 8 64 10.73 5967 746
4×2 8 64 15.63 4094 512
2×4 8 64 26.01 2461 308
16×1 16 64 16.93 3780 236
8×2 16 64 8.85 7233 452
在这组假设下:
- 一台机器内,PP 越大越慢。8 张卡、并发 64 时,TP=8 的吞吐是 5967 Token/s,4×2 降到 4094,2×4 只有 2461。原因是 PP 把 64 个请求拆成了 个小 Batch,每个小 Batch 都要把本段权重完整读一遍,权重读取没有被整个并发摊薄。
- 跨两台机器时,PP 的优势出来了。TP=16 每层两次 AllReduce 都过网卡,并发 64 时 TPOT 16.93 ms;TP=8 × PP=2 只有段边界跨机,TPOT 8.85 ms,吞吐高出约 90%。
- 单请求时,8×2 的 TPOT 和单机 TP=8 几乎一样(6.96 ms 对 6.93 ms)。多出来的 8 张卡没有让单请求变快,换来的是更大的 KV 容量:按 6.1 节的估算表,8×2 能放下的 Token 数约是单机 TP=8 的 2.4 倍。
- 按卡数归一化,16 卡的 8×2(452 Token/s/卡)不如单机 TP=8(746)。如果一台机器已经装得下模型、KV 也够用,两台机器各开一个 TP=8 副本做 DP,总吞吐更高。PP 适合”一台装不下”的情况。
真实系统里还有计算时间、调度开销和上文 3.3 小节说的各种不均匀,PP 的气泡只会比这个模型更多。这些结论只说明趋势,部署时以实测为准。
📝 总结
- PP 按层把模型切成 段,段间只传当前步的
hidden_states和residual,每步 次点对点发送,KV Cache 不跨段。 - Decode 每一步依赖上一步的采样结果,单个 Batch 下每段利用率只有 。填满流水线要靠 个由不同请求组成的 Batch 交错在飞,vLLM V1 用 Batch 队列实现。
- 任何一格变长都会让后面的 Batch 排队,长上下文、混入的 Prefill、请求进出都会制造气泡。
- 最慢的一段决定整条流水线的速度,首段的 Embedding、末段的 LM Head 和 MoE 的 Dense 层都会造成不均衡,vLLM 可用
VLLM_PP_LAYER_PARTITION手动调整。 - PP 不降低单请求延迟,换来的是 KV 容量随段数线性增长,且没有 KV 头复制。
- 单节点内优先 TP;单节点装不下时 TP 取每节点卡数、PP 取节点数。
🎯 自我检验清单
- 能说出 vLLM 中 PP 段间传递的张量,并算出给定 Batch 下每个段边界的传输量
- 能解释推理 Decode 为什么不能用”切 Micro-batch”填满流水线,以及多个 Batch 交错的条件
- 能画出 PP=4、4 个 Batch 交错的时间线,指出稳态下每段的利用率
- 能列举至少三种造成 PP 气泡的不均匀来源
- 能为一个层数不能整除段数的模型写出
VLLM_PP_LAYER_PARTITION - 能解释 PP 为什么不降低单请求 TPOT,以及它在 KV 容量上相对 TP 的优势
- 能用本节的成本模型比较同卡数下 TP 与 TP×PP 的吞吐,并说明结论只在什么条件下成立
📚 参考资料
- GPipe: Efficient Training of Giant Neural Networks using Pipeline Parallelism
- Efficient Large-Scale Language Model Training on GPU Clusters Using Megatron-LM
- vLLM 文档:Parallelism and Scaling
- vLLM 源码:vllm/v1/engine/core.py(v0.32.0)
- vLLM 源码:vllm/config/vllm.py(v0.32.0,max_concurrent_batches)
- vLLM 源码:vllm/distributed/utils.py(v0.32.0,层划分 get_pp_indices)
- NVIDIA H100 Tensor Core GPU