vLLM 调度器核心机制详解
本文深入解析 vLLM 调度器核心机制,涵盖请求接入、优先级策略、资源分配与抢占逻辑,揭示其如何实现高并发、低延迟的高效推理调度。
一、请求接入与初始状态
客户端通过 ZMQ ROUTER-DEALER 模式异步发送请求至 EngineCoreProc,经由 input_queue 消费后调用 add_request,最终将请求注册到调度器:
python
class EngineCore:
def add_request(self, request: Request, request_wave: int = 0):
self.scheduler.add_request(request)
class Scheduler:
def add_request(self, request: Request) -> None:
self.waiting.add_request(request) # 进入等待队列
self.requests[request.request_id] = request # 注册到全局请求表
请求入队后初始状态为 WAITING,其 prompt 已被 tokenizer 转换为整型 token ID 序列(如 [1234, 5678])。
二、调度优先级原则
vLLM 遵循以下 4 条调度优先级原则:
| 优先级 | 原则 | 原因 |
|---|---|---|
| 1(最高) | 已开始输出的请求优先 | 中断会让用户感受到明显卡顿 |
| 2 | 等待时间最长的请求优先 | 避免用户因无响应而流失(FCFS) |
| 3 | 新请求次之 | 尚未开始处理,等待代价相对较低 |
| 4 | 抢占最晚加入的请求 | 它做得最少,重算代价最小 |
两个核心队列的优先级关系:
running 队列(Decode 阶段) ← 高优先级,优先保障
waiting 队列(Prefill 阶段) ← 低优先级,资源有余才调度
三、理解"当前步要算多少 token"
关键变量
num_computed_tokens:该请求已经计算过的 token 数num_new_tokens:本步要计算的新 token 数
三个限制条件(取最小值)
限制 1:不能超过模型最大记忆长度
max_model_len = 4096,num_computed_tokens = 1000
本步最多可算:4096 - 1 - 1000 = 3095 个
限制 2:不能超过系统 Token 预算(token_budget)
总预算 512,其他请求已用 400
本请求最多可得:512 - 400 = 112 个
限制 3:超长请求要切片(long_prefill_token_threshold)
输入 10000 个 token,切片阈值 2048
本步只处理前 2048 个,剩余下步继续
python
num_new_tokens = min(
max_model_len - 1 - request.num_computed_tokens, # 限制1
token_budget, # 限制2
long_prefill_token_threshold # 限制3(若超长)
)
四、显存块(Block)分配机制
为什么用 Block?
GPU 显存按固定大小的块(Block) 分配,每块存放固定数量的 token 对应的 KV Cache(由 block_size 决定,如 16)。好处是避免频繁的细粒度显存申请释放,类似停车场用车位而非厘米来计量空间。
显存池:
┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ...
│Block0│ │Block1│ │Block2│ │Block3│ │Block4│
│16tok │ │16tok │ │16tok │ │16tok │ │16tok │
└──────┘ └──────┘ └──────┘ └──────┘ └──────┘
需要多少个 Block?
num_new_tokens = 50,block_size = 16
需要 Block 数 = ceil(50 / 16) = 4 个(向上取整)
分配结果的两种情况
python
new_blocks = self.kv_cache_manager.allocate_slots(request, num_new_tokens, ...)
# 情况一:资源充足
if new_blocks is not None:
# 正常执行,继续推理
# 情况二:资源不足
if new_blocks is None:
# 触发抢占机制
五、Running 队列调度流程
调度器优先处理 running 队列(Decode 阶段请求),按 FCFS 顺序遍历:
python
while req_index < len(self.running) and token_budget > 0:
request = self.running[req_index]
# 计算本步需处理的 token 数(三个限制取最小)
num_new_tokens = request.num_tokens - request.num_computed_tokens
num_new_tokens = min(num_new_tokens, token_budget)
num_new_tokens = min(num_new_tokens, max_model_len - 1 - request.num_computed_tokens)
if long_prefill阈值触发:
num_new_tokens = long_prefill_token_threshold
# 尝试分配 Block
new_blocks = self.kv_cache_manager.allocate_slots(request, num_new_tokens, ...)
if new_blocks is None:
# 资源不足 → 触发抢占
...
else:
# 成功 → 记录调度结果,更新 token_budget
req_to_new_block_ids[request.request_id] = new_blocks.get_block_ids()
num_scheduled_tokens[request.request_id] = num_new_tokens
token_budget -= num_new_tokens
req_index += 1
抢占机制(仅在 Running 调度阶段触发)
当 Block 分配失败时,从 running 队列末尾选取牺牲者(优先级最低):
python
# FCFS 策略:弹出最晚加入的请求
preempted_req = self.running.pop()
# PRIORITY 策略:按优先级+到达时间选最低优先级的
preempted_req = max(self.running, key=lambda r: (r.priority, r.arrival_time))
# 释放其显存块
self.kv_cache_manager.free(preempted_req)
# 重置状态,放回 waiting 队首(下次优先恢复)
preempted_req.status = RequestStatus.PREEMPTED
preempted_req.num_computed_tokens = 0
self.waiting.prepend_request(preempted_req)
为什么选末尾? 末尾请求最晚加入,计算量最少,被重算的代价最低。
特殊情况: 如果被抢占的恰好是当前请求自己(preempted_req == request),说明连自己都无法调度,本轮 running 调度终止。
六、Waiting 队列调度流程
前提条件 :running 队列调度阶段没有发生任何抢占 ,且 token_budget > 0。
python
if not preempted_reqs: # 前提:无抢占
while self.waiting and token_budget > 0:
request = self.waiting.peek_request()
# 检查是否有可复用的 KV Cache(Prefix Caching)
new_computed_blocks, num_computed_tokens = \
self.kv_cache_manager.get_computed_blocks(request)
# 计算本步需处理的 token 数
num_new_tokens = request.num_tokens - num_computed_tokens
num_new_tokens = min(num_new_tokens, token_budget)
# 尝试分配 Block
new_blocks = self.kv_cache_manager.allocate_slots(request, num_new_tokens, ...)
if new_blocks is None:
break # 资源不足,停止调度 waiting
# 成功 → 从 waiting 移入 running
request = self.waiting.pop_request()
self.running.append(request)
request.status = RequestStatus.RUNNING
request.num_computed_tokens = num_computed_tokens
# 记录调度结果
req_to_new_block_ids[request.request_id] = ...
num_scheduled_tokens[request.request_id] = num_new_tokens
token_budget -= num_new_tokens
Prefix Caching :即使请求刚到处于 WAITING 状态,若其 Prompt 开头与历史请求重合,
num_computed_tokens就不为 0,这部分 token 无需重算,直接复用缓存。
七、调度输出(SchedulerOutput)
调度函数最终生成 SchedulerOutput,是连接调度逻辑 与计算执行的关键桥梁:
| 字段 | 含义 |
|---|---|
scheduled_new_reqs |
本轮首次被调度的请求(Prefill 任务) |
scheduled_running_reqs |
本轮继续执行的请求(Decode 任务) |
scheduled_resumed_reqs |
被抢占后本轮恢复执行的请求 |
num_scheduled_tokens |
每个请求本步要计算的 token 数 |
req_to_new_block_ids |
每个请求本步新分配的 Block ID |
total_num_scheduled_tokens |
本轮总 token 数(用于监控和断言) |
finished_req_ids |
已完成的请求 ID 集合 |
python
scheduler_output = SchedulerOutput(
scheduled_new_reqs=new_reqs_data, # Prefill
scheduled_cached_reqs=cached_reqs_data, # Decode + 恢复
num_scheduled_tokens=num_scheduled_tokens,
total_num_scheduled_tokens=total_num_scheduled_tokens,
finished_req_ids=self.finished_req_ids,
...
)
八、调度后状态更新
调度完成后,必须更新每个请求的 num_computed_tokens,否则下一步调度器会误认为这些 token 还没算过:
python
def _update_after_schedule(self, scheduler_output):
for req_id, num_scheduled_token in num_scheduled_tokens.items():
request = self.requests[req_id]
request.num_computed_tokens += num_scheduled_token # 累加已算 token 数
九、完整调度流程图
新请求到达
│
▼
add_request → waiting 队列(状态: WAITING)
│
▼ (每个 step 触发一次 schedule())
│
├─── 【阶段1】调度 Running 队列(Decode 优先)
│ │
│ ├── 计算 num_new_tokens(三限制取最小)
│ ├── 分配 Block
│ │ ├── 成功 → 记录结果,token_budget -= num_new_tokens
│ │ └── 失败 → 抢占 running 末尾请求
│ │ ├── 释放其 Block
│ │ └── 放回 waiting 队首,num_computed_tokens = 0
│ └── (若抢占的是自己 → 本轮 running 调度终止)
│
└─── 【阶段2】调度 Waiting 队列(Prefill,仅无抢占时执行)
│
├── 检查 Prefix Cache 命中(节省重算)
├── 计算 num_new_tokens
├── 分配 Block
│ ├── 成功 → 移入 running,状态 RUNNING
│ └── 失败 → break,本轮不再调度 waiting
└── token_budget -= num_new_tokens
│
▼
生成 SchedulerOutput(做什么、做多少、用哪些 Block)
│
▼
_update_after_schedule(更新 num_computed_tokens)
│
▼
GPU 执行推理(execute_model)
十、概念速查表
| 概念 | 含义 |
|---|---|
num_computed_tokens |
该请求已算过的 token 数量 |
num_new_tokens |
本步要计算的新 token 数(三限制取最小) |
token_budget |
系统本步能处理的总 token 配额 |
long_prefill_threshold |
超长输入的单步切片上限 |
Block |
显存分配最小单位,每块存固定数量 token 的 KV Cache |
new_blocks = None |
Block 分配失败,触发抢占 |
running 队列 |
已在推理中的请求(Decode 阶段) |
waiting 队列 |
尚未开始推理的请求(Prefill 阶段) |
| 抢占(Preempt) | 踢出 running 末尾请求,释放显存,放回 waiting 队首 |
| Prefix Caching | 复用历史相同前缀的 KV Cache,减少重复计算 |
SchedulerOutput |
调度决策的封装,驱动 GPU 执行推理 |
| FCFS | First Come First Serve,先来先服务,默认调度策略 |