在 token 预算和 KV 容量内连续安排请求
vLLM V1 每轮分配 token 预算,先继续运行中的请求,再尝试接纳等待者;KV 块不足时抢占请求,把它放回等待队列。
工程问题#
生成请求的输入和输出长度不同,KV 缓存又持续增长。固定一批请求等到全部结束会留下空闲位置;只看 token 数接纳新请求,则可能超过可用 KV 空间。长提示词还可能吃完一轮计算预算。
实际实现#
Scheduler.schedule 统一计算每个请求尚需计算的 token 数,以本轮剩余预算、长预填充阈值和模型长度截断。KVCacheManager.allocate_slots 将 token 数换算成物理块需求,并扣除不能同时算作空闲的缓存命中块。若运行请求分配失败,调度器从 running 尾部取出请求、释放块并清零已计算计数,放回 waiting 头部;没有发生抢占时,再以剩余预算接纳等待请求。
处理流程#
- 每轮以 max_num_scheduled_tokens 初始化预算,先遍历 running 请求
- 计算尚需处理的 token,并按剩余预算、长预填充阈值和模型长度截断
- 为本轮 token 与必要 lookahead 申请 KV 槽,确认块容量足够后才记录调度
- 空间不足时抢占 running 尾部请求,释放 KV 引用,把请求放回 waiting 头部
- 本轮未抢占时,再尝试从 waiting 接纳请求;登记调度 token 数并扣减预算
源码片段#
固定版本 · 原文摘录174 # First, schedule the RUNNING requests.175 req_index = 0176 while req_index < len(self.running) and token_budget > 0:177 request = self.running[req_index]178179 num_new_tokens = (request.num_tokens_with_spec -180 request.num_computed_tokens)181 if (0 < self.scheduler_config.long_prefill_token_threshold <182 num_new_tokens):183 num_new_tokens = (184 self.scheduler_config.long_prefill_token_threshold)185 num_new_tokens = min(num_new_tokens, token_budget)186187 # Make sure the input position does not exceed the max model len.188 # This is necessary when using spec decoding.189 num_new_tokens = min(190 num_new_tokens,191 self.max_model_len - request.num_computed_tokens)优先继续 running 请求,按未计算 token 数确定工作,再用长预填充阈值、剩余 token 预算和模型长度限制本轮规模。
220 num_required_blocks = cdiv(221 num_computed_tokens + num_tokens + num_lookahead_tokens,222 self.block_size)223 num_new_blocks = (num_required_blocks - len(req_blocks) -224 len(new_computed_blocks))225226 # If a computed block of a request is an eviction candidate (in the227 # free queue and ref_cnt == 0), it cannot be counted as a free block228 # when allocating this request.229 num_evictable_computed_blocks = sum(1 for blk in new_computed_blocks230 if blk.ref_cnt == 0)231 if (num_new_blocks > self.block_pool.get_num_free_blocks() -232 num_evictable_computed_blocks):233 # Cannot allocate new blocks234 return None先把 token 需求向上取整为块数。已命中但引用数为 0 的块不能一边复用、一边又计入可分配空闲块,因此容量判断要扣除它们。
216 while True:217 new_blocks = self.kv_cache_manager.allocate_slots(218 request,219 num_new_tokens,220 num_lookahead_tokens=self.num_lookahead_tokens)221 if new_blocks is None:222 # The request cannot be scheduled.223 # Preempt the lowest-priority request.224 preempted_req = self.running.pop()225 self.kv_cache_manager.free(preempted_req)226 preempted_req.status = RequestStatus.PREEMPTED227 preempted_req.num_computed_tokens = 0228 if self.log_stats:229 preempted_req.record_event(230 EngineCoreEventType.PREEMPTED, scheduled_timestamp)231232 self.waiting.appendleft(preempted_req)233 preempted_reqs.append(preempted_req)234 if preempted_req == request:235 # No more request to preempt.236 can_schedule = False237 break分配失败时弹出 running 尾部请求,释放它的 KV 引用,标记 PREEMPTED、清零计算进度并放回 waiting 头部。如果连当前请求也被抢占,本轮停止尝试安排它。
337 # Number of tokens to be scheduled.338 # We use `request.num_tokens` instead of339 # `request.num_prompt_tokens` to consider the resumed requests,340 # which have output tokens.341 num_new_tokens = request.num_tokens - num_computed_tokens342 if (0 < self.scheduler_config.long_prefill_token_threshold <343 num_new_tokens):344 num_new_tokens = (345 self.scheduler_config.long_prefill_token_threshold)346 num_new_tokens = min(num_new_tokens, token_budget)347 assert num_new_tokens > 0等待或恢复请求只安排扣除缓存命中后的剩余 token;同样受长预填充阈值和全局预算截断。后续 allocate_slots 成功才会把它加入 running。
398 if self.lora_config and request.lora_request:399 scheduled_loras.add(request.lora_request.lora_int_id)400 req_to_new_block_ids[request.request_id] = [401 b.block_id for b in computed_blocks + new_blocks402 ]403 num_scheduled_tokens[request.request_id] = num_new_tokens404 token_budget -= num_new_tokens405 request.status = RequestStatus.RUNNING406 request.num_computed_tokens = num_computed_tokens记录缓存命中块与新块,登记本轮 token 数并扣减预算,然后设置 RUNNING 和已有计算进度;这不意味着 GPU 已经完成本轮计算。
片段分别对应计算预算、物理块检查、抢占恢复状态、等待请求分块和预算记账。它们展示 CPU 调度与缓存管理,不是 GPU Attention 内核或性能基准。
收益与取舍#
工程分析带来的收益
- 迭代之间可以加入新请求,批次成员不必等待同一批全部完成
- 长预填充可以分多轮推进,计算量和 KV 容量都有明确的准入检查
- 等待队列用 deque 支持头部接纳与抢占后前插,已有前缀缓存可参与恢复
付出的代价
- 抢占清零 num_computed_tokens,恢复需要重新取得可复用块或重新计算;释放的是引用,不能理解成所有 KV 数据都永久丢失
- 先处理 running 及等待队列中的特殊跳过规则,不构成严格公平或严格 FCFS 保证
- token 预算限制本轮计算输入量,不能直接保证延迟或显存总量;模型执行、块管理、编码器和推测解码另有开销
可比较的选择
固定批处理固定成员后一起执行,组织简单;变长请求完成时间不同,若等待整批结束才能补位,会降低可用并行度。具体吞吐差异必须结合模型和负载测量。
一次完成整段 prefill减少长提示词被拆成多轮的调度,但较长输入可能占用更大单轮预算,影响其他请求的等待时间。该版本 V1 统一按 token 进度调度,不应套用 V0 的阶段划分。
版本与适用范围#
固定 vLLM v0.8.5 的 V1 调度器。prefill 与 decode 共用 token 进度模型;long_prefill_token_threshold 仅在正值且低于本轮需求时额外截断。不能把 V0 的调度开关或新版调度策略当作本版本保证。多模态编码器、LoRA、推测解码和外部 KV 连接器还会加入各自约束。
参考来源#
源码、官方文档与原始论文vllm/v1/core/sched/scheduler.py · Scheduler.schedule — 调度说明
vllm/v1/core/sched/scheduler.py · Scheduler.schedule — running / preemption
vllm/v1/core/sched/scheduler.py · Scheduler.schedule — waiting admission
vllm/v1/core/kv_cache_manager.py · KVCacheManager.allocate_slots
相关实现与比较#
4 个相关案例把上下文身份编码进块哈希,让重复请求共享已经计算的 KV cache。
缓存块命中后可能需要从队列中间移除,双向链表让这一步保持常数时间。
将逻辑连续的 token 缓存映射到分散的物理块,减轻显存碎片与重复存储。
Redis List 提供左右两端的插入和弹出;在 quicklist 编码下,用带头尾指针的双向节点链组织紧凑数据块。