一个长 Prompt 为何让所有流式输出停顿?Chunked Prefill 的 Token 预算
从 prefill 阻塞 decode 的延迟尖峰出发,手算 token budget 下的分块调度,解释 KV 递增、优先级、容量保护与当前 vLLM 配置验证。
上一篇把请求调度粒度降到每次模型迭代:短请求完成后,新请求下一轮就能补位。然而一个 32K-token prompt 若作为不可分割的 prefill 塞进某一轮,这一轮的 GPU 时间仍会突然变长;原本每 30 ms 输出一个 token 的聊天请求,可能数百毫秒没有新 token。
Chunked Prefill(分块预填充)把长 prompt 拆成多个 token chunks(token 块),每轮只计算一部分,并把剩余预算留给 decode。它不截断 prompt,也不改变最终 KV Cache;改变的是计算发生的时间顺序。
01 Prefill 为什么会阻塞 Decode?#
假设 GPU 上已有 64 条请求在 decode,每轮各新增 1 token。此时到达一个 4096-token prompt:
不切分:
轮 t [64 decode tokens]
轮 t+1 [4096 prefill tokens] <- 超长迭代
轮 t+2 [64 decode tokens]
切分,预算 512:
轮 t+1 [64 decode] [448 prefill]
轮 t+2 [64 decode] [448 prefill]
... 保持每轮工作量有界textDecode 请求并没有丢失或抢占;它们只是必须等待同一个 forward 完成。这个现象称为 head-of-line blocking(队首阻塞):排在前面的重工作让后面的轻工作一起等待。
02 “切 prompt”不会破坏因果注意力吗?#
对长度为 的 prompt,完整 prefill 产生每层
若先计算前 个 token,就得到前缀 KV:
下一块位置 [c:c+d) 的 query 读取已缓存的 [0:c) KV,并与本块产生的 KV 做因果注意力,计算完成后把 cache 扩为 [0:c+d)。因为 causal mask 下位置 本来就只依赖 0..i,按顺序分块与一次算完数学上等价。
flowchart LR
P0[chunk 0<br/>tokens 0:4] --> K0[KV 0:4]
K0 --> P1[chunk 1<br/>tokens 4:8]
P1 --> K1[KV 0:8]
K1 --> P2[chunk 2<br/>tokens 8:10]
P2 --> K2[完整 prompt KV 0:10]
K2 --> D[生成第一个输出 token]mermaid03 用 8-token 预算手算三轮调度#
本轮 token budget 为 8,已有 3 条 decode 请求 D1/D2/D3,每条占 1 token;等待队列有一个 12-token prompt P。调度器优先保留 decode,剩余 5 个 token 给 prefill:
| 轮次 | Decode tokens | P 本轮 chunk | P 累计完成 | 预算使用 |
|---|---|---|---|---|
| 1 | 3 | 5 | 5/12 | 8/8 |
| 2 | 3 | 5 | 10/12 | 8/8 |
| 3 | 3 | 2 | 12/12 | 5/8 |
| 4 | 4 | 0 | P 开始 decode | 4/8 |
P 的 Time To First Token(首 token 延迟,TTFT)增加了调度轮数,但 D1–D3 的 Time Per Output Token(逐 token 间隔,TPOT)避免了单轮 12-token prefill 的尖峰。这是明确的延迟交换,不是免费优化。
04 Token Budget 是上限,不是精确耗时#
令第 轮 decode 序列集合为 ,prefill chunks 为 :
其中 对应 max_num_batched_tokens。但相同 token 数不保证相同耗时:
- decode token 读取的历史长度不同;
- prefill chunk 在更长前缀后计算,attention 工作量更大;
- batch shape、kernel、量化与 GPU 架构会改变效率;
- 多模态 token 可能对应额外 encoder 工作。
因此 budget 是稳定迭代大小的第一近似。最终仍要从 per-iteration trace 拟合真实耗时,而不是把 token 数直接当毫秒数。
05 一个最小可检查的分块调度器#
from dataclasses import dataclass
@dataclass
class Prefill:
rid: str
remaining: int
def schedule_step(
decode_ids: list[str],
prefills: list[Prefill],
token_budget: int,
):
if len(decode_ids) > token_budget:
raise ValueError("decode 已超过本轮 token budget")
budget = token_budget - len(decode_ids)
chunks: list[tuple[str, int]] = []
for req in prefills: # 教学版 FCFS
if budget == 0:
break
take = min(req.remaining, budget)
if take:
chunks.append((req.rid, take))
req.remaining -= take
budget -= take
return {
"decode": decode_ids,
"prefill_chunks": chunks,
"unused_tokens": budget,
}
p = [Prefill("P", 12)]
for _ in range(3):
print(schedule_step(["D1", "D2", "D3"], p, token_budget=8))
# 每轮 P 分别取 5、5、2 tokenspython输入是本轮 decode IDs、未完成 prompt 与 token budget;输出明确列出两类工作。测试应断言每轮使用量不超预算、chunk 长度为正、同一 prompt 的已计算区间不重叠且连续。
06 张量怎样从一个 Chunk 进入模型?#
假设 P 的总长度为 12,本轮计算位置 [5:10),chunk 长度 。概念上的输入包括:
| 张量/元数据 | Shape | 含义 |
|---|---|---|
input_ids | [5] | 本轮 5 个 prompt token |
positions | [5] | [5,6,7,8,9],不能从 0 重置 |
slot_mapping | [5] | 每个新 KV 写入哪个物理 slot |
block_table | [ceil(10/B)] | 前缀与新块的物理寻址 |
context_len | scalar | 本轮结束后为 10 |
模型内部 hidden states 为 [5,H];每层新 K/V 通常可看作 [H_kv,5,D_h],attention 还要读取此前 [H_kv,5,D_h] 的前缀 cache。不同实现会 flatten token 维或合并层维,但这五类语义不能丢。
07 Position 与 Mask 最容易怎样错?#
第二块从绝对位置 开始。如果错误地把 position IDs 重置为 0..d-1,RoPE(旋转位置编码)相位会重复,最终结果不再等价于完整 prefill。
本块内位置 的 query 可见:
而不是只看本块的 c..c+i。调试时用一个 6-token prompt 比较:
full_logits = model_full_prefill(tokens)
chunked_logits = model_chunked_prefill(tokens, chunks=[2, 3, 1])
torch.testing.assert_close(chunked_logits, full_logits, rtol=1e-4, atol=1e-5)python生产引擎不一定公开上述两个函数;可分别启动开启/关闭 chunked prefill 的同版本服务,在 greedy、固定模型与固定 dtype 下比较生成 token IDs。
08 为什么通常让 Decode 优先?#
已在流式输出的请求对停顿很敏感。先为每条 decode 分配 1 token,再用剩余 budget 做 prefill,可以限制 ITL 尖峰。伪代码是:
budget = max_num_batched_tokens
schedule running decode requests, each consumes 1
schedule cached/resumed work
fill remaining budget with prefill chunks
execute one model step
commit KV and request progress atomicallytext但严格 decode-first 也可能使 prefill starvation:当活跃 decode 数持续占满预算,新 prompt 永远得不到首 token。解决办法包括保留 prefill 配额、限制 decode admission、按等待时间 aging,或为不同服务等级拆池。
09 Chunk Size 怎样影响 TTFT、TPOT 与吞吐?#
小 chunk:
- 单轮更短,decode 的 TPOT/ITL 更平滑;
- prompt 要跨更多轮,调度与 kernel 开销增加;
- 矩阵更小,GPU 利用率可能下降,TTFT 变长。
大 chunk:
- prefill GEMM 更高效、prompt 更快完成;
- 单轮耗时尖峰更大,decode 尾延迟上升;
- 更容易触碰 KV 容量并导致抢占。
不要只 sweep 固定 chunk_size。在 vLLM 中,实际 chunk 由本轮剩余的 max_num_batched_tokens 决定,因此还会随 decode batch 大小动态变化。
10 当前 vLLM 配置的语义#
当前官方 SchedulerConfig 中:
enable_chunked_prefill=True:prefill 可按剩余 token budget 分块;max_num_batched_tokens:一次迭代最多处理多少 tokens;max_num_seqs:一次迭代最多包含多少 sequences;long_prefill_token_threshold:多长才视作 long prefill;max_num_partial_prefills:最多同时部分完成多少条 prefill;scheduler_reserve_full_isl:准入时检查完整 input sequence 是否能放入 KV cache,避免过度准入和反复抢占。
vllm serve your-org/your-model \
--enable-chunked-prefill \
--max-num-batched-tokens 2048 \
--max-num-seqs 128 \
--scheduler-reserve-full-islbash默认值会随版本、模型与 usage context 变化。部署时保存 vllm --version 和最终解析后的 engine config;不要从一篇旧博客复制默认值后假设它永久成立。
11 KV 容量为什么要按完整 Prompt 预留?#
若只看到第一块 512 tokens 就准入一个 64K prompt,很多长 prompt 可以同时“付得起首付”,却都没有足够 KV blocks 完成。后续每条继续申请 blocks,系统便反复抢占与重算,形成 cache thrashing(缓存颠簸)。
scheduler_reserve_full_isl 的思路是 admission 时按完整 Input Sequence Length(输入序列长度,ISL)检查容量。它更保守,可能降低瞬时并发,却避免把不可能同时完成的请求全部推进系统。是否预留还要结合 prefix caching、滑动窗口和模型的 KV 布局验证。
12 怎样设计一组有教学价值的压测?#
构造三种可控流量:
- 纯 decode 基线:prompt 16、output 512;
- 短交互:prompt 256、output 64;
- 长文档突发:prompt 16K/32K、output 32。
在相同 offered load 下比较 chunked prefill 开/关,并 sweep token budget。至少记录:
TTFT / TPOT / ITL / E2E: p50, p95, p99
prompt tokens/s, output tokens/s
per-step prefill tokens, decode tokens, step latency
waiting/running requests, KV utilization, preemptionstext关键图不是单一平均柱状图,而是“长 prompt 到达时刻”附近的 step latency 与各流式请求 ITL 时间线。它能直接显示阻塞尖峰是否被摊平。
13 常见错误与最短调试路径#
| 症状 | 常见原因 | 最短检查 |
|---|---|---|
| 开启后输出与完整 prefill 不同 | position、mask 或 KV 写入偏移错误 | 用 [2,3,1] 不规则分块逐 token 对账 |
| TPOT 仍周期性尖峰 | token budget 过大或多长 prompt 同轮 | 记录每轮 prefill tokens 与 kernel 时间 |
| TTFT 急剧变差 | chunk 太小或 decode 永久占满预算 | 查看每条 prefill 的 progress 与等待轮数 |
| KV 明明够却频繁抢占 | 只按首 chunk 准入,未考虑完整 ISL | 打开完整 ISL 容量检查并对账 blocks |
| GPU 利用率下降 | chunks 过碎、shape 变化导致图复用差 | sweep budget 并看 kernel/CUDA graph 命中 |
| 多模态输入在边界报错 | 图像 embedding 不允许任意切半 | 核对当前版本的 multimodal chunk 约束 |
14 与相近方法的边界#
Continuous batching 在每轮替换请求;chunked prefill 在多轮之间拆分同一个 prompt。Prefix Caching(前缀缓存)让相同前缀直接复用已有 KV,减少必须 prefill 的 token;它不能帮助从未见过的长 prompt。
Disaggregated Prefill/Decode(预填充—解码分离)把两类工作放到不同 GPU 池,进一步隔离干扰,但增加 KV 传输、路由和容量规划复杂度。Speculative Decoding(投机解码)则用 draft model 一轮提出多个候选 token,目标是减少 decode 串行轮数;它解决的是另一条轴。
15 今天真正需要记住什么?#
- 不可分割的长 prefill 会拉长整次 forward,使同批 decode 请求出现 ITL/TPOT 尖峰。
- 因果注意力允许 prompt 按顺序分块;每块必须继承绝对位置、前缀 KV 与正确可见范围。
- token budget 把单轮工作量控制在近似上限,通常以 TTFT 换取更平滑的 TPOT。
- 过度准入部分 prefill 会造成 KV 颠簸;配置必须与完整输入长度、KV 容量和抢占指标联合验证。
16 思考题与小练习#
- token budget 为 16,已有 6 条 decode;两个 prompt 分别剩 18 和 5 tokens。按 FCFS 写出前三轮 chunks,并说明第二个 prompt 的 TTFT 风险。
- 对总长 10 的 prompt,按
[4,3,3]分块。写出每块的 position IDs、结束后的 context length,以及最后一块每个 query 能看到的 key 范围。 - 修改教学调度器,为 prefill 保留每轮至少 25% budget;构造 decode 永不清空的流量,验证长 prompt 最终仍能完成。
相关工作#
- Agrawal et al., SARATHI: Efficient LLM Inference by Piggybacking Decodes with Chunked Prefills ↗,提出用 chunked prefill 消除 prefill-decode 干扰气泡。
- Agrawal et al., Taming Throughput-Latency Tradeoff in LLM Inference with Sarathi-Serve ↗,系统评估吞吐与调度延迟权衡。
- Zhong et al., DistServe: Disaggregating Prefill and Decoding for Goodput-optimized Large Language Model Serving ↗,从分离部署角度隔离两阶段干扰。
- Patel et al., Splitwise: Efficient Generative LLM Inference Using Phase Splitting ↗,研究 prefill/decode 分阶段资源配置。
- vLLM, SchedulerConfig 官方文档 ↗,定义当前 chunked prefill、token budget 与容量保护参数。
17 下一篇预告#
Chunked prefill 控制了每轮输入工作量,却没有减少生成 个输出通常需要 次串行大模型前向。下一篇将进入 speculative decoding:小 draft model 一次提议多个 token,大模型如何并行验证并保持目标分布不变。