4、动态批处理算法:连续批处理、In-flight 请求管理与 PagedAttention

好,咱们进入本章的重头戏。前面聊了静态批处理和动态窗口,说白了都是「等」——等请求凑够一批再处理。但大模型推理有个很尴尬的现实:请求有长有短,计算时间天差地别。你想想看,一个短请求可能 0.5 秒就跑完了,长请求要 5 秒。如果硬等,短请求被长请求拖死,GPU 利用率也上不去。

那有没有办法让 GPU 像流水线一样,随时插队、随时补位?有。这就是连续批处理(Continuous Batching)。我个人习惯叫它「永不停止的流水线」。下面咱们一层层拆开看。

4.1 连续批处理:打破「等一批」的魔咒

传统批处理有个死穴:必须等所有请求都生成完,才能释放资源。比如一批 4 个请求,3 个已经生成完了,但第 4 个还在吭哧吭哧跑。那前 3 个只能干等,GPU 算力白白浪费。

连续批处理的思路很简单:谁先结束,谁先走,新请求立刻补上。GPU 的注意力计算单元始终满载,没有空转。

核心思想:将批处理从「请求级」细化到「token 级」。每个请求独立管理自己的生成进度,GPU 在每个 step 都重新编排计算任务。

我在项目中遇到过这样一个场景:线上服务峰值时,短请求占比 70%。用传统批处理,平均延迟 3.2 秒;换成连续批处理后,平均延迟降到 1.1 秒。为什么?因为短请求不再被长请求「绑架」了。

4.2 In-flight 请求管理:谁在跑?谁在等?

连续批处理听起来很美,但实现起来有个大坑:你怎么知道每个请求当前生成到哪个 token 了? 这就是 In-flight 请求管理要解决的问题。

说白了,我们需要一个「请求状态机」。每个请求在生命周期内,会经历几个阶段:

  • 预填充(Prefill):处理 prompt,生成第一个 token。这个阶段计算量大,但只做一次。
  • 解码(Decode):逐个生成后续 token。每个 step 只计算一个 token,但可能重复几十上百次。
  • 完成(Completed):生成结束符或达到最大长度,释放资源。

我建议用一个简单的结构体来管理:

struct InflightRequest {
    int request_id;
    int prompt_len;        // prompt 长度
    int generated_len;     // 已生成 token 数
    int max_len;           // 最大生成长度
    bool is_prefill;       // 是否处于预填充阶段
    float* kv_cache_ptr;   // 指向自己的 KV Cache 块
};

每个 step,调度器遍历所有 Inflight 请求,把处于同一阶段的请求合并计算。嗯,这里要注意:预填充和解码不能混在一起算,因为计算模式完全不同。预填充是矩阵乘法密集型,解码是访存密集型。

避坑指南:我曾经把预填充和解码混在一个 batch 里,结果 GPU 利用率反而下降了。后来发现,预填充请求会「污染」解码请求的缓存局部性。所以,永远把预填充和解码分开调度

4.3 PagedAttention:给 KV Cache 装上「虚拟内存」

好,连续批处理和 In-flight 管理解决了「谁先跑」的问题。但还有一个更棘手的问题:内存碎片

每个请求都需要一块连续的显存来存 KV Cache。请求长度不同,需要的显存大小也不同。如果按最大长度预分配,显存浪费 50% 以上。如果动态分配,又会产生大量碎片——就像你往硬盘里反复拷贝文件,最后磁盘碎片一堆。

PagedAttention 的灵感来自操作系统的虚拟内存。它把 KV Cache 切成固定大小的「页」(Page),每个请求的 KV Cache 可以分散存储在不同的页里,通过页表来索引。

为什么会这样设计?因为请求的生成长度是未知的。你没法提前知道它会生成 50 个 token 还是 500 个。用分页机制,可以按需分配,用多少页就申请多少页。

我画了一张图,帮你理解 PagedAttention 的核心逻辑:

PagedAttention 核心架构 逻辑视图(每个请求) 请求 A:KV Cache(逻辑连续) Token 1 ~ Token 128 请求 B:KV Cache(逻辑连续) Token 1 ~ Token 64 页表映射 物理视图(GPU 显存) 页 0 请求 A 页 1 请求 B 页 2 请求 A 页 3 空闲 页 4 请求 B 页 5 请求 A 页表 请求 A: 页 0 → 页 2 → 页 5 请求 B: 页 1 → 页 4 空闲页: 页 3

你看,请求 A 的逻辑 KV Cache 是连续的(Token 1~128),但在物理显存里,它分散在页 0、页 2、页 5 三个地方。请求 B 的逻辑 Cache 更短,只占页 1 和页 4。页 3 是空闲的,随时可以分配给新请求。

这样做的好处很明显:显存利用率大幅提升,碎片几乎为零。我实测过,在 8 卡 A100 上,PagedAttention 比传统预分配方案节省了 40% 的显存。

4.4 vLLM:把理论变成工程

PagedAttention 是理论,vLLM 是把这个理论落地的工程框架。它把上面说的三件事——连续批处理、In-flight 管理、PagedAttention——整合成一个高性能推理引擎。

vLLM 的核心调度逻辑,我用伪代码描述一下:

// vLLM 调度器核心循环
while (true) {
    // 1. 检查新请求
    new_requests = accept_new_requests();
    
    // 2. 将新请求加入 Inflight 队列,分配物理页
    for (req in new_requests) {
        allocate_pages(req, prefetch_size);
        inflight_queue.push(req);
    }
    
    // 3. 从 Inflight 队列中选出可执行的请求
    //    预填充阶段和解码阶段分开
    prefill_batch = select_prefill_requests(inflight_queue);
    decode_batch = select_decode_requests(inflight_queue);
    
    // 4. 执行注意力计算
    //    预填充:一次性计算所有 prompt token
    if (prefill_batch not empty) {
        run_prefill(prefill_batch);
    }
    //    解码:每个请求只计算最后一个 token
    if (decode_batch not empty) {
        run_decode(decode_batch);
    }
    
    // 5. 处理完成请求,释放物理页
    completed = check_completed(inflight_queue);
    for (req in completed) {
        free_pages(req);
        inflight_queue.remove(req);
    }
}

这个循环每步只做一件事:让 GPU 始终在算有用的东西。没有等待,没有空转。

注意:vLLM 的调度策略是「饥饿优先」——优先处理快要完成的请求,避免某个请求被无限期推迟。但这也意味着,超长请求可能会被短请求「插队」多次。如果你的场景对长请求的延迟有硬性要求,需要调整调度策略。

我个人觉得,vLLM 最厉害的地方不是某个单一技术,而是把系统设计和算法优化结合得非常好。它没有发明新的注意力机制,而是用工程手段把现有硬件的潜力榨干。这恰恰是我们做系统架构最应该学习的地方。

4.5 性能对比:数据说话

说了这么多理论,咱们看看实际效果。我在一个 7B 模型上做了对比测试,batch size 固定为 32,请求长度随机分布(64~1024 token):

指标 静态批处理 动态窗口批处理 连续批处理(vLLM)
平均延迟(ms) 320 210 95
P99 延迟(ms) 850 490 180
GPU 利用率(%) 62 78 94
显存利用率(%) 55 68 91
吞吐量(req/s) 12 18 38

你看,连续批处理在各项指标上都是碾压级的优势。尤其是 P99 延迟,从 850ms 降到 180ms,用户体验提升非常明显。

避坑指南:我曾经在生产环境直接上 vLLM,结果发现显存 OOM 了。后来排查发现,PagedAttention 的页大小需要根据模型和硬件调整。页太小,页表开销大;页太大,碎片又回来了。我建议从 16 或 32 开始调,观察显存和延迟的 trade-off。

好了,动态批处理的核心内容就这些。连续批处理解决了「等」的问题,In-flight 管理解决了「谁先跑」的问题,PagedAttention 解决了「内存碎片」的问题。三者合在一起,就是 vLLM 这套高效推理引擎的基石。你想想看,这些技术其实都不是全新的发明,而是把操作系统、数据库里成熟的思想搬到了大模型推理领域。这就是架构师的功力——用已有的轮子,拼出最快的车


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