vLLM内核探秘-第11章 分块预填充与混合批处理

《vLLM 内核探秘》完整目录

第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),每步只处理一块。

sequenceDiagram participant 用户 as 用户视角 participant S as 调度器 Note over S: Step 1: 预填充块 1(1024)+ 50 解码 S->>用户: 50 个请求各产出 1 个 Token(耗时 ~80ms) Note over S: Step 2: 预填充块 2(1024)+ 50 解码 S->>用户: 50 个请求各产出 1 个 Token(耗时 ~80ms) Note over S: Step 3: 预填充块 3(1024)+ 50 解码 S->>用户: 50 个请求各产出 1 个 Token(耗时 ~80ms) Note over S: Step 4: 预填充块 4(1024)+ 50 解码 + 新请求开始解码 S->>用户: 51 个请求各产出 1 个 Token(耗时 ~82ms)

现在每步只需 ~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 的预算:

  1. prompt_token_ids[num_computed_tokens : num_computed_tokens + N] 送入 GPU
  2. GPU 计算完成后,num_computed_tokens += N
  3. 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_qcu_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
gantt title 分块预填充 vs 不分块 --- 解码延迟对比 dateFormat X axisFormat %L section 不分块 新请求预填充(4096t) + 50解码 :0, 200 51解码(恢复正常) :200, 225 section 分块(1024) 块1(1024t) + 50解码 :0, 60 块2(1024t) + 50解码 :60, 120 块3(1024t) + 50解码 :120, 180 块4(1024t) + 50解码 :180, 240 51解码 :240, 265

分块后,单步最大延迟从 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
相关推荐
杨艺韬6 小时前
vLLM内核探秘-第6章 Worker 与 Executor:GPU 军团
agent
杨艺韬6 小时前
vLLM内核探秘-第2章 EngineCore:引擎的心脏
agent
杨艺韬6 小时前
vLLM内核探秘-第17章 API 服务器与生产部署
agent
杨艺韬6 小时前
vLLM内核探秘-第3章 调度器:Token 的交通指挥
agent
杨艺韬6 小时前
vLLM内核探秘-第5章 KV Cache 管理:寸土寸金的显存
agent
杨艺韬6 小时前
vLLM内核探秘-第8章 前向计算与 CUDA Graph
agent
杨艺韬6 小时前
vLLM内核探秘-前言
agent
杨艺韬6 小时前
vLLM内核探秘-第16章 LoRA 适配器热切换
agent
Aaron_Chou3138 小时前
保姆级codex配置教程
gpt·ai·agent·ai编程·codex