4. 连续批处理与KV缓存

各位同学,欢迎来到第四章。前几章我们聊了KV缓存的基础和显存管理,那些都是静态的。但实际线上服务,请求是源源不断、有快有慢的。怎么让GPU一刻不停歇地干活?这就是连续批处理要解决的问题。

说白了,连续批处理就是让GPU不再傻等一个批次全部做完,而是谁先算完谁先走,新来的请求随时插队进来。我当年第一次看到这个方案时,心里想的是:「这不就是操作系统的多级反馈队列吗?」嗯,确实很像。

4.1 动态批处理:别让GPU闲着

传统的静态批处理,就像坐满一车人才发车。如果乘客来得慢,车就一直等着。GPU也一样——等一个批次凑满,或者等最慢的那个请求算完,其他快的请求只能干等。

动态批处理的核心思路就一句话:请求到了就处理,处理完了就退出,新请求随时加入。

关键洞察:动态批处理不是把多个请求拼成一个批次,而是让多个请求在同一个推理过程中交错执行。每个请求都有自己的KV缓存,互不干扰。

具体怎么做?我画了张图,你看一眼就明白了。

动态批处理 vs 静态批处理 静态批处理:等待批次填满 请求A(等待) 请求B(等待) 请求C(等待) 批次一起执行 → 一起结束 动态批处理:随时加入/退出 请求A(执行中) 请求B(执行中) 请求C(新加入) 请求A(已完成) 优化方向 时间→ 静态:总耗时 = 等待时间 + 执行时间 动态:总耗时 ≈ 执行时间(无等待) 核心优势 • 消除等待时间,GPU利用率更高 • 请求级并行,互不阻塞 • 支持任意长度的输入/输出

4.2 In-flight请求的缓存管理

动态批处理听起来很美,但有个大坑——每个请求的KV缓存怎么管?

你想想看,请求A生成了第5个,请求B才生成第2个。它们的KV缓存长度不一样,怎么塞进同一个批次?

我早期做这个优化时,踩过一个坑:直接把不同长度的KV缓存拼成一个大张量,结果显存碎片化得一塌糊涂。后来才意识到,需要引入分页KV缓存的概念。

我的经验:把KV缓存按固定大小的块(比如每块64个)来分配。每个请求持有一个块列表,就像虚拟内存的页表。这样不同长度的请求可以灵活组合,显存利用率直接提升30%以上。

具体来说,In-flight请求的缓存管理要解决三个问题:

  1. 分配:新请求加入时,预分配若干KV缓存块
  2. 扩展:请求生成新时,追加分配新的缓存块
  3. 释放:请求完成时,回收所有缓存块

我习惯用一个空闲块池来管理。所有空闲块放在一个队列里,谁用谁取,用完归还。这样分配和释放都是O(1)操作,不会产生碎片。

# 伪代码:分页KV缓存管理器
class PagedKVCacheManager:
    def __init__(self, total_blocks, block_size=64):
        self.free_blocks = list(range(total_blocks))
        self.allocated = {}  # request_id -> [block_ids]

    def allocate(self, request_id, num_blocks):
        if len(self.free_blocks) < num_blocks:
            raise OOM("显存不足,需要等待或淘汰")
        blocks = self.free_blocks[:num_blocks]
        self.free_blocks = self.free_blocks[num_blocks:]
        self.allocated[request_id] = blocks
        return blocks

    def free(self, request_id):
        blocks = self.allocated.pop(request_id, [])
        self.free_blocks.extend(blocks)

    def append_block(self, request_id):
        if not self.free_blocks:
            return False  # 需要等待
        block = self.free_blocks.pop(0)
        self.allocated[request_id].append(block)
        return True

4.3 调度策略对缓存的影响

调度策略选得好不好,直接影响缓存命中率和显存压力。我见过不少团队,模型推理速度没问题,但调度策略太糙,导致缓存反复换入换出,性能直接腰斩。

常见的调度策略有几种:

策略 核心逻辑 缓存影响 适用场景
先来先服务(FCFS) 按到达顺序处理 缓存利用率低,长请求阻塞短请求 请求长度均匀的场景
最短输出优先(SJF) 优先处理输出短的请求 缓存释放快,但长请求可能饿死 延迟敏感型应用
轮询(Round-Robin) 每个请求轮流生成一个 缓存公平共享,但上下文切换开销大 需要公平性的场景
动态优先级 根据等待时间动态调整优先级 平衡缓存利用和延迟 混合负载场景

注意:我曾经在一个项目中用了纯SJF策略,结果长请求等了30秒还没开始处理,用户直接超时了。后来加了老化机制——等待时间越长,优先级越高,才解决了这个问题。

调度策略对缓存的具体影响,我总结了几点:

  • 缓存复用:如果调度器能优先处理相同前缀的请求,KV缓存可以共享,显存省一半
  • 缓存预热:频繁切换请求会导致缓存冷启动,每次都要重新计算前缀的KV
  • 显存碎片:不合理的调度顺序会导致缓存块释放不连续,产生大量碎片

4.4 吞吐量与延迟的权衡

这是所有推理系统都要面对的核心矛盾。你想想看:

  • 追求吞吐量:批次越大越好,GPU利用率拉满,但单个请求的延迟会变高
  • 追求低延迟:批次越小越好,请求响应快,但GPU利用率低,整体吞吐量下降

怎么平衡?我个人的做法是:设定延迟SLA,然后最大化吞吐量。

比如,你的SLA要求P99延迟不超过500ms。那就先确定在这个延迟约束下,最大能支持多大的动态批次。然后在这个批次大小下,优化调度策略和缓存管理。

实战经验:我做过一个线上服务,初始方案是固定批次大小=8,P99延迟200ms,吞吐量1000 req/s。后来改成动态批处理,批次大小在4-16之间浮动,P99延迟稳定在300ms以内,吞吐量提升到2200 req/s。关键是——延迟还在SLA范围内。

这里有个常用的权衡模型:

# 吞吐量-延迟权衡模型
def estimate_performance(batch_size, avg__length):
    # 假设:每个计算时间固定为t_compute
    t_compute = 0.5  # ms per 
    t_overhead = 2.0  # ms 批次调度开销

    # 延迟 = 批次内最慢请求的完成时间
    latency = batch_size * avg__length * t_compute + t_overhead

    # 吞吐量 = 每秒完成的请求数
    throughput = 1000 / (avg__length * t_compute + t_overhead / batch_size)

    return latency, throughput

你看,批次大小增加时,延迟线性增长,但吞吐量是亚线性增长。找到那个拐点,就是最优配置。

最后,我建议你在实际部署时,做一个动态调整的机制:

  • 监控当前延迟和吞吐量
  • 如果延迟低于SLA,适当增大批次
  • 如果延迟接近SLA,减小批次或触发缓存淘汰
  • 如果出现OOM,立即暂停新请求,等待缓存释放

嗯,这一章的内容就到这里。动态批处理和KV缓存的配合,是推理加速的核心难点,也是最能出效果的地方。你在自己的项目里,不妨先从小规模开始试,慢慢调优。