KV cache:记住已经算过的历史
每次只生成一个新 token,为什么显存却越用越多?
→ 逐步推进 · ← 回看 · 拖动时间轴观察数据流
Q 是问题,K 是索引,V 是内容
当前 token 带着 Q 查历史:先匹配 K,再把对应的 V 混合起来。
假设正在补全“猫坐在……”。每个 token 的 hidden state 经过投影,得到 Q、K、V。
当前的 Q 与历史 K 做点积,得到一排匹配分数。分数越高,这个位置越值得关注。
softmax 把分数变成和为 1 的权重,再用这些权重加权求和 V,得到 attention 输出。
词语、权重和张量尺寸均为示意;真实 token 划分由 tokenizer 决定。
历史不会因为新词而改变
因果 attention 只允许一个位置读取自己和前面的位置。
横轴是被读取的 K,纵轴是发起查询的 Q。右上角是未来,被 causal mask 挡住。
旧位置的输入与可见历史都没变,所以已有 token 在每层产生的 K、V 可以继续使用。
新 token 加入后,只多出最后一行查询。每层追加一份 K、V,就能留给后续步骤。
先写出张量形状,再讨论缓存
B 是请求数,T 是本次输入长度,D 是 hidden size;Hq / Hkv 是 query / KV 头数,d 是每头维度。
| 张量 | 标准布局 | 在做什么 |
|---|---|---|
| X | [B, T, D] | 进入这一层的 token 表示 |
| Q | [B, Hq, T, d] | 本次查询 |
| K、V | 各 [B, Hkv, T, d] | 本次新生成的 key / value |
| cache K、V | 各 [B, Hkv, P + T, d] | P 个历史位置 + T 个新位置 |
布局可以换轴;存储的元素数不变。这里假设 K 与 V 维度相同。
一行 attention,真的算出一个数
已缩放的 logits 为 [0, ln 2, ln 3];exp 后是 [1, 2, 3],除以总和 6。V 为 [2, 4, 8]。
所有数值为手算教学示例。
为什么缓存 K、V,却通常不缓存 Q?
后续位置需要历史 K 来匹配、历史 V 来取内容。历史 Q 只服务历史位置的输出,不会被未来查询读取。
复用成立的条件
同一模型权重、相同前缀、相同位置编码与可见性;推理时关闭 dropout。
修改历史后怎么办?
从最早修改位置起重新计算。中间层 hidden state 已经受前文影响,不能只替换那个 token 的第一层 K。
这里讨论因果解码器。双向 encoder 的旧表示可能因新输入而变化。
Prefill:一次处理整段提示词
已知的 prompt 可以在一次前向中并行计算;因果约束由 mask 保证。
把“猫 / 坐 / 在 / 垫上”送入模型。这里画一个 attention 层,真实模型逐层执行。
这一层并行产生所有输入位置的 Q、K、V;K、V 写入这一层的 cache。
各位置通过 causal attention 读取允许看到的历史。最后一个位置的输出继续经过后续层,预测第一个新 token。
最小增量实现:追加后再 attention
q, k_new, v_new = project(x_new) q, k_new = rope(q, k_new, positions) k = concat(k_cache, k_new, axis="time") v = concat(v_cache, v_new, axis="time") scores = q @ k.transpose(-1, -2) / sqrt(d) scores = where(allowed, scores, -inf) y = softmax(scores, axis=-1) @ v return y, (k, v)
追加当前 token 后再读,才能关注自己。每一层有独立 cache;上一层的输出才是下一层输入。
concat 用于理解;服务端通常预分配或分页写入,避免每步复制整个历史。
缓存长度、绝对位置、物理槽位要分开
已有 5 个 token,窗口物理容量为 4。新 token 的 RoPE 位置继续增长,写入槽位按容量取模。
所有数值为手算教学示例。
一次追加两个 token,mask 不是 2×2
cache 已有 P = 3 个位置;本次 chunk 长 T = 2。Q 的长度是 2,K 的长度是 5。
| 本次 query | 可读 K0 | K1 | K2 | K3 | K4 |
|---|---|---|---|---|---|
| Q3 | ✓ | ✓ | ✓ | ✓ | 屏蔽 |
| Q4 | ✓ | ✓ | ✓ | ✓ | ✓ |
若直接用左上对齐的 2×5 下三角,Q3 会只看到 K0,结果不会报错,却已经错了。
不同 API 的非方形 causal mask 对齐规则不同;先用显式布尔 mask 与参考结果比对。
Decode:写一份,读一排
下一步只为刚生成的那个 token 计算新的 hidden state、Q、K、V。
新 token 到达当前层。历史 K、V 已在 HBM 中,Q 只是这一轮的临时查询。
先生成新 K、V,并追加到 cache。当前 token 也可以关注自己。
attention kernel 分块读取可见 K、V,用当前 Q 得到输出。旧 K、V 保留在 HBM,下一轮继续读。
省的是重复计算,历史读取还在
缓存保留每层的 K、V;当前 token 仍然要经过模型的每一层。
没有缓存:每生成一个词,都把旧 token 的投影和前向重新计算一遍。
有缓存:旧 K、V 原地复用,只计算新 token 的那一份。缓存越长,避免的重复工作越多。
但 full attention 的新 Q 仍需扫描全部历史。序列从 4 增长到 8,单层读取的 KV 也近似翻倍。
GQA 的头映射是一条索引关系
例:Hq = 32、Hkv = 8。每 4 个 query heads 共用一个 KV head,仍各自计算自己的 attention 权重。
| Query heads | KV head | 逻辑计算 |
|---|---|---|
| 0…3 | 0 | 4 个不同 Q 读取同一份 K0 / V0 |
| 4…7 | 1 | 4 个不同 Q 读取同一份 K1 / V1 |
| 28…31 | 7 | 4 个不同 Q 读取同一份 K7 / V7 |
参考代码可以 repeat_interleave 验证;高效 kernel 应复用原始 KV,不把它永久复制 4 遍。
每卡 KV 不是总量除以卡数就结束
假想模型:32 层、8192 token、8 个 KV 头、d = 128、BF16。完整逻辑 KV 为 1 GiB。
| 部署方式 | 每卡存几个 KV 头 | 每卡 KV | 所有卡合计 |
|---|---|---|---|
| TP = 4,按 KV 头均匀切分 | 2 | 0.25 GiB | 1 GiB |
| TP = 16,按头切分并复制 KV | 1 | 0.125 GiB | 2 GiB |
| 任意 TP,KV 全量复制 | 8 | 1 GiB | TP × 1 GiB |
第二行假设每个 KV head 被 2 张卡持有。真实实现可用其他布局;从每个 rank 的实际缓存形状核算。
显存怎么算?数一数这些轴
标准 MHA / GQA,且 K 与 V 的 head dimension 相同:
单条序列
1.00 GiB所有序列合计
1.00 GiB固定 head dim = 128、BF16 = 2 bytes;假设序列等长。未计分页空隙、元数据与并行副本。GiB = 2³⁰ bytes。
请求长短不同,要把各自长度加起来
L = 32、Hkv = 8、d = 128、b = 2 bytes:每个 token 的所有层 KV 是 131072 bytes = 128 KiB。
| 请求长度 | 对应 KV | 累计 |
|---|---|---|
| 1024 token | 128 MiB | 128 MiB |
| 4096 token | 512 MiB | 640 MiB |
| 8192 token | 1024 MiB | 1664 MiB = 1.625 GiB |
若全部填充到 8192,会占 3 GiB。分页按块取整,还要加尾块、元数据与预留空间。
把字节数变成延迟下界
沿用 1 GiB / 序列的 KV。假设有效带宽为 1.5 TB/s,单步把所有层 KV 各读一次。
≈ 0.716 ms
8 个等长请求
读 8 GiB,理想下界约 5.73 ms。这是一次 batch 步骤,不是乘 8 后再除 8 的单请求延迟。
测到 12 ms 怎么看?
还要拆开权重、投影/MLP、通信、调度和 kernel 利用率。0.716 ms 只是 KV 搬运的理想部分。
TB 用十进制,GiB 用二进制;这里的带宽是教学假设,不是某张卡的测量值。
为什么单 query 的算术强度很低?
一个 head 读 T 个 key / value,每个向量 d 维。一次乘加按 2 FLOP 计,不计 softmax。
KV 流量:2Tdb bytes
强度 I ≈ 4Td / (2Tdb) = 2/b FLOP/byte
BF16 的 b = 2,所以约 1 FLOP/byte。把历史加长,算术和读取一起增多,强度不会自动提升。
GQA 若多个 Q 共享一次 KV 读取,强度可提高;重复读取、cache 命中和融合方式也会改变实际结果。
容量够了,也可能被带宽拖慢
Decode 的 attention 要反复搬历史;每次只带来一小份新结果。
左侧是 HBM 中的 KV,右侧是执行 attention 的 SM。数据分块搬入,在片上计算并累积输出。
若一条序列的 KV 总计 1 GiB,那么生成一个 token,跨全部层的 KV 读取量约为 1 GiB。
再加入权重读取、调度和其他算子,才得到完整的 token 延迟。压缩 KV 既省容量,也可能减少这段搬运。
1 GiB 为简化示例;实际流量取决于 kernel 复用、cache 命中与并行方式。
把“存得下”和“读得快”分开想
存得下更多历史
减少 KV 头数、压缩表示、量化,直接减少数据量。KV cache 压缩 从这几个轴动手。
用好已经分配的显存
PagedAttention 把缓存分块管理,减少预留和碎片;数据本身仍然需要保存。
复用相同 prompt
Prefix caching 在请求间复用完整匹配的前缀,避免重复 prefill。
少读历史位置
Sparse attention 只读取选中的位置。节省多少取决于选择策略和 kernel。
用长度扫描定位瓶颈
假想剖析:固定 batch = 1、输出 64 token,预热后测稳定 decode 步骤。
| 上下文 | attention / 步 | 其他 GPU / 步 | CPU 空隙 |
|---|---|---|---|
| 2K | 0.3 ms | 4.0 ms | 0.5 ms |
| 8K | 1.1 ms | 4.0 ms | 0.5 ms |
| 32K | 4.2 ms | 4.1 ms | 0.5 ms |
attention 随历史增长,32K 已占约一半。优先检查 KV 流量、GQA 复用和压缩;2K 场景的最大收益则被固定部分限制。
对照时保持输出策略、batch、精度与频率一致;同时记录 TTFT、TPOT 和显存。
先证明增量输出等于完整前向
测试输入
随机小张量;长度 1、跨块边界、不同长度 batch。分别逐 token 和按 2 / 3 个 token 分块。
比较的位置
每层 Q / K / V、旋转后的 K、mask、attention 输出、最终 logits;容许浮点归约顺序造成的小误差。
若首个 token 对、第二个开始错:先查追加顺序与 position offset。若只有 chunk decode 错:先查非方形 causal mask。
推理用 eval 模式。不要只比较最终采样文本:采样会放大微小差异,也会掩盖一些数值错误。
推理缓存与训练激活解决不同问题
训练通常一次并行处理整段序列,并保留反向所需信息;推理则重复追加 token,复用历史前向结果。
跨步训练复用的风险
权重更新后,旧 K / V 属于旧参数。直接复用会改变训练计算图;detach 还会截断穿过历史的梯度。
两类显存要分清
KV cache 是生成状态;激活检查点是反向重算策略。训练时减少激活,不等于减少推理 KV。
固定权重的评估、流式模型的截断反传有专门定义;先确定需要的梯度语义。
48 GiB 还能同时接几条请求?
展开参考解答
每 token 是 2 × 32 × 8 × 128 × 2 = 128 KiB。8192 token = 1 GiB,所以最多 16 条。
连同输出为 10240 token = 1.25 GiB;floor(16 / 1.25) = 12 条。剩下 1 GiB,不能再完整保证一条。
先写出假设和单位,再代入数字。
三行 query,应该看到哪些列?
展开参考解答
本次 query 的绝对位置是 4、5、6,允许列分别为 0…4、0…5、0…6。
条件是 j ≤ P + i。无偏移下三角只允许 0…i,错误屏蔽几乎全部历史。
先写出假设和单位,再代入数字。
压缩 KV 一半,端到端能快多少?
展开参考解答
KV 从 4 ms 降为 2 ms。新延迟 = 2 + 0.5 + 6 = 8.5 ms。
加速比 = 10 / 8.5 ≈ 1.176,延迟减少 15%。若只报告“KV 快了 2 倍”,无法说明整条请求的收益。
先写出假设和单位,再代入数字。
亲手验证:完整前向 = 增量前向
用 NumPy 在 CPU 上跑一个可检查的参考实现。小尺寸便于把中间张量打印出来。
python3 -m pip install numpy python3 lab.py python3 lab.py --seed 7
full vs token: PASS full vs chunks: PASS wrong mask detected: PASS 128 KiB/token; 8192 tokens = 1.000 GiB
把 chunk 长度改成 [1, 3, 2, 5],再把 offset 故意写成 0;观察第一处不一致。增加 Hq,保持 Hkv 不变,确认缓存元素数不变。
下载实验代码 lab.py这是正确性与成本的教学实验;耗时不代表 GPU kernel 性能。
参考与延伸阅读
- Hugging Face · Caching:prefill、decode 与缓存实现
- GQA:用较少 KV heads 服务多个 query heads
- PagedAttention:高效管理推理中的 KV cache
继续看:KV 压缩 · Sparse attention · Linear attention