waiting queue 与 batch shaping#
第二部分已经把请求怎样进入 runtime 讲清楚了。到了这一章,问题不再是“请求能不能进来”,而是“已经进来的请求为什么这一轮能跑、下一轮不能跑,为什么有些请求先 prefill,有些请求继续 decode,最终 batch 又为什么长成了现在这个样子”。
这一节只聚焦 waiting queue 和 batch shaping。更具体地说,它回答三件事:
- scheduler 在等待队列里到底看什么;
ScheduleBatch为什么是调度层和执行层之间的桥;- batch 形状是被哪些运行时约束共同塑造出来的。
一张图先看 batch 成形路径#
flowchart TB
A["waiting queue"] --> B["Scheduler.get_next_batch_to_run()"]
B --> C["prefill admission / decode continuation"]
C --> D["ScheduleBatch"]
D --> E["ModelWorkerBatch"]
E --> F["ForwardBatch"]
F --> G["ModelRunner.forward(...)"]这张图里最重要的一点是:scheduler 并不是“从队列里取一个请求去跑”。它真正管理的是 batch 生命周期,而不是单个 request 的生命周期。
waiting queue 里的请求并不是平等排队#
如果只从表面看,waiting queue 很像普通队列。但在 SGLang 里,它更像一个等待被塑形的请求集合。Scheduler 真正关心的问题不是“谁先来”,而是:
- 当前还有多少 token 预算;
- running batch 还剩多少可用空间;
- 现在是更适合接新 prefill,还是继续推进已有 decode;
- 某个请求带来的 cache、grammar、priority 或 multimodal 约束会不会把整轮 batch 推向更差状态。
这也是为什么第三章之后,第四章还要专门讲 Scheduler 这层边界:它不只是“执行前的最后一站”,而是 batch 政策真正开始发生的地方。
Req 到 ScheduleBatch 的角色变化#
第三章里 Req 已经被构造出来了,但这还不是执行层真正消费的对象。到了第五章,关键桥接点是 ScheduleBatch
。
Req 站在调度器视角看,仍然是“一个请求”。而 ScheduleBatch 站在调度层和执行层中间,看的是:
- 这一轮有哪些 request 被选中;
- 它们当前的 seq len、prefix len、cache 位置、sampling 信息是什么;
- 哪些信息需要继续被压到执行层。
也就是说,Req 解决的是“单个请求现在是什么状态”,ScheduleBatch 解决的是“这一组请求这一轮怎样一起向前推进”。
ScheduleBatch 为什么是桥,而不是临时容器#
ScheduleBatch
的字段已经说明,它不是“方便打包一下参数”的临时对象,而是调度层真正的桥:
reqsreq_to_token_pooltoken_to_kv_pool_allocatortree_cacheinput_idsreq_pool_indicesseq_lensout_cache_locsampling_info
这些字段里有一半明显还在调度器语义里,例如 reqs、tree_cache、sampling_info;另一半已经明显偏执行输入,例如 input_ids、seq_lens、out_cache_loc。这说明 ScheduleBatch 的职责不是做纯粹的数据搬运,而是把调度层状态压到执行层可消费格式之前的最后一层。
ScheduleBatch.create(...) 的骨架也说明了这一点:
return cls(
reqs=reqs,
req_to_token_pool=req_to_token_pool,
token_to_kv_pool_allocator=token_to_kv_pool_allocator,
tree_cache=tree_cache,
model_config=model_config,
enable_overlap=enable_overlap,
return_logprob=return_logprob,
)这里的关键不是构造函数本身,而是它把 request、memory pool、cache 和 model config 一起拉到了同一层对象里。这个组合本身就意味着:batch shaping 不是一个纯队列问题,它同时是 cache 和执行前置条件问题。
batch 形状真正由什么决定#
如果把“为什么这一轮 batch 长成这样”说得更工程一点,它通常同时受四类约束影响:
- 队列状态
waiting queue 里有哪些请求、running batch 当前处于 prefill 还是 decode。 - 资源状态
当前还能分配多少 token / KV 位置,是否还能接新请求。 - cache 状态
prefix 能否复用,某些请求是否已经有 committed KV。 - 功能状态
grammar、multimodal、LoRA、priority 等约束是否把这一轮 batch 的可行空间压小了。
这也是为什么调度问题几乎从来不只是 scheduler.py 某个 if/else 的问题。真正的 batch 形状,往往是这些约束共同作用后的结果。
一个具体示例:5 个请求怎样形成一轮 batch#
抽象的约束列表不如一个具体场景直观。假设当前系统状态如下:
Running batch(正在 decode 的请求):
| 请求 | 阶段 | 已生成 tokens | prefixed KV |
|---|---|---|---|
| R1 | decode | 12 | 256 |
| R2 | decode | 8 | 512 |
| R3 | decode | 23 | 128 |
Waiting queue(等待 prefill 的请求):
| 请求 | prompt 长度 | 与 R2 共同前缀 | 备注 |
|---|---|---|---|
| W1 | 128 tokens | 无 | 普通请求 |
| W2 | 512 tokens | 512 tokens | 完全复用 R2 的 KV |
| W3 | 2048 tokens | 无 | 长 prompt |
KV 资源: 当前还有 600 个空闲 token slots。
这一轮 scheduler 会怎么做?
decode continuation:R1、R2、R3 继续推进各 1 个 decode token,消耗 3 个 token slots(总计 3)。
prefill admission 决策:
- W1(128 tokens):需要 128 个新 slots,资源充足,可以 admit;
- W2(512 tokens):前缀 512 tokens 已经在 R2 的 KV 里,实际只需分配 ~0 个新 slots(命中已有 committed KV),优先 admit;
- W3(2048 tokens):需要 2048 个新 slots,远超剩余 597 slots,这轮无法 admit。
这一轮 ScheduleBatch 的组成:
ScheduleBatch {
reqs: [R1, R2, R3, W2, W1]
input_ids: [R1的decode token, R2的decode token, R3的decode token,
W2的prefill(实际为空,已完全命中), W1的128个prompt tokens]
seq_lens: [268, 520, 151, 512, 128]
out_cache_loc: [R1新slot, R2新slot, R3新slot, W2(复用R2 KV), W1新slots...]
}W3 仍然留在 waiting queue,等下一轮资源释放后再尝试 admit。
这个示例说明了三件事:(1) decode 和 prefill 在同一个 batch 里共存;(2) cache 命中(W2)会显著改变 admission 决策;(3) 资源预算决定了 batch 的边界,而不是请求的到达顺序。
Chunked Prefill:长 prompt 怎样和 decode 共存#
上面的例子里,W3(2048 tokens)因为 token slots 不足而被整体推迟。但如果系统里有很多长 prompt 请求,这种"要么整批 prefill,要么等待"的策略会导致 decode 请求被频繁阻塞(Head-of-Line Blocking),TTFT 方差变大。
Chunked Prefill 解决的就是这个问题:把一个长 prefill 请求的 prompt 分成多个 chunk,每轮只 prefill 一部分,剩余部分继续留在 waiting queue,同时允许其他 decode 请求在同一轮 batch 里推进。
SGLang 通过 chunked_prefill_size(或 max_prefill_tokens)参数控制每轮最多处理多少 prefill tokens。当这个参数设为 512 时:
轮 1: [R1 decode, R2 decode, R3 decode, W3 prefill(tokens 1-512)]
轮 2: [R1 decode, R2 decode, R3 decode, W3 prefill(tokens 513-1024)]
轮 3: [R1 decode, R2 decode, R3 decode, W3 prefill(tokens 1025-1536)]
轮 4: [R1 decode, R2 decode, R3 decode, W3 prefill(tokens 1537-2048)]
轮 5: [R1 decode, R2 decode, R3 decode, W3 decode(生成第1个token)]代价和收益:
- 收益:decode 请求不再被长 prefill 阻塞,TTFT 更稳定,P99 更低;
- 代价:一个长 prompt 的 prefill 被分散到多轮,总 prefill 耗时可能略微增加(attention 操作做了多次而不是一次)。
通常推荐在对话服务(latency-sensitive)场景下开启 chunked prefill,在批量离线推理(throughput-sensitive)场景下不开或设置较大 chunk size。
为什么不是直接把 Req 交给执行层#
如果没有 ScheduleBatch 这一层,执行层就必须同时理解:
- request queue 语义;
- cache 复用语义;
- sampling metadata;
- seq len / prefix len / out cache 位置。
这样会让 ModelRunner 直接被调度策略污染。当前设计把这层桥保留下来,换来的好处是:
Scheduler继续掌握“这一轮为什么这样排”;ModelRunner只接更靠近执行的 batched 输入。
对读者来说,这一点非常重要。因为它解释了为什么本章不会直接跳到 attention kernel,而是先停在 batch 怎样被组织出来。
调试 batch shaping 时先看哪里#
如果你看到的现象是:
- 新请求进来了,但就是迟迟不进 running batch;
- decode 在推进,但 prefill 很难插进来;
- 某些请求一进来就把 batch 撑得很难看;
更稳的顺序通常是:
- 先看 scheduler 当前是在做 prefill 还是 decode continuation;
- 再看这一轮
ScheduleBatch里实际有哪些 request; - 然后确认是不是 cache、grammar、multimodal 或 priority 把 admission 空间压小了;
- 最后再下沉到执行层,看是不是执行输入本身有异常。
这条顺序的价值在于:先判断“batch 是怎么成形的”,再判断“这个 batch 跑得怎么样”。
小结#
这一节真正要建立的是一个简单判断:
- waiting queue 里排的不是“下一轮直接运行的请求”
ScheduleBatch才是调度层真正组织出来的运行对象- batch 形状总是由队列、资源、cache 和功能约束共同塑造
只要这三点先稳住,后面再读 KV cache 生命周期和执行模型时,就不会把所有问题都误看成单纯的 queue 顺序问题。
叶王 © 2013-2026 版权所有。如果本文档对你有所帮助,可以请作者喝饮料。