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管理 PagedAttention + 共享前缀 显存池 固定大小页,按需分配

嗯,这张图把整个流程串起来了。请求从队列进来,调度器决定谁进当前批次,GPU算完后通知调度器,同时KV Cache管理负责页的分配和回收。说白了就是一个闭环。

实战技巧: 我在调KV Cache时,会先用一个小负载跑一下,观察显存分配曲线。如果曲线锯齿状明显,说明页分配太频繁,该调大页大小了。如果曲线平稳但利用率低,说明页太大,内部碎片多。

最后说一句,连续批处理不是银弹。如果你的请求长度非常均匀,静态批处理反而更简单高效。但现实世界哪有那么完美?请求总是长短不一、到达时间随机。这时候,连续批处理就是你的最佳选择。


公众号:蓝海资料掘金营,微信deep3321