11. 分块预填充与统一调度
资料助手的高峰期,三十位同事正在看着回答一个字一个字地出现。这时小周上传了一份 3 万 token 的历年制度汇编提问。所有人的回答同时停住了将近半秒,然后才继续。这一篇解释这次停顿是怎样产生的,分块预填充(chunked prefill)怎样把长输入切开、和别人的 Decode 交错进行,以及块大小怎样在首字速度和出字平稳之间取舍。读完你应该能理解 vLLM V1 为什么把 Prefill 和 Decode 统一成“每个请求这一步推进多少 token”。
一个长 Prefill 卡住所有人
第 9 篇说过,连续批处理的每一步里,调度器会把新请求的 Prefill 和老请求的 Decode 安排进来。问题在于两者的计算量差别极大:
- 30 个用户的 Decode:每人 1 个 token,合计 30 个 token,这一步约 5 到 10 毫秒。
- 小周的 Prefill:3 万个 token。按第 6 篇的算法,8B 模型下界是 2 × 80 亿 × 3 万 ≈ 480 万亿次运算,在 H100 上至少约 0.5 秒,还没算随长度平方增长的注意力部分。
如果这 3 万个 token 被一次性塞进某一步,这一步就要跑半秒以上。同一步里的 30 个 Decode 请求只能等它算完,才能拿到各自的下一个 token。对这 30 个用户来说,这一次 token 间隔从 10 毫秒跳到了 500 多毫秒,P99 的 TPOT 直接被拉坏。
如果引擎采用 Prefill 优先、两种工作不混在同一步的策略,情况一样:这 0.5 秒里根本不做 Decode。
把长输入切成块
分块预填充的办法是:不要一次处理完整段输入,而是每一步只处理其中一块,比如 2048 个 token,剩下的留到下一步。
这样每一步都由两部分组成:所有正在生成的请求各推进 1 个 token,再加上长输入的一块。3 万 token 的输入被切成 15 块,分 15 步完成。每一步的耗时被控制在几十毫秒以内,其他用户的出字只是稍微变慢,不会突然停住。
为什么可以这样切?因为 Prefill 处理的是已知的输入,第 2049 个 token 的 K、V 只依赖它前面的 token。第一块算完后,它的 KV 已经写进缓存;第二块计算时,像 Decode 一样读取前面的 KV,再写入自己的 KV。数学结果和一次性算完完全相同。
这和第 10 篇的前缀缓存用的是同一个机制:已经算好的 KV 放在缓存里,后续的计算接着往下算。区别只是这里的“前缀”是同一个请求刚刚算完的上一块。
一步的 token 预算
分块之后,调度器的工作可以统一成一个问题:这一步给每个请求分配多少个 token。
- 正在 Decode 的请求:1 个。
- 正在 Prefill 的请求:剩余输入和剩余预算中较小的那个。
- 每一步的 token 总数不超过预算(
max_num_batched_tokens)。
vLLM 的 V1 引擎正是这样设计的:调度器不再区分“Prefill 步”和“Decode 步”,每个请求只记录“已经计算了多少 token”和“总共需要计算多少 token”,每一步决定推进多少。Prefill、Decode、分块 Prefill、前缀缓存命中后的续算,在这个框架下都是同一件事的不同参数。投机解码的验证(第 15 篇)也能套进来:一步推进多个 token。
调度时通常 Decode 优先:先把正在生成的请求都排进来,每个 1 个 token;剩下的预算再分给 Prefill。这样保证已经开始输出的用户不被打断,新请求在空余的预算里逐步推进。
nano-vllm 实现了一个简化版:Prefill 批里只有第一个请求允许被切块,而且 Prefill 和 Decode 不在同一步混合。它能防止单步过大,但不能让 Decode 和长 Prefill 交错进行。这正好可以和 vLLM 的完整版本对照着读。
块大小怎样取舍
token 预算(也就是每一步最多处理多少 token)是一个直接影响体验的参数:
| 预算 | 对正在生成的用户 | 对上传长输入的用户 | 整体吞吐 |
|---|---|---|---|
| 小(如 512) | 出字最平稳,TPOT 波动小 | TTFT 明显变长,要分很多步 | Prefill 矩阵小,GPU 利用率偏低 |
| 中(如 2048—8192) | 偶有轻微波动 | TTFT 适中 | 较好 |
| 大(如 16384 以上) | 长输入到来时出字明显卡顿 | TTFT 最短 | Prefill 效率最高 |
第 6 篇算过,算术强度大约等于一步里一份权重服务的 token 数,H100 的平衡点约 295。所以只要每一步的 token 数达到几百,矩阵乘法就已经接近计算受限,再增大预算对 Prefill 效率的提升有限,对 Decode 用户的干扰却在线性增加。这就是预算通常取几千而不是几万的原因。
分块也有代价:
- 长输入用户的 TTFT 变长。 小周的 3 万 token 要分 15 步完成,每一步还要和别人分享 GPU,他的首字比一次性算完来得晚。这是用他的等待换其他人的平稳。
- 注意力的重复读取。 第 15 块计算时要读取前面 14 块的 KV,比一次性计算多了一些显存读取。好在现代注意力内核对这种情况有专门优化。
- 调度更复杂。 请求多了一个“Prefill 进行到一半”的状态,取消、抢占、前缀缓存都要处理这种中间状态。
vLLM 按硬件和使用方式给出不同的默认预算:以在线服务方式启动时,普通 GPU 默认 2048 个 token,H100 这类大显存 GPU 默认 8192;离线批量推理的默认值更大,因为那时不需要照顾出字速度。V1 引擎对生成模型默认开启分块预填充,也不建议关闭。
最终怎样设置,要用真实负载验证:同时发送长短混合的请求,观察正在 Decode 的请求的 ITL 分布,以及长输入请求的 TTFT。
分块预填充和 P/D 分离
分块预填充是在同一台机器上缓解 Prefill 对 Decode 的干扰:让两者交错进行,每一步都有 Decode 的份。
第 17 篇的 P/D 分离走得更远:把 Prefill 和 Decode 放到不同的机器上,彻底消除干扰,代价是要把 KV 从一台机器传到另一台。两者解决的是同一个矛盾,规模和代价不同。对大多数单机部署来说,分块预填充已经足够。
一次性处理的长 Prefill 会占用 GPU 几百毫秒,让同一时间所有正在生成的请求停顿。分块预填充把长输入切成小块,每一步只处理一块,与 Decode 交错进行,数学结果不变。vLLM V1 把 Prefill 和 Decode 统一成“每个请求这一步推进多少 token”,在 token 预算内优先保证 Decode。预算越小出字越平稳,长输入的首字越慢。
到这里,调度层面的主要技术都讲完了。下一篇深入一次前向计算的内部,看 GPU 上的算子本身怎样变快:注意力算子与 CUDA Graph。
