2、连续批处理原理:什么是连续批处理、动态batching vs 静态batching、PagedAttention机制、KV Cache管理
好,咱们直接进入正题。连续批处理(Continuous Batching)这个概念,说白了就是让GPU一刻也别闲着。我刚开始接触大模型推理时,最头疼的就是GPU利用率上不去——显存占得挺满,算力却跑不满,你说气不气人?
2.1 什么是连续批处理
传统做法是静态批处理。你攒够一批请求,一起送进去算,算完再统一返回。这就像食堂开饭,必须等人齐了才动筷子。问题是,每个请求的生成长度不一样。有的请求生成10个token就结束了,有的要生成100个。短的请求只能干等长的算完。
连续批处理就不一样了。它允许请求动态地加入或离开当前批次。一个请求生成完了,立刻把它踢出去,腾出位置给新请求。GPU一直在算,没有空转。
核心思想: 把批处理从「静态集合」变成「动态流水线」。每个时间步,批次里的请求都可能不同。
我在项目中遇到过这样一个场景:用vLLM部署一个对话模型,用户提问长度差异巨大。有的问「你好」,有的问「请解释一下量子纠缠的数学原理」。静态批处理下,短请求的响应时间被长请求拖得很长。换成连续批处理后,P99延迟直接降了40%。嗯,这个数字我记得很清楚。
2.2 动态batching vs 静态batching
咱们来对比一下这两种方式。你想想看,静态batching就像坐大巴车——必须等所有乘客都上车才发车。动态batching则像地铁——到站就开门,有人下有人上,车一直在跑。
| 对比维度 | 静态batching | 动态batching(连续批处理) |
|---|---|---|
| 请求加入时机 | 批次开始时固定 | 任意时间步可加入 |
| 请求退出时机 | 整个批次结束 | 生成完成后立即退出 |
| GPU利用率 | 中等,有等待空闲 | 高,持续计算 |
| 延迟表现 | 受最长请求拖累 | 短请求延迟低 |
| 实现复杂度 | 简单 | 复杂,需管理状态 |
我个人习惯在生产环境用连续批处理。但要注意一点:动态batching对调度器的要求更高。我曾经踩过一个坑——调度器频繁地插入和踢出请求,导致GPU的kernel launch开销暴增。后来我加了一个最小批次大小的阈值,才把这个问题压下去。
避坑指南: 我曾经把动态batching的调度间隔设得太小(1ms),结果GPU一直在切换上下文,算力反而下降了。建议调度间隔设在5-10ms,具体要看你的模型大小和请求密度。
2.3 PagedAttention机制
说到PagedAttention,这玩意儿是vLLM的核心创新。它解决了一个很实际的问题:KV Cache怎么存?
传统做法是给每个请求预分配一块连续显存。但问题是,你根本不知道一个请求最终会生成多少个token。预分配多了,浪费显存;预分配少了,还得动态扩容,引发内存碎片。
PagedAttention的思路很简单——借鉴操作系统的分页机制。把KV Cache切成固定大小的「块」(pages),每个请求按需分配。不够了再加一页,用完了就回收。
# 伪代码示意:PagedAttention的块管理
class PagedAttention:
def __init__(self, page_size=16):
self.page_size = page_size # 每页存16个token的KV
self.page_table = {} # 请求ID -> 物理页号列表
def allocate(self, request_id, num_tokens):
# 按需分配,不够再加
needed_pages = ceil(num_tokens / self.page_size)
pages = self.free_list.pop(needed_pages)
self.page_table[request_id] = pages
def free(self, request_id):
# 请求结束,回收所有页
pages = self.page_table.pop(request_id)
self.free_list.extend(pages)
为什么这很重要?因为显存碎片是推理服务的头号杀手。我记得有一次线上排查,发现显存明明还有30%空闲,但新请求就是分配失败。一查,全是碎片。PagedAttention通过固定大小的页,从根本上解决了这个问题。
注意: PagedAttention虽然好,但页大小需要调优。页太大,内部碎片多;页太小,页表开销大。我一般从16个token开始试,根据平均生成长度调整。
2.4 KV Cache管理
KV Cache管理,说白了就是怎么高效地存和取Attention计算中的Key和Value矩阵。每个请求在生成每个token时,都要把之前的KV拿出来用。如果管理不好,内存带宽就全浪费在搬运数据上了。
这里有几个关键策略:
- 预取(Prefetching): 在计算当前token时,提前把下一个token需要的KV页加载到高速缓存。我习惯用双缓冲技术,让数据搬运和计算重叠。
- 共享前缀(Prefix Caching): 如果多个请求有相同的前缀(比如系统提示词),它们的KV Cache可以共享。这能省下大量显存。
- 淘汰策略: 显存不够时,踢掉哪些请求的KV?我一般用LRU(最近最少使用),但要注意别把正在生成的请求踢了。
嗯,这张图把整个流程串起来了。请求从队列进来,调度器决定谁进当前批次,GPU算完后通知调度器,同时KV Cache管理负责页的分配和回收。说白了就是一个闭环。
实战技巧: 我在调KV Cache时,会先用一个小负载跑一下,观察显存分配曲线。如果曲线锯齿状明显,说明页分配太频繁,该调大页大小了。如果曲线平稳但利用率低,说明页太大,内部碎片多。
最后说一句,连续批处理不是银弹。如果你的请求长度非常均匀,静态批处理反而更简单高效。但现实世界哪有那么完美?请求总是长短不一、到达时间随机。这时候,连续批处理就是你的最佳选择。
公众号:蓝海资料掘金营,微信deep3321