抢占
主线位置:调度这一段里「给请求分 KV 块」失败时的退路,紧挨着 02 部分的块分配。vLLM 在
Scheduler.schedule()和_preempt_request(),SGLang 在update_running_batch()和ScheduleBatch.retract_decode(),两边都是 Python。
版本:vLLM v0.32.0、SGLang v0.5.22
结论:
- 两边单机都只做重算:把被踢的请求的 KV 放掉,请求回等待队列,之后重新预填充 prompt 加已经生成的 token。v0.32.0 的 vLLM v1 调度器里没有换出到 CPU 的路径;SGLang 只在 PD 分离的解码端把 KV 备份到主机。
- vLLM:运行中的请求这一步要的块分不到,就抢占 running 列表最后一个(最晚进来的);被抢占的请求放回等待队列队首。这一步不再收新请求。
- SGLang:每个解码步之前检查「下一步解码的 KV 放不放得下」,放不下就 retract,先踢输出最少、输入最长的;被踢的请求放回等待队列队尾,同时按实际进度重估新 token 比例,让之后的收请求更保守。
- 重算的代价是一次额外的预填充。前缀缓存里还没被驱逐的块可以省掉一部分,两边都是如此。
stateDiagram-v2 state "等待" as W state "运行" as R state "被抢占(vLLM PREEMPTED)" as P W --> R: 进批,分到 KV R --> P: KV 不够,被选为牺牲者 P --> W: 放掉 KV,计数清零 W --> R: 重新预填充 prompt 加已生成 token R --> [*]: 完成
vLLM v1:分不到块就从尾部踢
触发条件
只在第一段(running 循环)里发生。某个运行中的请求这一步要 num_new_tokens 个 token,allocate_slots 返回 None(空闲块不够),就开始抢占,直到分得到或者踢到自己:
vllm/v1/core/sched/scheduler.py
while True:
new_blocks = self.kv_cache_manager.allocate_slots(
request,
num_new_tokens,
num_lookahead_tokens=self.num_lookahead_tokens,
)
if new_blocks is not None:
# The request can be scheduled.
break
# ...
# The request cannot be scheduled.
# Preempt the lowest-priority request.
if self.policy == SchedulingPolicy.PRIORITY:
preempted_req = max(
self.running,
key=lambda r: (r.priority, r.arrival_time),
)
else:
preempted_req = self.running[-1]
# ...
self._preempt_request(
preempted_req,
scheduled_timestamp,
drop_stale_output=self.requires_kv_delivery,
)
preempted_reqs.append(preempted_req)
if preempted_req == request:
# No more request to preempt. Cannot schedule this request.
break
fcfs(默认)下牺牲者是running[-1],也就是最晚进来的请求。running 循环是从头往后走的,排在前面的请求已经拿到了块,所以先进来的请求被保住。priority下牺牲者是优先级数值最大(最不优先)、平手时到达最晚的那个。它可能已经在本步排上了,这时把它占的预算退回来(第 808 行起)。- 踢到了请求自己,说明全让出来也不够,本步停止排 running(第 846 行)。
等待中的请求分不到块不触发抢占,只是停止扫描(第 1275 行)。而且本步只要抢占过,第二段整个跳过(第 900 行)。
被抢占之后
def _preempt_request(
self, request: Request, timestamp: float, drop_stale_output: bool = False
) -> None:
# ...
self._free_request_blocks(request)
self.encoder_cache_manager.free(request)
self._inflight_prefills.discard(request)
request.status = RequestStatus.PREEMPTED
request.num_computed_tokens = 0
# ...
request.num_preemptions += 1
# ...
# Put the request back to the waiting queue.
self.waiting.prepend_request(request)
num_computed_tokens = 0:KV 全部作废,回来时从头算。- 回来时要算的是
request.num_tokens,包括已经生成的输出 token,源码注释专门说了这是为了被恢复的请求(第 1127 行)。已经发给客户端的 token 不会重新采样。 prepend_request:FCFS 队列是一个 deque,被抢占的请求appendleft到队首(request_queue.py),有空间时第一个回来。
代价能省多少
放掉的块并不立刻清空。前缀缓存打开时(decoder 和 hybrid 生成模型默认打开,is_prefix_caching_supported),带块哈希的满块放回空闲队列时排在「最后驱逐」的一端(block_pool.py)。请求回来时走新请求的路径,先查前缀缓存;这些块要是还没被别人拿走,就直接命中,只重算没命中的尾部。抢占恰恰发生在块紧张的时候,这些块被复用的可能性不小,命中多少待实测(C3-2)。
减少抢占的参数
| 参数 | 默认 | 作用 |
|---|---|---|
scheduler_reserve_full_isl | True(config/scheduler.py) | 收新请求时检查整段输入放不放得下,而不只是第一块;注释说是为了防止分块预填充下的过量收入和 KV 抖动 |
watermark | 0.0(第 196 行) | 收等待中 / 被抢占的请求时额外留出这一比例的空闲块 |
max_num_seqs | 见连续批处理 | 运行中请求数越少,解码阶段一起长 KV 的请求越少 |
检查发生在 allocate_slots 里(kv_cache_manager.py)。vLLM 不预估请求还会生成多少 token,解码过程中块不够就靠抢占兜底。
SGLang:解码前检查,先 retract 输出最少的
触发条件
SGLang 收新请求时已经按 new_token_ratio 给运行中的请求预留了未来的 token(见连续批处理),但这只是估计。真正的检查在每个解码步之前:
python/sglang/srt/managers/scheduler.py
kv_full_retract_flag = not batch.check_decode_mem()
if kv_full_retract_flag and finishing_reqs:
# The queued result frees the finishing requests' KV at the end of
# this step; decode the rest next step instead of retracting them.
batch.batch_is_full = False
return None
# ...
retracted_reqs, new_token_ratio, reqs_to_abort = batch.retract_decode()
# ...
self.new_token_ratio_tracker.current = new_token_ratio
# ...
for req in retracted_reqs:
self._add_request_to_queue(req, is_retracted=True)
else:
self.new_token_ratio_tracker.decay_step()
- 有请求本步就会因长度结束(重叠调度下结果还在队列里),就先不 retract,等它们释放。
- 没有 retract 的解码步,
new_token_ratio往下衰减一点,收新请求变得更激进。
选谁
python/sglang/srt/managers/schedule_batch.py
def length_key(req: Req) -> Tuple[int, int]:
return (len(req.output_ids), -len(req.origin_input_ids))
# ...
sorted_indices.sort(
key=lambda i: length_key(reqs[i]),
reverse=True,
)
retract_decode 从这个列表的末尾往外弹(第 3298 行):
- 先踢已生成 token 最少的,平手时踢输入最长的。生成得少,重算时丢的进度少;输入长,放出来的 KV 多。
- 至少踢一个;一直踢到
check_decode_mem通过。 - 至少留一个(第 3310 行)。只剩一个还放不下,就把它以 OOM 中止,返回错误而不是让调度器崩溃(第 3363 行)。
--retraction-policy默认length,即上面这条;另一个选项priority先按优先级再按长度(schedule.py)。beam search 的组不能 retract,直接中止。
vLLM 的选法是「最晚进来的」,不看长度。两种选法在什么负载下差得多,待实测(C3-2)。
被 retract 之后
- KV 释放,不插进前缀树:「release memory and don't insert into the tree because we need the space instantly」(第 3334 行)。之前已经 checkpoint 进树的前缀只解锁、变成可驱逐(
free_kv_row只放受保护长度之后的部分,common.py);随后按「剩余请求数 ×SGLANG_RETRACT_DECODE_STEPS(默认 20)」个 token 从树里驱逐(第 2274 行)。 - 请求状态复位(
prefix_len = 0等,reset_for_retract),回来时重新匹配前缀、重新预填充。 - 放回等待队列的队尾:普通模式下就是
waiting_queue.append(scheduler.py)。fcfs下它要排在所有已经在等的请求后面;lpm下它的前缀若还在树里,会被排到前面。 - 新 token 比例改成按当前批实际进度重估(new_token_ratio_tracker.py):
total_decoded_tokens = sum(len(r.output_ids) for r in reqs)
total_max_new_tokens = sum(r.sampling_params.max_new_tokens for r in reqs)
new_estimate_ratio = (
total_decoded_tokens + envs.SGLANG_RETRACT_DECODE_STEPS.get() * len(reqs)
) / (total_max_new_tokens + 1) # avoid zero division
日志里会打一行「KV cache pool is full. Retract requests.」,带上 retract 了几个、腾出多少 token、比例从多少改到多少。频繁出现时,--schedule-conservativeness 调大(字段帮助就这么写,schedule.py),它乘在 new_token_ratio 的初值上。
换出只在 PD 解码端
release_req 只有在 PD 分离的解码角色下才先把 KV 备份到主机(第 2263 行),后端由 --disaggregation-decode-retraction-backup 选:cpu_tensor、host_pool 或 none,不设时按解码端的 KV 池推断(disagg.py)。源码注释说,这样回来时可以把 KV 读回来而不用重算;另有一条「重新 bootstrap」的路径走重算,会跳过这次拷贝。选 none 时被 retract 的请求以 503 中止、让客户端重试;host_pool 用尽时以 500 中止。
优先级抢占
这是另一条路,发生在收新请求时而不是解码前。--enable-priority-scheduling(默认关)打开后,enable_priority_preemption 默认跟着开(scheduler.py)。批满时,新请求的优先级比运行中某些请求高出 --priority-scheduling-preemption-threshold(默认 10)以上,就踢掉它们给新请求腾地方(preempt_to_schedule)。被踢的请求同样放回等待队列。
两边对照
| vLLM v1 | SGLang | |
|---|---|---|
| 什么时候查 | 给运行中的请求分块时 | 解码步之前查整批下一步;收新请求时按预估预留 |
| 选谁 | fcfs:最晚进来的;priority:最不优先的 | 输出最少、输入最长的(length);或按优先级 |
| KV 怎么处理 | 放回块池,满块保留哈希、最后驱逐 | 不进前缀树;已在树里的前缀解锁后可驱逐 |
| 回到哪里 | 等待队列队首 | 等待队列队尾 |
| 之后的收请求 | 本步不收;不改后续策略 | 重估 new_token_ratio,后续更保守 |
| 换出 | 无 | 只在 PD 解码端备份到主机 |
待实测
- 高并发、输出长度差别大的负载下,两边的抢占 / retract 次数,以及被抢占请求的端到端延迟:待实测(C3-2)。
- vLLM 被抢占请求回来时的前缀缓存命中比例:待实测(C3-2)。
小结
- 两边单机都是重算,没有换出;SGLang 只在 PD 解码端备份 KV。
- vLLM 是被动的:分不到块才踢,踢最晚进来的,放回队首,本步不收新请求。
- SGLang 是先估后查:收请求时按比例预留未来 token,解码前再查;不够就踢输出最少的,放回队尾,同时把预留比例调到实际进度。
- 重算的代价是一次预填充,前缀缓存里幸存的块能省掉一部分。