【大模型原理图解 11】推理加速:KV Cache、量化与长上下文的代价
上一篇《微调与对齐:SFT、RLHF、DPO 如何让模型“听话”》讲的是如何让模型更符合人的需求。本篇解决另一个问题:模型已经会回答了,怎样让它回答得更快,以及为什么上下文越长越吃显存。
先给结论:KV Cache 用显存换重复计算,量化用较低数值精度换存储和带宽;它们都不能让长上下文变得“免费”。
一、生成一个新 Token,为什么还要重算旧内容?
想象你在接龙写小说。每写一个新字,都把前面所有内容重新阅读、重新做笔记,显然很浪费。更合理的方式是:旧内容的笔记留着,新字来了只补一页,再查阅已有笔记。
自回归模型也类似:每轮根据已有 Token,预测下一个 Token。如果采用最直接的实现,每轮都把完整前缀重新送进模型:
第 1 轮:处理 [A B C] → 预测 D
第 2 轮:处理 [A B C D] → 预测 E
第 3 轮:处理 [A B C D E] → 预测 F
└─旧内容反复计算─┘
前面介绍过因果注意力:每个位置只能看自己和更早的位置。因此,在标准因果 Transformer 的推理中,追加新 Token 不会改变旧位置的表示,旧位置计算出的 Key、Value 也不需要更新。
这就是 KV Cache 能成立的关键。以下默认模型处于推理模式、位置编码处理一致,而且没有淘汰缓存等额外机制。
二、KV Cache:保存旧笔记,只计算新内容
2.1 到底缓存什么?
对某一层注意力,输入表示先投影成:
Q=XWQ,K=XWK,V=XWV Q=XW_Q,\qquad K=XW_K,\qquad V=XW_V Q=XWQ,K=XWK,V=XWV
其中:
- XXX:这一层的输入表示;
- WQ,WK,WVW_Q,W_K,W_VWQ,WK,WV:模型学到的投影矩阵;
- QQQ:查询,表示当前位置要寻找什么;
- K,VK,VK,V:键和值,供其他位置检索和读取。
每层分别保留历史位置的 K,VK,VK,V。新 Token 到来时,只计算它自己的 qt,kt,vtq_t,k_t,v_tqt,kt,vt,然后追加缓存:
K1:t=[K1:t−1;kt],V1:t=[V1:t−1;vt] K_{1:t}=[K_{1:t-1};k_t],\qquad V_{1:t}=[V_{1:t-1};v_t] K1:t=[K1:t−1;kt],V1:t=[V1:t−1;vt]
ot=softmax(qtK1:tTdh)V1:t o_t=\operatorname{softmax}\left(\frac{q_tK_{1:t}^{\mathsf T}}{\sqrt{d_h}}\right)V_{1:t} ot=softmax(dhqtK1:tT)V1:t
这里,ttt 是当前位置,分号表示沿序列维拼接,dhd_hdh 是每个注意力头的维度,oto_tot 是当前头的输出。
为什么通常不缓存 Q? 因为预测新位置时只需要当前查询,旧查询对应的输出已经算完了;未来 Token 要读取的是旧位置的 K、V。
各层历史 K、V ──────────────┐
↓
新 Token → 当前层 q、k、v → 追加 k、v → 当前 q 查询全部 K、V
↓
进入下一层
2.2 Prefill 与 Decode 是两种工作
| 阶段 | 处理内容 | 主要特点 |
|---|---|---|
| Prefill,提示词预填充 | 一次处理整个输入 | 建立各层 KV Cache,得到预测首个输出 Token 的分布 |
| Decode,逐步解码 | 每轮处理一个新 Token | 追加缓存,用当前查询读取历史 K、V |
对完整 Transformer 来说,缓存还避免了反复执行旧位置的前馈网络等计算。不过,它没有省掉新查询对历史 K、V 的访问,上下文越长,每个解码步通常仍然越贵。
三、KV Cache 到底吃多少显存?
把缓存看成一个表格:每层、每条请求、每个 Token、每个 KV 头,都要存一份 K 和一份 V。
在各层配置相同、每条序列长度相同的情况下:
MKV=2×L×B×S×HKV×dh×b M_{\mathrm{KV}}=2\times L\times B\times S\times H_{\mathrm{KV}}\times d_h\times b MKV=2×L×B×S×HKV×dh×b
各符号含义如下:
| 符号 | 含义 |
|---|---|
| 222 | K 和 V 两份数据 |
| LLL | Transformer 层数 |
| BBB | 同时缓存的序列数 |
| SSS | 每条序列已缓存的 Token 数 |
| HKVH_{\mathrm{KV}}HKV | 每层 KV 头数,不一定等于查询头数 |
| dhd_hdh | 每个头的维度 |
| bbb | 每个缓存元素占用的字节数 |
例如,32 层、8 个 KV 头、头维度 128、缓存使用 FP16,每条请求已有 8192 个 Token:
MKV=2×32×1×8192×8×128×2=1 GiB M_{\mathrm{KV}} =2\times32\times1\times8192\times8\times128\times2 =1\ \mathrm{GiB} MKV=2×32×1×8192×8×128×2=1 GiB
这里 1 GiB=2301\ \mathrm{GiB}=2^{30}1 GiB=230 字节。
| 改动 | 理论 KV 数据量 |
|---|---|
| 上述配置,单条 8192 Token | 1 GiB |
| 长度增加到 32768 Token | 4 GiB |
| 4 条请求,每条 8192 Token | 4 GiB |
| KV 头数由 8 增加到 32,单条 8192 Token | 4 GiB |
普通多头注意力通常每个查询头对应一个 KV 头;GQA 让多个查询头共享一组 K、V,因此可以减少缓存。
这只是 KV 数据本身,不是推理总显存。总显存还包括权重、临时张量、运行时工作区、缓存分配余量等。实际服务中,各请求长度不同,也要按各自长度累计估算。
四、长上下文为什么贵:平方与线性要分清
如果会议室里有 SSS 个人,每个人都要评估其他所有人的发言,就存在约 S2S^2S2 对关系。完整注意力的计算与此类似。
设序列长度为 SSS、查询头数为 HQH_QHQ、每头维度为 dhd_hdh。完整注意力的两次主要矩阵乘法,即 QKTQK^{\mathsf T}QKT 和注意力权重乘 VVV,计算量为:
O(S2HQdh) O(S^2H_Qd_h) O(S2HQdh)
因果掩码只允许看左侧,仍不改变平方级的渐近复杂度。若其他条件相同,长度变成原来的 4 倍,这部分计算约变成 16 倍,并非整个模型耗时必然变成 16 倍。
使用 KV Cache 后,单个解码步只有一个新查询,面对 SSS 个历史位置,注意力计算变成:
O(SHQdh) O(SH_Qd_h) O(SHQdh)
但若初始提示长度为 PPP,经过预填充后再执行 TTT 个增量解码步,累计注意力访问规模仍是:
∑t=1T(P+t)=TP+T(T+1)2 \sum_{t=1}^{T}(P+t)=TP+\frac{T(T+1)}{2} t=1∑T(P+t)=TP+2T(T+1)
所以单步线性,不代表生成很长一段文本的累计代价也是线性。
还要区分两种内存:
- KV Cache:随缓存长度线性增长。
- 显式注意力分数矩阵:形状包含 S×SS\times SS×S,内存随长度平方增长。
FlashAttention 一类实现通过分块等方法,避免在显存中完整保存这个平方矩阵并减少数据搬运,但没有把标准稠密注意力的算术复杂度改成线性。
五、量化:把精细刻度换成较粗刻度
量化像把精确到小数的测量值,改存成有限档位的整数编号:占用更小,但存在舍入误差。
一种简单的对称量化写法是:
q=clip(round(x/s),qmin,qmax),x^=sq q=\operatorname{clip}\left(\operatorname{round}(x/s),q_{\min},q_{\max}\right), \qquad \hat{x}=sq q=clip(round(x/s),qmin,qmax),x^=sq
其中,xxx 是原浮点数,s>0s>0s>0 是缩放系数,qqq 是量化整数,qmin,qmaxq_{\min},q_{\max}qmin,qmax 是整数范围,x^\hat{x}x^ 是近似恢复的浮点数。例如,x=0.73x=0.73x=0.73、s=0.1s=0.1s=0.1,量化后为 7,恢复后为 0.7。
| 格式 | 每个值的理论存储 | 相对 FP16 的原始数据量 |
|---|---|---|
| FP16 | 2 字节 | 100% |
| INT8 | 1 字节 | 50% |
| INT4 | 0.5 字节,需要打包 | 25% |
实际还要保存缩放系数,有些方案还有零点,并常按通道或分组量化,因此实际占用高于理想值。
量化对象也要分清:
- 权重量化:减小模型权重,可能降低权重读取带宽。
- 激活量化:对计算中的中间表示量化。
- KV Cache 量化:直接减小历史缓存,但需要专门支持,也可能影响精度。
权重用了 INT4,不代表 KV Cache 也用了 INT4,更不代表速度一定提高 4 倍。 一些实现仍使用浮点激活,读取压缩权重后再转换计算;实际收益取决于硬件、内核、批量大小、反量化开销,以及当前瓶颈是否在内存带宽上。
六、NumPy 实验:缓存前后少算了多少?
下面模拟一个单层、单头因果注意力:提示长度为 8,再逐步加入 3 个固定 Token,总共比较 4 次前向计算。两条路径使用相同输入,以便验证输出一致。
为聚焦缓存,省略位置编码、输出投影和前馈网络。统计单位为 MAC(一次乘加):矩阵 (m,k)(m,k)(m,k) 乘 (k,n)(k,n)(k,n) 记作 mknmknmkn 次 MAC;不统计 softmax、拼接和访存,因此不是速度测试。
import numpy as np
rng = np.random.default_rng(42)
prompt_len, steps, d = 8, 4, 16
x = rng.normal(size=(prompt_len + steps - 1, d))
wq, wk, wv = [rng.normal(size=(d, d)) / np.sqrt(d)
for _ in range(3)]
def softmax(a):
e = np.exp(a - a.max(axis=-1, keepdims=True))
return e / e.sum(axis=-1, keepdims=True)
def full_forward(tokens):
n = len(tokens)
q, k, v = tokens @ wq, tokens @ wk, tokens @ wv
scores = q @ k.T / np.sqrt(d)
mask = np.triu(np.ones((n, n), dtype=bool), k=1)
scores = np.where(mask, -np.inf, scores)
out = softmax(scores) @ v
macs = 3 * n * d * d + 2 * n * n * d
return out, k, v, macs
# 不缓存:每轮重新处理完整前缀。
full_outputs = []
full_macs = 0
for n in range(prompt_len, prompt_len + steps):
out, _, _, cost = full_forward(x[:n])
full_outputs.append(out[-1])
full_macs += cost
# 使用缓存:先预填充,之后每轮只处理一个新位置。
out, k_cache, v_cache, cached_macs = full_forward(x[:prompt_len])
cached_outputs = [out[-1]]
for pos in range(prompt_len, prompt_len + steps - 1):
token = x[pos:pos + 1]
q = token @ wq
k_cache = np.concatenate((k_cache, token @ wk), axis=0)
v_cache = np.concatenate((v_cache, token @ wv), axis=0)
scores = q @ k_cache.T / np.sqrt(d)
out = softmax(scores) @ v_cache
cached_outputs.append(out[0])
n = len(k_cache)
cached_macs += 3 * d * d + 2 * n * d
same = np.allclose(full_outputs, cached_outputs, atol=1e-10, rtol=1e-10)
print(f"输出一致: {same}")
print(f"无缓存 MACs: {full_macs}")
print(f"有缓存 MACs: {cached_macs}")
print(f"计算量比值: {full_macs / cached_macs:.2f}")
运行结果:
输出一致: True
无缓存 MACs: 40896
有缓存 MACs: 11456
计算量比值: 3.57
这里有三个值得注意的地方:
- 缓存复用了相同的 K、V,不是近似算法;两条路径的输出在浮点误差范围内一致。
- 增量步骤只有最新位置的查询,缓存里没有未来位置,因此无需额外的三角掩码。
- 示例的完整路径先计算整个分数矩阵再加掩码,统计包含了被遮住的位置;它对应这份代码,不代表所有优化内核的计算方式。
代码用 concatenate 便于理解,但每轮会复制缓存;生产实现通常采用预分配或分页管理。因此,3.57 是这个实验的矩阵乘加量比值,不能直接当成真实推理加速比。
七、把三件事放在一起看
| 方法或现象 | 主要影响 | 不能忽略的代价 |
|---|---|---|
| KV Cache | 避免反复计算旧位置 | 缓存占显存,新查询仍需读取历史 |
| INT8 / INT4 量化 | 减少存储,可能降低带宽压力 | 数值误差、转换开销、内核支持 |
| 更长上下文 | 允许输入更多内容 | 预填充更贵,缓存更大,解码访问更多历史 |
排查推理瓶颈时,可以先问:慢在预填充还是逐步解码?显存主要被权重占用,还是被并发请求的 KV Cache 占用?这比直接把所有参数都改成 INT4 更有针对性。
下一篇《RAG:让大模型先查资料再回答》,我们将讨论如何让模型利用外部资料回答问题,而不是只依赖参数中的知识。
如果这篇对你有帮助,欢迎点赞收藏,关注我获取更多实战干货。
更多推荐
所有评论(0)