连续批处理的调度、抢占与重算
在一组 8 卡 **** ****(单卡 64GB HBM,卡间互联 392 GB/s)上,用 **** 的基础软件栈配合 vLLM 起 OpenAI 兼容服务,压测并发从 16 提到 64。吞吐没有随并发同比例上升,在某个并发点后转为持平,再往上甚至下降;vllm:num_requests_waiting 长期大于零,调度日志里出现抢占计数,重算的 token 量接近本可产生新输出的算力。原因在调度循环里的抢占与重算:显存不足以容纳全部待运行序列时,调度器抢占部分序列,用重算腾出 KV 块,被抢占序列随后重新排队、重新计算已经处理过的 token,这部分算力不产生新输出。问题分三层:一步调度如何选序列、显存不足时走哪条路、重算对吞吐的挤占。
从静态批处理到按步组批
静态批处理把一批请求按最长序列对齐,组一次批后整批算到结束,短请求要陪着长请求把空位填满,算力按最长序列浪费。连续批处理把组批改为按步进行:每一步都把已经完成输出的序列移出,把等待队列里的新序列补进,批次随步变化。补入的新序列要先拿到 KV 块,这一步把调度问题从「凑一批」变成了「在固定的显存里持续地进出序列」。
调度循环每一步做什么
连续批处理按步推进,每步重新决定哪些序列进入本批。调度策略决定选取顺序,默认按到达顺序(FCFS)取等待队列头部的序列。一步的顺序是:
- 从运行中集合取出仍可推进的序列,为每个序列追加一个 token 的解码位置。
- 从等待队列取出新序列,尝试为它分配 KV 块;prefill 需要与 prompt 长度相称的块数,解码只需要一个块。
- 分配成功则把该序列并入本步批次;块不足则触发抢占,腾出空间。
- 组成本步批次,做一次前向,取回每个序列的下一个 token。
KV 块的分配是进入批次的前置条件。序列无法拿到块,就无法进入本步,只能留在等待队列或被抢占。分配由 KV cache manager 完成:先按已算 token 查命中块(get_computed_blocks()),再申请本步所需的新块(allocate_slots());申请时若算不出足够的新块,该次分配直接返回失败,这就是抢占的触发点。逐出按 LRU 从空闲队列头部取块,取到的块若是缓存块则一并逐出。
序列在调度器里由 RequestStatus 标记,被抢占时先进入独立的 PREEMPTED 状态,再退回等待队列:
# 字段名示意:请求状态迁移
WAITING ──(scheduled for prefill,拿到块)──► RUNNING ──(stop condition met)──► FINISHED_STOPPED / FINISHED_LENGTH_CAPPED / FINISHED_ABORTED / ...
▲ │
│ ▼
└──────────── (re-queued) ──────────── PREEMPTED
RequestStatus.WAITING # 在等待队列,等块分配
RequestStatus.RUNNING # 正在处理(prefill 或 decode)
RequestStatus.PREEMPTED # 被抢占,KV 块已释放,待重新入队
RequestStatus.FINISHED_* # 具名结束态:FINISHED_STOPPED / FINISHED_LENGTH_CAPPED / FINISHED_ABORTED 等
被抢占的序列进入 PREEMPTED,它的 KV 块被 kv_cache_manager.free(request) 释放,随后退回等待队列,下一次调度必须重新分配块并重算 prompt。
显存不足时的抢占
块不足时,调度器挑选被抢占对象(按调度策略,通常是最低优先级或最后加入的序列),把它置为 PREEMPTED 并释放其 KV 块:
| 策略 | 动作 | 代价 |
|---|---|---|
| recompute(重算) | 释放该序列的 KV 块,退回等待队列,下次从头重算 prefill | 已处理 token 被重算,算力重复消耗 |
v1 的抢占恒为重算,没有换出这条替代路径,代价落在算力上。
重算对吞吐的挤占
被抢占序列回到等待队列,重新被调度时,它的 prompt 要从头重算。重算的 token 数与「该序列已被抢占前算过的 token 数」成正比。并发越高,KV 池被占满越快,抢占越频繁,重算量随之上升。
到某个并发点后,新增算力被重算抵消:--max-num-batched-tokens 的预算里被重算 token 挤占,真正产生新输出的 token 变少。表现为吞吐曲线先升后平,越过拐点后下降;同时调度步骤变多,每步固定开销上升,进一步压低有效吞吐。
与 prefill 干扰的关系
吞吐下降不一定只来自重算。高并发下若同时有长 prompt 到达,一次大 prefill 会把单步设备打满,同批 decode 序列的 ITL 陡增,等待队列里的序列被拖住,调度循环整体变慢。两类现象常并发出现,须分开记录:抢占的特征是 vllm:num_preemptions_total 上升、KV 池占用贴近满;prefill 干扰的特征是 ITL 随长 prompt 到达出现尖峰,而 KV 池占用未必满。前者压并发,后者按 chunked prefill 处理。
现象
- 并发上升,吞吐先升后平,越过某点下降。
vllm:num_requests_waiting持续大于零。vllm:num_requests_running低于--max-num-seqs设定值。vllm:kv_cache_usage_perc长期贴近 1。- 抢占计数上升,重算比例(重算 token / 总处理 token)上升。
- ITL 抖动增大:平均吞吐看似正常,ITL 的 p95 与波动范围明显变差。
第二种现象与显存不足的静默形态一致:没有报错,服务不崩,但实际同时运行的序列数低于期望。
定位步骤
- 读队列与运行数:
vllm:num_requests_running与vllm:num_requests_waiting。等待队列持续非零且运行数低于设定值,指向 KV 块不足。 - 拆等待原因:
vllm:num_requests_waiting_by_reason{reason="capacity"}与reason="deferred"。capacity是没有容量、需加资源;deferred是被瞬时约束(如 adapter 预算、KV 传输)挡住,通常自行恢复。两类处置不同,不能合并看。 - 读 KV 池占用:
vllm:kv_cache_usage_perc贴近 1,说明池子已满。 - 读抢占计数与重算量:
vllm:num_preemptions_total是累计计数器,取窗口差分得近段抢占次数;重算 token 数按同法取。 - 算重算比例:重算 token 数除以本段时间处理的总 token 数。比例上升说明算力被重复消耗。
- 单变量对照:固定请求集与并发,只改一个参数(先改
--max-num-seqs),看吞吐曲线拐点是否右移。拐点右移说明此前受 KV 块约束。
处置
| 参数 | 作用 | 代价 / 前提 |
|---|---|---|
--max-num-seqs 调小 |
降低同时驻留序列数,减少 KV 压力与抢占 | 峰值并发下降 |
--gpu-memory-utilization 调大 |
增大 KV 池,容纳更多序列,减少抢占 | 挤占激活与工作区;prefill 峰值上升时仍可能失败 |
--max-num-batched-tokens 调小 |
压平 prefill 峰值,为 KV 让出预算 | 单批吞吐下降 |
--max-model-len 调小 |
压小单序列占用,减少池子压力 | 上下文窗口变小 |
处置顺序:先压 --max-num-seqs 或 --max-model-len,把并发或单序列占用降到池子能容纳的范围;再考虑调大 --gpu-memory-utilization。v1 的抢占恒为重算,没有换出可调,池子不足只能靠压并发或扩预算解决。
记录字段
# 一条连续批处理抢占记录(字段名示意)
scheduler:
policy: # 调度策略,如 FCFS
max_num_seqs: # 设定值
max_num_seqs_effective: # 实际同时运行数
gpu_memory_utilization:
preemption_mode: # v1 恒为 recompute
counters:
num_requests_running: # vllm:num_requests_running
num_requests_waiting: # vllm:num_requests_waiting
waiting_capacity: # vllm:num_requests_waiting_by_reason{reason="capacity"}
waiting_deferred: # vllm:num_requests_waiting_by_reason{reason="deferred"}
kv_cache_usage_perc: # vllm:kv_cache_usage_perc
num_preemptions_total: # vllm:num_preemptions_total(累计,取窗口差分)
recomputed_tokens: # 重算 token 数
recompute_ratio: # 重算 token / 总处理 token
workload:
model_name:
concurrency:
input_len_distribution:
output_len_distribution:
perf:
throughput_by_concurrency: # 并发-吞吐曲线(含拐点)
itl_mean:
itl_p95:
ttft_p95:
status: # 受 KV 块约束 / 受单序列长度约束 / 不成立
小结
连续批处理每步重新组批,进入批次的前置条件是拿到 KV 块。块不足时调度器抢占序列,重算释放 KV 块后重新计算已处理 token。被抢占与重算的量随并发上升,到某个并发点后新增算力被重算挤占,吞吐曲线转为下降。定位先读 vllm:num_requests_waiting、vllm:num_requests_running 与 vllm:kv_cache_usage_perc,再算重算比例,最后用只改 --max-num-seqs 的单变量对照确认拐点。处置按压并发、调预算的顺序推进,并记录每步的代价。