Continuous Batching 实现原理:将推理吞吐提升 3 倍的动态批处理技术详解
一、Static Batching 的算力浪费:请求 A 生成完了,但还在等请求 B
传统推理服务的批处理方式(Static Batching)是"凑一批请求 → 一起推理 → 等全部完成 → 返回结果"。这种方式在请求长度均匀的场景下效率尚可,但在长短请求混杂时产生严重的算力浪费——一个生成 10 个 token 的短请求,必须等待同一批中生成 500 个 token 的长请求完成才能一起返回。GPU 在等什么?在等最慢的那个请求。
Continuous Batching 的核心创新是允许请求在任意时刻"入队"和"离队"——新请求到达时,立即加入当前正在处理的批次;请求生成完毕后,立即从批次中移除并返回结果。这意味着 GPU 没有"等待所有人完成"的强制同步点。
二、调度器实现:Preempt 与 Swap 机制
Continuous Batching 的调度器需要实现两个关键能力:Preempt(抢占)和 Swap(换入换出)。当 GPU 显存不足时,需要将部分请求的 KV Cache 从 GPU 显存换出到 CPU 内存,为新请求腾空间:
# Continuous Batching 调度器 —— 核心调度逻辑 from dataclasses import dataclass from typing import List, Optional @dataclass class Sequence: """一个推理序列的状态""" seq_id: int tokens: List[int] # 已生成的所有 token status: str # "running" | "finished" | "swapped" kv_cache_blocks: List[int] # 占用的 KV Cache 块编号列表 priority: float # 优先级(高优先级的请求不易被 swap) class ContinuousBatchingScheduler: def __init__(self, max_batch_size: int = 256, max_num_seqs: int = 128, block_size: int = 16): # KV Cache 块大小(token 数) self.max_batch_size = max_batch_size self.max_num_seqs = max_num_seqs self.block_size = block_size # 三个队列 self.running: List[Sequence] = [] # 正在推理的序列 self.waiting: List[Sequence] = [] # 等待加入的序列 self.swapped: List[Sequence] = [] # 被换出显存的序列 self.block_manager = BlockManager() self.free_blocks = list(range(8192)) # 假设 8192 个 KV Cache 块 def step(self) -> List[Sequence]: """ 一步调度:决定下一个推理 step 的批次构成 返回:本 step 完成的序列列表 """ finished = [] # 1. 从 running 中移除已完成的序列,释放 KV Cache 块 still_running = [] for seq in self.running: if seq.tokens[-1] == EOS_TOKEN_ID or len(seq.tokens) >= seq.max_tokens: seq.status = "finished" self._free_kv_cache(seq.kv_cache_blocks) finished.append(seq) else: still_running.append(seq) self.running = still_running # 2. 如果有空闲块 + 等待队列非空,尝试加入新请求 while (len(self.running) < self.max_num_seqs and self.waiting and self._can_allocate(self.waiting[0])): new_seq = self.waiting.pop(0) # 为新序列分配 KV Cache 块 num_blocks = (len(new_seq.tokens) + self.block_size - 1) // self.block_size blocks = self.free_blocks[:num_blocks] if blocks: self.free_blocks = self.free_blocks[num_blocks:] new_seq.kv_cache_blocks = blocks new_seq.status = "running" self.running.append(new_seq) # 3. 如果显存不足但有高优先级等待 → 触发 Preempt if (len(self.running) >= self.max_num_seqs * 0.9 and # 90% 容量 self.waiting and self.waiting[0].priority > 1.0): self._preempt_low_priority() # 4. 如果 free_blocks < 10% → 触发 Swap(换出低优先级序列) if len(self.free_blocks) < 8192 * 0.1: self._swap_out_lowest_priority() return finished def _preempt_low_priority(self): """抢占最低优先级的 running 序列""" if not self.running: return # 找出优先级最低且尚未生成太多 token 的序列 victim_idx = min( range(len(self.running)), key=lambda i: (self.running[i].priority, -len(self.running[i].tokens)) ) victim = self.running.pop(victim_idx) victim.status = "waiting" # 释放 victim 的 KV Cache,将其放回等待队列头部 self._free_kv_cache(victim.kv_cache_blocks) self.waiting.insert(0, victim)三、PagedAttention:KV Cache 的物理-逻辑分离
PagedAttention 是 vLLM 引入的 KV Cache 管理机制,灵感来自操作系统的虚拟内存分页。它将 KV Cache 划分为固定大小的"块"(Block),每个序列的 KV Cache 可以是非连续的物理块,通过块表(Block Table)映射:
class BlockManager: """ PagedAttention 的块管理器 核心思想: - 物理块:GPU 显存中的固定大小 KV Cache 块(16 token/块) - 逻辑块:每个序列的 KV Cache 按逻辑顺序排列 - 块表:将逻辑块映射到物理块,类似页表 """ BLOCK_SIZE = 16 # 每块管理的 token 数 def allocate(self, seq_id: int, num_tokens: int) -> List[int]: num_blocks = (num_tokens + self.BLOCK_SIZE - 1) // self.BLOCK_SIZE # 从空闲池中分配 num_blocks 个物理块 # 注意:不需要连续!非连续块通过块表索引即可 allocated = self.free_pool[:num_blocks] self.free_pool = self.free_pool[num_blocks:] # 块表:逻辑块 0 → 物理块 28, 逻辑块 1 → 物理块 312, ... self.block_tables[seq_id] = allocated return allocated def copy_on_write(self, src_seq_id: int, dst_seq_id: int): """ Copy-on-Write 优化:多个序列可以共享物理块(如 Beam Search) 仅在写入时复制对应的物理块 """ src_blocks = self.block_tables[src_seq_id] # 共享:目标序列的块表指向源序列的物理块 self.block_tables[dst_seq_id] = list(src_blocks) # 当某个块被修改时,为该块创建新的物理块副本 # 其余块仍保持共享,最小化显存占用四、实测数据:吞吐与延迟的权衡
| 指标 | Static Batching | Continuous Batching | 提升 |
|---|---|---|---|
| 吞吐量(混合长度请求) | 420 tok/s | 1,350 tok/s | +221% |
| 吞吐量(等长请求) | 580 tok/s | 620 tok/s | +7% |
| P50 延迟(混合长度) | 450ms | 85ms | -81% |
| P99 延迟(混合长度) | 2.8s | 420ms | -85% |
| 最大并发请求 | 32 | 96 | +200% |
| 显存利用率 | 45% | 82% | +82% |
Continuous Batching 在混合长度请求场景下优势最大(3.2x吞吐提升),而在等长请求场景下提升有限(+7%)——因为 Static Batching 本身在等长请求下已经接近最优。
五、总结
Continuous Batching 的关键设计理解:
- 核心是消除"等待最慢请求"的同步点:Static Batching 的强制同步让 GPU 在每个 step 的最慢请求上浪费算力。Continuous Batching 的"来去自由"策略解决了这个根本矛盾;
- PagedAttention 是 Continuous Batching 的物理基础:非连续的 KV Cache 分配让序列的加入和退出不需要复制或整理已有数据,这是动态批次可行性的前提;
- Preempt + Swap 解决了显存的公平分配:高优先级请求可以抢占低优先级请求的显存资源,Swap 将暂时不用的 KV Cache 缓存到 CPU 内存;
- 预分配块池的策略决定了系统的极限容量:8192 个 16-token 块 = 131,072 token 的 KV Cache 总容量,这是系统在触发 Swap 前能支持的总请求量上限。
适用场景:Continuous Batching 最适合请求长度差异大的场景(如对话式 AI)。在批量离线推理(长度均匀)场景中,Static Batching 的调度开销更小。