KV Cache 里为什么没有 Q?

0 阅读10分钟

显存里躺着几个 GB 的缓存,里面存的是 K 和 V。 可是注意力一次计算明明要用三样东西:Q、K、V。Q 呢?它不需要缓存吗?

本文收录于微信公众号:愤怒的代码 AngryCode

先把注意力的一次计算摆出来:每个 token 会算出三样东西——Q、K、V。Q 负责“提问”,K 负责“被匹配”,V 负责“给内容”。推理时缓存里只存了 K 和 V,Q却不用存。

真正的原因是:Q 和 K/V 的生命周期完全不同——前者用完即弃,后者要被读到最后一刻。


0x00、推理的两个阶段:prefill 和 decode

LLM 推理分成两个阶段。

prefill(预填充):你发出一段 prompt,比如「深圳今天」。模型把这 4 个 token 一次性并行处理——每个 token 算出 Q、K、V,互相做注意力,再过前馈层(FFN)。这一步算力开销最大,但只做一次,产出两个东西:每个 token 的 K/V 存进缓存;最后一个位置吐出第一个生成字「天」。

decode(解码):从第一个生成字开始,一次只处理一个 token。每个新 token 算出自己的 Q/K/V,拿 Q 查缓存里的历史 K/V(因为算注意力只需要看自己前面的 Token 即可),算完把自己的 K/V 追加进缓存,吐出下一个字。循环往复,直到结束——第 1 步追加「天」的 K/V,第 2 步追加「气」的,一路往后排。

prefill 预填充decode 解码
处理什么整段 prompt,一次算完每次一个新 token
并行度全并行串行,一个接一个
主要开销算力(大矩阵乘)缓存读取(显存带宽)
对缓存的动作写入 prompt 全部 K/V每步追加一个 token 的 K/V

LLM 推理两阶段示意图:上方 prefill 并行处理 prompt 并写入缓存,中间 KV Cache 逐步生长,下方 decode 循环逐 token 生成转存失败,建议直接上传图片文件

↑ prefill 一次性算完「深圳今天」并把 K/V 写进缓存;decode 每步只处理一个新 token,查缓存、追加一格、吐出下一个字,循环往复

生成的「天」,它的 Q/K/V 是什么时候算的?

prefill 吐出「天」的时候,没有为它算 Q/K/V。那一刻的「天」只是一个从概率分布里采样出来的 token id,还没进过网络。

prefill 算的是 prompt 那 4 个 token:每层的投影矩阵为它们各算一套 Q/K/V——K/V 写进缓存(4 格),Q 用在 4 个 token 互相之间的注意力里(因果掩码),算完丢弃。

生成的「天」要等到被喂回模型的那一刻——decode 第 1 步——才拥有自己的 Q/K/V:

prefill:   深 圳 今 天        → 缓存 4 格,输出「天」
decode 1:  输入「天」(生成的)  → 为它新算一套 Q/K/V
                              → K/V 追加进缓存(第 5 格)
                              → Q 查缓存,输出「气」
decode 2:  输入「气」          → 又一套新的 Q/K/V → 第 6 格 → 输出「晴」

这时缓存里有两个「天」的 K/V:第 4 格是 prompt 里的天,第 5 格是生成的天。不是重复数据——位置不同,hidden state 也不同(生成的天带着「今天」的语境,要往「气」的方向走)。K/V 只由“这个位置的这个 token”决定,位置变了就得重算。

Q/K/V 具体是怎么算出来的?

就是矩阵乘法,没有任何特殊操作:

Q = H × W_Q        K = H × W_K        V = H × W_V

H 是 token 的隐藏状态(4096 维),W_Q/W_K/W_V 是该层的投影矩阵(4096 × 4096)。每个 token 在每一层都各算一套——32 层就是 32 套。

  • prefill:H 是 4×4096 的矩阵 → 矩阵×矩阵(GEMM),一次算出 4 个 token 的 Q/K/V。prompt 再长也是同一种乘法,几百上千个 token 一次并行处理
  • decode:H 只有 1×4096 → 矩阵×向量(GEMV),一次只算 1 个 token 的

这也解释了上面表格里“主要开销”那一行为什么这么写:prefill 的 GEMM 规模大,能把 GPU 喂饱,是算力型;decode 的 GEMV 每步只处理一行,瓶颈全在搬缓存上,是带宽型。

「Q 为什么不缓存」这个问题,两个阶段都得看。先从 decode 说起。


0x01、decode:把一次注意力摊开看

prefill 已经把「深圳今天」四个 token 的 K、V 存进了缓存。现在 decode 开始,模型要生成下一个字。

decode 每一步只处理一个新 token,它在每一层做的事是:

新 token 的隐藏状态
   ├─→ 算出自己的 Q(出题)
   ├─→ 拿 Q 去和缓存里所有历史 K 算相似度(对答案)
   ├─→ 按相似度取历史 V,加权求和(取资料)
   └─→ 自己的 K、V 存进缓存,供以后的 token 查

这一步输出「气」。它随即变成下一步的输入,再输出「晴」——序列就这样一个字一个字长出来。

注意这个过程里 Q 的角色:它只在“算相似度”这一步被用到一次,用完这一步就结束了。

llama.cpp 的实现能直接印证。decode 时进网络的 cur 只有当前这一个 token 的隐藏状态,Q、K、V 都只为它计算:

auto [Qcur, Kcur, Vcur] = build_qkv(model.layers[il], cur, ...);
// cur 里只有新 token —— Q 只为新 token 而算
// Kcur/Vcur 随后写入缓存,Qcur 直接进注意力,用完即弃

0x02、被读几次,决定要不要缓存

缓存的对象从来不是“重要的数据”,而是会被反复读的数据。

把每样东西在整个生成过程中被读的次数数一遍(同一层内):

数据谁会读它被读几次
q_t(token t 的 Q)只有 token t 自己的注意力1 次
k_j / v_j(token j 的 K/V)之后每一个新 tokenn − j 次

生成 n 个 token 时,第 j 个位置的 K/V 会被第 j+1、j+2……n 个 token 各读一遍,越早的位置被读得越多。代个具体数:生成到 n = 100 时,「深」被读 99 次、「圳」98 次、「今」97 次、prompt 里的「天」96 次。序列越长,读的次数越多——这正是它值得常驻缓存的原因。

而 q_t 在它诞生的那一步被读一次,之后没有任何 token 会再读它。token t+1 会算出自己的 q_{t+1},token t+2 算出 q_{t+2}——每一步的题都是新的,没有人回头翻旧题。

所以这不是“Q 不重要”,是它的生命周期只有一步。生命周期一步的东西,缓存的收益是零。

读取次数示意图:上方 decode 每步全量读取历史 K/V 的网格,下方各位置被读次数的柱状图,Q 全程只被读 1 次转存失败,建议直接上传图片文件

↑ 上:decode 每一步都要把历史 K/V 全查一遍(蓝格),自己的 Q 只在当步用一次(琥珀框);下:生成 100 个 token 时各位置被读次数,越早越多,Q 全程只有 1 次


0x03、过去的 Q 去哪了:效果已经进了残差流

到这里容易冒出一个更深的问题:过去 token“想找什么”,后来的 token 真的用不上吗?

用得上,但不是通过读它的 Q。

跟踪一下信息流。token i 在第 L 层用自己的 q 算出注意力输出 o_i,o_i 被加进残差流——Transformer 里每层的输出都会加回输入,这条一层层往前传的主干就是残差流——成为 token i 在第 L+1 层输入的一部分。与此同时,后面的 token 在第 L 层就已经通过注意力读到了 token i 的表达(K/V),读到的结果同样进了它们自己的残差流。

也就是说,等 token t 走到第 L 层准备“出题”时,token i 想“说”的话,早就一层一层渗进了 t 的输入里。t 会带着这些信息算出属于自己的 q_t——它不需要、也不应该去读 i 的 q_i。

所以过去的 Q 不是被扔掉了,是被消化了。它的效果已经折进残差流,以另一种形式存在于后续每个 token 的输入中。

K/V 则完全不同:第 L 层的注意力需要的是历史 token 在第 L 层的表达。这个没法提前“消化”,因为读它的人一个接一个还在来的路上——每个新 token 到了第 L 层都要现查。这就是 K/V 必须在缓存里躺着的理由。

一句话:Q 是读者的问题,K/V 是图书馆的书。问题问完就散,书得一直在架上。

残差流示意图:左边 token i 的 q_i 在第 L 层用一次,o_i 并入残差流;右边 token t 每层都读 i 当层的 K/V,并算出自己的新 Q转存失败,建议直接上传图片文件

↑ 跟着信息流走一遍:q_i 在第 L 层用一次,o_i 并入残差流,效果跟着 i 的 K/V 一层层传下去;t 每层读到的都是 i 当层的 K/V,自己的 Q 则每层现算


0x04、prefill 和训练阶段,Q 同样不留

decode 之外的两个阶段,Q 的命运一样。

prefill 阶段上面已经说了:所有 token 并行前向,Q、K、V 全部算出来。算完之后,Q 全部丢弃,只有 K/V 写进缓存。缓存里存的 K/V 数量等于 prompt 长度,Q 一个都不占。

训练阶段更是全员并行,一次前向所有位置同时算,注意力矩阵 Q·Kᵀ 一步到位——压根不存在“缓存”这个概念,Q 算完随着这一层的计算结束就被释放。

所以无论哪个阶段:Q 从进入注意力到被消费,生命周期都不超过一步。


0x05、强行缓存 Q:显存翻倍,收益为零

假设非要缓存 Q,代价算得出来。

Llama 2 7B 的 KV 缓存是 512 KB/token。Q 和 K 形状相同(都是 32 头 × 128 维),缓存 Q 意味着:

单 token 缓存32K 上下文单请求
只缓存 K/V512 KB16 GB
K/V + Q 全缓存1 MB32 GB

显存直接翻倍。而收益是——零。这些缓存的 Q 没有任何代码路径会去读它们,纯粹占地方。

这也是为什么它叫 KV Cache,而不是 QKV Cache。

这个逻辑在模型架构上也留了痕迹。GQA(分组查询注意力)让多个注意力头共享同一份 K/V,但 Q 从来不共享——每个头各有各的 Q。道理是同一个:能共享的只有“图书馆”(K/V 对大家是同一排书架),读者的“问题”本来就各不相同。


0x06、总结

  1. decode 每步只为新 token 计算 Q,它随即被用于本步注意力,之后无人再读——生命周期一步。
  2. K/V 会被之后每个新 token 反复读取,第 j 个位置被读 n − j 次——这才是缓存该存的东西。
  3. 过去 token 的 Q 不是丢了,是它的注意力输出进了残差流,效果已经传给了后来的 token;后来的 token 带着这些信息算出自己的 Q。
  4. prefill 并行算完就丢 Q,训练阶段压根没有缓存——所有阶段 Q 都不留。

本文收录于微信公众号:愤怒的代码 AngryCode