4. KV缓存的核心思想:空间换时间,缓存历史Key和Value矩阵

聊到KV缓存,我先说说自己的经历。

几年前我第一次做大模型推理优化时,看着显存占用曲线直发懵。明明只生成了几十个token,显存却快爆了。后来一查,原来是每次生成新token时,注意力层都在重复计算之前所有token的Key和Value矩阵。说白了,这就是在做无用功。

4.1 为什么需要KV缓存?

先想想Transformer的推理过程。你输入一句话,模型逐个token地生成回复。每生成一个新token,注意力机制都要看一遍之前所有的token。

没有KV缓存时,这个过程是这样的:

  • 生成第1个token:计算第1个token的Q、K、V
  • 生成第2个token:重新计算第1、2个token的Q、K、V
  • 生成第3个token:重新计算第1、2、3个token的Q、K、V
  • ……

你发现问题了吗?每次都在重复计算前面已经算过的K和V。序列越长,浪费越严重。

核心洞察:在自回归生成中,历史token的Key和Value矩阵不会改变。重复计算它们,就是纯粹的浪费。

4.2 空间换时间:缓存策略

KV缓存的思想很简单:把算过的K和V存起来,下次直接用。

具体做法:

  1. 为每一层注意力维护两个缓存矩阵:K_cache和V_cache
  2. 每生成一个新token,只计算它的K和V
  3. 把新计算的K、V追加到缓存中
  4. 注意力计算时,直接从缓存读取所有历史K、V

我习惯用一个比喻来理解:这就像做菜时提前切好配菜。第一次切菜花时间,但后面每次炒菜直接拿就行,不用再切一遍。

4.3 缓存带来的性能提升

咱们算笔账。假设序列长度为L,注意力头维度为d。

没有缓存时,生成第L个token需要计算:

  • Q矩阵:1个token的计算量
  • K矩阵:L个token的计算量
  • V矩阵:L个token的计算量

有缓存时,生成第L个token只需要计算:

  • Q矩阵:1个token的计算量
  • K矩阵:1个token的计算量(只算当前token)
  • V矩阵:1个token的计算量(只算当前token)
序列长度 无缓存计算量 有缓存计算量 加速比
128 128×3 1+1+1 128x
1024 1024×3 1+1+1 1024x
4096 4096×3 1+1+1 4096x

序列越长,加速效果越明显。这就是空间换时间的威力。

4.4 缓存的内存代价

当然,天下没有免费的午餐。KV缓存要占显存。

缓存大小怎么算?

每层缓存大小 = 2 × 序列长度 × 隐藏层维度 × 数据类型字节数
总缓存大小 = 每层缓存大小 × 层数 × batch_size

举个例子,一个7B模型:

  • 隐藏层维度:4096
  • 层数:32
  • 序列长度:2048
  • 数据类型:float16(2字节)

单条序列的KV缓存:

2 × 2048 × 4096 × 2 × 32 = 1,073,741,824 字节 ≈ 1GB

嗯,一条序列就要1GB。batch_size=32时,光缓存就吃掉32GB显存。

我曾经踩过的坑:刚开始做推理优化时,我只关注模型参数占了多少显存,完全没算KV缓存。结果一跑batch推理,显存直接OOM。从那以后,我每次做显存预算,都会把KV缓存单独列一项。

4.5 缓存的生命周期管理

KV缓存不是一直存在的。它的生命周期和请求绑定:

  • 请求开始时:分配缓存空间
  • 生成过程中:逐步填充缓存
  • 请求结束时:释放缓存空间

这里有个细节要注意。预填充阶段(prefill)和生成阶段(decode)的缓存使用模式不同:

  • 预填充阶段:一次性计算所有输入token的K、V,填满缓存
  • 生成阶段:每次只计算一个新token的K、V,追加到缓存末尾

我个人习惯在预填充阶段就把缓存一次性分配好,避免生成阶段频繁扩容。虽然会多占一点显存,但省去了内存分配的开销。

4.6 缓存的数据结构

实际工程中,KV缓存通常用连续内存块存储。为什么?

你想想看,注意力计算时要访问所有历史K、V。如果缓存是分散的,内存访问就不连续,cache miss率会很高。用连续内存,GPU的显存带宽才能跑满。

常见的实现方式:

# 伪代码示意
class KVCache:
    def __init__(self, max_seq_len, hidden_dim, num_layers):
        # 预分配连续内存
        self.k_cache = [torch.zeros(max_seq_len, hidden_dim) 
                       for _ in range(num_layers)]
        self.v_cache = [torch.zeros(max_seq_len, hidden_dim) 
                       for _ in range(num_layers)]
        self.current_len = 0
    
    def append(self, layer_idx, k, v):
        # 追加到缓存末尾
        self.k_cache[layer_idx][self.current_len] = k
        self.v_cache[layer_idx][self.current_len] = v
        self.current_len += 1
    
    def get(self, layer_idx):
        # 返回有效长度的缓存
        return (self.k_cache[layer_idx][:self.current_len],
                self.v_cache[layer_idx][:self.current_len])

工程小技巧:预分配缓存时,可以按最大序列长度来。虽然一开始会多占显存,但避免了动态扩容带来的性能抖动。如果显存实在紧张,也可以用分页缓存(PagedAttention)的思路,按需分配页面。

4.7 知识体系总览

下面这张图总结了KV缓存的核心逻辑:

KV缓存核心逻辑 无KV缓存 生成第1个token → 计算K1, V1 生成第2个token → 计算K1, V1, K2, V2 生成第3个token → 计算K1, V1, K2, V2, K3, V3 ... 重复计算越来越多 有KV缓存 生成第1个token → 计算K1, V1 → 缓存 生成第2个token → 计算K2, V2 → 追加缓存 生成第3个token → 计算K3, V3 → 追加缓存 每次只算1个token的K、V 缓存结构(连续内存) K_cache: [K1, K2, K3, ..., KL] V_cache: [V1, V2, V3, ..., VL] 预分配最大长度,避免动态扩容 注意力计算时直接读取连续内存,带宽利用率高

说白了,KV缓存就是拿显存换算力。在GPU上,显存相对便宜,算力才是稀缺资源。这个取舍,在绝大多数场景下都是划算的。

不过要注意,缓存不是万能的。当序列极长(比如百万token级别)时,缓存本身的显存开销会变得难以承受。这时候就需要用到后面章节要讲的稀疏注意力、滑动窗口等技术了。

一句话总结:KV缓存的核心思想,就是把重复计算历史K、V的算力开销,转化为存储历史K、V的显存开销。在大多数推理场景中,这个交换是值得的。


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