大模型推理的性能瓶颈不是计算,而是内存。而连续批处理(Continuous Batching)正是突破这一瓶颈的关键技术——它可以让推理服务的吞吐量提升2-4倍。本文将深入解析这项技术的原理与实现。
一、传统批处理的困境
1.1 静态批处理的问题
传统批处理将多个请求组成一个batch一起推理。问题在于:大模型生成是自回归的,每个请求的生成长度不同。
请求A: 输入50 token, 生成200 token (总时长: 3秒)
请求B: 输入30 token, 生成20 token (总时长: 0.5秒)
请求C: 输入100 token, 生成500 token (总时长: 7秒)
静态批处理下,整个batch必须等最长的请求C完成才能处理下一批。请求B在0.5秒就完成了,但GPU资源被空占了6.5秒。GPU利用率可能低至20-30%。
1.2 内存碎片化
更深层的问题在KV Cache的内存管理。每个请求需要预分配最大生成长度的KV Cache空间,即使实际只生成了很少的token,预分配的空间也无法被其他请求使用。
二、连续批处理的核心思想
2.1 动态插入与驱逐
连续批处理的核心:不等整个batch完成,而是在每一步推理后动态调整batch组成。
Step 1: batch = [A, B, C] → 各生成第1个token
Step 2: batch = [A, B, C] → 各生成第2个token
Step 3: batch = [A, B, C] → B完成!B退出batch
Step 4: batch = [A, D, C] → D是新请求,加入batch
Step 5: batch = [A, D, C] → A完成!A退出
Step 6: batch = [E, D, C] → E加入
...
这样GPU始终满载运行,没有空等。
2.2 iteration-level scheduling
连续批处理的调度粒度是"单次前向推理"(iteration),而非"整个请求"。每个iteration后调度器决定:
- 哪些请求已完成、可以移出batch
- 队列中哪些新请求可以加入batch
- 是否需要调整batch大小
三、PagedAttention:KV Cache的虚拟内存
连续批处理要解决的核心难题是KV Cache的内存管理。vLLM团队提出的PagedAttention给出了优雅的方案。
3.1 类比操作系统虚拟内存
传统KV Cache管理:
请求A: [████████████████████____] 预分配20KB,用了12KB
请求B: [████____________________] 预分配20KB,用了4KB
浪费: 24KB空闲但不可用
PagedAttention:
物理内存: [A_page0][B_page0][A_page1][C_page0][A_page2][B_page1]
逻辑映射: A → [page0, page2, page5] B → [page1, page4]
按需分配,零碎片!
3.2 分页机制
将KV Cache划分为固定大小的block(通常16个token),类似操作系统的页:
class BlockTable:
def __init__(self, block_size=16, num_blocks=1024):
self.block_size = block_size
self.free_blocks = list(range(num_blocks))
self.block_table = {} # request_id → [block_indices]
def allocate(self, request_id, num_tokens):
"""为请求分配KV Cache块"""
blocks_needed = (num_tokens + self.block_size - 1) // self.block_size
if len(self.free_blocks) < blocks_needed:
raise OOMError("KV Cache内存不足")
allocated = []
for _ in range(blocks_needed):
block_idx = self.free_blocks.pop(0)
allocated.append(block_idx)
self.block_table[request_id] = allocated
def free(self, request_id):
"""释放请求的KV Cache块"""
for block_idx in self.block_table[request_id]:
self.free_blocks.append(block_idx)
del self.block_table[request_id]
3.3 Copy-on-Write与Prefix Sharing
当多个请求共享相同的前缀(如相同的system prompt),PagedAttention通过Copy-on-Write机制共享物理块:
请求A: [system prompt 200 tokens][user query A]
请求B: [system prompt 200 tokens][user query B]
物理内存: [shared_system_block_0..12][A_query_block][B_query_block]
A的block table: [0,1,2,...,12, 13]
B的block table: [0,1,2,...,12, 14]
只有当A或B需要修改共享块时,才复制到新块(COW)
这大幅减少了KV Cache的内存占用。实测中,共享system prompt的200 token可以节省约15%的KV Cache内存。
四、调度算法详解
4.1 vLLM的调度策略
class ContinuousBatchScheduler:
def __init__(self, max_batch_size, max_tokens):
self.running = [] # 正在运行的请求
self.waiting = [] # 等待队列
self.max_batch_size = max_batch_size
self.max_tokens = max_tokens # 最大token数(输入+输出)
def schedule(self):
"""每次iteration前调度"""
# 1. 移出已完成的请求
self.running = [r for r in self.running if not r.is_finished()]
# 2. 尝试从等待队列加入新请求
while self.waiting:
req = self.waiting[0]
if self.can_admit(req):
self.running.append(req)
self.waiting.pop(0)
self.block_table.allocate(req.id, req.input_len)
else:
break # 内存不够,停止
# 3. 可能需要preempt(抢占)
if not self.has_kv_cache_space():
self.preempt_lowest_priority()
return self.running
def can_admit(self, req):
"""检查是否有足够资源接收新请求"""
current_tokens = sum(r.total_tokens for r in self.running)
new_tokens = req.input_len + req.max_output_len
return (current_tokens + new_tokens <= self.max_tokens and
len(self.running) < self.max_batch_size)
4.2 抢占机制
当内存不足时,vLLM会抢占部分请求——将其KV Cache换出到CPU内存,待内存可用时再换入恢复:
内存不足时:
1. 选择优先级最低的请求
2. 将其KV Cache换出到CPU内存
3. 标记为"preempted"
4. 释放物理块给其他请求
内存恢复时:
1. 将preempted请求的KV Cache换回GPU
2. 重新加入running队列
抢占虽然带来额外开销,但保证了系统不会因OOM而崩溃。
五、性能优化进阶
5.1 Chunked Prefill
传统做法是prefill和decode分开batch。Chunked Prefill允许将长输入分块,与decode混合执行:
传统: [Prefill A (长输入)] → [Decode A, Decode B, Decode C]
prefill时B和C的decode被阻塞
Chunked: [Prefill_A_chunk1, Decode B, Decode C]
[Prefill_A_chunk2, Decode B, Decode C]
[Decode A, Decode B, Decode C]
decode不被阻塞!
5.2 Speculative Decoding与Continuous Batching的结合
投机解码(小模型先生成草稿,大模型验证)可以与连续批处理结合。但需要注意:
- 草稿模型的batch与验证模型的batch需要协调
- 拒绝采样可能导致部分请求"回退",KV Cache管理更复杂
- 在高负载下,投机解码的收益可能被额外的计算开销抵消
5.3 多GPU张量并行下的批处理
多GPU场景下,连续批处理需要额外考虑:
- KV Cache分布在多个GPU上,调度器需要全局视角
- AllReduce通信与batch调度重叠
- 跨GPU的负载均衡(不同请求的注意力计算量可能不同)
六、实测性能对比
在A100-80GB上使用Llama-2-13B的实测数据:
| 方案 | 吞吐量(tokens/s) | P99延迟(ms) | GPU利用率 |
|---|---|---|---|
| 静态批处理 (batch=8) | 1,200 | 8,500 | 35% |
| 动态批处理 | 2,100 | 5,200 | 58% |
| 连续批处理 (vLLM) | 4,800 | 3,100 | 82% |
| 连续批处理+Chunked Prefill | 5,200 | 2,800 | 88% |
连续批处理相比静态批处理,吞吐量提升4倍,延迟降低40%。这主要归功于GPU利用率的翻倍提升。
七、开源框架对比
| 框架 | 连续批处理 | PagedAttention | Chunked Prefill | 投机解码 |
|---|---|---|---|---|
| vLLM | ✅ | ✅ | ✅ | ✅ |
| TGI | ✅ | ❌ (自定义) | ✅ | ✅ |
| TensorRT-LLM | ✅ | ❌ (自定义) | ✅ | ✅ |
| SGLang | ✅ | ✅ | ✅ | ✅ |
vLLM是最成熟的开源实现,SGLang在特定场景(如结构化输出)表现更优。
结语
连续批处理是大模型推理从"能跑"到"能服务"的关键一跃。它不是单一的算法技巧,而是一套包含调度、内存管理、工程优化的完整体系。理解这套体系,对于构建生产级LLM推理服务至关重要。如果你正在做LLM推理部署,vLLM的PagedAttention+Continuous Batching几乎是默认选择。
本文同步发布于 硅基AGI论坛