waiting queue 与 batch shaping#

第二部分已经把请求怎样进入 runtime 讲清楚了。到了这一章,问题不再是“请求能不能进来”,而是“已经进来的请求为什么这一轮能跑、下一轮不能跑,为什么有些请求先 prefill,有些请求继续 decode,最终 batch 又为什么长成了现在这个样子”。

这一节只聚焦 waiting queue 和 batch shaping。更具体地说,它回答三件事:

  1. scheduler 在等待队列里到底看什么;
  2. ScheduleBatch 为什么是调度层和执行层之间的桥;
  3. 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 政策真正开始发生的地方。

ReqScheduleBatch 的角色变化#

第三章里 Req 已经被构造出来了,但这还不是执行层真正消费的对象。到了第五章,关键桥接点是 ScheduleBatch

Req 站在调度器视角看,仍然是“一个请求”。而 ScheduleBatch 站在调度层和执行层中间,看的是:

  • 这一轮有哪些 request 被选中;
  • 它们当前的 seq len、prefix len、cache 位置、sampling 信息是什么;
  • 哪些信息需要继续被压到执行层。

也就是说,Req 解决的是“单个请求现在是什么状态”,ScheduleBatch 解决的是“这一组请求这一轮怎样一起向前推进”。

ScheduleBatch 为什么是桥,而不是临时容器#

ScheduleBatch 的字段已经说明,它不是“方便打包一下参数”的临时对象,而是调度层真正的桥:

  • reqs
  • req_to_token_pool
  • token_to_kv_pool_allocator
  • tree_cache
  • input_ids
  • req_pool_indices
  • seq_lens
  • out_cache_loc
  • sampling_info

这些字段里有一半明显还在调度器语义里,例如 reqstree_cachesampling_info;另一半已经明显偏执行输入,例如 input_idsseq_lensout_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 长成这样”说得更工程一点,它通常同时受四类约束影响:

  1. 队列状态
    waiting queue 里有哪些请求、running batch 当前处于 prefill 还是 decode。
  2. 资源状态
    当前还能分配多少 token / KV 位置,是否还能接新请求。
  3. cache 状态
    prefix 能否复用,某些请求是否已经有 committed KV。
  4. 功能状态
    grammar、multimodal、LoRA、priority 等约束是否把这一轮 batch 的可行空间压小了。

这也是为什么调度问题几乎从来不只是 scheduler.py 某个 if/else 的问题。真正的 batch 形状,往往是这些约束共同作用后的结果。

一个具体示例:5 个请求怎样形成一轮 batch#

抽象的约束列表不如一个具体场景直观。假设当前系统状态如下:

Running batch(正在 decode 的请求):

请求阶段已生成 tokensprefixed KV
R1decode12256
R2decode8512
R3decode23128

Waiting queue(等待 prefill 的请求):

请求prompt 长度与 R2 共同前缀备注
W1128 tokens普通请求
W2512 tokens512 tokens完全复用 R2 的 KV
W32048 tokens长 prompt

KV 资源: 当前还有 600 个空闲 token slots。

这一轮 scheduler 会怎么做?

  1. decode continuation:R1、R2、R3 继续推进各 1 个 decode token,消耗 3 个 token slots(总计 3)。

  2. 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。
  3. 这一轮 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 撑得很难看;

更稳的顺序通常是:

  1. 先看 scheduler 当前是在做 prefill 还是 decode continuation;
  2. 再看这一轮 ScheduleBatch 里实际有哪些 request;
  3. 然后确认是不是 cache、grammar、multimodal 或 priority 把 admission 空间压小了;
  4. 最后再下沉到执行层,看是不是执行输入本身有异常。

这条顺序的价值在于:先判断“batch 是怎么成形的”,再判断“这个 batch 跑得怎么样”。

小结#

这一节真正要建立的是一个简单判断:

  • waiting queue 里排的不是“下一轮直接运行的请求”
  • ScheduleBatch 才是调度层真正组织出来的运行对象
  • batch 形状总是由队列、资源、cache 和功能约束共同塑造

只要这三点先稳住,后面再读 KV cache 生命周期和执行模型时,就不会把所有问题都误看成单纯的 queue 顺序问题。