动画图解 LLM 系统

KV cache:记住已经算过的历史

每次只生成一个新 token,为什么显存却越用越多?

→ 逐步推进 · ← 回看 · 拖动时间轴观察数据流

动画图解 LLM 系统

Q 是问题,K 是索引,V 是内容

当前 token 带着 Q 查历史:先匹配 K,再把对应的 V 混合起来。

假设正在补全“猫坐在……”。每个 token 的 hidden state 经过投影,得到 Q、K、V。

当前的 Q 与历史 K 做点积,得到一排匹配分数。分数越高,这个位置越值得关注。

softmax 把分数变成和为 1 的权重,再用这些权重加权求和 V,得到 attention 输出。

词语、权重和张量尺寸均为示意;真实 token 划分由 tokenizer 决定。

动画图解 LLM 系统

历史不会因为新词而改变

因果 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]。

位置 0:权重 1/6 × value 2位置 1:权重 2/6 × value 4位置 2:权重 3/6 × value 80

所有数值为手算教学示例。

工程细节

为什么缓存 K、V,却通常不缓存 Q?

Qₜ = XₜWQ;Kₜ = XₜWK;Vₜ = XₜWV

后续位置需要历史 K 来匹配、历史 V 来取内容。历史 Q 只服务历史位置的输出,不会被未来查询读取。

复用成立的条件

同一模型权重、相同前缀、相同位置编码与可见性;推理时关闭 dropout。

修改历史后怎么办?

从最早修改位置起重新计算。中间层 hidden state 已经受前文影响,不能只替换那个 token 的第一层 K。

这里讨论因果解码器。双向 encoder 的旧表示可能因新输入而变化。

动画图解 LLM 系统

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 位置 5 → 槽位 5 mod 4 = 1新 token 位置 6 → 槽位 6 mod 4 = 2新 token 位置 7 → 槽位 7 mod 4 = 30

所有数值为手算教学示例。

工程细节

一次追加两个 token,mask 不是 2×2

cache 已有 P = 3 个位置;本次 chunk 长 T = 2。Q 的长度是 2,K 的长度是 5。

allowed[i, j] = (j ≤ P + i),i = 0…T−1
本次 query可读 K0K1K2K3K4
Q3✓✓✓✓屏蔽
Q4✓✓✓✓✓

若直接用左上对齐的 2×5 下三角,Q3 会只看到 K0,结果不会报错,却已经错了。

不同 API 的非方形 causal mask 对齐规则不同;先用显式布尔 mask 与参考结果比对。

动画图解 LLM 系统

Decode:写一份,读一排

下一步只为刚生成的那个 token 计算新的 hidden state、Q、K、V。

新 token 到达当前层。历史 K、V 已在 HBM 中,Q 只是这一轮的临时查询。

先生成新 K、V,并追加到 cache。当前 token 也可以关注自己。

attention kernel 分块读取可见 K、V,用当前 Q 得到输出。旧 K、V 保留在 HBM,下一轮继续读。

动画图解 LLM 系统

省的是重复计算,历史读取还在

缓存保留每层的 K、V;当前 token 仍然要经过模型的每一层。

没有缓存:每生成一个词,都把旧 token 的投影和前向重新计算一遍。

有缓存:旧 K、V 原地复用,只计算新 token 的那一份。缓存越长,避免的重复工作越多。

但 full attention 的新 Q 仍需扫描全部历史。序列从 4 增长到 8,单层读取的 KV 也近似翻倍。

工程细节

GQA 的头映射是一条索引关系

例:Hq = 32、Hkv = 8。每 4 个 query heads 共用一个 KV head,仍各自计算自己的 attention 权重。

KV head(h) = floor(h / (Hq / Hkv))
Query headsKV head逻辑计算
0…304 个不同 Q 读取同一份 K0 / V0
4…714 个不同 Q 读取同一份 K1 / V1
28…3174 个不同 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 头均匀切分20.25 GiB1 GiB
TP = 16,按头切分并复制 KV10.125 GiB2 GiB
任意 TP,KV 全量复制81 GiBTP × 1 GiB

第二行假设每个 KV head 被 2 张卡持有。真实实现可用其他布局;从每个 rank 的实际缓存形状核算。

动画图解 LLM 系统

显存怎么算?数一数这些轴

标准 MHA / GQA,且 K 与 V 的 head dimension 相同:

KV bytes = 2 × layers × tokens × KV heads × head dim × bytes

单条序列

1.00 GiB

所有序列合计

1.00 GiB

固定 head dim = 128、BF16 = 2 bytes;假设序列等长。未计分页空隙、元数据与并行副本。GiB = 2³⁰ bytes。

工程细节

请求长短不同,要把各自长度加起来

M = 2 × L × Hkv × d × b × ΣᵣTᵣ

L = 32、Hkv = 8、d = 128、b = 2 bytes:每个 token 的所有层 KV 是 131072 bytes = 128 KiB。

请求长度对应 KV累计
1024 token128 MiB128 MiB
4096 token512 MiB640 MiB
8192 token1024 MiB1664 MiB = 1.625 GiB

若全部填充到 8192,会占 3 GiB。分页按块取整,还要加尾块、元数据与预留空间。

工程细节

把字节数变成延迟下界

沿用 1 GiB / 序列的 KV。假设有效带宽为 1.5 TB/s,单步把所有层 KV 各读一次。

tKV ≥ 2³⁰ bytes / (1.5 × 10¹² bytes/s)
≈ 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。

QKᵀ:2Td FLOP;AV:2Td FLOP
KV 流量:2Tdb bytes
强度 I ≈ 4Td / (2Tdb) = 2/b FLOP/byte

BF16 的 b = 2,所以约 1 FLOP/byte。把历史加长,算术和读取一起增多,强度不会自动提升。

GQA 若多个 Q 共享一次 KV 读取,强度可提高;重复读取、cache 命中和融合方式也会改变实际结果。

动画图解 LLM 系统

容量够了,也可能被带宽拖慢

Decode 的 attention 要反复搬历史;每次只带来一小份新结果。

左侧是 HBM 中的 KV,右侧是执行 attention 的 SM。数据分块搬入,在片上计算并累积输出。

若一条序列的 KV 总计 1 GiB,那么生成一个 token,跨全部层的 KV 读取量约为 1 GiB。

再加入权重读取、调度和其他算子,才得到完整的 token 延迟。压缩 KV 既省容量,也可能减少这段搬运。

1 GiB 为简化示例;实际流量取决于 kernel 复用、cache 命中与并行方式。

动画图解 LLM 系统

把“存得下”和“读得快”分开想

存得下更多历史

减少 KV 头数、压缩表示、量化,直接减少数据量。KV cache 压缩 从这几个轴动手。

用好已经分配的显存

PagedAttention 把缓存分块管理,减少预留和碎片;数据本身仍然需要保存。

复用相同 prompt

Prefix caching 在请求间复用完整匹配的前缀,避免重复 prefill。

少读历史位置

Sparse attention 只读取选中的位置。节省多少取决于选择策略和 kernel。

工程细节

用长度扫描定位瓶颈

假想剖析:固定 batch = 1、输出 64 token,预热后测稳定 decode 步骤。

上下文attention / 步其他 GPU / 步CPU 空隙
2K0.3 ms4.0 ms0.5 ms
8K1.1 ms4.0 ms0.5 ms
32K4.2 ms4.1 ms0.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。

固定权重的评估、流式模型的截断反传有专门定义;先确定需要的梯度语义。

练习 1

48 GiB 还能同时接几条请求?

32 层、8 KV heads、d = 128、BF16;权重与其他预留共 32 GiB。每条最大长度 8192。忽略分页开销时,剩余 16 GiB 能接几条?若每条再预留 2048 个输出 token 呢?
展开参考解答

每 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,不能再完整保证一条。

先写出假设和单位,再代入数字。

练习 2

三行 query,应该看到哪些列?

已有 P = 4 个 token,再输入 T = 3 个。写出 [3, 7] mask 每行允许的列,并说明为何不能直接使用无偏移的下三角。
展开参考解答

本次 query 的绝对位置是 4、5、6,允许列分别为 0…4、0…5、0…6。

条件是 j ≤ P + i。无偏移下三角只允许 0…i,错误屏蔽几乎全部历史。

先写出假设和单位,再代入数字。

练习 3

压缩 KV 一半,端到端能快多少?

某场景每步 10 ms:KV 读取 4 ms,其余 6 ms。把 KV 字节数减半,新增反量化 0.5 ms。假设各部分串行且带宽不变,求新延迟与加速比。
展开参考解答

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 性能。

参考资料

参考与延伸阅读