vLLM 推理内核深度解析

第11章 分块预填充与混合批处理

作者 杨艺韬 · 7,788 字

第11章 分块预填充与混合批处理

“The art of scheduling is the art of saying ‘not all at once’.” — 任何做过交通调度、操作系统内核、或 LLM 推理的人

“调度的艺术不是让 GPU 一直满,而是让用户感到输出稳定——偶尔牺牲一点局部利用率,也比让所有流式输出同时停顿更可控。”

本章要点

  • 用调度模型理解”预填充阻塞”问题:长 prefill 为什么会把 decode 的 TPOT 拉出尖峰
  • 读懂 V1 Scheduler 里的两处 min(num_new_tokens, token_budget):RUNNING 请求和 WAITING 请求都受同一个 token budget 约束
  • 走完恢复机制:V1 抢占会清零 num_computed_tokens,下一次调度再通过 prefix cache 找回已计算的完整 block
  • 理解 FlashAttention backend 的混合批协议:query_start_locseq_lensblock_table 如何把长短请求放进同一次 varlen attention
  • 掌握 max_num_batched_tokenslong_prefill_token_thresholdmax_num_partial_prefills 的源码默认和约束
  • 了解 Chunked Prefill 的学术背景:SARATHI/SARATHI-Serve 的动机 + vLLM 的工程化落地
  • 看懂 V1 为什么”总是开启 chunked prefill”,以及它和 prefix caching、spec decode、preemption 的边界
  • 拿到生产调参的测量方法,而不是背固定 chunk size

11.1 预填充阻塞问题:用一个数字认识它

11.1.1 一个典型投诉

让我以一个典型投诉开始这一章。某 AI 产品的用户反馈:

“你们的 AI 对话有时候会突然卡住,所有正在说话的助手同时停顿,然后又恢复。用户感觉像服务抖了一下。”

这不一定是功能 bug,而常常是预填充阻塞(Prefill Blocking):一个长 prompt 的 prefill 占住一次调度 step,正在 decode 的请求也要等这个 step 结束才能继续流 token。理解这个现象的根源,就能理解为什么 V1 选择把 chunked prefill 做成默认调度行为。

11.1.2 为什么预填充会”独占” GPU

回到第 3 章的对照:

  • Prefill:一次处理 prompt 的一段或全部 token;更容易 compute-bound;单步时间随本次处理的 token 数、模型大小和 attention 后端增长
  • Decode:通常每个请求每步只推进少量 token;更容易 memory-bound;用户主要感知的是 token 间隔是否稳定

如果把一个长 prefill 和多个 decode 塞进同一 step,会发生什么?

GPU 的一次 step 只有一个 wall-clock 时间。这个时间由本 step 里所有请求共同决定。长 prefill 把大量 prompt token 放进同一次 step;decode 请求虽然每个只推进少量 token,也必须等同一个 model execution 返回。

这段额外等待就是”预填充阻塞”

用一张表对比两种负载的本质差异:

特性PrefillDecode
每 step token 数可能很多通常很少,spec decode 会更多
常见瓶颈更偏 compute / attention更偏权重和 KV 访存
用户感知TTFTTPOT / ITL(流畅度)
调度形态一段 prompt 做完才出首 token多个小 step 连续推进
风险长 prompt 拉大单 step尾延迟和抖动被用户直接感知

混在一起时”最慢的那部分”决定全局——这就是木桶原理的 GPU 版

11.1.3 一个延迟尖峰示意

场景:多个 decode 请求稳定流着。这时候来了一个新请求,prompt 很长。

不分块 的逻辑时间线:

gantt
    title 不分块情况下 decode 被一个长 prefill 拉长
    dateFormat X
    axisFormat step-%L

    section Decode 请求
    小 step :d1, 0, 1
    小 step :d2, 1, 2
    小 step :d3, 2, 3
    长 prefill 造成尖峰 :crit, spike, 3, 8
    恢复小 step :d5, 8, 9
    小 step :d6, 9, 10

    section GPU 工作
    decode batch :g1, 0, 1
    decode batch :g2, 1, 2
    decode batch :g3, 2, 3
    long prefill + decode :crit, big, 3, 8
    decode batch :g5, 8, 9

从用户视角看:多个用户正在顺畅接收流式输出,某一瞬间所有人的输出同时停一下。停顿来自调度 step 的共同边界,而不是某个单独请求内部出错。

生产流量里这种长 prompt 并不少见。RAG、代码仓库问答、长文档总结都会把检索上下文塞进 prompt;如果它们被一次性 prefill,就会周期性制造 TPOT 尖峰。

11.1.4 分块预填充的直觉

既然长 prefill 是”一顿大餐”,能不能”分四顿吃”?

把长 prefill 切成几段。每一 step 只处理一段 prefill,再和 decode 混在一起。长请求的 TTFT 可能多经历几次 step,但其他 decode 请求不会被一个巨大的 step 一次性拖住。

gantt
    title 分块后 decode 更平稳出 token
    dateFormat X
    axisFormat step-%L

    section Decode 延迟
    小 step :d1, 0, 1
    混合 step :d2, 1, 3
    混合 step :d3, 3, 5
    混合 step :d4, 5, 7
    混合 step :d5, 7, 9
    恢复小 step :d6, 9, 10

    section GPU 工作
    decode batch :g1, 0, 1
    prefill chunk + decode :s1, 1, 3
    prefill chunk + decode :s2, 3, 5
    prefill chunk + decode :s3, 5, 7
    prefill chunk + decode :s4, 7, 9
    decode batch :g5, 9, 10

这件事的本质不是某个固定毫秒数,而是尾延迟形态变化:

指标不分块分块
Decode 单步最大延迟被整个长 prefill 拉长被 chunk 上限限制
Decode p99 抖动容易出现尖峰更平滑
新请求 TTFT长 prefill 一次做完可能因为分多步略增
总吞吐取决于是否饿死 decode取决于 chunk 是否太小

关键结论:Chunked Prefill 的目标不是让每个请求都更快,而是把长尾延迟平滑化。它用一点 TTFT 和调度复杂度,换 decode 流式体验的稳定性。

11.1.5 心理学层面的权衡

为什么”多等一小段”比”突然明显卡住”好接受?这其实涉及用户体验心理学:

  • 稳定的延迟——用户会适应。稳定响应通常比大幅波动的体验好
  • 尖峰感知——脑子对”异常中断”极其敏感。打字打到一半停顿感是”bug 感”
  • 群体效应——多个用户同时卡顿,比单个请求慢一点更容易暴露成服务质量问题

好产品的第一条原则是消除意外,而不是追求极致均值。Chunked Prefill 正是这个理念的工程化体现。

11.2 学术背景:SARATHI-Serve 的关键贡献

Chunked Prefill 不是 vLLM 原创。SARATHI / SARATHI-Serve 系列论文系统性讨论了 decode 和 prefill 混合服务时的吞吐-延迟矛盾。论文观察到:

  1. 在线 LLM 服务同时服务 decode 和 prefill 流量
  2. 把 prefill 独立批处理(prefill-only batch)会让 decode 流暂停
  3. 把 prefill 全量塞到 decode batch 里会让 decode TPOT 爆炸
  4. 把 prefill 切块后再混入 decode batch 是最佳折中

论文的重要启发是:如果每个 step 的 prefill token 数有上限,decode 的等待时间也会被这个上限间接约束。这个上限不是越小越好;太小会增加 step 数和 kernel 启动占比,太大又回到 prefill blocking。

vLLM 的工程化落点在配置和调度器里很清楚:SchedulerConfig.enable_chunked_prefill 的文档写明 prefill 可以根据剩余 max_num_batched_tokens 被 chunk(config.py:1871-1873);V1 参数默认阶段直接把 enable_chunked_prefill 设为 Trueengine/arg_utils.py:1565-1569)。

11.3 V1 的统一 token budget 实现

V1 的统一 Token 调度(第 3 章)让 Chunked Prefill 不再需要一套独立的 _schedule_chunked_prefill 方法。源码总纲写在 vllm/v1/core/sched/scheduler.py:137-147:scheduler 不区分 decoding phase 和 prefill phase;每个请求只有 num_computed_tokensnum_tokens_with_spec,每一轮都试图让前者追上后者。这套表示天然覆盖 chunked prefill、prefix caching 和 speculative decoding。

核心逻辑分两段。第一段先调度 RUNNING 请求:

# vllm/v1/core/sched/scheduler.py:176-191(简化)
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
num_new_tokens = min(num_new_tokens, token_budget)
num_new_tokens = min(num_new_tokens,
                     self.max_model_len - request.num_computed_tokens)

第二段再调度 WAITING 请求:

# vllm/v1/core/sched/scheduler.py:323-347(简化)
computed_blocks, num_computed_tokens = \
    self.kv_cache_manager.get_computed_blocks(request)
num_new_tokens = request.num_tokens - 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
num_new_tokens = min(num_new_tokens, token_budget)

三层限制叠加在 num_new_tokens 上:

  1. 还剩多少没做:RUNNING 看 num_tokens_with_spec - num_computed_tokens,WAITING 看 num_tokens - num_computed_tokens
  2. 长请求阈值long_prefill_token_threshold 只有在大于 0 且小于待调度 token 数时才截断
  3. 本步剩余预算token_budget 初始值来自 max_num_batched_tokensscheduler.py:162-164

min() 不是全部代码,但它是机制的核心:它把”一口气吃完”变成了”这一 step 能吃多少吃多少,剩下的下一 step 继续”。

11.3.1 V0 vs V1 的代码量对比

对比 V0 的实现,差异更清楚。V0 专门有 _schedule_chunked_prefillvllm/core/scheduler.py:1325-1438),还在 _schedule 中按 chunked_prefill_enabled 分流(scheduler.py:1455-1460),并把 enable_chunking 传入多个 helper。V1 则把 chunking 收敛进统一的 token budget。

度量V0V1
是否有专用调度方法_schedule_chunked_prefill无专用方法
调度入口chunked_prefill_enabled 分流schedule() 统一处理
chunk 上限enable_chunking 传入 helpermin(num_new_tokens, token_budget)
请求状态V0 SequenceGroup/Sequence 状态更多V1 Request.num_computed_tokens 更集中
源码体量V0 scheduler 2060 行V1 scheduler 841 行

这不是说 V1 真的”一行代码”完成所有边界,而是说它没有为 chunked prefill 维护一条平行调度路径。统一抽象把复杂度放在 token budget、KV block 分配和 request 进度这几个已有概念里。

这是统一抽象最好的广告:当 scheduler 不再把 prefill 和 decode 做成两个世界,分块就变成 token budget 的自然结果。“切片”是现象,“预算限制”是本质。

11.4 num_computed_tokens:断点续传的唯一状态

V1 的 Request 对象有一个关键字段:

# vllm/v1/request.py(简化)
class Request:
    prompt_token_ids: list[int]     # 完整 prompt
    num_computed_tokens: int = 0    # 已经计算到的位置
    output_token_ids: list[int]     # 已生成的 output
    status: RequestStatus            # WAITING / RUNNING / FINISHED
    block_ids: list[int]             # 已分配的 KV 块号

Chunked Prefill 的”状态”就是这一个整数。每完成一个块,num_computed_tokens += chunk_size;等它到 num_prompt_tokens 时 prefill 结束、切进 decode 阶段。

不需要保存中间激活、不需要保存 softmax 状态、不需要”记住”block 内部的什么——KV Cache 已经在 GPU 上了,它就是整个计算状态的全部载体。num_computed_tokens 只是一个指针,告诉下一 step 从哪里开始。

这种”状态最小化”的设计跟我们在第 4 章看到的 PagedAttention / 第 5 章的 BlockPool 一脉相承——物理数据住在 GPU,Python 对象只记录元数据

11.4.1 Decode 阶段也走同一个字段

有趣的是,进入 decode 阶段后,num_computed_tokens 继续增加:每 decode 一个新 token,它 +1(spec decode 场景 +N)。这意味着在 Scheduler 眼里,Prefill 和 Decode 不是两个阶段——它们是同一个”计算进度”的连续演化

这正是第 3 章所说”统一 Token 调度”的深层含义——不只是”调度器看起来统一”,连请求的状态表示也统一了。

11.4.2 这一个整数的”扁平哲学”

这个设计值得一个专门的小节——因为它体现了一种工程哲学:“能用整数表达的就别用对象”。

常见的过度工程:为了”优雅”引入一堆状态:

# ❌ 过度设计
class RequestProgress:
    phase: Literal["prefill_phase_1", "prefill_phase_2", "decode"]
    prefill_chunks_done: int
    decode_tokens_generated: int
    attention_cache_ready: bool
    last_chunk_boundary: int

V1 的做法:一个 int,搞定所有:

# ✅ 扁平
num_computed_tokens: int = 0

阶段判断可以从这个整数推导:num_computed_tokens < num_prompt_tokens 时还在 prompt 进度内,超过后就是输出 token 的进度。V1 scheduler 不直接写一个 phase 字段,而是比较 num_computed_tokensnum_tokensnum_tokens_with_spec

简单的数据结构往往比复杂的对象图更可靠、更好维护。这是一条值得每个系统工程师背下来的原则。

11.4.3 Request 的源码字段与双重 token 列表

上面伪代码把 prompt_token_idsoutput_token_ids 列为平级字段。打开 vllm/v1/request.py:18 看源码实现——字段比想象中多一个,而且公开 API 是只读视图

class Request:
    def __init__(self, ..., prompt_token_ids, ...):
        self.prompt_token_ids = prompt_token_ids
        self.num_prompt_tokens = len(self.prompt_token_ids)
        self._output_token_ids: list[int] = []
        self._all_token_ids: list[int] = self.prompt_token_ids.copy()  # ← 第三个列表
        self.spec_token_ids: list[int] = []
        self.num_computed_tokens = 0
        # ...
        # Read-only views
        # Prevent directly appending to these lists since
        # they should also be updated simultaneously.
        self.output_token_ids = ConstantList(self._output_token_ids)
        self.all_token_ids = ConstantList(self._all_token_ids)

关键三点

1. _all_token_idsprompt + output 的合并视图(line 51)——prompt_token_ids.copy() 初始化为 prompt、之后每 decode 一个 token 都 append 到这里。它才是**“num_computed_tokens 对照的完整序列”**——上面讲的”阶段判断 num_computed_tokens >= num_prompt_tokens” 精确语义里的”长度”指的是 len(_all_token_ids)、不是 num_prompt_tokens(那只是初始起点)。

2. output_token_ids / all_token_ids 公开是 ConstantList(line 70-71)——用户代码拿到的是只读视图。源码注释直白:“Prevent directly appending to these lists since they should also be updated simultaneously”。如果允许用户直接 request.output_token_ids.append(42)_all_token_ids 就没同步更新、num_tokens_with_spec 的计算就错了。只读视图封住了这个 API 坑

3. append_output_token_ids 双列表原子 append(line 94-103):

def append_output_token_ids(self, token_ids):
    if isinstance(token_ids, int):
        self._output_token_ids.append(token_ids)
        self._all_token_ids.append(token_ids)
    else:
        self._output_token_ids.extend(token_ids)
        self._all_token_ids.extend(token_ids)

唯一合法的 mutation 入口——两个列表在同一函数里同时更新、不可能漏同步。方法名直接就是契约:想改 output_token_ids、走这个方法、保证 _all_token_ids 跟着动。

这种”内部双列表 + 外部只读 + 唯一 mutation 方法” 的三件套是 Python 里模拟”不变式保护”的最干净方式——Python 没有 Rust 那样的类型系统直接表达 &mut,但用 naming convention(下划线前缀)+ wrapper 类(ConstantList)+ 集中 API 手动维护不变式。效果接近 Rust 的类型安全、但代价是每个 Request 多一层 wrapper 对象(内存轻微开销)。

11.4.4 WAITING_FOR_FSM 初始状态

上面的例子里 status 字段没展开。源码初始化(line 40-42)有个细节:

self.status = (RequestStatus.WAITING_FOR_FSM
               if sampling_params.guided_decoding is not None else
               RequestStatus.WAITING)

——两种不同的 waiting 状态

  • WAITING——普通请求,进入 waiting queue 等调度
  • WAITING_FOR_FSM——有 guided decoding 配置(JSON Schema / regex grammar)的请求、需要先等 FSM(有限状态机)编译完才能进调度

FSM 编译是额外的 CPU 工作。vLLM 不想让未准备好的 structured output 请求直接参与 token budget 竞争,所以设计上先入 WAITING_FOR_FSM 状态;scheduler 看到还没有 grammar 的请求,会把它从队首挪开,等待后续再调度(scheduler.py:301-310)。这种”两阶段等待”是 structured output 在 scheduler 视角里的状态转换。

num_computed_tokens 这个”一个整数搞定阶段”的扁平哲学配合 status 枚举的多态表达——数值推进由整数、语义分类由枚举——各司其职。两个字段合起来精确刻画一个请求的完整状态空间,其他所有信息都从这两个字段 + KV 块派生。

11.5 被抢占后的恢复:清零,再靠 prefix cache 找回

这里原理很容易误解:V1 里 RUNNING 请求被抢占时,scheduler 会释放它的 KV、把状态设为 PREEMPTED,并把 num_computed_tokens 直接清零(scheduler.py:224-228)。它回到 WAITING 队列后,不是靠 Python 对象保留进度,而是下一次调度时重新查询 prefix cache。

路径 A:前缀缓存还在

下一次调度 WAITING 请求时,scheduler 先调用 KVCacheManager.get_computed_blocks(request)scheduler.py:323-326)。如果 prefix cache 开着,KVCacheManager 会为请求计算 block hash,查找最长命中的完整 block 前缀,并返回命中的 block 列表和 num_computed_tokens = len(computed_blocks) * block_sizekv_cache_manager.py:105-162)。

这种情况下,恢复成本几乎为零:

  • 不需要重新计算任何 KV
  • scheduler 会把 request 的 num_computed_tokens 设回命中的完整 block 末尾(scheduler.py:403-407
  • 新 chunk 从命中前缀之后继续 Prefill

路径 B:KV 块已被覆盖

如果 prefix cache 没开,或抢占期间块已经被复用、hash 查不到连续前缀,get_computed_blocks 会返回更短前缀,甚至返回 0。由于 num_computed_tokens 已经在抢占时清零,恢复语义天然保守:只相信 cache manager 找回来的完整 block。

恢复时:

  • num_computed_tokens 等于命中的完整 block 数 × block_size
  • 不完整 block 不参与共享,源码注释明确 num_computed_tokens 总是 block_size 的倍数(kv_cache_manager.py:158-161
  • 从回退位置继续 Prefill
flowchart TD
    A[RUNNING 请求被抢占] --> B[num_computed_tokens 清零]
    B --> C{下次调度查询 prefix cache}
    C -->|完整前缀命中| D[恢复到命中 block 末尾]
    C -->|部分命中| E[恢复到最后连续命中 block]
    C -->|未命中| F[从 0 开始 prefill]

    D --> G[继续调度新 token]
    E --> G
    F --> G

    style D fill:#10b981,color:#fff,stroke:none
    style E fill:#f59e0b,color:#fff,stroke:none
    style F fill:#ef4444,color:#fff,stroke:none

前缀缓存 + Chunked Prefill + 抢占三者形成了一个漂亮的协同:

  • Chunked Prefill 让”做到一半下次继续”成为正常调度形态
  • Prefix cache 让”被抢占后仍能复用完整 block”成为可能
  • 抢占让系统在 KV 饥饿时有保守恢复路径:宁可重算,也不相信已经释放的 KV

11.6 FlashAttention backend 的混合批实现

Chunked Prefill 的 kernel 端挑战:一个 batch 里既有 prefill(长 Q)又有 decode(Q=1),如何在单次 kernel 调用里高效处理?

vLLM 的 FlashAttention backend 使用 variable-length batch protocolFlashAttentionMetadataBuilder.build() 会把 query_start_locseq_lensblock_tableslot_mapping 放进 metadata(flash_attn.py:314-450),forward 时再传给 flash_attn_varlen_funcflash_attn.py:586-626)。Worker 构造的 attention 输入可以这样理解:

# 假设一个 batch 里有 3 个请求:
#   req_A: prefill 第 2 块,本步 1024 token
#   req_B: decode 1 token,已有 context 2048
#   req_C: prefill 完整短 prompt,本步 128 token
query = tensor([
    # req_A 的 1024 个 Q 向量
    # req_B 的 1 个 Q 向量
    # req_C 的 128 个 Q 向量
])  # shape = [1024 + 1 + 128, num_heads, head_dim]

query_start_loc = tensor([0, 1024, 1025, 1153])  # 累积 Q 长度
seq_lens = tensor([2048, 2049, 128])             # 每个请求当前 K/V 长度
# req_A 的 k_len=2048(之前 chunk + 本步 chunk 的上下文)
# req_B 的 k_len=2048+1=2049(之前所有 token 的 KV + 本步新 token 的 KV)
# req_C 的 k_len=128

在源码里,query_start_loc 会作为 cu_seqlens_q 传入 varlen attention,seq_lens 会作为 seqused_k 传入,paged KV 的物理位置由 block_table 指定(flash_attn.py:597-620)。kernel 端需要的信息因此被拆成三类:

  • 通过 cu_seqlens_q[i]cu_seqlens_q[i+1] 算出本请求的 Q 范围
  • 通过 seqused_k[i] 知道本请求可见的 K/V 长度
  • 通过 block_table[i] 把逻辑 KV 位置映射到 PagedAttention 的物理块

这种设计的精髓在于:长短请求不用 padding 成同一个矩形,也不用为 decode 请求构造巨大的无效 attention mask。每个请求的 Q 范围、K/V 长度和 block table 都是显式元数据。

源码里还可以看到一个边界:当 get_flash_attn_version() == 3 时,builder 会尝试使用 AOT scheduler metadata(flash_attn.py:305-358);否则 scheduler_metadataNone,仍走 varlen 调用。也就是说,本章关心的混合批协议不应被简化成某个单一后端版本,真正的公共接口是 varlen metadata。

graph LR
    subgraph "fixed shape 思路"
        T1[把请求 padding 到同一长度] --> T2[无效 token 也参与形状]
        T2 --> T3[大矩形 attention]
        T3 --> T4[再取有效输出]
    end

    subgraph "FlashAttention varlen(混合 prefill+decode)"
        V1[Q 拼接成连续 token]
        V2[query_start_loc: 每请求 Q 边界]
        V3[seq_lens: 每请求 K/V 长度]
        V4[Block Tables: Paged KV 索引]
        V1 --> V5[Kernel 按元数据计算有效区间]
        V2 --> V5
        V3 --> V5
        V4 --> V5
    end

    style T3 fill:#ef4444,color:#fff,stroke:none
    style V5 fill:#10b981,color:#fff,stroke:none

11.7 chunk_size 的四条调优曲线

Chunked Prefill 最重要的旋钮是每步 token 预算。V1 里它主要由 max_num_batched_tokens 控制:scheduler 初始化时把它保存成 max_num_scheduled_tokensscheduler.py:60-64),每轮 token_budget 从这个值开始(scheduler.py:162-164)。它的取值影响四个指标,呈现四条不同的曲线:

graph TB
    subgraph "chunk_size 的四条曲线"
        C["chunk_size 从小到大"]
        C --> T1["TTFT"]
        C --> T2["TPOT (decode 平滑度)"]
        C --> T3["吞吐"]
        C --> T4["GPU 利用率"]
    end

    T1 -->|"chunk 越大,TTFT 越低<br/>少分几步完成 prefill"| R1["单调下降"]
    T2 -->|"chunk 越大,每步越慢<br/>decode 抖动越大"| R2["单调上升"]
    T3 -->|"先升后降<br/>太小 kernel 启动占比大<br/>太大 decode 被阻塞"| R3["倒 U 型"]
    T4 -->|"先升后稳<br/>太小 GPU 喂不饱"| R4["Plateau"]

    style R1 fill:#10b981,color:#fff,stroke:none
    style R2 fill:#ef4444,color:#fff,stroke:none
    style R3 fill:#f59e0b,color:#fff,stroke:none
    style R4 fill:#3b82f6,color:#fff,stroke:none

曲线 1:TTFT vs chunk_size(单调下降) chunk_size 越大,长 prompt 越快跑完 Prefill,第一个 output token 越早出来。

曲线 2:TPOT 稳定度 vs chunk_size(单调上升) chunk_size 越大,单 step GPU 时间越长,decode 请求感知到的 TPOT 也就越长/越抖。

曲线 3:吞吐 vs chunk_size(倒 U 型) 太小 → step 数和调度开销占比变大,有效 Prefill tokens/s 下降 太大 → Prefill 独占 step,decode 饿死、大家都慢 甜点 = GPU 能在一 step 内刚好吃饱,但又不会让 decode 等太久

曲线 4:GPU 利用率 vs chunk_size(Plateau) chunk 足够大之后,继续增加预算不一定提高 GPU 利用率,反而可能把 decode 等待时间拉长。太小则可能喂不饱 GPU。

11.7.1 源码里的默认值

不要把某个固定 chunk size 当成通用推荐。当前源码在 V1 默认参数里按设备显存和使用场景设置 max_num_batched_tokens

条件LLM_CLASSOPENAI_API_SERVER源码
设备显存 ≥ 70 GiB163848192engine/arg_utils.py:1596-1602
其他设备81922048engine/arg_utils.py:1603-1609

这不是”最佳值”,只是默认起点。生产服务要按 prompt/decode 分布、P95/P99 TPOT、TTFT、吞吐和显存余量重新压测。

11.7.2 long_prefill_token_threshold 补丁的精妙

if 0 < long_prefill_token_threshold < num_new_tokens:
    num_new_tokens = long_prefill_token_threshold

这条检查的意思是:只有当待调度 token 数超过阈值时,才先截到阈值。短请求不会被这个阈值截断。

源码默认不是 inf,而是 0config.py:1854-1856)。因为条件写的是 0 < threshold < num_new_tokens,所以 0 表示不启用这个阈值。用户可以显式设置:

--long-prefill-token-threshold 2048

意思是:待调度 token 数超过 2048 时,先截到 2048;之后还会再被 token_budget 截一次。另一个相关细节是:如果 max_num_partial_prefills > 1 且阈值仍为 0,SchedulerConfig.__post_init__ 会把阈值设成 max_model_len * 0.04config.py:2008-2018),用于区分 long partial prefill 和较短请求。

这是一个”最少原则”补丁——Chunked Prefill 是有代价的(多 kernel 启动),只在代价值得的地方用它。

11.7.3 调参的诊断方法

如何判断你的 chunk_size 是不是最优?用三个指标反推:

观察可能问题调整方向
TPOT p99 远大于 p50(尖峰明显)chunk 太大减小 max_num_batched_tokens
TTFT 远大于正常单次 prefill 时间chunk 太小,分太多次增大 max_num_batched_tokens
GPU SM 利用率偏低且 chunk 小没吃饱增大预算或混更多 decode
TPOT 稳定但总吞吐低可能 decode 饿死查 waiting 队列长度

生产经验:不要盲目调,用 vLLM 自带的 /metrics 端点看 Prometheus 指标,根据数据调。

11.8 Chunked Prefill 与其他优化的组合

Chunked Prefill 不是孤立的优化,它和 V1 的其他机制有密切的协同:

11.8.1 + 前缀缓存

最常见的组合。一个长 prompt 的前 N 个 block 如果命中前缀缓存:

  • 那 N 个 block 直接 ref_cnt++,零计算
  • 剩下的部分才进入 chunked prefill
  • budget 消费也只扣”真算了”的那部分

假设 RAG 场景里 system prompt 和工具说明固定复用,用户 query 较短。第二个请求来的时候:

  • 固定前缀如果命中缓存,就直接复用完整 block
  • 只有用户 query 和未命中的尾部需要进入 prefill
  • token_budget 消费的是需要本轮实际计算的新 token

前缀缓存会降低长 prefill 的实际计算量。注意它只共享完整 block;KVCacheManager.get_computed_blocks 明确只返回完整 computed blocks,不完整 block 不参与共享(kv_cache_manager.py:95-103158-161)。

11.8.2 + 投机解码(Spec Decode)

Spec Decode 让一个 RUNNING 请求一步可能携带多个 speculative token。从 Scheduler 角度看,请求的目标长度变成 num_tokens_with_spec = len(_all_token_ids) + len(spec_token_ids)request.py:109-111)。

这些 token 挤占的 budget 也是实实在在的。V1 在 RUNNING 调度里先计算 request.num_tokens_with_spec - request.num_computed_tokens,再被 token_budget 截断;如果实际调度到 spec token,还会裁剪 spec_token_idsscheduler.py:179-185261-270)。Chunked Prefill 和 Spec Decode 因此共用同一个预算模型。

11.8.3 + 抢占

Chunked Prefill 正在”分片 prefill 中”的请求,会不会被抢占?

V1 会先调度 RUNNING 队列。一个已经调度过一段 prefill、但还没完成 prompt 的请求,也在 RUNNING 队列里继续追赶 num_tokens_with_spec。当 allocate_slots 失败时,scheduler 从 self.running.pop() 取一个请求抢占,释放 KV,设为 PREEMPTED,并把 num_computed_tokens 清零后放回 waiting 队首(scheduler.py:216-233)。如果是 WAITING 新请求分配失败,则本轮直接停止继续拉新请求(scheduler.py:362-370)。

11.8.4 + Chunked Prefill 的 token budget 分布

一个常被问起的问题:当 token budget 有限,有一批 RUNNING 请求和几个 WAITING 新请求时,budget 怎么分?

V1 的顺序:

  1. 先续 RUNNING:从 self.running 头部开始,只要 token_budget > 0 就调度(scheduler.py:174-180
  2. 再拉 WAITING:没有 preemption 时,才从 waiting 队首尝试调度新请求(scheduler.py:293-299
  3. 每个请求都受同一套截断:先看剩余 token,再看 long_prefill_token_threshold,再看 token_budget

这个顺序决定了:decode/已运行请求优先保持流动,新请求在 waiting 队列里按策略进入。SchedulerConfig.policy 支持 fcfspriority 两种语义,源码注释说明 priority 用更小的 priority 值优先,同 priority 再按到达时间排序(config.py:1912-1917)。

11.8.5 协同矩阵

把所有交互汇成一张表:

组合相互关系生产建议
Chunked + 前缀缓存减少实际计算的新 tokenRAG 场景优先验证
Chunked + Spec Decode共享 token budget看 draft 接受率和 TPOT
Chunked + 抢占抢占后清零,再靠 cache 恢复关注 preemption 指标
Chunked + TP每个 step 仍要跨 rank 同步关注通信尾延迟
Chunked + Multi-LoRALoRA 影响缓存复用边界结合第 16 章的 KV/LoRA 约束

11.9 生产评估:该测什么

不要把网上某张 benchmark 表直接搬到自己的服务里。Chunked Prefill 的收益对流量形态高度敏感,至少要固定下面这些维度:

维度为什么重要
prompt 长度分布决定长 prefill 出现频率
decode 长度分布决定 RUNNING 队列压力
并发 / QPS决定 token budget 是否长期被打满
TTFT p50/p95/p99看新请求首 token 代价
TPOT/ITL p50/p95/p99看流式输出是否平滑
num_waiting_reqs / num_running_reqs判断排队是否被长 prompt 推高
GPU cache usage / preemption判断 KV 是否成为真正瓶颈

读结果时看四个方向:

  • TTFT 是否可接受:chunk 太小会让长 prompt 分太多步
  • TPOT 尾部是否下降:这是 chunked prefill 的主要目标
  • 吞吐是否被牺牲:chunk 太小或过多调度空转会伤吞吐
  • preemption 是否增加:如果 KV 不足,chunked prefill 可能暴露更频繁的抢占

如果 TPOT 尾部明显改善、TTFT 增量可接受、吞吐没有明显下降,这个配置才算适合当前流量。

11.10 Chunked Prefill 的 5 种误区

即便是老手也常犯的误解:

误区真相
”分块一定降低吞吐”不一定。要看 chunk 是否太小、decode 是否被饿死、GPU 是否更稳态
”chunk 越小越好,decode 越稳”不对。过小会增加 step 数和调度/kernel 开销
”只有长 prompt 才开”V1 默认开启 chunked prefill,但阈值和预算决定实际是否截断
”要和 prefix cache 二选一”不对。prefix cache 命中的完整 block 会减少实际 prefill 工作量
”抢占后一定从原位置继续”不对。V1 抢占会清零进度,只信 prefix cache 找回的完整 block

11.10.1 源码统计:V0 vs V1 chunked prefill

把 V0 vs V1 的相关源码体量统计一次:

路径角色
V1 vllm/v1/core/sched/scheduler.py841整个 V1 调度器全部代码
↳ V1 RUNNING 截断逻辑(line 179-191)~13num_tokens_with_speclong_prefill_token_thresholdtoken_budgetmax_model_len 四层约束
↳ V1 WAITING 截断逻辑(line 323-347)~25prefix cache 命中后,再用同样预算截断新请求
V1 vllm/v1/request.py178Request 数据类(§11.4.3 主角)+ num_computed_tokens 字段定义在 line 53
V0 vllm/core/scheduler.py2060V0 调度器全部
↳ V0 _schedule_chunked_prefill 专用方法(line 1325)~114独立的 prefill/decode 分桶 + 优先级排序 + capacity 管理
chunked_prefill_enabled 条件分支(line 940 / 1457 / 1954)散布多处 if-else 分支区分 chunked vs 普通模式
V0 vllm/attention/ops/chunked_prefill_paged_decode.py366V0 时代的 chunked prefill / paged decode kernel

两条值得记住的物理事实——

  1. V1 没有独立 _schedule_chunked_prefill 方法。chunking 的核心被收敛到 RUNNING/WAITING 两段通用调度逻辑里。
  2. V0 有显式 chunked prefill 分流_schedule() 根据 chunked_prefill_enabled 选择 _schedule_chunked_prefill_schedule_default,这是两条调度路径。

Request 字段的最小性——request.py 仅 178 行,num_computed_tokens 是 line 53 的一个 int 字段。进度状态集中,恢复语义交给 prefix cache 和 KV block manager,而不是在 Python 对象里维护一棵复杂状态树。

这就是本章最重要的源码结论:V1 不是没有复杂度,而是把复杂度压进了统一调度协议。代码少,是因为概念少;概念少,是因为 prefill、decode、prefix cache、spec decode 都用同一个 token 进度来表达。

11.11 本章小结

Chunked Prefill 是 V1 默认开启的调度能力:

  • 预填充阻塞问题——长 prompt 会拉长单 step,让 decode TPOT 出现尖峰
  • 学术动因——SARATHI/SARATHI-Serve 指出 chunked+mixed 能缓解 throughput-latency tradeoff
  • V1 统一实现——RUNNING 和 WAITING 都通过 num_new_tokenstoken_budgetmin() 截断
  • 状态最小化——num_computed_tokens 一个整数记录进度;KV Cache 和 prefix cache 承担物理状态
  • 抢占恢复——抢占时清零,恢复时只相信 prefix cache 找回的完整 block
  • FlashAttention 混合批——query_start_locseq_lensblock_table 共同描述 varlen attention
  • 预算调参——TTFT / TPOT / 吞吐 / GPU 利用率各自不同走向;需要根据场景压测
  • long_prefill_token_threshold——默认 0,不启用阈值;并发 partial prefill 时可能由配置派生
  • 和其他优化的协同——前缀缓存减少实际计算,Spec Decode 共享 budget,抢占共用 KV 释放路径
  • 生产评估——看 TTFT、TPOT/ITL、吞吐、waiting/running 队列、KV 使用率和 preemption,而不是只看均值

一句话记忆

Chunked Prefill 用 token budget 把长 prompt 切进多个 step,让 decode 的流式体验不被一个大 prefill 一次性拖住。

物理事实:V1 scheduler 841 行,Request 178 行;V0 scheduler 2060 行,并有 _schedule_chunked_prefill 专用方法和 chunked_prefill_paged_decode.py 366 行 kernel。V1 的关键变化不是某个神奇参数,而是统一 token 进度和统一 budget。


源码导航

  • V1 Scheduler(分块逻辑):vllm/v1/core/sched/scheduler.py
  • Request 状态字段:vllm/v1/request.pynum_computed_tokens
  • FlashAttention backend:vllm/v1/attention/backends/flash_attn.py
  • Prefix cache 恢复:vllm/v1/core/kv_cache_manager.py
  • 参数文档:SchedulerConfig.long_prefill_token_threshold / max_num_batched_tokens / enable_chunked_prefill

论文

  • Agrawal et al., “SARATHI: Efficient LLM Inference by Piggybacking Decodes with Chunked Prefills”, MLSys 2024 (arXiv:2308.16369)
  • Agrawal et al., “Taming Throughput-Latency Tradeoff in LLM Inference with Sarathi-Serve”, OSDI 2024 (arXiv:2403.02310)