← All notes

vLLM Scheduler:Continuous Batching 与 Chunked Prefill

Table of contents

本篇要回答什么

请求到达时间、prompt 长度和输出长度都不同,GPU 却希望持续获得足够工作。vLLM Scheduler 的任务是在每次 iteration 边界重新决定:哪些请求进入本轮、各推进多少 tokens、KV blocks 是否够,以及容量不足时谁被抢占。

1. 三种 batch

  • client batch:一次 API 调用提交的 prompts;
  • static/request batch:一段时间内成员固定的 batch;
  • iteration batch:某次模型执行真正打包的请求和 tokens。

Continuous Batching(连续批处理)缩短的是成员决策周期:模型每执行一轮,已完成请求可以退出,新请求可以在下一轮补入。

static batch:
[A B] ─────────────────── 等 A、B 都结束后才能换成员

continuous batching:
step 0 [A B]
step 1 [A B]
step 2 [A C]   B 已完成,C 补入

C 落在 step 2 还是 3,取决于它在本轮 schedule 之前还是之后到达;画时间线时必须声明事件边界。

iteration batch 也不一定是简单的 [batch, sequence] 矩形:decode 请求可能贡献 1 token,partial prefill 贡献数百 tokens,speculative path 贡献多个候选位置。

静态 batch 的浪费有两层。prompt 长度 [4,4,20] 若做矩形输入,会有 $3\times20-28=32$ 个 padding positions;输出长度 [2,8,2] 若成员固定,A/C 完成后对应 rows 还会等待 B。高效 kernel 可用 mask/packing 避免一部分 padding 计算,但“短请求不能及时退出并让新请求加入”仍是调度问题。

请求到达边界必须精确到 schedule:

iteration k execute ───────┐
                           ├─ result/update ─ schedule(k+1) ─ execute
C 在 schedule 前到达 ─────┘                 ↑ 可进入 k+1
D 在 schedule 后到达 ──────────────────────┘ 只能等 k+2

Continuous Batching 不承诺零等待,只把重新选择成员的机会缩短到 iteration 边界。

一次 iteration 内已经打包好的 GPU batch 通常要作为一个执行单元完成,Scheduler 才能可靠消费结果并决定下一轮成员。它不是在 kernel 执行一半时把某一 row 抽走;动态性发生在 iteration 之间。异步/batch-queue 路径可以重叠 CPU 准备和相邻批次,但仍要维护明确的数据依赖。

2. 请求状态不等于 prefill/decode

WAITINGRUNNING 表示调度生命周期,不是模型阶段的别名。长 prompt 被接纳并计算第一个 chunk 后已经是 RUNNING,但仍在做 prefill;请求也可能等待 grammar、remote KV、streaming 或处于 PREEMPTED/FINISHED 状态。

vLLM V1 的统一视角是比较逻辑 token 数与已计算进度。在省略 async placeholder 等分支的普通情形下:

\[deficit= \text{num\_tokens\_with\_spec} -\text{num\_computed\_tokens}.\]

这一个差额能表达多种工作:

场景 deficit 的含义
10-token prompt,computed=0 仍需 prefill 10
已完成前 4 个 prompt tokens 仍需 prefill 6
APC 命中 8 个 tokens 只追赶未命中后缀
刚采样 1 个 token 通常差 1,处理它以产生下一个
添加 draft tokens 一轮待验证位置增加多个

Scheduler 内部因此不必把所有路径硬拆成独立的 prefill loop 和 decode loop。

完整 deficit 还要考虑“逻辑上已加入但尚未验证”的 speculative tokens,以及异步路径的 placeholders。可以把三个量分开:

  • all_token_ids:已确认的 prompt + output token 序列;
  • spec_token_ids:proposer 给出的待验证候选;
  • num_computed_tokens:目标模型/KV 已实际推进的位置。

因此 num_tokens_with_spec - num_computed_tokens 是“当前可安排工作”,而不是剩余输出长度。PREEMPTED 请求可能已有逻辑 tokens 却因 KV 被回收而需要 recompute;WAITING 请求也可能因为 APC/remote KV 已有非零 computed progress。

这里还要修正变量名带来的误解:v0.20.0 的 Scheduler 在构造 SchedulerOutput 后,会用 _update_after_schedulenum_scheduled_tokens 加入 num_computed_tokens,此时 GPU 可能尚未物理完成。对 Scheduler 而言它更接近“已完成或已提交、下一轮不能重复安排的计算前沿”。同步路径在下一次消费该进度前通常已完成执行;spec rejection、KV load 失败或异步结果会在 output 处理时校正。

旧 computed=4
  → 构造 plan:从 position 4 开始处理 4 个
  → Scheduler 账面 computed=8(4..7 已被本轮认领)
  → executor/GPU 真正执行
  → result 校正/采样

必须先用旧进度构造 plan;若先改为 8,worker 可能错误地从 position 8 开始。

async output placeholder

num_output_placeholders 表示“已启动的异步采样将产生、但真实 token id 尚未并入 Request 的输出位置”,不是假 token,也不是结束标志。10-token prompt 按 4/4/2 做 prefill,只有最后 2 个追平 prompt、将产生首个输出时才增加 placeholder;前两个 chunk 不产生输出。

结果写回 token 42 时:

写回前:num_tokens_with_spec=10, placeholder=1, effective_frontier=11
写回后:num_tokens_with_spec=11, placeholder=0, effective_frontier=11

真实 token 数增加 1、placeholder 减少 1,有效逻辑前沿不凭空增长。结束由 EOS、stop、max tokens、abort/error 等真实条件决定,不由 placeholder 值决定。异步流水可能在 EOS 结果揭晓前提前提交下一轮,造成少量无用 GPU 工作;正确实现必须丢弃晚到结果、不能把 EOS 后 token 交给用户,并最终释放资源。

3. token budget 不是输出长度

token budget 是本 iteration 可以处理的 token 工作量上限;max_tokens 是某请求最多生成多少输出。二者回答不同问题。

即使 budget 有余,请求仍可能因以下约束无法执行:

  • KV blocks 不足;
  • maximum model length;
  • running request 数量上限;
  • encoder budget/cache;
  • LoRA 限制;
  • alignment、connector 或 structured-output 状态。

同样,sequence slot 有空也不代表还有足够 token 或 KV 容量。

三个资源的单位不同:

资源 单位 用尽后的表现
token budget 本 iteration 的 scheduled positions 剩余请求等下一轮
sequence slots 同时驻留/打包的请求数 有 budget 也不能再接请求
KV blocks pool 中的物理 blocks 分配失败、等待或抢占

max_tokens=128 是单请求采样停止条件;max_num_batched_tokens=2048 一类预算约束本轮执行规模。二者即使数值相同也没有同一语义。

4. 三个请求的逐轮时间线

固定一个教学策略:每轮 token budget=6,单请求 prefill chunk 上限=4;先安排 RUNNING,再按 FCFS 接纳 WAITING。

请求 到达 prompt $P$ output $G$
A step 0 4 2
B step 0 8 3
C step 1 3 2
step 0: {A:4, B:2}
  A prefill 完成并采样 A1;B computed=2/8

step 1: {A:1, B:4, C:1}
  A 处理 A1、采样 A2 后完成;B 到 6/8;C 到 1/3

step 2: {B:2, C:2}
  B、C 都完成 prompt,各采样第一个输出
  剩余 budget=2 不能“回到本轮输入”处理刚刚才采样出的 token

step 3: {B:1, C:1}
  B 采样第二个输出;C 采样第二个输出并完成

step 4: {B:1}
  B 采样第三个输出并完成

每个请求真正送入模型的位置数为 $P+G-1$:

\[A=5,\quad B=10,\quad C=4,\quad total=19.\]

各轮 scheduled totals 也是 $6+6+4+2+1=19$。完成时每个请求的逻辑 token 数通常比 computed 多 1,因为最终采样 token 无需再次送入模型。

新采样 token 不能在同一 iteration“用剩余 budget 立即喂回”,因为本轮 model forward 的 inputs、positions、attention metadata 和 CUDA 工作在采样前已经确定。把输出再作为输入需要构造下一张执行图/下一批 tensor,这正是下一个 schedule 边界。推测解码是在本轮开始前已有多个 draft positions,不是 forward 结束后递归复用同一批次。

可执行的简化 Scheduler simulator

from dataclasses import dataclass

@dataclass
class Req:
    name: str
    arrival: int
    prompt: int
    output: int
    computed: int = 0
    sampled: int = 0

    @property
    def logical(self):
        # prompt 加已采样 token;最后采样 token尚未 computed
        return self.prompt + self.sampled

    @property
    def done(self):
        return self.sampled == self.output

reqs = [Req("A",0,4,2), Req("B",0,8,3), Req("C",1,3,2)]
budget, chunk = 6, 4
history = []

for step in range(5):
    left, plan = budget, {}
    for r in reqs:                    # 教学版 FCFS
        if r.arrival > step or r.done or left == 0:
            continue
        deficit = r.logical - r.computed
        if deficit == 0:
            continue
        n = min(deficit, chunk if r.computed < r.prompt else 1, left)
        plan[r.name] = n
        left -= n
    # execute:只推进本轮开始前已经存在的 positions
    for r in reqs:
        if r.name not in plan:
            continue
        r.computed += plan[r.name]
        # prompt 或上一个 sampled token被追平后,本轮产生一个新 token
        if r.computed == r.logical and r.sampled < r.output:
            r.sampled += 1
    history.append(plan)

assert history == [
    {"A": 4, "B": 2},
    {"A": 1, "B": 4, "C": 1},
    {"B": 2, "C": 2},
    {"B": 1, "C": 1},
    {"B": 1},
]
assert sum(sum(p.values()) for p in history) == 19
assert all(r.done for r in reqs)

输入是到达轮次、prompt/output 长度、budget 与 chunk;输出是每轮 request→scheduled tokens。长期状态为 computed/sampled。它验证 19-token 守恒与“采样结果下一轮再输入”。它没有 KV blocks、真实 RUNNING/WAITING 队列、APC、spec decode、并行、优先级和失败回滚,不能代表生产公平性或性能。

5. Scheduler.schedule() 的主干

生产代码分支很多,第一次阅读可以先固定普通 decoder-only、单 KV group、无 connector/spec/multimodal 的子集:

1. 初始化本轮 collections 与 budgets
2. 遍历 RUNNING,请求先追赶自己的 deficit
3. KV 不足时选择 victim、preempt,再重试分配
4. 若没有被抢占阻断,使用剩余资源准入 WAITING
5. 构造 SchedulerOutput
6. 推进 num_computed_tokens 等进度,等待执行结果返回

RUNNING 优先通常保护已有 stream 的 ITL,但 RUNNING 中也可能有 partial prefill,不能简单把这段命名为 decode loop。

allocate_slots 成功后才应记录 blocks、scheduled token 数并扣减 budget。容量不足时需要撤销受害请求已安排的本轮记录,再改变其状态和资源。FCFS/priority 下的 victim 选择必须按固定版本代码判断,不能概括成永远抢占“最新”或“最长”请求。

WAITING 准入还要区分新请求和被抢占后需要 recompute 的请求,并结合 APC/remote KV 的 computed progress。Chunking 关闭时,过长的未计算输入可能不能用本轮剩余小 budget 部分接纳;开启后才可分块推进。

KV 分配失败与 rollback

一个安全的教学主干是“先试资源,成功后提交计划;要抢占时撤销本轮临时记录”:

# 教学伪代码
for req in running_in_policy_order:
    n = min(req.deficit, token_budget)
    while n > 0:
        blocks = kv_manager.allocate_slots(req, n)
        if blocks is not None:
            plan.commit(req, n, blocks)
            token_budget -= n
            break

        victim = choose_victim(running, policy)
        if victim is None:
            n = 0
            break
        plan.rollback_if_scheduled(victim)
        kv_manager.free(victim)
        victim.num_computed_tokens = recompute_boundary(victim)
        victim.status = PREEMPTED
        move_to_waiting(victim)

if not preemption_blocked_admission:
    admit_waiting_with_remaining_resources()
return SchedulerOutput.from_plan(plan)

输入是权威队列、token budget 与 KV pool;输出是本轮不可变计划。长期状态可能改变队列、请求状态、computed progress、block ownership。真实 victim policy、是否 swap/recompute、connector 状态和调度顺序必须按 v0.20.0 固定代码核对,不能把伪代码中的名字当成真实函数。

为什么要 rollback:假设 victim 先被记入 plan 2 tokens,随后为了另一个请求释放它的 blocks;若不同时撤销 plan 和返还 budget,worker 会收到一个引用已释放 blocks 的计划,或者守恒式重复扣预算。

APC、remote KV 与 recompute 怎样进入统一进度

  • APC:等待请求 lookup 到 $h$ 个连续命中 tokens,可把 computed 起点推进到安全命中边界;
  • remote KV:connector 可能让请求等待传输/加载完成,完成前不能把远端数据当作本地可读;
  • recompute:抢占后物理 KV 已释放,逻辑 tokens 仍在,但 computed 可能退回可重建边界;
  • speculative:本轮目标模型实际接受几个 token,要在 runner output 返回后才能提交。

共同原则是:在 iteration 边界,num_computed_tokens 必须对应可复用或已按计划提交、且有失败校正机制覆盖的计算前沿;不能把“过去某时算过但 KV 已释放”或“尚未进入有效计划的愿望”算进去。

APC lookup 的频率也要区分:NEW/WAITING 请求每次尝试准入时可查询最长缓存前缀;若资源不足仍留在 WAITING,下轮重试时 cache 状态可能变化,所以可再查;PREEMPTED 请求重新准入也会再查。已经 RUNNING 的请求通常沿用自己绑定的 blocks,不会每轮重新搜索 APC,但仍会每轮分配/提交/释放 KV metadata。

new_reqs、FCFS 与 full-ISL reserve

NewRequestData/new_reqs 记录本轮首次同步给 worker-side runner 的请求;cached/running request 只需传增量。worker 需要这一区分来创建本地 request row、初始化 token/position/block-table 状态,避免每轮重发完整对象。它描述“本轮执行计划里的新成员”,不是预先保存下一轮请求。

FCFS(First-Come, First-Served,先到先服务)按到达/排队顺序考虑请求;priority policy 则还比较优先级与到达时间。KV 不足时“让当前请求原地等待”并不能释放任何 block,若希望它继续而 pool 已满,只能选择某个活跃 victim 释放,或本轮放弃当前请求。一个请求释放后仍不够,Scheduler 可以继续选择更多 victims,直到分配成功或当前请求本身也无法保留。

Chunked Prefill 只看首 chunk 容量时可能过度准入很多长 prompt:每个先拿 128 tokens 的 blocks,后续增长又争夺 pool,形成抢占—重算 thrashing。scheduler_reserve_full_isl(ISL,Input Sequence Length)尝试在准入时按完整输入长度预留/检查,而不是说“每次 chunk 都占同样 128”。概念上:

tokens_to_schedule = min(本轮剩余输入, chunk limit, token budget)
tokens_to_reserve  = full ISL 对应容量(开启 reserve 时)

tokens_to_reserve 由请求长度、computed/cached 进度和配置逻辑计算,不是用户随意再写一个常数。字段名和具体公式应从 v0.20.0 SchedulerConfigschedule() 分支核对。reserve 更保守,可能降低并发/增加 queue time,也不能覆盖未来无限 output 增长。

6. Chunked Prefill 改变粒度,不减少总工作

本轮 budget=2048,100 个 running decode requests 各需要 1 token,剩余 1948。一个长 prompt 尚有 6000 tokens 未计算:

本轮:100 decode + 1948 prompt tokens
下轮:继续安排剩余 prompt tokens

长 prompt 最终仍计算 6000 tokens,也仍要保存相应 KV。Chunked Prefill 只改变它分几轮执行。

选择 可能的收益 可能的代价
小 chunk decode 更频繁获得机会,tail ITL 可能改善 更多 iterations、launch 和调度;长请求 TTFT/input throughput 可能变差
大 chunk prefill GEMM 更饱满,长请求更快到首 token 单轮更长,已有 streams 等待,P99 ITL 和 activation 峰值可能增大

拐点依模型、GPU、并发、backend 和 shape 而变,不能从机制直接推出所有用户都更快。

Continuous Batching 决定请求成员能否跨轮变化;Chunked Prefill 决定一个 prompt 能否跨轮处理。二者可组合但不是同义词。

P/D disaggregation(prefill/decode 分离)又是第三件事:它把两个阶段放到不同 worker/节点,并传输 KV;Continuous Batching 与 Chunked Prefill 仍可在各自节点内部使用。

chunk size 的可归因实验

固定模型、权重/attention backend、GPU、并发到达轨迹、prompt/output 长度、KV 容量和随机种子,只改变 chunk size,例如 256/512/1024/2048。每档预热并重复,记录:

请求级:queue time、TTFT、ITL/TPOT、E2E p50/p95/p99
系统级:input/output tok/s、iteration 时长、batch tokens、preemption
设备级:GEMM shapes、kernel launch 数、GPU active、activation 峰值

若小 chunk 改善 p99 ITL,却增加 iteration/launch 并降低 input tok/s,这是机制预期内的权衡,不应只挑一个指标宣称全面优化。

7. 调度不是越满越好

更大的 token budget 和更多 running requests 可能提高吞吐与权重读取摊销,也可能:

  • 增加 queue time 或单轮 device time;
  • 消耗更多 KV blocks;
  • 让 TTFT、ITL 的尾部更差;
  • 扩大动态 shape,降低某些 graph bucket 的命中;
  • 更频繁触发 preemption/recompute。

评估时至少同时记录 input/output token throughput、TTFT、ITL/TPOT、E2E 分位数、queue time、preemption 和 KV 使用。

常见误区

  • “Continuous Batching 就是把 batch size 调大”:它强调 iteration 边界动态换成员。
  • “每个请求每轮只算一个 token”:prefill chunk 和 speculative tokens 可以贡献多个位置。
  • “WAITING 是 prefill,RUNNING 是 decode”:状态与计算阶段不能一一翻译。
  • “Chunked Prefill 减少 prefill FLOPs”:它改变粒度和竞争方式,不减少总 prompt 计算。

源码阅读入口(v0.20.0)

  • vllm/v1/core/sched/scheduler.pyScheduler.schedule() 主干;
  • vllm/config/scheduler.py:token budget、chunked prefill 与相关限制;
  • vllm/v1/request.py:请求状态与 token progress;
  • vllm/v1/core/kv_cache_manager.py:调度时的 block 查找和分配。

本篇总结

Scheduler 的统一语言不是“这轮是 prefill 还是 decode”,而是请求当前拥有多少逻辑 tokens、已有多少被模型计算,以及本轮 budget 和 KV 容量能填补多少 deficit。Continuous Batching 动态重组成员,Chunked Prefill 拆分长输入;两者共同在吞吐、TTFT 与 ITL 之间做可测量的权衡。


上一篇:Prefix Caching · 系列首页 · 下一篇:量化与推测解码

资料