TT Lab
博客

한국어 English 日本語 中文

开始
博客

博客

为什么需要 KV 缓存:记住之前的内容以避免重复计算

状态:草案(2026-10-13),包含部分实际测量。 玩具模型测量(第 3 节)由作者在笔记本电脑 CPU 上直接测量。GPU 服务器测量(第 4、6 节)引用了同系列文章《Toss ML/AI Infra 准备》中单张 8GB 级别 GPU 的测量值。模型结构通过官方配置文件和论文表格进行了确认(2026-10-13 查阅),KV 大小根据这些值计算得出。 编写规则:以实测数据为基础,未能测量的数据标记为“未实测”,无法确认的内容标记为“未确认”。

目录

1. 逐个生成标记的模型

聊天机器人的回答并不是一次性生成的。模型会查看目前的文本(提问 + 至今生成的回答),并选择 下一个标记,将该标记附加到文本末尾,然后再次选择下一个标记。这个过程被称为自回归解码。

在选择单个标记时,Transformer 的每一层都会计算注意力。每个标记会生成三个向量:

将新标记的 Q 与前面标记的 K 逐个进行对比并打分(关联度有多高),然后用这些分数将 V 进行混合。用公式表示就是 softmax(q·Kᵀ/√d)·V 。

这里有一个重要的性质。语言模型的注意力机制 被遮挡以防看到后面(因果掩码,causal mask)。由于第 3 个标记的 K 和 V 仅根据第 1 到第 3 个标记生成,因此即使后面附加了 100 个标记,它们也 不会改变 。既然如此,就没有理由丢弃已经创建的 K 和 V 并重新创建。只需将它们保存并在下一步中取出使用即可。这就是 KV 缓存。

Q 不会被保存。因为前一个标记的 Q 在生成该标记时使用过一次后就不会再被用到了。新步骤所需的 Q 只有新标记的那一个。

2. 没有缓存时每一步都从头开始

토큰 5개를 만드는 동안 계산하는 K·V 캐시 없음 — 걸음마다 처음부터 다시 걸음 1 걸음 2 걸음 3 걸음 4 걸음 5 앞 토큰의 K·V 를 버리고 매번 전부 다시 만든다 계산 1+2+3+4+5 = 15칸 n 걸음이면 약 n²/2 캐시 있음 — 새 토큰만 계산, 지난 것은 읽기 걸음 1 걸음 2 걸음 3 걸음 4 걸음 5 새 토큰의 K·V 하나만 만들어 캐시에 붙이고, 어텐션은 전부 본다 계산 5칸, 읽기 10칸 n 걸음이면 계산 n칸 새로 계산한 K·V 캐시에서 읽은 K·V 한 칸 = 한 토큰의 K·V(모든 층). 어텐션 점수 계산은 두 방식 모두 지난 토큰 수만큼 든다.
图:无缓存与有缓存解码的对比

如果没有缓存,在第 t 步时需要重新生成全部 t 个标记的 K 和V。由于每层执行的线性投影(Q、K、V、输出)和 MLP 与标记数量成正比,单步的开销与 t 成正比,积累 n 步后总开销为 1+2+…+n ≈ n²/2 。如果有缓存,每一步只需投影并输入一个新标记到 MLP 中,因此 n 步的总开销为 n 。

注意力分数的计算在两种方式中都存在。即使有缓存,新标记的 Q 也必须与前面的所有 K 进行对比,因此每一步需要消耗与 t 相关的计算量(n 步总计 n²/2)。如果没有缓存,连这一部分在每一步中都是 t²,从而使总和增加到 n³/3。在上下文不是很长的情况下,投影和 MLP 占了计算的大部分,因此实际感受到的差异是“每一步中 t 与 1 的区别”。

3. 动手尝试:numpy 玩具 Transformer

理论上很简单,但很难有直观感受,于是我们用 numpy 构建了一个权重填满随机数的轻量 Transformer,并对这两种方式进行了测量。由于这不是一个训练过的模型,输出的标记没有实际意义,但计算的形态(投影、注意力、MLP、归一化)与真实的模型完全一致。

带缓存一侧的核心代码只有几行。将当前标记的 K 和 V 写入预先分配好的数组末尾,注意力机制则查看至今为止累积的全部内容(省略了按头拆分的 reshape)。

k = mm(h, W["k"])  # 새 K
v = mm(h, W["v"])  # 새 V
n = cache["n"]
cache["k"][:, n:n+T] = k  # 끝에 붙임
cache["v"][:, n:n+T] = v
K = cache["k"][:, :n+T]  # 지난 것 전부
V = cache["v"][:, :n+T]
a = attend(q, K, V)

结果 1. 输出结果相同,但计算量和时间大不相同

运算量(GFLOP,代码统计值):

总长度 无缓存 有缓存 倍数
128 6.71 0.121 55倍
256 31.0 0.264 118倍
512 148.2 0.599 247倍

时间(秒,三次运行的中位数):

总长度 无缓存 有缓存 倍数
128 0.205 0.084 2.5倍
256 1.16 0.131 8.9倍
512 8.01 0.485 16.5倍
한 토큰을 만드는 데 걸린 시간(ms) numpy 장난감 모델 · 노트북 CPU · 실측(64토큰 구간 중앙값) 0 10 20 30 40 0 128 256 384 512 지금까지의 문맥 길이(토큰) 캐시 없음 35ms 캐시 있음 0.4→0.6ms 캐시 없음(실선·점) 캐시 있음(점선) 512토큰 전체: 없음 8.0초, 있음 0.49초 · 연산량 247배 차이
图:按生成位置划分的单步耗时

结果 2. 如果没有缓存,单步耗时会随上下文成比例变慢。 在运行 512 个标记时,观察每 64 个标记区间的单步耗时中位数,无缓存侧的耗时从 1.25ms 增加到了 35.1ms,而有缓存侧仅从 0.43ms 略微增加到了 0.60ms。即使有缓存也会稍微变慢的原因是第 2 节中提到的注意力分数计算(每一步为 t)。

结果 3. 缓存数组的大小与公式及字节完全一致。 在预填充 1,024 个标记后,将缓存数组的 nbytes 相加。

三种情况下的查询头数均为 8 个,官方计算公式为:2 × 层数(2) × KV 头数 × 头维度(16) × 标记数(1,024) × 4 字节。

方式 KV 头数 实际字节数 公式计算值
MHA 8 2,097,152 2,097,152
GQA 2 524,288 524,288
MQA 1 262,144 262,144

这三种方式中,使用缓存的最后一个 logit 与没有缓存、从头开始计算的值完全相同(允许误差 10⁻³)。

4. 缓存的代价:内存

토큰 하나가 남기는 KV 의 크기 2 × 층 × KV 헤드 × 헤드 차원 × 바이트 K 와 V 두 벌 · 이 값에 토큰 수를 곱하면 한 요청의 KV MHA GQA MQA MLA 헤드마다 KV 그룹마다 KV KV 하나 공유 압축해 저장 8칸 (기준) KV 헤드 = 쿼리 헤드 2칸 (1/4) 헤드 4개가 한 벌 공유 1칸 (1/8) 모든 헤드가 한 벌 잠재 벡터 하나 쓸 때 K·V 로 펼침 쿼리 헤드(저장 안 함) 캐시에 남는 K·V Llama 3.1 70B(80층·헤드 차원 128·bf16): GQA 8 → 320KiB/토큰 KV 헤드가 쿼리 헤드만큼(64) 있었다면 2.5MiB/토큰 (계산)
图:KV 内存公式与 MHA·GQA·MQA·MLA

缓存是用计算换取内存。单个标记留下的 KV 是每层各有一组 K 和 V,因此公式如下:

KV 字节/标记 = 2 × 层数 × KV 头数 × 头维度 × 数据类型字节数

这个公式是否符合实际服务器的情况已在其他系列中得到验证。Qwen3-1.7B(28层,KV 头数 8,头维度 128,bf16)根据公式计算得出每个标记 114,688 字节,而 vLLM 服务器日志中的 KV 缓存内存(2.36GiB)除以标记数(22,048)约为 114,900 字节,误差在 0.2% 以内(8GB 级别 GPU,vLLM 0.31.0,实测)。如果将 KV 设置为 8 位(fp8),同样的内存可以容纳 41,920 个标记,几乎达到了两倍。

将其代入广为人知的开源模型中,结果如下。层数和头数已从官方资料中确认,大小按 KV 为 bf16(2字节)进行计算。以下模型的头维度均为 128。“32K”表示单个 32,768 标记请求的 KV 大小。

模型 层数 × KV 头数 每标记 KV 32K
Qwen2.5-1.5B 28 × 2 28KiB 0.88GiB
Qwen2.5-7B 28 × 4 56KiB 1.75GiB
Llama 3.1 8B 32 × 8 128KiB 4GiB
Mistral-7B 32 × 8 128KiB 4GiB
Qwen3-8B 36 × 8 144KiB 4.5GiB
Llama 3.1 70B 80 × 8 320KiB 10GiB
Llama 3.1 405B 126 × 8 504KiB 15.75GiB
如果 70B 是 MHA(假设) 80 × 64 2.5MiB 80GiB
DeepSeek-V2(MLA) 60 层,潜在维度 576 67.5KiB 2.11GiB

查询头数在 Qwen2.5-1.5B 中为 12,Qwen2.5-7B 中为 28,Llama 3.1 8B·Mistral-7B·Qwen3-8B 中为 32,70B 中为 64,405B 中为 128。KV 头数少于查询头数的情况即为第 5 节中的 GQA。

5. 减少大小的方法:MHA → GQA → MQA → MLA

公式中能够减少的部分是 KV 头数(或替代它的某些内容)以及字节数。层数和头维度与模型的能力直接相关,因此不能随意减少。

为什么非要精简到这种程度?第 6 节就是答案。

6. 预填充与解码:性质截然不同的两个阶段

一个请求需要经历两个阶段。

  1. 预填充(prefill):一次性输入整个提示词,生成所有标记(token)的 K·V 并写入缓存。第一个标记就是在这里生成的。它决定了首字延迟(TTFT)。
  2. 解码(decode):此后逐个标记(token)地生成。它决定了标记间延迟(ITL·TPOT)。

两者虽然使用相同的模型,但在硬件上表现出的特征却大不相同。

在玩具模型中也能看到这种差异(带有缓存,取三次测量中位数)。

提示词 每个预填充标记耗时 单步解码耗时 倍率
64 0.018ms 0.445ms 24倍
256 0.034ms 0.506ms 15倍
1,024 0.144ms 0.852ms 6倍

预填充的总时间为:64 个标记 1.16ms,256 个标记 8.76ms,1,024 个标记 146.9ms。

解码单个标记花费的时间是预填充单标记时间的 6 到 24 倍。由于使用的是 CPU 的 numpy,其中混入了 Python 的调用开销,且 1,024 个标记的预填充会导致注意力机制的平方项变大,从而拉高单标记成本。因此,比起倍率本身,更应该关注这样一个方向:「批量一次性处理在每个标记上的成本要便宜得多」.

在 GPU 上,这种差异更为巨大。在其他系列的实际测量中(8GB 级 GPU、Qwen3-1.7B、vLLM),输入处理速度约为 8,200 个标记/秒,而当解码单个请求时,标记间延迟为 15ms(约合 66 个标记/秒)。 单标记速度相差超过 120 倍。

如果解码受限于内存,那么提高吞吐量的方法只有一个。 在读取一次权重的同​​时解码多个请求,也就是增大批处理大小(batch size)。在同一实际测量中,当并发请求从 1 增加到 8 时,吞吐量从 66 增加到 383 个标记/秒(5.8 倍),从 32 增加到 831 个标记/秒。然而,批处理中的每一个请求都必须将其自身的 KV 放在 GPU 内存中。如果是 512 个输入标记 + 128 个输出标记,那么一个请求就是 640 个标记,该 GPU 的 KV 空间(22,048 个标记)大约可以容纳 34 个。当并发发送 64 个请求时,吞吐量没有增加,而首字延迟飙升至 5.7 秒。 吞吐量的上限是由 KV 内存决定的。

7. 总结:本系列将探讨的问题

事实上 由此产生的问题 涉及篇幅
KV 不会改变,因此将其存储起来并重复使用 — 第 1 篇
批处理越大,解码成本越低 如何无浪费地使用 KV 内存来增大批处理? 第 2 篇(PagedAttention、连续批处理)
相同前缀的 KV 是相同的 如何复用其他请求的 KV? 第 2 篇(前缀缓存)、第 3 篇
GPU 内存很小 如果将 KV 放在 CPU、磁盘或其他服务器上并按需加载呢? 第 3 篇(LMCache)
预填充和解码的性质不同 如果将两者分配到不同的 GPU 上?如果将请求发送到已经拥有该 KV 的地方呢? 第 4 篇(Dynamo)

Titi 的一句话。 在乒乓球比赛中,没有哪个裁判每次都从第一个发球开始重新计算分数,大家都是记在记分牌上然后逐分累加的。KV 缓存正是这样一个记分牌。只不过如果有几百个球场,光是找地方放记分牌就开始不够用了。

参考资料

登录后即可点赞

评论

还没有评论。

登录后即可发表评论