KV cache · quantization · low-rank

KV Cache 压缩实验室

五个可以动手的小实验,从"一组数怎么压成 4 bit"讲到"DeepSeek 为什么能把 KV 砍掉 90%"。每个实验都是真实计算,不是示意图:拖滑块,看数字怎么变。

01量化就是把数放到网格上

一个 fp16 的数能表示 6 万多种取值;int4 只有 16 个档位。量化做的事是:看这组数的最小值和最大值,在两者之间均匀铺 2bits 条网格线,每个数吸附到最近的一条上。存的时候只存"第几条线"(整数 q),外加整组共享的 scale(网格间距)和 zero(起点)。

为什么是"6 万多"和"16"? 一个 bit 只能是 0 或 1,n 个 bit 能拼出 2n 种不同的组合。fp16 用 16 个 bit: ±eeeeemmmmmmmmmm 1 位符号、5 位指数、10 位尾数,216 = 65 536 种组合(去掉 NaN、无穷等保留编码后约 6.3 万个有效数值)。它是"浮点":指数位让小数点可以滑动,所以既能表示 0.0001 也能表示 60000。int4 只有 4 个 bit,24 = 16 种组合,而且是"定点"整数,只能表示 0~15(或 −8~7)这 16 个相邻的整数,没有小数、没有大范围。要让它承载原本 fp16 里的浮点值,就必须靠外部的 scale 和 zero 把这 16 个整数"映射"回真实范围。
"2bits 条网格线"是什么意思? bits 就是你给每个数分配的位数。bits = 2 时有 22 = 4 条线,bits = 4 时有 16 条,bits = 8 时有 256 条。这些线在 [min, max] 之间等距排开,相邻两条线的间距就是 scale = (max − min) / (2bits − 1)。每个原值只能落到离它最近的一条线上,所以线越密,吸附过去时移动的距离(误差)越小;线越稀,误差越大。下面的图里,下排的灰色竖线就是这些网格线,拖 bits 滑块能直接看到它们变密变疏。

下面是一组 16 个真实采样值。上排是原值,下排是量化后的位置,竖线长度就是误差。试着把 bits 拉到 2,再打开"离群值"开关。

4
16可用档位 2^bits
—实际被用到的档位
—scale(网格间距)
—相对均方误差
4×压缩比 vs fp16

开离群值后注意"实际被用到的档位":一个 ×12 的数把网格撑宽,其余 15 个数挤在最左边两三格里,分辨率全被那一个数吃掉了。这就是 KV 量化的核心难题。

02注意力为什么扛得住这种误差

注意力的计算是:query 和每个 token 的 K 做点积得到分数,softmax 之后变成权重,再用权重加权求和 V。softmax 是指数放大:分数最高的几个 token 拿走几乎全部权重,其余 token 的 K 偏一点,权重本来就接近 0,偏了还是接近 0。V 那一侧是加权平均,舍入误差正负抵消。

这里是 1 个 query 对 12 个 token 的真实 attention。深色是原始权重,浅色是量化 K/V 之后的权重。看右边"输出余弦相似度"在几 bit 时才开始明显下降。

4
原始权重量化后权重
—输出向量余弦相似度
—top-1 token 是否不变
—权重分布 KL 散度

经验规律:int8 几乎无损,int4 相似度仍在 0.99 以上,int2 开始翻车。而且"只 V"通常比"只 K"更耐压:K 的误差经过点积和 softmax 被指数放大,V 的误差只是被平均。

03K 的离群值长在通道上,V 的不是

先把三个词说清楚 token:输入序列里的一个单元(大致一个字或一个词),每个 token 在每一层都会产生一个 K 向量和一个 V 向量。
通道(channel):向量里的某一维。一个 K 向量有 128 维,就是 128 个通道;第 7 通道指的是"每个 token 的 K 向量的第 7 个数"。
离群值(outlier):和周围数相比大得离谱的值。实验 01 已经演示过,一个离群值会把整组的 scale 撑大,让其余的数失去分辨率。

把一层的 KV cache 画成矩阵:每行是一个 token,每列是一个通道。于是"K 的离群值长在通道上"的意思就是:K 矩阵里有几条竖着的亮线,某几个固定的通道,对所有 token 都偏大。原因有两个:RoPE 位置编码是把相邻两维当作一个二维平面做旋转,某些低频维度在旋转后数值系统性地偏大;另外,某些注意力头会把少数几个维度当"锚点"用,这些维度的 K 值天生就大。无论哪种原因,离群出现的位置由通道决定,和这个 token 是什么字无关。

V 不一样。V 不经过 RoPE,也不参与点积打分,它就是"要被取走的内容"。V 矩阵里的大值是零散的:某个 token 整行偏大(这个 token 携带的信息多),或者某个单独的位置偏大,但不会在某一列上整齐排开。所以 V 的离群值要说有规律的话,更接近按 token(按行)分布。

这决定了分组方式。量化时的一组共享一个 scale,分组的目标是让离群值和正常值不在同一组里。按 token 分组(每行一个 scale)时,K 的每一行都包含那几个大通道,每一行的 scale 都被撑大,没有一行逃得掉。按通道分组(每列一个 scale)时,大通道自成一组,它的 scale 大是它自己的事,其余列不受影响。对 V 则反过来:按行分组时,偏大的那几行自成一组,其余行正常;按列分组反而让每一列都混进几个大值。下面的实验可以把两种分组各试一遍,表格里会给出误差数字。

2
|原值| 热力图 · 32 token × 48 通道
量化误差 |x − x̂| 热力图(越亮误差越大)
矩阵按 token 分组按通道分组更好的选择
K———
V———

表里是当前 bits 下的相对均方误差。KIVI 论文的结论就是这张表:K 按通道、V 按 token,int2 都能用。KVQuant 更进一步,把那几个极端值单独用 fp16 存成稀疏矩阵,剩下的再量化。

04算一算到底省了多少显存

每个 token 在每一层都要存一份 K 和一份 V:2 × 层数 × KV头数 × 头维度 × 每个数的字节数。GQA(分组查询注意力)让多个 query 头共用一个 KV 头,所以 70B 模型只有 8 个 KV 头而不是 64 个,这本身已经是一次 8 倍压缩。MLA 走得更远:每层只存一个 512 维的潜向量加 64 维的位置信息。

什么是并发请求? 一张 GPU 很少只服务一个人。推理服务器会把同时到达的多个对话(比如 32 个用户各自在问问题)打包成一个 batch 一起算,这样权重只需从显存读一次就能给 32 个人用,吞吐量高得多。但 KV cache 是每个对话各自一份的:32 个并发请求就是 32 份独立的 KV cache,显存占用线性叠加。这也是为什么服务商关心 KV 压缩远胜过个人用户:对个人来说 KV 压 4 倍只是"能跑更长上下文",对服务商来说是"同一张卡能同时服务 4 倍的用户",直接等于成本。
128K
1
—每 token 占用
—KV cache 总量
—每生成一个 token 要读的量
—一张 H100 (80 GB) 能放几份
80 GB

横条满格 = 一张 H100 的 80 GB(还没算模型权重本身)。把 70B 拉到 128K、16 bit,再切到 4 bit,再换成 MLA,感受三种压缩的量级。

05MLA:不压每个数,而是少存数

量化压的是每个数的位宽,上限大约 4~8 倍。MLA(Multi-head Latent Attention)换了个思路:K 和 V 本来就是从同一个隐状态 h 乘两个矩阵投影出来的,信息高度冗余。那就不存 K 和 V,只存一个低维潜向量 c = W_down · h;用的时候再 K = W_uk · c、V = W_uv · c 展开。展开矩阵 W_uk 可以提前乘进 query 侧(矩阵乘法结合律),所以解码时根本不用真的把 K 还原出来。

下面的方块面积按维度数画。拖 d_c 看潜向量多小时还划算;DeepSeek-V3 选的是 512。

512
128
—MHA 每层每 token 存的数
—GQA (8 KV头) 存的数
—MLA 存的数 (d_c + 64 rope)
—MLA 相对 MHA 压缩比

MLA 和量化正交:先用 MLA 把"数的个数"砍掉几十倍,再对潜向量做 int4/int8 量化,两段收益相乘。代价是 MLA 必须在训练时就这样设计,没法给已训好的模型事后加上;量化可以。

把这五件事串起来