静态 batching 把一批请求绑成同生共死的一条船:短请求先完成,槽位也只能空等到整批结束。
Continuous batching 把调度粒度降到一次迭代;vLLM V1 又往前一步,取消独立的 prefill / decode 阶段,让所有请求共享一个 token 预算。
代价藏在预算、max_num_seqs 与 KV 块的三角关系里。
静态 batching 的浪费不是算错了,而是等:一条短请求生成完,它的槽位要等整批结束才释放。 Continuous batching 把「整批完成」改成「每生成一个 token 就重新组批」。
vLLM V1 的调度器没有独立的 prefill / decode 队列。每个请求只记 num_computed_tokens 和总 token 数,
调度器每步给它分配一段 token,把这个差补齐;max_num_batched_tokens 是全部请求共享的预算。
源码先跑 running,再跑 waiting:已经在跑的 decode 先拿 token,新来的 prefill 只能吃剩余预算。 chunked prefill 默认开启,长 prompt 被切片塞进空档;预算被 running 吃光时,prefill 可能一步都排不上。
KV 块不够时没有「阈值」可调:allocate_slots 返回 None 就触发抢占。
V1 默认把请求打回 waiting 并重算(RECOMPUTE),不是换出到 CPU。
先承认静态 batching 的浪费来自哪里:不是 kernel 慢,而是批次里先完成的序列不能释放槽位。
自回归生成每次只产出一个 token,但一条请求要生成多少 token,在准入时并不知道。 静态 batching 把 N 条请求组成一批,整批一起跑前向;某条请求先遇到 EOS,它仍然占着批次里的位置, 直到最慢的那条也结束。对 GPU 来说,这些位置只会让矩阵乘法的行数变小,不会自动变成另一条请求的算力。
这不是调度器「算得慢」,而是决策发生的时刻太晚:批次在准入时冻结,下一次重组要等整批结束。 Continuous batching 把决策点挪到每个 token 迭代之后:完成的请求离开,排队的请求在下一步就能进来。
表 1 · 三种批处理粒度(页面整理,用于概念对照,不是 vLLM 表格)
| 方案 | 何时补新请求 | 槽位何时释放 | 主要问题 |
|---|---|---|---|
| 静态 batching | 整批结束后 | 整批结束后 | 短请求空等长请求 |
| 请求级动态 batching | 当前批全部结束后 | 整批结束后 | 比静态好,但粒度仍粗 |
| iteration-level 调度 | 每个 token 迭代后 | 请求完成即释放 | 调度与 KV 管理复杂 |
难点在于 LLM 的「槽位」不只是计算行,还挂着一条长度不断增长的 KV cache。多塞一条请求,就多一份显存压力; 调度器不能只看 token 数,还要在每一步为请求分配和回收 KV 块。continuous batching 的收益来自更细的调度粒度, 它的复杂度也来自这里。
两代方案的差别不在批大小,而在调度决策发生的时刻;概念和 vLLM V1 的实现也要分开看。
静态 batching 在准入时固定批次;请求级动态 batching 会等当前批全部完成后,再从队列里组下一批。 两者都没有改变「一条请求结束后,它的槽位要等到批次边界才能复用」这件事。
序列长度越分散,尾部空转越明显。
每生成一个 token,调度器重新看一遍 running 与 waiting:完成的请求退出,排队的请求进入, 新进入的请求从自己的 prefill 开始补进度。批次组成在每一步都可能不同。
这就是 continuous batching 的核心;它描述的是调度粒度,不是某个固定算法。
continuous batching 是概念,vLLM V1 的 token-budget scheduler 是它的一种具体实现。把两者画等号, 会漏掉 V1 真正的设计选择:没有 phase 字段、running 先于 waiting、chunked prefill 与 KV 抢占都在同一个循环里处理。
调度发生在 iteration 边界:每步都可以让请求进出批次,而不是等整批完成。 它回答的是「什么时候允许换人」,不规定 token 预算怎么切、KV 不够时怎么办。
用 max_num_batched_tokens 做共享预算,用 max_num_seqs 限制 running 数,
每个请求靠 num_computed_tokens 追赶总 token 数;KV 块不够时在同一循环里抢占并重算。
这些是工程选择,不是 continuous batching 的定义本身。
看懂 num_computed_tokens 如何追赶 num_tokens,就看懂了 V1 的 prefill / decode 混合。
源码开头就把话说死了:调度器里没有 “decoding phase”,也没有 “prefill phase”。
每个请求只有两个关键计数:已经算过多少 token(num_computed_tokens),以及总共有多少 token 要算
(num_tokens_with_spec,含 prompt、已生成 token 和投机 token)。
调度器每一步做的事,就是给请求分配一段 token,让前者追上后者(vllm/v1/core/sched/scheduler.py:721-732)。
scheduler.py:861-870);waiting 准入用
num_tokens − num_computed_tokens(:1362,少了在途 placeholder 项)。
token_budget 初值就是 max_num_scheduled_tokens,通常等于
max_num_batched_tokens(:139-143)
循环从 running 开始(scheduler.py:815)。已经在跑的请求,无论它这一格是 decode 还是
prefill 的下一块,都先按上面的式子拿 token;每排一个,就从 token_budget 里扣掉相应的数量。
running 排完后,才轮到 waiting(scheduler.py:1071)。准入同时看两个上限:
len(running) >= max_num_active_reqs 时不再准入(:1100-1103);
有预算时,新请求从 num_computed_tokens = 0 开始补 prefill。
chunked prefill 开启时,prefill 被切到剩余预算大小;关闭时,如果整段 prompt 放不进剩余预算,
源码直接 break,这条请求本轮不排(:1391-1400)。这就是「长 prompt 被饿死」的代码入口之一。
token 预算够,不代表显存够。每个请求在跑之前要先 allocate_slots(scheduler.py:936)。
KV 块池不足时,allocate_slots 返回 None(vllm/v1/core/kv_cache_manager.py:607-609),
调度器进入抢占循环:FCFS 选 running[-1],Priority 选 (priority, arrival_time) 最大的请求
(scheduler.py:953-960)。
被抢占的请求释放 KV 块,num_computed_tokens 归零,插回 waiting:FCFS 到队首,
Priority 按 (priority, arrival_time) 重新插入(:1894-1919)。
这一轮也不会再准入新的 waiting 请求(:1072)——避免一边释放、一边继续往 running 里塞人。
下面把问题缩到最小:running 里全是 decode 请求(每个要 1 token),waiting 里只有一条 prefill。 拖动 D / P / B / M,切换策略与 chunked 开关,看这一轮的 batch 怎么组成。
组 A(共享预算):D=10、P=24、B=16、M=32,decode 优先,chunked 开。
6。min(24, 6) = 6;本轮后剩 24 − 6 = 18。4 步。组 B(预算被 decode 吃光):D=16、P=24、B=16、M=32,decode 优先,chunked 开。
0。min(24, 0) = 0;每轮 0 token,按同一策略永远不完成。min(16, 0) = 0;
下一步 prefill 拿剩下的 8,decode 才能拿 8(另外 8 个仍排不上)。prefill 两步完成,
但 decode 在这两步都没有被完整服务。
页面里的 JS 只做整数加减和 Math.min / Math.ceil,没有随机数;上面每个数都能在模拟里逐位复现。
覆盖:running 先于 waiting 的顺序;每个请求的 token 上限;max_num_seqs 的准入上限;
chunked prefill 开启时的预算切分;关闭时「整段放不下就不排」的 break 行为。
未覆盖:每个请求的 KV 块分配与释放;抢占 victim 的选择与重算;prefix cache 命中;
speculative decoding;FCFS / priority 队列排序;async scheduling;encoder / multimodal 输入;
long_prefill_token_threshold 的截断。真实调度器还要处理这些分支。
因此它是简化模拟,不是源码还原。模拟里的 D 个 decode 被当作一个整体,真实代码里它们各自独立排队、
各自有 num_computed_tokens。
每一步的调度决策很便宜;贵的是它触发的显存分配、释放,以及抢占后的重新 prefill。
EngineCore 的一步是一个很短的流水线:调度器返回本步每请求要算多少 token,模型执行器跑前向,调度器再用输出更新请求状态。
vllm/v1/engine/core.py:709-735 的 step() 就是这三段:
num_computed_tokens 在这一步末尾推进
调度器交给模型执行器的核心之一,是一张 num_scheduled_tokens 表:
请求 ID 到本步 token 数的映射;total_num_scheduled_tokens 是它们的和
(vllm/v1/core/sched/output.py:248-253)。模型侧拿到的是拼好的输入,
不需要知道哪个位置属于哪个阶段。
表 2 · 调度相关默认值(页面整理,逐项来自源码)
| 参数 | 源码默认 | 它约束什么 | 出处 |
|---|---|---|---|
| max_num_batched_tokens | 2048 | 单步全部请求共享的 token 预算 | config/scheduler.py:41 |
| max_num_seqs | 128 | running 请求数上限,也是默认准入上限 | config/scheduler.py:43;sched/scheduler.py:131 |
| enable_chunked_prefill | True | 长 prompt 能否按剩余预算切片 | config/scheduler.py:147 |
| long_prefill_token_threshold | 0 | 0 = 不限制单请求的 prefill 块大小 | config/scheduler.py:91 |
| watermark | 0.0 | 准入时保留的空闲 KV 块比例;不是抢占阈值 | config/scheduler.py:209 |
注意:SchedulerConfig 里的 2048 / 128 是「方便测试」的代码默认值;
真实服务在 EngineArgs 里按 GPU 和 UsageContext 覆写(vllm/engine/arg_utils.py:3004-3037)。
同一份代码在 H100 与 A100 上可能拿到不同的默认预算。
token 预算按 token 计,KV cache 按块计。V1 的默认块大小是 64 token(vllm/config/cache.py:185),
每个请求每步都要为新 token 申请块;块池不足时 allocate_slots 返回 None,而不是返回一个「部分成功」。
抢占的代价是重算。被抢占请求的进度被清零(scheduler.py:1899),释放块后回到 waiting;
再次准入时要从头 prefill 整个序列(prompt + 已生成 token),除非 prefix cache 或 KV connector 能恢复一部分。
vLLM 文档明确说 preemption / recomputation 会伤害端到端延迟(docs/configuration/optimization.md:37),
并建议优先加 gpu_memory_utilization、降 max_num_seqs 或 max_num_batched_tokens。
切片不会改变这个 prompt 要算的 token 总数;它改变的是这些 token 落在哪些 step,以及能否和 decode 同批。
小预算(文档例子 2048)让 decode 被更少的 prefill token 拖慢,ITL 更好;
大预算把更多 prefill token 塞进一批,TTFT 更好;文档建议追求吞吐时把 max_num_batched_tokens 设到大于 8192
(docs/configuration/optimization.md:62-67)。
文档把混合的收益归因于两类负载的硬件画像:prefill 偏 compute-bound,decode 偏 memory-bound;
同一批里同时有两者,算力和带宽都更不容易闲置(docs/configuration/optimization.md:51-58)。
调度循环本身的簿记量与每步排上的请求数成正比;真正的成本在抢占后的重算(一次与序列长度 L 成正比的前向)
和 KV 块的分配 / 释放。vLLM 还用 async scheduling 把 CPU 调度与 GPU 执行重叠:GPU 跑第 N 步时,
CPU 已经在排第 N+1 步(docs/design/arch_overview.md:173-179)。
把 6 个调度步画出来,看 prefill 块和 decode token 如何共享同一个 8-token 预算。
这张图里没有「prefill 阶段结束才能 decode」的屏障。t1 这一列同时有 A 的 decode 和 B 的 prefill; t2 更是三个请求同列:A decode、B 补完 prefill、C 开始 prefill。每一步的列宽是 8 token 预算, 色块宽度就是分到的 token 数;空白不是没画满,而是本步没有更多请求可排。
调度器的输出也没有 phase 字段。它交给模型执行器的是
scheduled_new_reqs、scheduled_cached_reqs、num_scheduled_tokens 这些结构
(vllm/v1/core/sched/output.py:238-253)。请求处于 prefill 还是 decode 不是队列属性;
代码里用 is_prefill_chunk 这类派生标记在需要时判断(scheduler.py:1940-1942),
没有两个独立队列。
官方文档给出的是方向性权衡;下面的步数是按本页简化模型算的,不是实测吞吐。
"In V1, chunked prefill is enabled by default whenever possible. With chunked prefill enabled, the scheduling policy prioritizes decode requests. It batches all pending decode requests before scheduling any prefill operations. When there are available tokens in the max_num_batched_tokens budget, it schedules pending prefills."
docs/configuration/optimization.md:53
文档把「优先 decode」写成了策略描述。源码里的机制更具体:先排 running(已经在跑的请求),再用剩余预算排 waiting(新请求)。 因为 running 里主要是 decode,效果上就是 decode 先拿 token;但代码里并没有一个独立的 decode 队列先出队。
"Smaller values (e.g., 2048) achieve better ITL because there are fewer prefills slowing down decodes. Higher values achieve better time to first token (TTFT) as you can process more prefill tokens in a batch."
docs/configuration/optimization.md:64-65
这两句话看起来像常识:预算小,decode 被 prefill 拖累得少;预算大,prefill 一次推进得多。 但把「running 先于 waiting」放进这个框架,会出现一个更硬的结果:预算小到被 running 的 decode 吃光时,waiting 的 prefill 不是变慢,而是本轮拿到 0 token。
表 3 · 简化模拟:D=10 个 decode、P=24 token prefill,只改预算 B(页面模拟,不是实测)
| 预算 B | decode 排上 | prefill 排上 | 本轮后 prefill 剩余 | 按此速率 |
|---|---|---|---|---|
| 8 | 8 | 0 | 24 | 0 token/步,永远不完成 |
| 16 | 10 | 6 | 18 | 24/6 = 4 步 |
| 32 | 10 | 22 | 2 | 第一步 22,第二步 2 |
表 3 的每一行都能在页面交互里复现:把 D 设为 10、P 设为 24,再把 B 依次拖到 8、16、32。 B=8 时 10 个 decode 里只有 8 个排上,prefill 还是 0;B=16 时 10 个 decode 全排上,prefill 还能拿 6; B=32 时 prefill 拿 22,下一步只剩 2。预算不是越大越「浪费」,也不是越小越「温和」——它决定的是谁先被满足。
在没有投机解码、每个 decode 请求只排 1 token 的前提下,running 的 decode 请求数大于等于 token 预算时,
running 循环会把预算用尽;没有异步 KV load 时,waiting 循环看到
token_budget <= 0 就直接退出(scheduler.py:817、:1100-1103)。
如果每一步都维持这个状态,新来的长 prompt 会一直停在 waiting。这不是 bug,是 running 优先策略的极端情形;
调大预算、降低 running 并发(例如收紧 max_num_seqs),都会改变这个极端情形出现的条件。
源码里没有 preemption_threshold 这个配置。触发条件是 allocate_slots 在分配这一批新 token 的 KV 块时
发现块池不够(kv_cache_manager.py:607-609),调度器才进入抢占。换句话说,
抢占是分配失败后的补救,不是一个可按百分比调节的阈值。
watermark(默认 0.0)是准入时保留的空闲块比例,用来减少频繁驱逐,不是抢占线
(config/scheduler.py:209-214)。
把「continuous batching 能做什么」和「vLLM V1 调度器不做什么」分开看。
running 先于 waiting 是一个吞吐导向的选择:已在跑的请求先推进,新请求吃剩余预算。
--scheduling-policy 管的是 waiting 的排队顺序和抢占 victim,不改变「running 优先」;
Priority 让高优先级请求更早被服务,但也不承诺 waiting 的最大等待时间。长 prompt + 小预算 + 多 decode 时,
prefill 饿死是策略的直接后果,不是异常。
token 预算和 KV 块是两套资源。预算还有余量,块池可能已经空了;这时 allocate_slots 返回 None,
调度器抢占正在跑的请求。被抢占请求的进度清零,重算整个序列;文档明确说这会伤害端到端延迟。
max_num_batched_tokens 不会为请求预留 KV,max_num_seqs 也不会。
continuous batching 提升的是聚合吞吐:让 GPU 在每个迭代都有活干。它不保证单请求的 ITL 或 TTFT; 相反,把更多 prefill 塞进同一批会让正在 decode 的请求多等一会儿。 需要严格延迟隔离时,工程上通常从并发上限、优先级、部署拆分(prefill / decode 分离)这些层面做,而不是只调 batch 参数。
max_num_batched_tokens 必须大于 max_model_len,否则文档警告可能在启动时崩溃
(docs/configuration/optimization.md:69-71);同时 max_num_batched_tokens 还必须不小于
max_num_seqs(config/scheduler.py:367-371)。
覆盖:running→waiting 的顺序、每请求 token 上限、max_num_seqs 准入、
chunked 开 / 关时的单步预算分配。
未覆盖:KV 块分配与抢占、prefix cache、speculative decoding、priority / FCFS 队列、
async scheduling、encoder / multimodal 输入、long_prefill_token_threshold。
页面里的时间线和表 3 都是按规则构造的示意,不是源码 trace,也不是 benchmark。
vLLM V1 是持续演进的代码库;本页只锚定 e740bad 这一版。换 commit 后,默认值、分支和行号都可能变。