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,先来先服务,默认调度策略
相关推荐
科研前沿1 小时前
镜像孪生VS视频孪生核心技术产品核心优势
大数据·人工智能·算法·重构·空间计算
水蓝烟雨1 小时前
1931. 用三种不同颜色为网格涂色
算法·leetcode
晨曦夜月1 小时前
map与unordered_map区别
算法·哈希算法
图码2 小时前
如何用多种方法判断字符串是否为回文?
开发语言·数据结构·c++·算法·阿里云·线性回归·数字雕刻
handler012 小时前
Linux 内核剖析:进程优先级、上下文切换与 O(1) 调度算法
linux·运维·c语言·开发语言·c++·笔记·算法
minglie12 小时前
实数列的常用递推模式
算法
代码小书生2 小时前
math,一个基础的 Python 库!
人工智能·python·算法
AI科技星2 小时前
全域数学·数术本源·高维代数卷(72分册)【乖乖数学】
人工智能·算法·数学建模·数据挖掘·量子计算
生成论实验室2 小时前
《事件关系阴阳博弈动力学:识势应势之道》第一篇:生成正在发生——从《即事经》到事件-关系网络
人工智能·科技·算法·架构·创业创新
漂流瓶jz2 小时前
UVA-1152 和为0的4个值 题解答案代码 算法竞赛入门经典第二版
数据结构·算法·二分查找·题解·aoapc·算法竞赛入门经典·uva