《vLLM 内核探秘》完整目录
- 前言
- 第1章 架构总览
- 第2章 EngineCore:引擎的心脏
- 第3章 调度器:Token 的交通指挥
- 第4章 PagedAttention:虚拟内存的启示
- 第5章 KV Cache 管理:寸土寸金的显存
- 第6章 Worker 与 Executor:GPU 军团
- 第7章 模型加载与权重管理
- 第8章 前向计算与 CUDA Graph
- 第9章 采样与输出处理
- 第10章 前缀缓存:零开销的加速
- 第11章 分块预填充与混合批处理(当前)
- 第12章 投机解码:以小博大
- 第13章 量化引擎:精度与速度的平衡
- 第14章 张量并行与流水线并行
- 第15章 多模态推理
- 第16章 LoRA 适配器热切换
- 第17章 API 服务器与生产部署
- 第18章 设计模式与架构哲学
第11章 分块预填充与混合批处理
"The art of scheduling is the art of saying 'not all at once'."
:::tip 本章要点
- 理解预填充阻塞问题:为什么长 Prompt 会影响解码请求的延迟
- 掌握分块预填充的工作原理:将预填充拆分为多个可控大小的块
- 深入 V1 统一调度如何天然支持分块预填充
- 理解混合批处理在 FlashAttention3 中的实现
- 认识分块大小的选择策略及其对延迟/吞吐的影响 :::
11.1 预填充阻塞问题
回忆第 3 章的内容:预填充和解码对 GPU 的使用模式截然不同。预填充是计算密集型------一次处理大量 Token;解码是内存带宽密集型------每次只处理 1 个 Token。
问题出在它们共享 GPU 时间。假设一个批次中有:
- 1 个新请求,需要预填充 4096 个 Token
- 50 个老请求,每个需要解码 1 个 Token
如果不做分块,这一步要处理 4096 + 50 = 4146 个 Token。预填充的 4096 Token 主导了计算时间------可能需要 200ms。在这 200ms 内,50 个正在解码的请求全部被"冻结",用户感到输出中断了 200ms。
分块预填充的解法:将 4096 Token 切成 4 块(每块 1024),每步只处理一块。
现在每步只需 ~80ms,解码请求不再被长时间阻塞。代价是新请求的首 Token 延迟从 200ms 增加到了 4 × 80ms = 320ms------但这通常是可接受的折中。
11.2 V1 的自然实现
V1 统一 Token 调度的美妙之处在于:分块预填充不需要任何特殊处理。
源码版本 :本节基于 vLLM v0.8.5,核心文件
vllm/v1/core/sched/scheduler.py。
让我们看调度器中实现分块预填充的真实代码 (scheduler.py:176-185):
python
# vllm/v1/core/sched/scheduler.py:176-185
while req_index < len(self.running) and token_budget > 0:
request = self.running[req_index]
num_new_tokens = (request.num_tokens_with_spec -
request.num_computed_tokens)
# 长预填充阈值:超过这个长度就分块
if (0 < self.scheduler_config.long_prefill_token_threshold <
num_new_tokens):
num_new_tokens = (
self.scheduler_config.long_prefill_token_threshold)
# 核心:用 min 限制不超过剩余 budget
num_new_tokens = min(num_new_tokens, token_budget)
# ... 调度该请求 ...
token_budget -= num_new_tokens
关键在 min(num_new_tokens, token_budget) 这一行------如果一个请求需要 4096 token 但 budget 只剩 1024,就只调度 1024 个 token。下一步从 num_computed_tokens + 1024 处继续。整个分块逻辑就是这一个 min 调用------没有特殊的分块代码路径。
回忆调度器的输出:{request_id: num_tokens}。对于上面的场景:
python
# Step 1
{"new_req": 1024, "req_1": 1, "req_2": 1, ..., "req_50": 1}
# Step 2
{"new_req": 1024, "req_1": 1, "req_2": 1, ..., "req_50": 1}
# Step 3
{"new_req": 1024, "req_1": 1, "req_2": 1, ..., "req_50": 1}
# Step 4
{"new_req": 1024, "req_1": 1, ..., "req_50": 1} # 预填充完毕,下一步 new_req 也是 1
调度器只是限制了每步给新请求的 Token 数量。Worker 端不需要知道"这 1024 Token 是预填充的前半段还是后半段"------它只管计算这 1024 个 Token 的注意力。请求的 num_computed_tokens 记录了已处理到哪里,下次从断点继续。
这就是统一抽象的力量------分块预填充、全量预填充、纯解码,对 Worker 来说都是"处理 N 个 Token",没有区别。
11.3 断点续传机制
分块预填充的一个关键问题是:如何记住一个请求"已经计算到哪里了"?
V1 的 Request 对象维护了一个 num_computed_tokens 字段:
python
# vllm/v1/request.py(简化)
class Request:
prompt_token_ids: list[int] # 完整的 Prompt Token 序列
num_computed_tokens: int = 0 # 已完成预填充的 Token 数
@property
def num_remaining_tokens(self):
return len(self.prompt_token_ids) - self.num_computed_tokens
每次调度器给这个请求分配 N 个 Token 的预算:
- 取
prompt_token_ids[num_computed_tokens : num_computed_tokens + N]送入 GPU - GPU 计算完成后,
num_computed_tokens += N - 当
num_computed_tokens == len(prompt_token_ids)时,预填充完成,请求进入解码阶段
这个机制非常简洁------不需要保存任何中间计算结果(KV Cache 已经存在于 GPU 显存中),只需要一个整数记录进度。
被抢占后的恢复
如果一个正在分块预填充的请求被抢占(显存不足),它的部分 KV Cache 块可能被回收。恢复时有两种情况:
情况 1:前缀缓存命中 ------被回收的块仍然在前缀缓存中(引用计数降为 0 但未被驱逐)。KV Cache Manager 通过哈希链找到这些块,直接将引用计数加回来。num_computed_tokens 不需要回退------因为 KV Cache 数据仍然有效。
情况 2:块已被驱逐 ------KV Cache 数据已被覆盖。num_computed_tokens 回退到仍然有效的最后一个块的末尾位置,然后从那里重新开始预填充。
这就是分块预填充与前缀缓存协同工作的优雅之处------抢占的代价不再是"从头来",而是"从断点来"。
11.4 FlashAttention3 的混合批处理
分块预填充的一个技术挑战是:预填充 Token 和解码 Token 在同一步中如何高效地执行注意力计算?
预填充 Token 需要对一大段 Token 做自注意力(算力密集),解码 Token 需要与长序列做 KV Cache 注意力(带宽密集)。两者的计算特征差异很大。
FlashAttention3(vllm/v1/attention/backends/flash_attn.py)支持变长序列的混合批处理 ------在一次内核调用中处理不同长度的多个序列。它通过 cu_seqlens(累积序列长度)数组标记每个序列的边界,内核内部根据边界选择不同的计算路径。
具体来说,FlashAttention3 的输入是:
yaml
query: [total_tokens, num_heads, head_dim] # 所有请求的 Q 拼接
key: 分页 KV Cache(通过块表间接访问)
value: 分页 KV Cache(通过块表间接访问)
cu_seqlens_q: [0, 1024, 1025, 1026, 1090, ...] # 每个请求的 Q 长度累积和
cu_seqlens_k: [0, 1024, 2048, 4096, 1090, ...] # 每个请求的 KV 长度累积和
cu_seqlens_q 和 cu_seqlens_k 的差异体现了预填充和解码的混合:
- 预填充请求:
q_len = 1024(本次处理 1024 个 Token),kv_len = 1024(已有的 KV Cache 长度) - 解码请求:
q_len = 1(本次只处理 1 个新 Token),kv_len = 2048(该请求之前的所有 Token)
FlashAttention3 内核会根据每个序列的 q_len 选择不同的计算策略------长 Q 序列使用更多的线程并行,短 Q 序列(解码)使用更少的线程但更高的内存带宽利用。
11.5 定量分析:分块预填充的延迟影响
让我们用具体数字理解分块预填充的效果。
场景:A100 GPU,Llama-2-70B(TP=4),50 个并发解码请求 + 1 个新请求(4096 Token Prompt)。
不分块:
| 步骤 | 处理 Token | GPU 时间 | 解码请求延迟 |
|---|---|---|---|
| Step 1 | 4096 + 50 = 4146 | ~200ms | 200ms(被阻塞) |
| Step 2 | 51 | ~25ms | 25ms(恢复正常) |
解码请求在 Step 1 遭遇 200ms 的延迟尖峰------用户感觉输出"卡"了一下。
分块(chunk_size=1024):
| 步骤 | 处理 Token | GPU 时间 | 解码请求延迟 |
|---|---|---|---|
| Step 1 | 1024 + 50 = 1074 | ~60ms | 60ms |
| Step 2 | 1024 + 50 = 1074 | ~60ms | 60ms |
| Step 3 | 1024 + 50 = 1074 | ~60ms | 60ms |
| Step 4 | 1024 + 50 = 1074 | ~60ms | 60ms |
| Step 5 | 51 | ~25ms | 25ms |
分块后,单步最大延迟从 200ms 降到 60ms(3.3x 改善),但新请求的 TTFT 从 200ms 增到 240ms(4 步 × 60ms)。这是经典的延迟尖峰 vs TTFT 权衡。
long_prefill_token_threshold
vLLM v0.8.5 新增了 long_prefill_token_threshold 配置(scheduler.py:181-184)。只有当请求的待处理 token 数超过这个阈值时才强制分块------短请求直接全量预填充,避免不必要的分块开销:
python
# scheduler.py:181-184
if (0 < self.scheduler_config.long_prefill_token_threshold <
num_new_tokens):
num_new_tokens = self.scheduler_config.long_prefill_token_threshold
11.6 分块大小的选择
max_num_batched_tokens 参数控制了每步的最大 Token 数,间接决定了分块大小:
- **较大值(如 8192)**→ 新请求的预填充块更大,首 Token 延迟(TTFT)更低,但解码请求被阻塞的时间更长
- **较小值(如 512)**→ 解码请求的延迟更稳定,但新请求的 TTFT 更高(需要更多步才能完成预填充)
最佳值取决于工作负载特征。如果大部分请求是短对话(Prompt < 256 Token),分块没有必要------全量预填充的延迟本就很低。如果有大量长文档(Prompt > 4096 Token),分块的收益就很明显。
11.6 本章小结
- 预填充阻塞------长 Prompt 独占 GPU 会冻结解码请求
- 分块预填充------将长 Prompt 切块,与解码请求混合处理
- V1 自然支持 ------统一 Token 调度 +
num_computed_tokens断点续传 - FlashAttention3------单次内核调用处理混合批次
- 分块大小------在 TTFT 和解码延迟之间权衡
源码导航
- 调度器(分块逻辑):
vllm/v1/core/sched/scheduler.py- FlashAttention3 后端:
vllm/v1/attention/backends/flash_attn.py