连续批处理
主线位置:请求分好词、进了引擎之后,在「排队 → 进批 → 每一步拿几个 token」这一段。vLLM 在引擎核心进程的
Scheduler.schedule(),SGLang 在调度器进程的Scheduler.get_next_batch_to_run(),两边都是 Python。SGLang 按前缀排序时查的前缀树,核心在 Linux 上默认是 Rust。
版本:vLLM v0.32.0、SGLang v0.5.22
结论:
- 两边都是每步重组:上一步做完的请求离开,新到的请求在预算允许时进来,不用等整批做完。
- vLLM v1 没有预填充阶段和解码阶段。每个请求只有「已算到第几个 token」,调度器在一个 token 预算里先喂运行中的请求、再收新请求。解码 token 和预填充块进同一次前向。
- SGLang 分两种批。能组出预填充批就只跑预填充;组不出才让运行中的请求解码一步。默认不混。
- 24 GB 卡上 vLLM OpenAI 服务的默认预算是每步 2048 个 token、最多 256 个请求;SGLang 的分块大小默认 4096。都是按显存分档写在源码里的。
sequenceDiagram
participant W as 等待队列
participant S as 调度器
participant R as 运行中
participant G as 前向
Note over S: vLLM 一步
S->>R: 逐个给 token(解码 1 个,预填充给剩余预算)
S->>W: 还有预算且这一步没抢占,按序收新请求
S->>G: 一个批:解码行 + 预填充块
Note over S: SGLang 一步
S->>W: 试组预填充批(PrefillAdder)
alt 组得出
S->>G: 只跑预填充批
else 组不出
S->>R: 检查显存,解码一步
S->>G: 只跑解码批
end
vLLM v1:一个预算,两段循环
没有阶段,只有「追上」
调度器开头的注释把模型说得很清楚:
vllm/v1/core/sched/scheduler.py
# There's no "decoding phase" nor "prefill phase" in the scheduler.
# Each request just has the num_computed_tokens and
# num_tokens_with_spec. num_tokens_with_spec =
# len(prompt_token_ids) + len(output_token_ids) + len(spec_token_ids).
# At each step, the scheduler tries to assign tokens to the requests
# so that each request's num_computed_tokens can catch up its
# num_tokens_with_spec.
一个刚进来的请求,差的是整段 prompt;一个在解码的请求,差的是上一步采样出来的 1 个 token;被分块的长 prompt,差的是剩下那段。三种情况走同一套代码。
第一段:先喂 running
token_budget = self.max_num_scheduled_tokens
# ...
# First, schedule the RUNNING requests.
req_index = 0
while req_index < len(self.running) and token_budget > 0:
request = self.running[req_index]
# ...
num_new_tokens = (
request.num_tokens_with_spec
+ request.num_output_placeholders
- request.num_computed_tokens
)
if 0 < long_prefill_token_threshold < num_new_tokens:
num_new_tokens = long_prefill_token_threshold
num_new_tokens = min(
num_new_tokens, token_budget, input_budget - draft_slots
)
running是一个按进入顺序排的列表。排在前面的先拿预算。- 一个还没做完预填充的请求也在
running里,它会拿走min(剩余 prompt, 剩余预算)。这就是分块预填充(下一页)。 - 拿到 token 数之后要给它分 KV 块(
allocate_slots)。分不到就抢占别的请求,见抢占。
第二段:再收 waiting
# Next, schedule the WAITING requests.
if not preempted_reqs and self._pause_state != PauseState.PAUSED_ALL:
# ...
while token_budget > 0:
# ...
num_running = len(self.running) + self.num_waiting_for_streaming_input
if num_running >= self.max_num_active_reqs:
break
request = request_queue.peek_request()
(并发上限检查)
- 这一步只要抢占过任何请求,就不收新请求。显存刚不够,没有理由再放人进来。
- 并发上限是
max_num_active_reqs,不设时等于max_num_seqs。 - 新请求先查前缀缓存(
num_computed_tokens == 0时,第 960 行),命中的部分不算进本步的 token。 - 收进来的请求直接追加到
running末尾(第 1345 行),下一步就在第一段里。 - 分 KV 块失败时直接停止扫描(
break),不会为等待中的请求去抢占运行中的请求。
队列顺序由 policy 决定:默认 fcfs,另一个选项是 priority(数值小的先,平手看到达时间)(config/scheduler.py)。
预算和并发上限的默认值
SchedulerConfig 里写的 2048 / 128(第 41 行、第 43 行)注释说「主要为测试方便」。真正生效的是 EngineArgs 按显存和入口填的值(get_batch_defaults):
| 设备 | max_num_batched_tokens(LLM 类 / OpenAI 服务) | max_num_seqs(同左) |
|---|---|---|
| 显存 ≥ 160 GiB(B200 等) | 16384 / 16384 | 1024 / 1024 |
| 显存 ≥ 70 GiB 且不是 A100(H100、H200) | 16384 / 8192 | 1024 / 1024 |
| 其他 GPU(含 24 GB 卡、A100) | 8192 / 2048 | 256 / 256 |
| CPU 后端 | 4096 × 卡数 / 2048 × 卡数 | 256 × 卡数 / 128 × 卡数 |
TPU 另有一档。再往后还有两处修正(_set_default_max_num_seqs_and_batched_tokens_args):
--performance-mode throughput时,没手设的两项都翻倍(第 3129 行);默认模式是balanced(config/vllm.py)。- 关掉分块预填充且没手设预算时,预算抬到
max(max_model_len, 默认值)(第 3140 行),否则长 prompt 一步放不下。
所以一张 24 GB 卡起 vllm serve,默认每步最多 2048 个 token、最多 256 个请求同时在 running 里。还有一个独立的 max_num_scheduled_tokens,不设时等于 max_num_batched_tokens,投机解码会用到它。
SGLang:预填充优先,两种批
一步怎么定
python/sglang/srt/managers/scheduler.py
if new_batch is not None:
# Run prefill first if possible
ret = new_batch
else:
# Run decode (skip for prefill-only batches)
if not running_batch.is_empty() and not running_batch.is_prefill_only:
decode_batch = self.update_running_batch(running_batch)
new_batch 来自 get_new_batch_prefill。上一步的预填充批在本步开头先并进 running_batch(get_next_batch_to_run)。于是:
- 有新请求可以进,这一步就是纯预填充,运行中的请求这一步不出 token。
- 没有新请求(或者显存 / 并发不允许),这一步是纯解码,
update_running_batch先检查下一步解码的显存,不够就 retract。 - 默认的事件循环是重叠调度(CPU 准备下一批和 GPU 跑当前批重叠,
--disable-overlap-schedule关,默认不关,schedule.py)。它改变的是 CPU 时间藏在哪里,不改变上面「先预填充」的选择。
有一个让解码先跑几轮的开关 --prefill-decode-interval:一个预填充批之后先跑 N 步解码再考虑下一个预填充。默认解析成 0,即关闭(validation_hook.py),只有 Qwen3-VL 在 Hopper 上的一组配置会自动设成 22。
PrefillAdder:三笔预算
组预填充批的是 PrefillAdder(schedule_policy.py),调度器这样构造它:
adder = self.get_prefill_adder_class()(
self.page_size,
self.tree_cache,
self.token_to_kv_pool_allocator,
running_batch,
self.new_token_ratio_tracker.current,
self.max_prefill_tokens,
chunked_prefill_size,
running_bs if self.is_mixed_chunk else 0,
# ...
)
| 预算 | 来自 | 默认 | 作用 |
|---|---|---|---|
rem_input_tokens | --max-prefill-tokens | 16384(schedule.py) | 一个预填充批的输入 token 上限 |
rem_chunk_tokens | --chunked-prefill-size | 按显存分档,24 GB 卡是 4096(下一页) | 一个批里预填充 token 的上限;超过就切块 |
| 显存预算 | KV 池剩余 + 可驱逐的前缀树 | — | 新请求的输入加上预计还要生成的 token 要放得下 |
第三笔是 SGLang 和 vLLM 差别较大的地方。SGLang 收新请求时,把运行中每个请求「还可能生成多少 token」按一个比例预留出来(_get_running_request_total_token_offset):
ratio = 1.0 if req.sampling_params.ignore_eos else self.new_token_ratio
return (
min(
(req.sampling_params.max_new_tokens - len(req.output_ids)),
CLIP_MAX_NEW_TOKENS,
)
* ratio
)
这个比例 new_token_ratio 初值 0.7 × --schedule-conservativeness(默认 1.0),每个没有 retract 的解码步往下衰减,600 步降到初值的 0.14 倍;发生 retract 时改成按当前批实际进度重估的值(见抢占),调度器空闲时复位到初值(environ.py、new_token_ratio_tracker.py)。vLLM 不做这种预估,只要求新请求的整段输入放得下(scheduler_reserve_full_isl,默认开,config/scheduler.py),之后不够了靠抢占。
收请求的循环按 waiting_queue 当前顺序走(第 4081 行),add_one_req 返回不是 CONTINUE 就停。
调度策略
--schedule-policy 默认 fcfs(schedule.py),可选值分两类(schedule_policy.py):
| 类 | 策略 | 排序依据 |
|---|---|---|
| 看缓存 | lpm | 在前缀树里命中最长的先(_sort_by_longest_prefix) |
| 看缓存 | dfs-weight、hrrn、shortest-prefill-first | 树上的 DFS 权重;按 token 计的响应比老化;未命中部分最短的先(标为实验) |
| 不看缓存 | fcfs、lof、random、routing-key | 到达顺序;最长输出先;随机;运行批里常见的路由键先 |
priority 也在可选列表里,优先级调度另由 --enable-priority-scheduling 打开。两条回退规则:
- 前缀缓存关掉时,看缓存的策略一律退成
fcfs(_validate_and_adjust_policy)。 lpm和hrrn在等待队列超过 128 个请求时,本步退成fcfs,省掉逐个前缀匹配和排序(_determine_active_policy、阈值)。
lpm 的意义:前缀相同的请求挨着进批,前一个算好的 KV 还在树里,后一个直接命中。这依赖前缀树,见 02 部分。
运行中请求的上限
--max-running-requests 默认不设(schedule.py),实际上限由请求槽位池和 KV 池的大小定,放到 07 部分讲显存预算时展开。
对照:Rust 写的调度器
两个引擎的调度器都是 Python。纯 Rust 的 mistral.rs 自己写了分页注意力的调度器,默认常量是每步 4096 个 token、预填充块 512、连续解码至多 8 步就要让预填充进来(scheduler/mod.rs,判断函数 completion_is_due)。它也是「这一步做解码还是做预填充」二选一的结构,和 SGLang 的默认形状接近。
吞吐和延迟
两边在同一张卡上并发 1–256 的吞吐、TTFT、TPOT 曲线:待实测(C3-2)。
小结
- vLLM v1:一个 token 预算,先 running 后 waiting;解码和预填充块在同一次前向。抢占过的步不收新请求。
- vLLM 的默认预算按显存分档,24 GB 卡起 OpenAI 服务是 2048 token / 256 请求。
- SGLang:能组预填充批就只跑预填充,否则解码;默认不混。收新请求时按
new_token_ratio给运行中请求预留未来的 token。 - SGLang 默认
fcfs;lpm按前缀树命中长度排,队列超过 128 时临时退回fcfs。