vllm抢占机制详解

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,先来先服务,默认调度策略
相关推荐
Hello--_--World2 小时前
Vue2的 双端 diff算法 与 Vue3 的 快速diff 算法
前端·vue.js·算法
坚持编程的菜鸟2 小时前
The Blocks Problem
数据结构·c++·算法
2301_822703202 小时前
Flutter 框架跨平台鸿蒙开发 - 家庭时间胶囊应用
算法·flutter·华为·图形渲染·harmonyos·鸿蒙
tankeven2 小时前
HJ171 排座椅
c++·算法
2301_822703202 小时前
成语小词典:鸿蒙Flutter实现的成语查询与管理应用
算法·flutter·华为·开源·图形渲染·harmonyos
Bczheng12 小时前
八.账号生成规则 哈希 密钥
算法·哈希算法
黎阳之光2 小时前
视频孪生领航者,以中国技术定义全球数智化新高度
大数据·人工智能·算法·安全·数字孪生
6Hzlia2 小时前
【Hot 100 刷题计划】 LeetCode 39. 组合总和 | C++ 回溯算法与 startIndex 剪枝
c++·算法·leetcode
患得患失9492 小时前
【前端WebSocket】心跳功能,心跳重置策略、双向确认(Ping-Pong) 以及 指数退避算法(Exponential Backoff)
前端·websocket·算法