4、显存池与大模型推理:KV Cache的显存管理、动态Batch与显存调度

聊到大模型推理,显存永远是个绕不开的话题。我刚开始做推理优化那会儿,最头疼的就是显存碎片化问题。明明总显存还有不少,但就是分配不出一个连续的KV Cache块。后来我意识到,没有显存池,大模型推理就像在堆满杂物的房间里跳舞——迟早要绊倒。

4.1 KV Cache:推理中的显存大户

先说说KV Cache是什么。说白了,它就是Transformer解码过程中,每一层自注意力计算产生的Key和Value矩阵的缓存。你想想看,模型每生成一个token,都要重新计算前面所有token的注意力。如果没有缓存,每次都得从头算,那计算量就爆炸了。

KV Cache的大小怎么算?我直接给公式:

KV Cache大小 = 2 × 层数 × 头数 × 序列长度 × 头维度 × 精度字节数

举个例子,一个70B参数的模型,层数80,头数64,序列长度4096,头维度128,用FP16存储。算下来:

2 × 80 × 64 × 4096 × 128 × 2 = 10.7 GB

嗯,一个请求就要吃掉10GB+显存。如果并发32个请求,光KV Cache就要340GB。这还没算模型权重和中间激活呢。

核心痛点:KV Cache是动态增长的,每个请求的序列长度不一样,而且请求会随时到达、随时结束。这就导致显存分配和释放非常频繁,碎片化严重。

4.2 显存池:从“随用随分”到“预分配复用”

传统的做法是每个请求来了,动态malloc一块显存。我在项目中遇到过,跑着跑着突然OOM,但一看显存使用率才70%。为什么?碎片化!

显存池的思路很简单:提前申请一大块连续显存,切成固定大小的块(比如每个块存一个token的KV)。请求来了,从池子里拿块;请求走了,把块还回去。这样避免了频繁的系统级显存分配,也消除了碎片。

我的经验:块大小怎么定?我一般按最大序列长度来切。比如最大支持4096个token,那就预分配4096个块。但实际请求可能只用512个token,剩下的块就浪费了。所以后来我改用“按需增长”策略——先给每个请求分配少量块,不够了再从池子里追加。

显存池的核心数据结构,我画了个图:

显存池结构示意图 显存池(预分配连续显存,例如 32GB) 空闲块链表(Free List) 块1 → 块2 → 块3 → ... 已分配块(每个请求持有若干块) 请求1: 块4,5,6 请求2: 块7,8 请求3: 块9,10,11,12

4.3 动态Batch:让显存利用率翻倍

静态Batch有个问题:你得提前定好batch size。设小了,吞吐上不去;设大了,显存不够用。我见过有人把batch size设为64,结果大部分请求序列很短,显存浪费了一半。

动态Batch的思路是:不固定batch size,而是根据当前显存池的空闲块数量,动态决定能塞进多少个请求。说白了,就是“有多少米煮多少饭”。

核心算法:

  1. 每个请求到达时,先估算它需要的最大块数(根据输入长度 + 最大生成长度)
  2. 检查显存池空闲块是否足够
  3. 如果够,分配块并加入当前batch
  4. 如果不够,把请求放入等待队列
  5. 每完成一个请求,释放块,唤醒等待队列中的请求

代码实现大概长这样:

class DynamicBatchScheduler:
    def __init__(self, memory_pool, max_batch_tokens):
        self.pool = memory_pool
        self.max_batch_tokens = max_batch_tokens
        self.active_requests = []
        self.waiting_queue = []
    
    def add_request(self, req):
        needed_blocks = req.input_len + req.max_output_len
        if self.pool.available_blocks() >= needed_blocks:
            blocks = self.pool.allocate(needed_blocks)
            req.assign_blocks(blocks)
            self.active_requests.append(req)
        else:
            self.waiting_queue.append(req)
    
    def step(self):
        # 每步推理后,检查完成的请求
        for req in self.active_requests:
            if req.is_finished():
                self.pool.free(req.blocks)
                self.active_requests.remove(req)
        # 尝试从等待队列拉入新请求
        while self.waiting_queue:
            req = self.waiting_queue[0]
            needed_blocks = req.input_len + req.max_output_len
            if self.pool.available_blocks() >= needed_blocks:
                self.waiting_queue.pop(0)
                self.add_request(req)
            else:
                break

注意:动态Batch不是万能的。如果请求的序列长度差异太大,短请求会被长请求“拖累”——因为整个batch要等最长的那个请求做完才能一起释放。我建议配合“请求分桶”策略,把序列长度相近的请求放在同一个batch里。

4.4 显存调度:从“被动等待”到“主动管理”

有了显存池和动态Batch,是不是就万事大吉了?还差一步——调度策略。

我见过最朴素的调度是FIFO:先来先服务。但问题来了,如果先来的请求要生成1024个token,后来的请求只生成10个token,那后来的请求得等死。用户体验极差。

更好的做法是引入优先级调度。我常用的策略:

调度策略 核心思想 适用场景
最短剩余时间优先(SRTF) 优先处理剩余生成token最少的请求 在线服务,追求低延迟
最大吞吐优先(MTF) 优先处理能填满显存池的请求组合 离线批处理,追求高吞吐
公平调度(Fair Share) 按用户/租户分配显存配额 多租户场景

我的实战经验:在vLLM项目中,我们用的是“先来先服务 + 显存池水位线”的混合策略。当显存池空闲块低于30%时,启动“紧急回收模式”——暂停新请求的调度,优先完成短请求。这招在流量突增时特别管用。

还有一个容易被忽略的点:显存池的碎片整理。即使有预分配,长时间运行后还是会产生碎片。我建议每隔一段时间(比如每1000次推理)做一次“显存压缩”——把分散的空闲块合并成大块。代价是暂停服务几十毫秒,但能避免显存利用率持续下降。

4.5 总结一下

KV Cache的显存管理,说白了就是三个层次:

  • 分配层:显存池预分配,避免碎片
  • 调度层:动态Batch,按需分配显存
  • 策略层:优先级调度,平衡延迟和吞吐

我刚开始做的时候,总想着用更复杂的算法。后来发现,很多时候一个简单的显存池 + 动态Batch就能解决80%的问题。剩下的20%,靠监控和调参慢慢磨。嗯,工程嘛,就是在理想和现实之间找平衡。

想要更多资料,联系可可