NOTE 本文基于 vLLM v0.27.1(tag
6e448d0, 2026-08-11)源码深度剖析。文中所有文件路径、类名和行号均以该版本为准;vLLM 迭代很快,阅读时请以你手上的版本对照。
前面我们已经解决了一个重要问题:
KV Cache 如何管理?
PagedAttention 将 KV Cache 从连续的大块显存变成可以按 Block 动态分配和回收的资源,使得不同请求可以灵活共享 GPU 显存。
但有了 KV Cache 之后,还有一个更直接的问题:
GPU 这一轮到底给谁用?每个请求这一轮应该推进多少?
这正是 Scheduler 要解决的问题。
传统深度学习推理通常以固定 Batch 为基本调度单位:
Batch
├── Request A
├── Request B
└── Request C
↓
执行完整 Batch
↓
所有 Request 完成
这种模式对于 CNN、分类模型等输入输出长度相对固定的任务比较合适。
但 LLM Serving 完全不同:
- 不同请求的 Prompt 长度不同;
- 每个请求最终生成多少 token 是未知的;
- Prefill 和 Decode 的计算特征不同;
- 请求会不断到达和完成;
- KV Cache 会随着生成过程动态增长;
- Speculative Decoding 甚至会让一次迭代推进多个 token。
因此,LLM Serving 的 Batch 不能再是一个固定不变的集合。
vLLM 的调度可以理解为逐渐回答五个问题:
为什么 Batch 必须动态变化?
│
▼
1. Continuous Batching
│
▼
为什么一个长 Prompt 也不能一次执行完?
│
▼
2. Chunked Prefill
│
▼
Scheduler 每一轮到底给每个 Request 多少计算额度?
│
▼
3. Token Budget
│
▼
为什么 Prefill / Decode / Speculative 可以同时存在?
│
▼
4. Mixed Batch
│
▼
如果 KV Cache 不够怎么办?
│
▼
5. Admission Control & Preemption
最终可以把 Scheduler 理解为:
在计算资源和 KV Cache 资源的双重约束下,每一轮动态决定哪些 Request 执行,以及每个 Request 本轮推进多少 token。
1. Continuous Batching:为什么 Batch 必须动态变化?
1.1 Static Batching 的问题
传统推理系统通常采用 Static Batching。
例如有三个请求:
Request A:生成 12 tokens
Request B:生成 6 tokens
Request C:生成 4 tokens
如果采用固定 Batch:
Step 1:
A ████████████
B ██████
C ████
Step 2:
A ████████████
B ██████
C ████
...
Step 4:
A ████████████
B ██████
C ████
↑
B、C 已经完成
...
Step 12:
A ████████████
B idle
C idle
问题很明显:
请求 B、C 已经完成,但 Batch 仍然要等请求 A。
GPU 的部分计算资源因此被浪费。
更严重的是,新来的 Request D 即使已经排队,也无法及时加入当前 Batch:
A ──────────────────────────────►
B ───────────► done
C ────────► done
D ────────► waiting...
于是系统形成:
GPU
│
├── 已完成请求释放资源
│
├── Batch 仍然没有结束
│
└── 新请求无法及时进入
1.2 Continuous Batching
LLM 的生成过程天然是迭代式的:
Iteration 1
↓
Iteration 2
↓
Iteration 3
↓
...
因此,一个更合理的做法是:
不再固定整个 Batch 的生命周期,而是在每一轮迭代开始之前重新决定本轮有哪些 Request 参与执行。
这就是 Continuous Batching。
Continuous Batching
Iter 1: [A] [B] [C]
Iter 2: [A] [B] [C]
Iter 3: [A] [B] [C] → A 完成
Iter 4: [B] [C] [D] ← D 加入
Iter 5: [B] [C] [D] [E] ← E 加入
Iter 6: [B] [D] [E] ← C 完成
...
与 Static Batching 相比:
| 特性 | Static Batching | Continuous Batching |
|---|---|---|
| Batch 生命周期 | 固定 | 每轮动态调整 |
| 新请求加入 | 等 Batch 结束 | 每轮都可以尝试加入 |
| 已完成请求 | 占位直到 Batch 结束 | 立即释放资源 |
| Padding | 通常需要 | 不需要为了 Batch 对齐而等待 |
| GPU 利用率 | 请求完成后逐渐下降 | 可以持续填充 |
| 调度复杂度 | 较低 | 较高 |
因此,Continuous Batching 的核心不是简单地:
“把更多 Request 放进 Batch。”
而是:
每一次迭代,都重新决定 GPU 这一轮应该服务哪些 Request。
2. Chunked Prefill:为什么一个 Request 也不能一次吃完?
Continuous Batching 解决了:
不同 Request 之间如何动态加入和退出。
但还有一个问题:
如果某一个 Request 自己就非常大怎么办?
例如一个 32K token 的 Prompt:
Request A
┌─────────────────────────────────────────────┐
│ 32K Prompt │
└─────────────────────────────────────────────┘
如果一次性完成 Prefill:
Iteration 1:
[A: Prefill 32K]
那么 A 可能长时间占用 GPU。
此时其他已经处于 Decode 状态的请求:
Request B → Decode
Request C → Decode
Request D → Decode
都可能被迫等待。
于是出现典型的 Head-of-Line Blocking:
Long Prefill
████████████████████████████████████████
B Decode ──────────────────────────────
C Decode ──────────────────────────────
D Decode ──────────────────────────────
↑
被阻塞
这会直接影响 Decode 请求的 TPOT。
2.1 Chunked Prefill
因此,长 Prompt 也需要被拆成多个 Chunk:
32K Prompt
┌──────┬──────┬──────┬──────┬──────┐
│Chunk1│Chunk2│Chunk3│Chunk4│Chunk5│ ...
└──────┴──────┴──────┴──────┴──────┘
例如:
Iteration 1:
[A: Chunk1] [B: Decode] [C: Decode]
Iteration 2:
[A: Chunk2] [B: Decode] [C: Decode]
Iteration 3:
[A: Chunk3] [B: Decode] [C: Decode]
...
这样,A 不再一次性独占 GPU,而是和其他请求交替推进。
没有 Chunked Prefill:
Iter 1 [Long Prefill ████████████████████]
Iter 2 [Long Prefill ████████████████████]
Iter 3 [Long Prefill ████████████████████]
Iter 4 [Decode A][Decode B][Decode C]
有 Chunked Prefill:
Iter 1 [Chunk1][Decode A][Decode B]
Iter 2 [Chunk2][Decode A][Decode B]
Iter 3 [Chunk3][Decode A][Decode B]
Iter 4 [Chunk4][Decode A][Decode B]
...
2.2 Chunked Prefill 的代价
Chunked Prefill 并不是免费优化。
它实际上是在做一个典型的:
TTFT ↔ TPOT trade-off
如果长 Prompt 一次性执行:
Long Request
↓
快速完成 Prefill
↓
TTFT 较低
但:
Decode Requests
↓
被阻塞
↓
TPOT 出现尖峰
如果采用 Chunked Prefill:
Long Request
↓
Prefill 被拆成多个 Chunk
↓
TTFT 上升
但:
Decode Requests
↓
可以持续执行
↓
TPOT 更平稳
因此,Chunked Prefill 的本质不是简单的“把 Prompt 切小”。
更准确地说:
Chunked Prefill 将一个巨大的 Prefill 工作量拆成多个可调度的 token 额度,使 Scheduler 可以在长 Prompt 与 Decode 请求之间重新分配 GPU 计算资源。
这里已经出现了一个非常关键的概念:
Token Budget。
2.3 long_prefill_token_threshold
在 vLLM 中,可以通过:
long_prefill_token_threshold
限制一次 Prefill 可以推进的 token 数量。
概念上可以理解为:
num_new_tokens = min(
num_new_tokens,
long_prefill_token_threshold,
)
例如:
Prompt = 2050 tokens
long_prefill_token_threshold = 512
那么这个 Prefill 最多可以被拆成:
512 + 512 + 512 + 512 + 2
即:
Chunk 1 → 512
Chunk 2 → 512
Chunk 3 → 512
Chunk 4 → 512
Chunk 5 → 2
代价是这个请求需要更多轮才能完成 Prefill,TTFT 可能上升;收益则是其他 Decode 请求不容易被一个长 Prefill 长时间阻塞。
因此:
Chunked Prefill 实际上是在调度层把“长请求”变成多个可以跨 iteration 分配的工作单元。
而接下来真正需要回答的问题是:
Scheduler 每一轮到底有多少工作额度可以分配?又应该如何在不同 Request 之间分配?
3. Token Budget:Scheduler 每一轮到底怎么分配?
这一节是整个 Scheduler 的核心。
前面的 Continuous Batching 解决了:
Request 可以动态加入和退出。
Chunked Prefill 又解决了:
一个 Request 也可以被拆成多个部分逐步推进。
于是 Scheduler 每一轮都面临一个非常具体的问题:
这一轮 GPU 最多处理多少 token?这些 token 应该分给哪些 Request?
3.1 Request 是调度对象,Token 是调度资源
这里需要先纠正一个非常容易产生误解的说法。
不要简单理解成:
Scheduler 调度的不是 Request,而是 Token。
更准确的说法是:
Request 是 Scheduler 的调度对象,Token 是 Scheduler 分配计算资源的基本单位。
也就是说:
Scheduler
│
调度哪些 Request?
│
▼
Request A
Request B
Request C
│
│
每个 Request
分配多少 token?
│
▼
num_new_tokens
所以,Scheduler 并不是把 token 当成独立任务进行调度,而是:
以 Request 为对象,以 token 为资源,决定每个 Request 本轮推进多少 token。
3.2 每一轮首先确定 Token Budget
Scheduler 首先需要确定:
本轮最多允许处理多少 token?
在 vLLM V1 中,Scheduler 有一个配置项:
max_num_scheduled_tokens
它定义的是:
一次
schedule()iteration 中,最多允许调度多少个 token。
例如:
max_num_scheduled_tokens = 512
那么:
本轮 Token Budget = 512
意味着:
这一轮最多安排 512 token 的计算工作
所有 Request 在这一轮消耗的 token 额度之和不能超过这个预算:
Σ num_new_tokens_i ≤ 512
于是 Scheduler 的问题就可以抽象成:
给每一个 Request 分配 x_i 个 token
满足:
0 ≤ x_i ≤ request_remaining_i
Σ x_i ≤ Token Budget
这就是 Scheduler 最核心的资源分配问题。
3.3 Request 还需要推进多少?
Scheduler 需要知道:
这个 Request 到底还差多少 token?
vLLM 中有两个非常关键的量:
num_tokens_with_spec
num_computed_tokens
它们可以帮助我们理解 Scheduler 的工作方式。
num_computed_tokens
表示这个 Request 当前已经完成计算的 token 数量。
例如:Prompt = 2050 tokens,而当前:num_computed_tokens = 1024,
这意味着:
前 1024 tokens
↓
已经完成计算
↓
对应 KV Cache 已经建立
num_tokens_with_spec
它表示当前 Request 这一轮希望推进到的目标 token 位置。
普通 Decode 情况下,通常只需要推进一个 token:
num_tokens_with_spec ≈ num_computed_tokens + 1
而如果涉及 speculative decoding,则可能一次需要推进多个 token:
num_tokens_with_spec > num_computed_tokens + 1
因此,Scheduler 可以计算:
remaining_tokens = num_tokens_with_spec - num_computed_tokens
也就是:
这个 Request 当前还需要多少 token 的计算额度。
3.4 num_new_tokens:本轮真正分配多少?
Scheduler 最终真正关心的是:
num_new_tokens
即:
这个 Request 在本轮到底推进多少 token。
概念上可以理解为:
remaining_tokens = (
req.num_tokens_with_spec
- req.num_computed_tokens
)
num_new_tokens = min(
remaining_tokens,
token_budget,
other_constraints,
)
然后:
token_budget -= num_new_tokens
于是:
Token Budget
│
▼
┌─────────────┐
│ 512 │
└──────┬──────┘
│
┌─────────┼─────────┐
▼ ▼ ▼
Req A Req B Req C
400 1 1
│ │ │
└─────────┼─────────┘
▼
剩余 110
这就是 Scheduler 最核心的工作。
3.5 用一个完整例子看懂 schedule()
假设当前:
max_num_scheduled_tokens = 512
也就是:
Token Budget = 512
当前有三个正在运行的 Request:
Request A:
长 Prompt Prefill
remaining = 400
Request B:
普通 Decode
remaining = 1
Request C:
普通 Decode
remaining = 1
Scheduler 可以分配:
A → 400
B → 1
C → 1
消耗:
400 + 1 + 1 = 402
于是:
剩余 Token Budget
=
512 - 402
=
110
此时 waiting 队列中还有:
Request D:
Prompt = 300 tokens
Scheduler 不需要等待 A、B、C 全部完成。
只要还有预算,就可以继续接纳 D:
D → 110
最终:
┌─────────────────────────────────────────────┐
│ Token Budget = 512 │
├─────────────────────────────────────────────┤
│ Request A:Prefill 400 tokens │
│ Request B:Decode 1 token │
│ Request C:Decode 1 token │
│ Request D:Prefill 110 tokens │
├─────────────────────────────────────────────┤
│ Total 512 tokens │
└─────────────────────────────────────────────┘
这就是一个典型的 Mixed Batch。
但注意:
此时 Scheduler 根本不需要创建三个不同的 Batch。
它只是在一个统一的 Token Budget 下:
A → 400
B → 1
C → 1
D → 110
3.6 Decode 为什么也是同一个调度模型?
现在看普通 Decode。
假设:
Request B:
num_computed_tokens = 100
num_tokens_with_spec = 101
那么:
remaining_tokens = 101 - 100 = 1
因此:
B → 1 token
这就是普通 Decode。
而一个长 Prompt:
num_computed_tokens = 1000
num_tokens_with_spec = 1500
那么:
remaining_tokens = 1500 - 1000 = 500
这就是 Prefill workload。
因此,从 Scheduler 的角度看:
Prefill:
remaining_tokens 很大
Decode:
remaining_tokens 通常为 1
Chunked Prefill:
remaining_tokens 很大
但单轮只能推进一部分
Speculative Decode:
remaining_tokens 可能大于 1
所以可以得到一个非常重要的结论:
从 Scheduler 的角度,Prefill、Decode 并不是两套完全独立的调度算法,而只是不同 Request 在当前 iteration 中具有不同的 token 推进需求。
Prefill 和 Decode 依然是非常重要的性能分析概念,但它们并不意味着 Scheduler 内部必须存在两个完全独立的“Prefill Scheduler”和“Decode Scheduler”。
3.7 Scheduler 的核心源码逻辑
在 vLLM V1 中,关键逻辑位于:
vllm/v1/core/sched/scheduler.py
其中 Scheduler.schedule() 可以概念化为:
def schedule(self, throttle_prefills=False):
token_budget = self.max_num_scheduled_tokens
# 1. 先处理已经 Running 的 Request
for req in self.running:
target = req.num_tokens_with_spec
num_new_tokens = (
target - req.num_computed_tokens
)
num_new_tokens = min(
num_new_tokens,
token_budget,
)
# 长 Prefill 的额外限制
if self.scheduler_config.long_prefill_token_threshold > 0:
num_new_tokens = min(
num_new_tokens,
self.scheduler_config.long_prefill_token_threshold,
)
# 2. 尝试为这些 token 分配 KV Cache
blocks = self.kv_cache_manager.allocate_slots(
req,
num_new_tokens,
)
if blocks is None:
self._preempt_request(req)
continue
# 3. 消耗本轮 Token Budget
token_budget -= num_new_tokens
# 4. 再尝试接纳 Waiting Request
for req in self.waiting:
computed_blocks, num_computed = (
self.kv_cache_manager.get_computed_blocks(req)
)
num_new_tokens = (
req.num_tokens_with_spec
- num_computed
)
num_new_tokens = min(
num_new_tokens,
token_budget,
)
blocks = self.kv_cache_manager.allocate_slots(
req,
num_new_tokens,
)
if blocks is None:
break
req.status = RequestStatus.RUNNING
token_budget -= num_new_tokens
return SchedulerOutput(...)
这里最值得注意的是:
num_new_tokens = (
req.num_tokens_with_spec
- req.num_computed_tokens
)
以及:
token_budget -= num_new_tokens
它们共同构成了 Scheduler 的核心逻辑:
Request 当前还差多少?
│
▼
remaining_tokens
│
│ 与本轮 Token Budget 比较
▼
num_new_tokens
│
▼
allocate_slots()
│
▼
消耗 Token Budget
│
▼
继续处理下一个 Request
需要特别注意:
上面的两个
for循环并不是“Decode 循环”和“Prefill 循环”。
它们更准确地表示:
Running Requests
↓
Waiting Requests
在这两个集合中,Scheduler 都是在做同一件事情:
计算 Request 当前需要推进多少 token,并在本轮剩余 Token Budget 和其他资源约束下决定实际推进多少。
3.8 Token Budget 与 KV Cache 是两个不同维度的约束
到这里还需要区分一个非常重要的概念。
Scheduler 同时受到两类资源约束:
Scheduler
│
┌─────────┴─────────┐
│ │
▼ ▼
Compute Resource Memory Resource
Token Budget KV Cache
│ │
▼ ▼
本轮最多算多少 token 能否容纳这些 token
Token Budget 回答:
这一轮最多执行多少计算?
KV Cache 回答:
这些 token 对应的 KV Cache 是否有地方存?
因此,即使:
Token Budget = 512
也不代表一定可以执行 512 tokens。
例如:
Token Budget 足够
│
▼
需要分配 KV Cache
│
▼
KV Cache Block 不够
│
▼
无法继续接纳 / 需要抢占
这就是下一节 Admission Control 与 Preemption 要解决的问题。
整体调度流程如下:
Scheduler
│
▼
max_num_scheduled_tokens
│
▼
┌──────────────┐
│ Token Budget │
└──────┬───────┘
│
▼
Request token demand
│
▼
num_new_tokens
│
▼
allocate_slots()
│
▼
KV Cache
│
┌─────────┴─────────┐
▼ ▼
足够 不足
│ │
▼ ▼
执行 等待/抢占
4. Mixed Batch:为什么 Prefill、Decode 与 Speculative 可以共存?
前面的 Token Budget 机制实际上已经自然产生了 Mixed Batch。
所谓 Mixed Batch,并不是 Scheduler 专门定义了一种新的:
MixedBatch
而是:
多个不同类型的 Request 在同一个 Token Budget 下同时获得 token 推进额度。
4.1 一轮 GPU 中可以同时有什么?
假设当前:
Token Budget = 512
系统中有:
Request A:
长 Prompt,还没有完成 Prefill
Request B:
正常 Decode
Request C:
Speculative Decode
Request D:
刚刚进入系统,需要 Prefill
Scheduler 可以得到类似这样的分配:
| Request | 本轮 token | Workload | 含义 |
|---|---|---|---|
| A | 256 | Prefill | 长 Prompt 的一个 Chunk |
| B | 1 | Decode | 生成下一个 token |
| C | 5 | Speculative Decode | 推进真实 token + 候选 token |
| D | 250 | Prefill | 新请求开始 Prefill |
| Total | 512 |
于是这一轮:
┌─────────────────────────────────────────────┐
│ Token Budget = 512 │
├─────────────────────────────────────────────┤
│ A:Prefill Chunk 256 tokens │
│ B:Decode 1 token │
│ C:Speculative 5 tokens │
│ D:Prefill 250 tokens │
├─────────────────────────────────────────────┤
│ Total 512 tokens │
└─────────────────────────────────────────────┘
这就是 Mixed Batch。
4.2 Mixed Batch 并不是三种 Batch 拼起来
这里非常容易产生误解。
不要理解成:
Prefill Batch
+
Decode Batch
+
Speculative Batch
↓
Mixed Batch
更准确的理解是:
Scheduler
│
▼
Token Budget
│
┌──────────┼──────────┐
▼ ▼ ▼
Req A Req B Req C
│ │ │
256 token 1 token 5 token
│ │ │
└──────────┼──────────┘
▼
SchedulerOutput
也就是说:
Scheduler 只有一套统一的 token 分配逻辑,而 Prefill、Decode、Speculative 只是不同 Request 在这一轮产生了不同的 token 推进需求。
这也是为什么 Token Budget 是理解 Mixed Batch 的关键。
4.3 Speculative Decoding 为什么可以自然融入?
普通 Decode:
当前已经计算到 token N
下一轮:
推进 1 token
因此:
remaining ≈ 1
而 Speculative Decoding 可能希望一次推进多个 token:
当前:
N
Speculative:
N+1
N+2
N+3
N+4
因此:
remaining > 1
对于 Scheduler 来说,两者最终都只是:
Request
↓
num_tokens_with_spec
↓
num_computed_tokens
↓
remaining tokens
↓
num_new_tokens
所以 Scheduler 不需要重新设计一套:
Speculative Scheduler
而是使用同一个 Token Budget 模型处理。
这也是一个非常重要的架构思想:
上层优化可以改变一个 Request 一轮希望推进的 token 数量,但不需要改变 Scheduler 的基本资源分配抽象。
因此,Speculative Decoding 与普通 Decode 可以在同一个 iteration 中共存。
4.4 SchedulerOutput:调度完成后发生什么?
Scheduler 完成本轮决策后,并不会直接执行模型计算。
它会将本轮调度结果通过:
SchedulerOutput
传递给执行层。
可以把整个过程理解为:
Scheduler
│
│ schedule()
▼
┌─────────────────────┐
│ SchedulerOutput │
├─────────────────────┤
│ Request 列表 │
│ token 数量 │
│ KV Cache 分配结果 │
│ 其他调度元数据 │
└──────────┬──────────┘
│
▼
ModelRunner
│
▼
InputBatch
│
├── slot_mapping
├── block_table
└── attention metadata
│
▼
GPU 执行
因此:
Scheduler 负责“决定这一轮算什么”,执行层负责“把这个决定真正变成 GPU 上的计算”。
执行层需要通过 InputBatch、slot_mapping、block table 和 attention metadata,把这些不同形态的 token 组织成一次 GPU forward。
┌──────────────── 一轮 Mixed Batch 的 token 构成 ──────────────────────┐
│ │
│ SchedulerOutput (本轮 token 预算分配结果): │
│ │
│ ┌─ Req A (长 prefill, 已推进 256, 本轮再推 256) ──────────────────┐ │
│ │ |████████████████████████| ← 256 个 prefill tokens │ │
│ │ block_table = [7, 13] (新分配的块) │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Req B (普通 decode) ───────────────────────────────────────────┐ │
│ │ |█| ← 1 个 decode token │ │
│ │ block_table 追加 slot 到已有块 │ │
│ │ KV Cache 已累积 seq_len=512 │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Req C (decode, spec decode) ───────────────────────────────────┐ │
│ │ |█ █ █ █ █| ← 1 个真实 token + 4 个候选 token │ │
│ │ block_table 额外预留 lookahead slots │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │
│ GPU 侧组装: │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ InputBatch (扁平的 token 序列): │ │
│ │ [A₀ A₁ … A₂₅₅ | B₀ | C₀ C₁ C₂ C₃ C₄] │ │
│ │ total_tokens = 256 + 1 + 5 = 262 │ │
│ │ │ │
│ │ slot_mapping (每个 token 的 KV 写入位置): │ │
│ │ A: [block7:0..15, block13:0..15, ...] (新块, 全部写入) │ │
│ │ B: [block3:slot15] (追加到已存在块尾部) │ │
│ │ C: [block5:slot12..16] (含 lookahead 预留) │ │
│ │ │ │
│ │ attention metadata 统计(用于 backend 内部分支): │ │
│ │ num_prefill_tokens = 256 → backend 先处理这 256 个 prefill token│ │
│ │ num_decode_tokens = 6 → 再处理这 6 个 decode/candidate token │ │
│ │ block_table (per-req) 告诉 kernel 每个请求的 KV 块映射 │ │
│ │ query_start_loc 区分不同请求的 token 起始位置 │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │
│ 关键: 模型级只有一个 attention backend, 不是 per-request 切换。 │
│ 该 backend 根据 metadata 中的 prefill/decode token 计数, │
│ 在自己的 forward 内调用不同的底层 kernel (如 FlashInfer 的 │
│ trtllm_batch_context_with_kv_cache 和 trtllm_batch_decode_...)。 │
└──────────────────────────────────────────────────────────────────────┘
5. Admission Control 与 Preemption:KV Cache 不够怎么办?
到这里,Scheduler 已经解决了:
计算资源怎么分?
但还存在另外一个问题:
如果 KV Cache 已经没有足够空间了怎么办?
这是 LLM Serving 中非常现实的问题。
因为每推进一个 token,都可能需要增加对应的 KV Cache。
于是 Scheduler 同时受到:
计算约束:
Token Budget
内存约束:
KV Cache Blocks
5.1 Admission Control:不是所有 Request 都能立即进入 Running
假设:
GPU KV Cache
┌─────────────────────────────┐
│ █ █ █ █ █ █ █ █ █ █ █ █ █ │
│ █ █ █ █ █ █ █ █ █ █ █ █ █ │
│ █ █ █ █ █ █ █ █ █ █ █ █ █ │
└─────────────────────────────┘
Free Blocks = 很少
此时来了一个新的 Request:
Request D
Prompt = 4K tokens
即使:
Token Budget 足够
也不代表 D 能立即运行。
因为:
Token Budget 足够
│
▼
需要 KV Cache
│
▼
Free Blocks 不足
│
▼
无法继续
因此 Scheduler 在接纳 Waiting Request 时,需要同时考虑 KV Cache 是否能够满足需求。
这就是 Admission Control。
可以简单理解成:
先判断“GPU 有没有能力接纳这个 Request”,再决定是否让它进入 Running。
5.2 KV Cache 不够:Preemption
如果当前 Running Requests 已经占满 KV Cache,而又有更高优先级的调度需求,Scheduler 就可能需要抢占某些 Request。
概念流程:
KV Cache 不足
│
▼
需要释放 KV Cache
│
▼
选择一个 Running Request
│
▼
Preemption
│
├── 释放 KV Cache
│
├── Request 状态变为 PREEMPTED
│
└── 重新进入 Waiting
vLLM V1 当前主要使用 Recomputation 思路:
Preempt
↓
释放该 Request 的 KV Cache
↓
num_computed_tokens = 0
↓
重新进入 Waiting
↓
恢复运行时重新计算
也就是说:
抢占并不是把 Request 本身删除,而是释放它占用的 KV Cache,之后再重新计算。
5.3 Recomputation vs Swapping
从实现策略上,可以将抢占后的处理方式分为两类:
| 策略 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| Recomputation | 释放 KV Cache,恢复时重新计算 | 不需要额外 CPU KV 存储 | 浪费 GPU 计算 |
| Swapping | GPU KV → CPU,恢复时再传回 GPU | 保留已经计算的 KV | 消耗 CPU 内存和 PCIe 带宽 |
| Quantization + Offload | KV 量化后再换出 | 减少传输量 | 增加量化误差和实现复杂度 |
vLLM V1 当前主要采用 Recomputation。
其核心逻辑可以概念化为:
def _preempt_request(request, ...):
self._free_request_blocks(request)
request.status = RequestStatus.PREEMPTED
request.num_computed_tokens = 0
request.num_preemptions += 1
self.waiting.prepend_request(request)
其中最关键的是:
request.num_computed_tokens = 0
这意味着:
从 Scheduler 的角度,这个 Request 后续需要重新计算。
5.4 为什么 Recomputation 不一定像想象中那么昂贵?
乍看之下:
Preemption
↓
KV Cache 全部释放
↓
重新 Prefill
↓
不是浪费大量计算吗?
理论上确实如此。
但 Prefix Cache 会改变实际成本。
如果 Request 的大量前缀仍然命中 Prefix Cache:
Request
┌───────────────────────────────────────┐
│ Prefix Cache Hit │ 需要重新计算 │
└───────────────────────────────────────┘
│ │
▼ ▼
跳过 重算
那么恢复时并不一定需要从头重新计算整个 Prompt。
因此:
Prefix Cache 可以显著降低 Recomputation 的实际代价。
这也是为什么 KV Cache、Prefix Cache 和 Scheduler 并不是三个互相独立的模块,而是共同参与请求生命周期管理。
5.5 LIFO Preemption 与重新入队
在当前实现中,抢占选择和重新入队还涉及队列策略。
一个重要原则是:
尽量避免已经运行很久的 Request 被反复抢占。
当前实现采用 LIFO 风格的抢占策略,并且被抢占的 Request 会重新放回 Waiting 队列的前部,以便尽快恢复。
概念上:
Running:
A
B
C
D
如果 D 被抢占:
D
↓
Preempt
↓
Waiting Queue Head
恢复时:
Waiting:
D → A → B → ...
这样可以避免被抢占的 Request 长时间得不到恢复。
5.6 Watermark:给 KV Cache 留一点安全余量
Scheduler 还需要避免一种非常糟糕的情况:
接纳 Request
↓
KV Cache 几乎耗尽
↓
下一轮马上又不够
↓
立即 Preemption
↓
刚运行又被抢占
↓
反复震荡
因此可以预留一定比例的 KV Cache 作为安全余量。
这就是 Watermark 的基本思想:
KV Cache Blocks
┌────────────────────────────────┐
│ 可正常使用 │
│ │
│ │
├────────────────────────────────┤
│ Watermark │
│ 安全余量 │
└────────────────────────────────┘
概念上:
watermark_blocks = int(
watermark * num_blocks
)
在接纳新的 Waiting / Preempted Request 时:
required_blocks = (
num_blocks_to_allocate
+ watermark_blocks
)
只有剩余 Block 足够时才接纳。
这样可以减少:
Admission → 立即 Preemption → 再 Admission
这种资源震荡。
6. 本章小结:Scheduler:从“Batch 调度”到“资源调度”
到这里,可以把 vLLM Scheduler 的整个设计串起来。
传统推理系统更像:
Request
↓
固定 Batch
↓
执行
↓
Batch 完成
而 vLLM 更接近:
Incoming Requests
│
▼
Waiting Queue
│
▼
Scheduler
│
┌──────────┴──────────┐
│ │
▼ ▼
Token Budget KV Cache
计算资源约束 内存约束
│ │
└──────────┬──────────┘
▼
本轮调度结果
│
▼
SchedulerOutput
│
▼
ModelRunner
│
▼
GPU
从一个 Request 的生命周期来看:
Request 到达
│
▼
Waiting
│
▼
Admission Control
│
├── KV Cache 不足 ──→ Waiting
│
▼
Running
│
▼
Scheduler.schedule()
│
├── 计算 remaining tokens
│
├── 分配 Token Budget
│
├── allocate_slots()
│
└── 生成 SchedulerOutput
│
▼
GPU 执行
│
├── Prefill
├── Chunked Prefill
├── Decode
└── Speculative Decode
│
▼
num_computed_tokens 更新
│
├── 未完成 ──────→ 下一轮 Scheduler
│
└── 完成 ────────→ 释放 KV Cache
因此,本章最重要的结论可以概括为:
vLLM Scheduler 并不是简单地维护一个“当前 Batch”。它实际上是在每一个 iteration 中,结合 Token Budget、Request 状态和 KV Cache 可用空间,动态决定哪些 Request 可以执行,以及每个 Request 本轮可以推进多少 token。
而 Continuous Batching、Chunked Prefill 和 Mixed Batch,实际上都可以从这个统一的资源调度模型中自然推导出来:
Scheduler
│
▼
Token Budget
│
┌─────────────┼─────────────┐
▼ ▼ ▼
Request A Request B Request C
│ │ │
256 tokens 1 token 5 tokens
│ │ │
▼ ▼ ▼
Prefill Decode Spec Decode
│ │ │
└─────────────┼─────────────┘
▼
Mixed Batch
最终形成一个非常重要的抽象:
LLM Serving 的 Scheduler,本质上不是在调度“Batch”,而是在动态调度一组具有不同计算需求和不同 KV Cache 状态的 Request,并通过 Token Budget 将有限的 GPU 计算能力分配给它们。
这也是为什么 vLLM 能够在同一轮中同时处理:
长 Prompt Prefill
+
Chunked Prefill
+
普通 Decode
+
Speculative Decode
+
新 Request Admission
而不需要为每一种 workload 设计一套完全独立的 Scheduler。
Take Away:
- Continuous Batching:Request 可以在每个 iteration 动态加入和退出。
- Chunked Prefill:一个超长 Request 也不能无限制地独占一轮计算。
- Token Budget:Scheduler 为每个 iteration 设置一个 token 数量上限,以控制本轮 GPU 的最大调度工作量;在此基础上,再结合 Request 的 token 推进需求和 KV Cache 可用空间,决定每个 Request 实际推进多少 token。
- Mixed Batch:Prefill、Decode 和 Speculative Decode 可以在同一轮自然共存,因为它们最终都被统一表示成 token 推进需求。
- Admission Control + Preemption:Token Budget 解决“算多少”,KV Cache 管理解决“能不能装下”。
📂 本章源码导航
| 想看什么 | 从哪开始 |
|---|---|
| Scheduler 核心调度逻辑 | vllm/v1/core/sched/scheduler.py → Scheduler.schedule() |
| Request 状态与生命周期 | vllm/v1/core/sched/ |
| Token Budget | Scheduler.schedule() → max_num_scheduled_tokens |
| KV Cache 分配 | vllm/v1/core/kv_cache_manager.py → allocate_slots() |
| Prefix Cache | vllm/v1/core/kv_cache_manager.py / block_pool.py |
| Preemption | Scheduler._preempt_request() |
| Scheduler → Executor | SchedulerOutput |
| 执行层 InputBatch 构造 | ModelRunner,见后续执行层章节 |