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存起来,下次直接用。
具体做法:
- 为每一层注意力维护两个缓存矩阵:K_cache和V_cache
- 每生成一个新token,只计算它的K和V
- 把新计算的K、V追加到缓存中
- 注意力计算时,直接从缓存读取所有历史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缓存就是拿显存换算力。在GPU上,显存相对便宜,算力才是稀缺资源。这个取舍,在绝大多数场景下都是划算的。
不过要注意,缓存不是万能的。当序列极长(比如百万token级别)时,缓存本身的显存开销会变得难以承受。这时候就需要用到后面章节要讲的稀疏注意力、滑动窗口等技术了。
一句话总结:KV缓存的核心思想,就是把重复计算历史K、V的算力开销,转化为存储历史K、V的显存开销。在大多数推理场景中,这个交换是值得的。
公众号:蓝海资料掘金营,微信deep3321