静态 batching 为什么不够:从单请求推理走向调度器
1. 这一篇要解决什么问题
前面几篇文章里,我们已经完成了从单请求生成到请求状态抽象的过渡。
我们知道了一个 token 是如何生成出来的,也知道了 KV cache 为什么可以避免重复计算历史 token。随后,我们把一个请求封装成了 Sequence,让它可以维护自己的 prompt tokens、generated tokens、KV cache、status 和 stop reason。
到这里为止,系统已经有了“单个请求如何运行”的基本能力。
但是,一个推理服务真正要面对的从来不是一个请求。
真实场景下,请求会不断到达。
有的请求 prompt 很短,只问一句话。
有的请求 prompt 很长,可能带着几千个 token 的上下文。
有的请求只生成几十个 token。
有的请求要求生成上千个 token。
有的请求刚进入系统。
有的请求已经生成了一半。
有的请求马上就要结束。
如果我们还是按照单请求 generate 的思路去写系统,那么无论模型本身多强,服务端吞吐都会很差。
这一篇我们要讨论的问题是:
为什么不能简单地把多个请求组成一个固定 batch,然后调用模型生成?
换句话说:
为什么静态 batching 不够?
这个问题非常关键。因为 vLLM 这类推理系统的调度器、continuous batching、KV cache block manager,都是为了解决这个问题一步步长出来的。
如果不先理解静态 batching 的局限,就很容易把调度器看成一个复杂但不知道为什么存在的模块。
2. 单请求推理的问题
先从最朴素的服务方式开始。
每来一个请求,系统就单独跑一次 generate。
def handle_request(prompt):
sequence = Sequence(prompt)
while not sequence.is_finished():
engine.step(sequence)
return sequence.output_text()
这很容易理解,也很容易实现。
但是它有一个明显的问题:GPU 利用率通常不高。
尤其是在 decode 阶段,每一轮只处理一个 token。
对于单请求来说,decode 的输入形状通常是:
[1, 1]
第一个维度是 batch size,第二个维度是当前 decode token 数。
也就是说,模型每一轮只为一个请求生成一个 token。
这对 GPU 来说太小了。
GPU 擅长做大规模并行计算。如果每次只处理一个 token,很多计算资源会处于空闲状态。即使模型很大,decode 阶段也可能因为 batch 太小、访存占比太高,导致整体吞吐上不去。
所以我们自然会想到:既然一个请求吃不满 GPU,那就把多个请求放在一起。
这就是 batching 的动机。
3. 静态 batching 的第一种形式:一起 prefill
最容易想到的 batching 是把多个 prompt 放到一起做 prefill。
例如同时来了三个请求:
请求 A: prompt 长度 16
请求 B: prompt 长度 128
请求 C: prompt 长度 512
我们把它们组成一个 batch,一起送进模型。
但是问题很快出现了:Transformer 模型通常要求一个 batch 内的序列长度对齐。
所以短序列需要 padding。
它会变成这样:
A: 16 tokens + 496 padding
B: 128 tokens + 384 padding
C: 512 tokens
实际 batch 的形状变成:
[3, 512]
虽然请求 A 只有 16 个有效 token,但它在这个 batch 里被 padding 到了 512。
这意味着大量无效位置也参与了张量形状的构造。即使 attention mask 可以让模型忽略 padding token,很多底层计算和显存分配仍然会被最大长度影响。
这就是静态 batching 的第一个问题:
batch 内请求长度差异越大,padding 浪费越严重
对于普通 NLP 任务,这个问题已经存在。
对于 LLM 推理,它会更加严重。
因为 prompt 长度差异可能非常大。有的请求几十个 token,有的请求几千个 token。把它们粗暴地放到一个固定 batch 里,很容易让短请求被长请求拖累。
4. 静态 batching 的第二种形式:一起 decode
再看 decode 阶段。
假设有三个请求同时开始生成:
请求 A: 最多生成 8 个 token
请求 B: 最多生成 64 个 token
请求 C: 最多生成 512 个 token
如果我们用静态 batch 的方式生成,流程可能是:
把 A、B、C 放进同一个 batch
每一轮一起 decode
直到整个 batch 都结束
表面上看,这样能提高 GPU 利用率。
但是请求 A 生成 8 个 token 就结束了。
请求 B 生成 64 个 token 就结束了。
请求 C 要继续生成到 512 个 token。
如果 batch 是固定的,那么 A 和 B 结束之后,batch 中的位置会空出来。后面的 decode 实际上只剩下 C 还在工作。
于是 batch size 会从 3 逐渐退化到 1。
更糟糕的是,如果系统不允许新请求加入这个 batch,那么 GPU 又回到了单请求 decode 的状态。
这就是静态 batching 的第二个问题:
请求结束时间不同,batch 会逐渐变空
LLM 生成长度高度不确定。
即使用户设置了 max_tokens,模型也可能提前遇到 EOS,也可能命中 stop words,也可能用户中途断开连接。
所以一个 batch 内的请求几乎不可能同时结束。
静态 batch 一开始看起来很满,跑着跑着就会变得越来越空。
5. 静态 batching 的第三个问题:新请求进不来
线上请求不是整齐到达的。
它们不是这样来的:
第 0 秒来了 32 个请求
第 10 秒又来了 32 个请求
第 20 秒又来了 32 个请求
真实情况更像这样:
第 0.01 秒来了请求 A
第 0.03 秒来了请求 B
第 0.08 秒来了请求 C
第 0.20 秒来了请求 D
第 1.35 秒来了请求 E
请求是持续流入的。
如果系统使用固定 batch,就会遇到一个尴尬问题:
当前 batch 还没结束,新请求要不要加入?
如果不加入,新请求只能排队等待。
如果加入,batch 的 KV cache、position ids、attention mask、请求状态都要动态变化。
一旦允许动态加入,这就已经不是静态 batching 了,而是进入了 continuous batching 的范畴。
静态 batching 的第三个问题是:
它假设请求以固定批次到达,但真实服务中请求是流式到达的
这会直接影响用户体验。
如果系统必须等当前 batch 完全结束才能处理下一批请求,那么短请求可能会被长请求堵住。
这就是典型的队头阻塞问题。
6. 队头阻塞为什么严重
我们用一个具体例子来理解。
假设当前系统正在处理一个长请求:
请求 A: 需要生成 2000 个 token
这个请求刚开始生成没多久,用户又发来了一个很短的请求:
请求 B: 只需要生成 20 个 token
如果系统采用简单的同步生成方式,请求 B 可能要等请求 A 完全结束之后才能开始。
这显然不合理。
请求 B 本来只需要很短时间,但它被请求 A 挡在了队列后面。
在在线服务里,用户更关心的是延迟,尤其是首 token 延迟。
所谓首 token 延迟,就是从用户发出请求,到模型返回第一个 token 的时间。
如果短请求经常被长请求挡住,首 token 延迟就会非常糟糕。
所以高吞吐不是唯一目标。
推理系统必须同时考虑两个目标:
吞吐量:单位时间生成多少 token
延迟:单个请求多久能开始返回,多久能完成
这两个目标经常会冲突。
为了吞吐,我们希望 batch 尽量大。
为了延迟,我们又希望新请求不要等太久。
调度器的价值就在这里。
它要在吞吐和延迟之间做权衡。
7. prefill 和 decode 混在一起会发生什么
前面我们讲过,LLM 推理分成 prefill 和 decode。
这两个阶段的性能特征不同。
prefill 一次处理完整 prompt,计算量大,矩阵乘法规模大,更容易把 GPU 跑满。
decode 每次只处理一个新 token,但要访问历史 KV cache,访存压力更明显。
如果系统没有清楚地区分这两个阶段,就会出现调度问题。
例如,某一轮系统里有两类请求:
新请求:需要做 prefill
老请求:需要继续 decode
新请求的 prompt 很长,比如 4096 个 token。
老请求已经在流式输出,用户正在等下一个 token。
如果调度器直接把长 prefill 放进去,可能会导致 decode 请求等待很久。
用户看到的效果就是:流式输出突然卡住。
这说明 prefill 和 decode 不能随便混在一起调度。
它们都要消耗 GPU 资源,但它们对用户体验的影响不同。
prefill 决定首 token 延迟。
decode 决定后续输出是否流畅。
一个好的调度器必须意识到这两种任务的差异。
8. 为什么不能只追求最大 batch size
很多人第一次优化推理服务时,会直觉地认为:
batch size 越大,吞吐越高。
这句话只对了一部分。
batch size 增大,确实通常能提高 GPU 利用率。
但是在 LLM 推理里,batch size 不是唯一变量。
更准确地说,我们要关注的是:
一个 batch 里一共有多少有效 token
这些 token 属于 prefill 还是 decode
每个请求的 KV cache 占用了多少显存
这批请求会不会让某些短请求等待太久
如果为了凑大 batch,系统一直等待更多请求到来,那么首 token 延迟会变高。
如果为了处理长 prompt,把大量 token 都放进 prefill,running 中的 decode 请求可能会被饿死。
如果为了塞更多请求,KV cache 显存被占满,后续请求可能无法进入系统。
所以调度器真正要控制的不是一个简单的 batch size,而是一组资源预算。
最核心的预算包括:
每轮最多处理多少 token
最多允许多少个 running sequence
当前还剩多少 KV cache 空间
prefill 和 decode 谁优先
新请求最多能等多久
这也是为什么后面我们会引入 token budget。
它比 batch size 更接近 LLM 推理系统的真实约束。
9. 从 batch size 到 token budget
传统深度学习任务里,我们经常说 batch size。
但在 LLM 推理里,更自然的单位其实是 token。
因为不同请求的长度差异太大。
看两个 batch:
Batch 1:
8 个请求,每个 prompt 128 tokens
Batch 2:
8 个请求,每个 prompt 4096 tokens
它们的 batch size 都是 8。
但计算量完全不是一个量级。
再看 decode:
Batch 3:
128 个 running 请求,每个请求 decode 1 个 token
它的 batch size 是 128,但这一轮只有 128 个输入 token。
而一个长 prefill 请求可能就有 4096 个输入 token。
所以只看 batch size 会误导我们。
调度器真正需要关心的是 token budget。
例如每一轮最多处理:
max_num_batched_tokens = 8192
这意味着调度器可以选择:
8 个长度 1024 的 prefill 请求
也可以选择:
1 个长度 4096 的 prefill 请求 + 4096 个 decode token
也可以选择:
大量 decode 请求 + 少量短 prefill 请求
当然,真实系统不会这么简单,但这个思路很重要。
用 token budget 思考问题,才更接近 LLM serving 的本质。
10. 一个最小调度循环应该长什么样
现在我们先不实现完整调度器,只思考一个最小调度循环需要做什么。
系统里有两类请求:
waiting sequences
running sequences
waiting sequences 还没有做 prefill。
running sequences 已经完成 prefill,需要继续 decode。
每一轮 engine step,大概需要做这些事:
从 waiting 里挑一些请求做 prefill
从 running 里挑一些请求做 decode
把它们组成本轮要执行的 batch
调用 model runner
采样新 token
更新每个 sequence 的状态
把未完成的请求留在 running
把完成的请求放到 finished
这里最关键的是第一步:
这一轮到底选谁
如果只选 waiting 请求,那么正在流式输出的 running 请求会卡住。
如果只选 running 请求,那么新请求永远做不了 prefill,首 token 延迟会变高。
如果长 prompt 一次性占满 token budget,那么其他请求会被阻塞。
所以调度器不能只是一个队列弹出器。
它必须根据资源预算和请求状态做选择。
11. 静态 batching 和 continuous batching 的核心差别
现在我们可以正式对比静态 batching 和 continuous batching。
静态 batching 的思路是:
收集一批请求
一起开始
一起生成
等这一批大部分或全部结束
再处理下一批
continuous batching 的思路是:
系统每一轮都重新决定 batch 由谁组成
已完成的请求可以退出
新请求可以进入
未完成的请求继续 decode
batch 是动态变化的
重点是这一句:
每一轮重新构造 batch
这就是 continuous batching 的核心。
它不是说请求真的连续不断地在同一个固定 batch 里运行。
恰恰相反,它的 batch 每一轮都可能不同。
第 1 轮:
A, B, C 做 prefill
第 2 轮:
A, B, C 做 decode
D 做 prefill
第 3 轮:
A, C, D 做 decode
B 已结束
E 做 prefill
第 4 轮:
C, D, E 做 decode
A 已结束
F 做 prefill
这种动态变化,才是 LLM 在线推理真正需要的模式。
它既提高了 GPU 利用率,也减少了新请求等待时间。
12. continuous batching 对 Sequence 抽象的要求
第四篇里我们抽象了 Sequence。
现在可以看到这个抽象为什么重要。
如果每个请求只是一个封闭的 generate 函数,那么系统很难在每一轮之间插入调度逻辑。
但如果每个请求都是一个 Sequence,事情就变得自然了。
每个 Sequence 都知道自己的状态:
waiting
running
finished
每个 Sequence 都知道自己下一步需要什么:
waiting sequence 需要 prefill
running sequence 需要 decode
finished sequence 不再需要执行
每个 Sequence 都能在每轮执行后更新自己:
append token
update KV cache
check stop condition
change status
所以 continuous batching 的前提是:
请求必须是可暂停、可恢复、可逐步推进的状态对象
这正是 Sequence 的意义。
没有 Sequence,调度器只能调函数。
有了 Sequence,调度器才能调状态。
13. 为什么调度器需要知道 KV cache
调度器不能只看请求数量。
它还必须知道 KV cache 资源是否够用。
因为每个 running sequence 都会占用 KV cache。
每生成一个 token,它的 KV cache 就增长一点。
如果显存不足,新请求就不能盲目进入 prefill。
否则 prefill 完成后没有空间保存 KV cache,系统就会崩掉,或者被迫做复杂回收。
在当前阶段,我们还没有实现 block manager,但调度器从一开始就应该有资源意识。
它至少要思考两个问题:
这个 waiting 请求的 prompt 做完 prefill 后,需要多少 KV cache
这些 running 请求继续 decode 后,还需要追加多少 KV cache
后面实现 block manager 时,调度器会和 cache manager 协作:
scheduler 负责决定谁能运行
cache manager 负责判断有没有足够 KV cache block
只有资源足够的请求,才能被调度进 batch。
这也是 vLLM 这类系统复杂的地方。
调度不是单纯的排队问题。
调度同时也是显存资源分配问题。
14. 一个简单 FIFO 调度器够不够
最简单的调度策略是 FIFO。
谁先来,谁先处理。
这很公平,也很好实现。
但是在 LLM 推理里,FIFO 不一定总是好。
假设 waiting 队列里有两个请求:
请求 A: prompt 长度 16000
请求 B: prompt 长度 32
A 先到,B 后到。
如果严格 FIFO,系统会先处理 A。
但 A 的 prefill 很长,可能占满本轮 token budget,也可能让 running decode 请求等待。
B 其实很短,很快就能 prefill 完成并返回第一个 token。
这时是否应该让 B 先走?
从公平性看,A 先到,应该先处理 A。
从平均延迟看,让 B 先走可能更好。
从用户体验看,短请求被长请求挡住通常很不划算。
所以推理调度里经常会出现策略权衡:
公平性
吞吐量
平均延迟
尾部延迟
显存利用率
流式输出流畅度
第一版 mini vLLM 可以先用 FIFO。
但我们要知道,FIFO 只是起点,不是终点。
后面要优化调度策略时,就会围绕这些指标展开。
15. 尾部延迟为什么值得关注
很多系统优化只看平均延迟,但推理服务里尾部延迟同样重要。
平均延迟可能看起来不错,但少数请求可能非常慢。
例如:
90% 的请求 1 秒返回首 token
10% 的请求 15 秒才返回首 token
平均下来可能还可以接受,但那 10% 用户体验会非常差。
这些慢请求通常就是被长 prompt、长输出、资源不足或调度不公平影响了。
所以后面做 benchmark 时,我们不能只看平均值。
还要看:
P50 latency
P90 latency
P99 latency
time to first token
time per output token
throughput
调度器设计会直接影响这些指标。
这一点很重要。
因为调度器不是为了让代码看起来更工程化,而是为了真正改善服务质量。
16. 静态 batching 的本质缺陷
现在我们可以总结静态 batching 的问题。
它的问题不是“不能用”。
在离线推理或固定任务里,静态 batching 仍然很有价值。
例如你有一批固定数据,要一次性跑完摘要、分类或评测,那么静态 batching 简单有效。
但在线 LLM serving 不一样。
它有几个特点:
请求持续到达
prompt 长度差异大
生成长度不可预测
请求结束时间不同
KV cache 动态增长
用户关心首 token 延迟
流式输出不能长时间卡住
静态 batching 很难同时适应这些特点。
它的问题可以归纳为四点:
padding 浪费
batch 逐渐变空
新请求难以及时加入
长请求容易阻塞短请求
continuous batching 不是为了炫技,而是这些问题逼出来的设计。
17. mini vLLM 的下一步
经过这一篇,我们已经知道为什么需要调度器。
下一步,我们要真正开始实现它。
第一版调度器不需要很复杂。
它只需要维护三类状态:
waiting
running
finished
每一轮 step 做两件事:
选择一批 waiting sequence 做 prefill
选择一批 running sequence 做 decode
然后把执行结果写回 Sequence。
第一版策略可以先简单一点:
优先让 running sequence 继续 decode
如果还有 token budget,再接收 waiting sequence 做 prefill
这个策略的好处是流式输出会比较稳定。
但它也有问题:如果 running 请求太多,waiting 请求可能等待较久。
所以后续我们还会继续改进它。
推理调度就是这样。
没有一个策略在所有场景下都是最优的。
我们需要先实现一个能工作的版本,再通过 workload 和 benchmark 观察它的问题,然后再优化。
18. 小结
这一篇我们讨论了静态 batching 为什么不适合直接用于在线 LLM serving。
单请求推理吃不满 GPU,所以我们需要 batching。
但固定 batch 又会遇到新的问题:
请求长度不同导致 padding 浪费
生成长度不同导致 batch 逐渐变空
新请求持续到达却无法及时加入
长请求可能阻塞短请求
prefill 和 decode 的性能特征不同
KV cache 显存也会限制可调度请求数量
所以,一个真正的推理引擎不能只写一个 generate 函数,也不能只写一个固定 batch 循环。
它需要一个调度器。
调度器的任务不是简单地把请求塞进 batch,而是在每一轮 step 中,根据请求状态、token budget、KV cache 资源和延迟目标,动态决定哪些请求应该运行。
这就是从静态 batching 走向 continuous batching 的核心思想。
下一篇文章,我们会实现第一个版本的 Scheduler。
它会维护 waiting、running 和 finished 三类请求,并在每一轮中构造 prefill batch 和 decode batch。
从那一篇开始,mini vLLM 就不再只是单请求推理 demo,而会真正进入多请求推理引擎阶段。