跳转到内容

批处理调度:从静态批到分块 Prefill

推理调度器决定一轮 GPU 执行包含哪些请求、多少新 token。连续批处理允许在生成轮次之间接入新请求;分块 Prefill 把长输入拆开,限制它一次占用的工作量。 两者可以减少等待或输出停顿,但也会改变其他请求的首 token 时间,不能仅凭批处理形式判断性能优劣。

下面用四条请求逐轮计算。模拟器将一个 Prefill token 的成本设为 1、一个 Decode token 的成本设为 2,时间由本轮成本相加得到。这是模拟器的成本假设,不是 GPU 毫秒数;它刻意不模拟并行加速,让请求选择和延迟取舍可以单独核对。

请求首先等待接入。需要处理输入的请求处于 Prefill,输入处理完后产生第一枚输出;若还要继续输出,就进入 Decode。请求完成或取消后,其资源才可供其他请求使用。

已到达:等待接入Prefill:仍有输入未处理输入处理完:第一枚输出Decode:每轮继续生成完成并释放取消并释放

请求数预算限制活跃请求数量,token 预算限制本轮新计算的位置数。Decode 请求本轮通常需要一个新位置;一次完整 Prefill 可能需要几千个。两条请求不一定是相同工作量。

模拟器最多同时接纳 4 条请求,每轮 token 预算为 16。优先给可 Decode 的请求各分配一个 token,再按到达顺序安排 Prefill。分块模式额外规定每条请求本轮最多 Prefill 4 个 token。这项单请求上限是模拟规则,不能当成 vLLM 的同名配置。

请求到达时间输入长度输出长度
A044
B041
C242
D2122

时间 0 只有 A、B 可以接入。第一轮 Prefill 一共处理 8 个输入 token,成本为 8;到时间 8,两条请求都产生第一枚输出,B 已完成。

三种模式之后的区别是:

模式何时接纳 C、D怎样处理 D 的 12 个输入
静态批当前批的 A、B 都完成后一次处理完整输入
连续批轮次之间允许补入输入必须整段放入本轮预算,否则等待
分块连续批轮次之间允许补入分几轮处理,每轮最多 4 个输入 token

静态批在 B 完成后仍不补入 C、D,但模拟器不会继续为 B 计算无效 Decode。固定形状实现还可能有 padding 浪费,那是这里没有建模的另一种成本。

连续批怎样改变排队和输出间隔

Section titled “连续批怎样改变排队和输出间隔”

静态批先完成 A 的三个 Decode:时间 10、12、14 依次输出。然后 C、D 一起 Prefill 16 个 token,时间 30 才给出首枚输出,时间 34 结束。

连续批在时间 8 接纳 C、D,但本轮先为 A 的 Decode 留一个 token。再安排 C 的 4 个 Prefill token,预算剩 11,放不下 D 的完整 12 个,因此 D 等下一轮。

轮次起止时间选中的工作本轮成本输出事件
10 → 8A Prefill 4,B Prefill 48A、B 首 token;B 完成
28 → 14A Decode 1,C Prefill 42+4=6A 第二枚;C 首 token
314 → 30A、C 各 Decode 1,D Prefill 122+2+12=16A 第三枚;C 完成;D 首 token
430 → 34A、D 各 Decode 14A、D 完成

C 的首 token 等待从静态批的 28 降到 12,但 A 的输出时间变成 [8,14,30,34],最大相邻间隔为 16。新请求更早接入,不代表已有流式请求的体验同步改善。

注意第三轮:调度器优先选择 Decode 仍不足以避免停顿。Decode 与长 Prefill 在同一轮执行,轮次完成时间会受到这批工作的影响。真实 GPU 内核的成本不是简单相加,但共享执行预算造成的竞争仍需观察。

分块 Prefill 怎样限制长输入占用

Section titled “分块 Prefill 怎样限制长输入占用”

把 D 的 12 个输入分成三份,每轮只处理 4 个。中间两轮已经完成的 Decode 可以继续获得机会,而 D 的首输出必须等全部输入处理完。

轮次起止时间选中的工作D 剩余输入输出事件
10 → 8A、B 各 Prefill 4尚未接入A、B 首 token
28 → 18A Decode;C Prefill 4;D Prefill 48A 第二枚;C 首 token
318 → 26A、C Decode;D Prefill 44A 第三枚;C 完成
426 → 32A Decode;D Prefill 40A 完成;D 首 token
532 → 34D Decode0D 完成
同轮同轮同轮D 输入前 4 个 tokenD 输入中间 4 个 tokenD 输入最后 4 个 tokenA Decode;C PrefillA、C DecodeA DecodeD 的第一枚输出

A 的最大输出间隔从 16 降到 10;D 的 TTFT 从 28 增到 30。C 首输出也从连续批的 12 增到 16,因为第二轮同时做了 D 的一个输入块。分块限制了长输入一次带来的停顿,却没有让所有指标同时变好。

下载完整调度代码,执行:

Terminal window
python3 scheduler.py --mode all --token-budget 16 --chunk-size 4

程序输出每轮选择、起止时间和每条请求的输出时间。默认结果如下,单位均为模拟成本单位。

指标静态批连续批分块连续批
A 的最大 ITL21610
C 的 TTFT281216
D 的 TTFT282830
全部完成时间343434

完成时间相同,因为固定工作量的成本被简单相加,模拟器没有加入批量执行的并行效率。它只能说明这个规则下请求何时获服务,不能用来宣称连续批或分块 Prefill 的真实吞吐收益。

完整源码:到达、状态、Decode 优先与 token 预算
scheduler.py
"""Scheduling simulator. Time units are invented costs, not milliseconds or GPU results."""
import argparse
import json
from dataclasses import dataclass, field
@dataclass
class Request:
name: str
arrival: int
prompt: int
output: int
remaining: int = field(init=False)
emitted: list = field(default_factory=list)
def __post_init__(self):
if self.arrival < 0 or self.prompt < 1 or self.output < 1:
raise ValueError("Invalid request")
self.remaining = self.prompt
DEFAULTS = [("A", 0, 4, 4), ("B", 0, 4, 1), ("C", 2, 4, 2), ("D", 2, 12, 2)]
def simulate(mode, specs=DEFAULTS, token_budget=16, chunk_size=4):
if mode not in {"static", "continuous", "chunked"}:
raise ValueError("Unknown mode")
if token_budget < 1 or chunk_size < 1:
raise ValueError("Budgets must be positive")
all_requests = [Request(*spec) for spec in specs]
if not all_requests:
raise ValueError("At least one request is required")
if mode != "chunked" and max(r.prompt for r in all_requests) > token_budget:
raise ValueError("Unchunked prompt cannot exceed token_budget")
waiting = sorted(all_requests, key=lambda r: r.arrival)
active, trace, now = [], [], 0
while waiting or active:
if not active and waiting[0].arrival > now:
now = waiting[0].arrival
if mode != "static" or not active:
while waiting and waiting[0].arrival <= now and len(active) < 4:
active.append(waiting.pop(0))
budget, actions = token_budget, []
# Reserve decode tokens before considering new prefill work.
for r in active:
if r.remaining == 0 and budget:
actions.append((r, "decode", 1))
budget -= 1
for r in active:
if r.remaining == 0 or budget == 0:
continue
amount = min(r.remaining, budget, chunk_size) if mode == "chunked" else r.remaining
if amount <= budget:
actions.append((r, "prefill", amount))
budget -= amount
if not actions:
raise RuntimeError("No progress; check token budget")
duration = sum(tokens * (1 if phase == "prefill" else 2)
for _, phase, tokens in actions)
end = now + duration
for r, phase, tokens in actions:
if phase == "prefill":
r.remaining -= tokens
if r.remaining == 0:
r.emitted.append(end) # First output comes from completed prefill.
else:
r.emitted.append(end)
trace.append({"start": now, "end": end,
"actions": [f"{r.name}:{phase}:{tokens}" for r, phase, tokens in actions]})
active = [r for r in active if len(r.emitted) < r.output]
now = end
results = []
for r in all_requests:
assert len(r.emitted) == r.output and r.emitted[0] >= r.arrival
gaps = [b - a for a, b in zip(r.emitted, r.emitted[1:])]
results.append({"name": r.name, "ttft": r.emitted[0] - r.arrival,
"e2e": r.emitted[-1] - r.arrival, "max_itl": max(gaps, default=0),
"output_times": r.emitted})
return {"mode": mode, "kind": "virtual cost units; no GPU prediction",
"finish": now, "requests": results, "trace": trace}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--mode", choices=["static", "continuous", "chunked", "all"], default="all")
parser.add_argument("--token-budget", type=int, default=16)
parser.add_argument("--chunk-size", type=int, default=4)
args = parser.parse_args()
modes = ["static", "continuous", "chunked"] if args.mode == "all" else [args.mode]
print(json.dumps([simulate(mode, token_budget=args.token_budget, chunk_size=args.chunk_size)
for mode in modes], indent=2))
if __name__ == "__main__":
main()

把块大小分别改为 2、4、8,观察 A 的最大 ITL、C 和 D 的 TTFT。再改变 D 的到达时间或输入长度。每次只改一个条件,先根据时间线推断影响,再核对输出。

非分块模式必须能把最大输入放入单轮预算,因此把预算降到 8 时,D 的 12 个输入无法前进,代码会明确拒绝这组配置。分块模式则可以分轮完成;真实系统同样需要处理超出单轮或总上下文限制的请求。

vLLM V1 的分块 Prefill 策略在可用时优先调度 Decode,再用剩余 max_num_batched_tokens 预算处理 Prefill;放不下的输入可以被分块。预算如何影响延迟和吞吐需按负载测量,不能套用模拟器中的成本或单请求块上限。vLLM 分块 Prefill 与调优

实际调度还受 max_num_seqs、KV 块容量、模型长度、抢占和其他功能约束。待处理 token 没超过预算,也可能因缓存空间不足而不能接入。请求已接入,也不代表本轮一定生成一个输出。

先用已安装版本的 vllm serve --help 核对参数。固定模型、精度、上下文与活跃序列上限,只改变批 token 预算,例如先比较 1024 和 2048。两项数值是实验档位,不是推荐的通用最优值。

Terminal window
vllm serve Qwen/Qwen2.5-1.5B-Instruct \
--host 127.0.0.1 --port 8000 \
--max-model-len 4096 \
--max-num-seqs 16 \
--max-num-batched-tokens 1024

先测短输入,再测长输入,最后测两类请求混合到达。分别记录 TTFT、ITL 分布、成功吞吐、等待数、KV 占用与抢占。第二档改为 2048 时,其余配置与负载不变;冷启动与预热阶段单列。

观察下一步分析
ITL 下降、长输入 TTFT 上升Prefill 被拆得更细,检查是否符合交互目标
吞吐上升、P95 TTFT 恶化检查更大批次的等待和长 Prefill 对小请求的影响
两档都大量排队且缓存接近满先看缓存容量和输入分布,不能只继续增大批预算
重复前缀使结果突然改善单列前缀缓存条件,避免把命中变化归因于批预算

这里没有 GPU 实测数据。下一节给出指标计算、压测与 Profiler 的完整方法。