主题
KV Cache 量化
本章要回答的问题:长上下文最吃内存,能不能只压 KV Cache?压了之后质量掉多少?
1.4 节算过 KV Cache 的账:它随上下文线性增长,不随 batch 增长。权重是固定的 5.7 GB,KV 却能从几十 MB 涨到几个 GB。权重已经压到 4 位了,能不能把 KV 也压一压?
先看清这个模型的 KV 有多大
Qwen3.5-9B 是混合结构:32 层里每 4 层才有一层是全注意力(Gated Attention),其余是线性注意力(Gated DeltaNet),后者的状态大小固定、不随 token 增长。所以只有 8 层需要 KV Cache(引用 [1])。
每个 token、每层的 KV 体积:
2(K 和 V)× 4 个 KV 头 × 256 维 × 2 字节(fp16)= 4096 字节
8 层合计 = 32 KiB / token(估算,参数取自模型卡与 GGUF 元数据:head_count_kv=4、key_length=256、full_attention_interval=4)
| 上下文 | fp16 | q8_0 | q8_0 K + q4_0 V | q4_0 |
|---|---|---|---|---|
| 512 | 16 MiB | 8 MiB | 6 MiB | 4 MiB |
| 16K | 512 MiB | 272 MiB | 208 MiB | 144 MiB |
| 256K(原生上限) | 8 GiB | 4.3 GiB | 3.3 GiB | 2.3 GiB |
(估算,按上式计算;位宽取 8.5 与 4.5 位/值)
如果这是传统的全注意力模型,同样的宽度要乘 4:16K 上下文就是 2 GiB 的 KV。混合结构本身已经把 KV 账单砍到了四分之一,这一点在选型时经常被忽略——大家只看参数量,不看注意力布局。
怎么压
KV 量化和权重量化是同一套东西:把 fp16 值按块转成整数,每块配一个 scale。llama.cpp 里用 -ctk 和 -ctv 分别指定 K 和 V 的类型:
bash
llama-server -m "$MODEL" -c 32768 -fa on -ctk q8_0 -ctv q8_0三个选择:
- q8_0:8 位整数 + 32 个值一个 fp16 scale,8.5 位/值,体积是 fp16 的 53%。
- q4_0:4 位,4.5 位/值,体积 28%。
- K 和 V 分开设:K 决定"往哪看",V 决定"看到什么"。经验上 K 更敏感,所以常见组合是 K 用 q8_0、V 用 q4_0。
K 和 V 的统计特性确实不同:K 在每个头上共享一组通道尺度,V 的离群值更随机。KIVI 那篇论文就是按这个观察设计的——K 按通道量化、V 按 token 量化,做到 2 位还能保住质量(引用 [2])。llama.cpp 的实现比论文简单,只做块量化,所以别指望 2 位。
实测:短上下文看不出来,长上下文才见分晓
同一段文本(约 10.5 万 token 的混合语料,生成方式见 benchmarks/quantization/build-calib.sh),只换 KV 类型和上下文长度,跑困惑度:
| 上下文 | KV 类型 | 困惑度 | 相对 fp16 |
|---|---|---|---|
| 512 | f16 / f16 | 2.6147 | — |
| 512 | q8_0 / q8_0 | 2.6149 | +0.0002 |
| 512 | q8_0 / q4_0 | 2.6147 | ±0 |
| 512 | q4_0 / q4_0 | 2.6226 | +0.0079 |
| 16K | f16 / f16 | 5.1401 | — |
| 16K | q8_0 / q8_0 | 5.1408 | +0.0007 |
| 16K | q8_0 / q4_0 | 5.1541 | +0.0140 |
| 16K | q4_0 / q4_0 | 5.1525 | +0.0124 |
(实测,见 benchmarks/quantization/kv-quant.csv;脚本 labs/04-quantization/kv_quant.py,命令与硬件见 benchmarks/quantization/env.txt)
四个读数:
q8_0 基本是免费的。 两种上下文下,困惑度变化都在 0.001 以内(512 下 +0.0002,16K 下 +0.0007),体积却减半。没有理由不用。
q4_0 的代价是 0.2%–0.3%,不是 0。 16K 下从 5.1401 涨到 5.1525,比 q8_0 明显。这个量级小到在任务指标上看不出来(4.6 节会证明),但在长上下文、多轮对话里会累积。
短上下文测不出代价。 512 token 时 KV 只有 16 MiB,占总量不到 0.3%,压它当然没感觉。任何"KV 量化无损"的结论,都要先问是在多长的上下文下测的。
q8_0 K + q4_0 V 没有比全 q4_0 更好。 5.1541 对 5.1525,方向甚至反了。这个差值小于困惑度的块间标准差(±0.14),只能说"两者相当",不能说"K 用 8 位更优"。想要这个结论,得换多段文本重复测。
还有一个不能忽略的坑:同样在 16K 下,q8_0 K + q4_0 V 这一组跑了 338 秒,其他三组都在 100 秒上下。混合 K/V 类型可能落进一条没有优化的计算路径。这是单次观察、没有复现,但足以提醒:换 KV 类型后要重新测速度,别只看显存。
为什么质量掉得这么少
KV 里存的是中间结果,不是知识。一个被量化到 4 位的 key,仍然保留了"这个位置大概在关注什么"的排序信息;注意力是 softmax 之后再加权的,个别值的误差会被归一化摊平。相比之下,权重误差会经过几十层复合放大。
这也解释了为什么 KV 量化可以比权重量化更激进:它作用在计算图的中段,而且每层用完就丢。
工程含义
长上下文才开 KV 量化。 2K 上下文、单轮问答,KV 只有几十 MB,压它省不下什么,反而多一层风险。
默认 q8_0,别默认 q4_0。 q8_0 的收益(体积减半)和代价(16K 下困惑度 +0.014%)严重不对称;q4_0 只在你确实需要再省一半、且上下文很长时才考虑。
容量规划要用最坏情况。 262144 原生上下文、q8_0 KV 是 4.3 GiB;再叠上并发数(每个请求一份 KV),24GB 的 Mac 根本放不下。这也是为什么 3.6 节说"并发不是免费的"——KV 是并发的主要成本项。
换 KV 类型要重测速度。 上表里那个 3 倍慢的组合说明,显存账算得再漂亮,也可能被一条未优化的 kernel 吃掉。
动手实验
动手实验:长上下文下的任务指标,而不只是困惑度
假设:16K 上下文、q4_0 KV 时,困惑度只涨 0.24%,但"从 16K 文档里找一句话"的任务正确率会掉 3–8 个百分点。
做法:把 60 题探针(labs/04-quantization/task_probe.py)改成"长文档 + 单问题",文档拼到 12K–16K token,分别用 f16 与 q4_0 KV 跑,比较正确率与 Wilson 区间。
记录:两种 KV 设置下的正确率、区间、以及每题的首 token 延迟。注意:prefill 16K 在 M4 上约 100 秒/次,60 题要分两次跑,别指望一次跑完。
参考资料
- Qwen. Qwen3.5-9B 模型卡(32 层 = 8 ×(3 × Gated DeltaNet + 1 × Gated Attention),KV 头数 4、头维 256). https://huggingface.co/Qwen/Qwen3.5-9B
- Liu, Z. et al. KIVI: A Tuning-Free Asymmetric 2bit Quantization for KV Cache(K 按通道、V 按 token 的非对称 2 位量化). https://arxiv.org/abs/2402.02750
- ggml 项目.
llama-perplexity/llama-server的--cache-type-k、--cache-type-v参数. https://github.com/ggml-org/llama.cpp/blob/master/tools/perplexity/README.md - ggml 项目.
ggml-common.h(block_q8_0、block_q4_0的块结构与位宽). https://github.com/ggml-org/llama.cpp/blob/master/ggml/src/ggml-common.h