连续批处理机制:原理、优势、与传统静态批处理的区别
核心原理:从“等满发车”到“随到随走”
传统深度学习推理服务(如静态批处理)的工作方式类似于“班车”:请求到达后先进入队列,必须等待攒够一批(例如 32 个请求)或达到固定时间窗口,才统一交给 GPU 执行。这种方式在请求到达率不均匀时会造成严重的 GPU 空转或请求排队延迟。vLLM 引入的 连续批处理(Continuous Batching) 则彻底改变了这一逻辑——它把“批”从物理上的同步边界,变成了逻辑上的动态调度单位。
连续批处理的核心思想是:GPU 上始终运行着多个序列,但每个序列的生命周期独立推进。当一个序列完成生成(遇到 EOS 或达到 max_tokens)时,它占用的 KV Cache 空间立即被释放,调度器立刻从等待队列中拉入一个新的请求,填充到刚空出的位置。这样,GPU 的计算资源被持续利用,不再需要等待整个批次全部结束。
从底层看,vLLM 的 PagedAttention 将 KV Cache 划分为固定大小的物理块(block),每个序列只需持有自己实际用到的块。调度器以“块”为粒度管理显存,使得不同序列可以交错占用物理块,从而支持任意数量的并发序列。连续批处理正是建立在这一显存管理机制之上的高层调度策略。
方法流程:连续批处理的工作循环
vLLM 的调度器在每个时间步(step)执行以下逻辑,形成连续批处理的闭环:
- 收集就绪序列:从等待队列中取出所有满足条件的请求(如 prefill 优先级高、显存足够分配初始块)。
- 执行 prefill:为新进入的序列计算完整的 prompt 表示,生成首个 token,并分配对应的 KV Cache 物理块。
- 执行 decode:对当前所有活跃序列(包括刚完成 prefill 的和之前就在解码的)并行执行一次前向传播,每个序列生成下一个 token。
- 处理完成序列:检查每个序列是否满足停止条件(EOS、max_tokens、stop 字符串)。若完成,则释放其全部 KV Cache 块,将其从活跃列表移除。
- 返回步骤 1:调度器再次检查等待队列,将新请求补入空出的显存位置。
这一循环在 GPU 上以极高的频率重复(通常每秒钟数十次),使得“批”的构成不断变化。vLLM 还引入了 抢占(preemption) 机制:当显存不足时,调度器会暂停某些序列(swap 到 CPU 或丢弃其 KV Cache),优先保证新请求的 prefill 不被阻塞,从而避免死锁。
# 伪代码示意:连续批处理调度循环
while True:
# 1. 补充新请求(prefill 阶段)
while waiting_queue and gpu_memory_available():
seq = waiting_queue.pop()
prefill(seq) # 计算 prompt,生成首个 token
active_seqs.append(seq)
# 2. 并行 decode 所有活跃序列
if active_seqs:
outputs = decode(active_seqs) # 一次前向传播,所有序列各得一个 token
# 3. 清理已完成序列,释放 KV Cache
for seq in active_seqs:
if seq.is_finished():
free_kv_cache(seq)
active_seqs.remove(seq)
# 4. 若队列空且无活跃序列,则等待新请求
if not waiting_queue and not active_seqs:
wait_for_request()
案例与场景:为什么连续批处理能带来数量级提升
假设某在线服务需要处理 1000 个请求,每个请求的输入长度(prompt)为 200 tokens,输出长度(completion)为 150 tokens。GPU 单次最大批处理容量为 32 个序列。
- 静态批处理:必须凑满 32 个请求才执行。由于每个请求的生成时间不同(有的 50 tokens 就结束,有的需要 200 tokens),批次中最早完成的请求必须等待最慢的请求结束后,整个批次才能释放。实际 GPU 利用率可能只有 60%~70%,且队列尾部的请求等待时间极长。
- 连续批处理:第一批 32 个请求进入后,每当其中某个请求提前完成,立即释放其位置并拉入第 33 个请求。GPU 始终处理 32 个并发序列,直到所有 1000 个请求全部完成。总吞吐量可提升 2~3 倍,且尾部延迟显著降低。
以下表格对比了两种模式在典型场景下的行为差异:
| 对比维度 | 传统静态批处理 | vLLM 连续批处理 |
|---|---|---|
| 批次构成 | 固定集合,同进同退 | 动态集合,序列独立进出 |
| GPU 空闲时间 | 批次切换间隙明显 | 几乎为零,持续有 token 计算 |
| 短请求影响 | 被长请求拖累,等待批次结束 | 完成后立即释放资源,不阻塞他人 |
| 显存管理粒度 | 整批预留,浪费严重 | 按需分配 KV Cache 块,利用率高 |
| 调度频率 | 每批一次(秒级) | 每个 decode 步骤(毫秒级) |
| 适用场景 | 请求长度均匀、到达规律 | 长度差异大、到达随机、延迟敏感 |
误区与复盘:连续批处理的常见误解
有人误以为连续批处理只是“每次 decode 时动态调整 batch 大小”。实际上,动态 batch size 只是表象,其本质是 序列级调度——每个序列的 prefill 和 decode 阶段可以交错进行,而不是所有序列都同步处于同一阶段。vLLM 允许新请求的 prefill 与旧请求的 decode 在同一个 step 中执行(通过 chunked prefill 进一步细化),这是静态批处理无法做到的。
恰恰相反,由于 GPU 利用率提升,单个请求的平均排队时间大幅缩短。虽然并发度增加可能导致单次 decode 的延迟略有上升(batch 变大),但整体端到端延迟(排队 + 计算)通常显著下降。只有在极端过载场景下,才需要配合
max_num_seqs 和 max_num_batched_tokens 参数限制并发度,以保障延迟 SLO。
--max-num-seqs(最大并发序列数)和 --max-num-batched-tokens(单次前向传播的最大 token 数)两个参数。前者控制显存占用上限,后者控制计算粒度。若请求长度差异极大,建议开启 --enable-chunked-prefill,将长 prompt 拆分为多个 chunk 处理,避免单个 prefill 阻塞整个调度循环。
理解连续批处理的关键,在于跳出“批次”的思维定式,转而从 序列生命周期 的角度观察 GPU 上发生的一切。每个请求都是一个独立的、有始有终的任务,调度器的工作不是“组队”,而是“管理流水线”——让不同阶段的请求在 GPU 上无缝衔接,最大化每一毫秒的计算价值。这也是 vLLM 能在同等硬件条件下,将推理吞吐提升数倍的核心原因。