从零理解 MLA
前言
回顾 EP.0 的 Transformer,每个 Block 里真正做计算的就是两个模块:注意力与 FFN。现代 LLM 的架构创新也主要集中在这两处:EP.1 讲的 MoE 是在 FFN 上做稀疏化改造,本文要讲的 MLA 则是在注意力上动刀。
MLA 全名多头潜在注意力(Multi-head Latent Attention),由 DeepSeek-V2 在 2024 年 5 月首次提出,之后被 DeepSeek-V3、R1 等模型沿用。它主要针对自回归推理时的 KV cache 开销,同时也通过查询压缩减少训练时的激活显存。EP.3 在介绍解耦 RoPE 时曾留下一个伏笔:为什么 DeepSeek 要专门拆出一条位置通道?MLA 的结构会解释这个选择。
我们的路线是:先看清问题本身,即 KV cache 为什么成为推理瓶颈;再看最直接的解法 MQA 与 GQA 付出了什么代价;然后理解 MLA 的两件武器,低秩联合压缩与矩阵吸收;最后处理它与 RoPE 的冲突,把 DeepSeek-V2 的注意力维度配置摆出来。
KV Cache
为什么需要缓存
语言模型的生成是逐个 token 进行的:每步只产生一个新 token,把它拼到输入后面,再算下一个。由于因果掩码的存在,第 步的 token 只能看到位置 到 的内容。
第 步要做的事,是让新 token 的 query 与所有历史位置的 key、value 做注意力。推理时,历史位置看不到后续 token,因此续写不会改变这些位置在各层的表示,也不会改变它们的 K/V。若每步重新计算整个前缀,就会对不变的历史表示重复做 K/V 投影。
把历史 K/V 算一次并保存,后续直接复用,就是 KV Cache。工程上把生成过程拆成两个阶段:prefill 阶段一次性处理整段 prompt,把所有 K/V 算好存起来;decode 阶段每步只给新 token 算 Q、K、V,把新的 K/V 追加进缓存,再与全部缓存做注意力。
缓存开销
KV cache 用显存换计算。标准 MHA 下,每个 token 每层缓存完整的 K 和 V,共 个元素。其中 是注意力头数, 是每头维度。乘上层数 ,就是每个 token 的全部缓存。
以 DeepSeek-V2 的规模估算:、、,如果它用标准 MHA,每个 token 要缓存约 197 万个元素;一条 token 的序列就是约 2580 亿个元素,fp16 下约 480 GiB。这还没有计入模型权重与其他运行时开销,batch size 增大时缓存还会成倍增加。长上下文 decode 的注意力计算往往受缓存读取带宽限制:每生成一个 token,都要把整个缓存读一遍,缓存越大,生成越慢。KV cache 同时卡住了显存容量和显存带宽。
代码示例
本文的代码只依赖 PyTorch,所有代码块共用下面的导入,完整可运行的版本见开源仓库 small-language-model 的 MLA 模块:
import math
import torchimport torch.nn as nn先实现一个支持缓存的 MHA。forward 一次可以只处理新进来的若干个 token,历史 K/V 通过参数传入并拼接:
class MultiHeadAttention(nn.Module): """标准 MHA:q, k, v 各自由输入独立投影。"""
def __init__(self, d: int, n_heads: int) -> None: super().__init__() self.n_heads = n_heads self.d_head = d // n_heads self.w_q = nn.Linear(d, d, bias=False) self.w_k = nn.Linear(d, d, bias=False) self.w_v = nn.Linear(d, d, bias=False) self.w_o = nn.Linear(d, d, bias=False)
def forward(self, x: torch.Tensor, kv_cache=None): # x: [B, L, d];返回输出与更新后的 (K, V) 缓存 B, L, _ = x.shape q = self.w_q(x).view(B, L, self.n_heads, self.d_head).transpose(1, 2) k = self.w_k(x).view(B, L, self.n_heads, self.d_head).transpose(1, 2) v = self.w_v(x).view(B, L, self.n_heads, self.d_head).transpose(1, 2) if kv_cache is not None: # 拼接历史 K/V k = torch.cat([kv_cache[0], k], dim=2) v = torch.cat([kv_cache[1], v], dim=2) S = k.shape[2] scores = q @ k.transpose(-1, -2) / math.sqrt(self.d_head) # [B, H, L, S] mask = torch.ones(L, S, dtype=torch.bool, device=x.device) scores = scores.masked_fill(~mask.tril(diagonal=S - L), -torch.inf) o = scores.softmax(dim=-1) @ v # [B, H, L, d_head] out = self.w_o(o.transpose(1, 2).reshape(B, L, -1)) return out, (k, v)这里有个容易写错的细节:掩码的下三角不能直接用 torch.tril(torch.ones(L, S)),因为带缓存时 ,新 token 要能看到全部历史位置。正确做法是把下三角向右偏移 ,即 tril(diagonal=S - L)。
下面对比两种解码方式:无缓存时每个位置都把整个前缀重新算一遍,有缓存时每次只算新 token:
torch.manual_seed(0)mha = MultiHeadAttention(d=64, n_heads=4).eval()h = torch.randn(1, 12, 64) # 一段 12 个 token 的输入
# 无缓存:每个位置都重算整段前缀outs_naive = [mha(h[:, : t + 1])[0][:, -1:] for t in range(12)]
# 有缓存:prefill 第 1 个 token,之后每步只算 1 个新 tokenout, cache = mha(h[:, :1])outs_cached = [out]kv_proj = 1 # prefill 只投影了 1 个位置的 K/Vfor t in range(1, 12): out, cache = mha(h[:, t : t + 1], cache) outs_cached.append(out) kv_proj += 1 # decode 每步只投影 1 个位置的 K/V
for t in range(12): assert torch.allclose(outs_cached[t], outs_naive[t], atol=1e-6)print(f"K/V 投影次数:无缓存 {sum(range(1, 13))} 次,有缓存 {kv_proj} 次")print(f"缓存元素数:{cache[0].numel() + cache[1].numel()} = 2 × {mha.n_heads} × {mha.d_head} × 12")输出:
K/V 投影次数:无缓存 78 次,有缓存 12 次缓存元素数:1536 = 2 × 4 × 16 × 12两种方式的输出在给定误差范围内一致,而 K/V 投影次数从 降到了 12,这正是 KV cache 的价值;而 1536 个缓存元素正好对应公式 每 token。把每 token 每层这个数字乘以 60 层、 的序列长度和不算大的 batch,就能看出问题所在。
MQA 与 GQA
最直接的压缩方法是让多个注意力头共享 K/V,按共享比例减少缓存。
多查询注意力(Multi-Query Attention,MQA)把这个思路推到极限:所有 个 query 头共享同一份 K 和 V,每个 token 每层只缓存 个元素。分组查询注意力(Grouped-Query Attention,GQA)折中一些:把 个头分成 组,组内共享 K/V,缓存为 每 token 每层。Llama、Qwen 等系列广泛采用 GQA,但组数因型号而异;本文用 GQA-8 表示有 8 个 K/V 头的配置。
共享 K/V 减少了键值投影的独立性,但各头仍有不同的 query,可以形成不同的注意力权重,并不等于合并成一个注意力头。DeepSeek-V2 的技术报告附录 D.1 对比了三个约 7B 的 dense 模型:三者同样训练 1.33T token,除注意力机制外,还调整了层数以使总参数量接近:
| 基准 | MQA | GQA-8 | MHA |
|---|---|---|---|
| MMLU | 37.9 | 41.2 | 45.2 |
| BBH | 33.2 | 35.6 | 37.0 |
| C-Eval | 30.0 | 37.7 | 42.9 |
| CMMLU | 34.6 | 38.4 | 43.5 |
这组实验中,MQA 在四个基准上均低于 MHA,GQA-8 居中,说明共享 K/V 可能损失模型质量。
代码示例
把 K/V 的头数做成参数,一个模块就能覆盖 MHA、GQA、MQA 三种形态。组内共享用 repeat_interleave 把每份 K/V 复制给同组的头。注意这里每头维度 d_head 是一个独立参数:DeepSeek-V2 中 ,Q、K、V 的总维度比模型宽度还大。
class GroupedAttention(nn.Module): """n_kv 组共享 K/V:n_kv = n_heads 时是 MHA,n_kv = 1 时是 MQA。"""
def __init__(self, d: int, n_heads: int, n_kv_heads: int, d_head: int) -> None: super().__init__() assert n_heads % n_kv_heads == 0 self.n_heads, self.n_kv_heads = n_heads, n_kv_heads self.d_head = d_head self.w_q = nn.Linear(d, n_heads * self.d_head, bias=False) self.w_k = nn.Linear(d, n_kv_heads * self.d_head, bias=False) self.w_v = nn.Linear(d, n_kv_heads * self.d_head, bias=False) self.w_o = nn.Linear(d, n_heads * self.d_head, bias=False)
def forward(self, x: torch.Tensor) -> torch.Tensor: B, L, _ = x.shape q = self.w_q(x).view(B, L, self.n_heads, self.d_head).transpose(1, 2) k = self.w_k(x).view(B, L, self.n_kv_heads, self.d_head).transpose(1, 2) v = self.w_v(x).view(B, L, self.n_kv_heads, self.d_head).transpose(1, 2) rep = self.n_heads // self.n_kv_heads k = k.repeat_interleave(rep, dim=1) # [B, H, L, d_head] v = v.repeat_interleave(rep, dim=1) scores = q @ k.transpose(-1, -2) / math.sqrt(self.d_head) mask = torch.ones(L, L, dtype=torch.bool, device=x.device).tril() scores = scores.masked_fill(~mask, -torch.inf) o = scores.softmax(-1) @ v return self.w_o(o.transpose(1, 2).reshape(B, L, -1))用 DeepSeek-V2 的规模算一笔账:
d, n_heads, d_head, layers = 5120, 128, 128, 60 # DeepSeek-V2 的真实配置for n_kv in [128, 8, 2, 1]: attn = GroupedAttention(d, n_heads, n_kv, d_head) params = sum(p.numel() for p in attn.parameters()) cache = 2 * n_kv * d_head * layers print(f"K/V 头数 {n_kv:>3}:参数量 {params / 1e6:6.1f}M,每 token 缓存 {cache / 1e3:7.1f}K 元素")输出:
K/V 头数 128:参数量 335.5M,每 token 缓存 1966.1K 元素K/V 头数 8:参数量 178.3M,每 token 缓存 122.9K 元素K/V 头数 2:参数量 170.4M,每 token 缓存 30.7K 元素K/V 头数 1:参数量 169.1M,每 token 缓存 15.4K 元素从 128 个 K/V 头砍到 1 个,缓存缩小了 128 倍,注意力模块参数量却只省了一半。MLA 要进一步解决的是:能不能找到一种压缩方式,缓存接近 MQA,性能却不输 MHA?
MLA
MLA 的回答是:不直接共享 K/V,改压缩。所有头共享一个低维隐向量,再通过各头自己的投影还原 K/V。
低秩联合压缩
设注意力层的输入为 。MLA 先用一个下投影矩阵把 K 和 V 的信息联合压缩成一个隐向量:
其中 是压缩维度,。真正要用 K 和 V 的时候,再用两个上投影矩阵各自还原:
,输出同样切分成 个头。缓存里只放 ,每 token 每层 个元素。
这和 MQA 的共享有什么本质区别?
区别在于「各自还原」。MQA 是所有头拿到完全相同的一份 K/V;MLA 里第 个头的 是隐向量经 的第 个分块投影出来的,每个头仍然有自己的 key 和 value。低秩约束作用于所有头拼接后的联合表示:、 以及两者纵向拼接的矩阵,其秩都至多为 。因此,共享的是低维表示,各头的 K/V 仍可不同。DeepSeek-V2 的消融实验显示,MLA 在多数所测指标上优于 MHA;可以把低秩瓶颈理解为一种类似正则化的约束。
矩阵吸收
上面的方案省下了缓存,却引入了一个新问题:推理时取出缓存的 ,还得先乘 、 解压出 K/V 才能算注意力。若每步都还原全部历史 K/V,就会引入重复计算。
矩阵乘法的结合律可以避免这一步。先看 logits,第 个头的 query 为 ,与位置 的 key 做内积:
其中 是 的第 个头的分块。看等式右侧: 是一个 维向量,它与缓存的 直接做内积就得到 logits,相当于把解压矩阵从 K 侧「吸收」到了 Q 侧,每头预先算好 的转置即可,历史 K 根本不需要显式还原。
输出侧同理。第 个头的输出是 value 的加权和,而 value 也是隐向量解压出来的:
先在隐空间里对 做加权求和得到 ,再一次性上投影。而各头输出最后要拼起来乘 ,把 按头分块记作 ,则
每头的 可以预先合并成一个矩阵, 被「吸收」进了 。
吸收后的推理流程是:缓存存 ,logits 在隐空间里直接算,value 也以隐向量形式加权聚合,K 和 V 从头到尾都不需要被还原出来。
查询压缩
MLA 对 query 也做了同样的低秩分解:
,。不过目的不同:query 不需要缓存,压它省的不是 KV cache,而是训练时的激活显存与参数量。配合上一节的吸收,logits 最终写成
代码示例
下面这个模块按论文公式实现 MLA 的朴素前向:先压缩,再显式解压出 K/V 做注意力。先不引入位置编码,下一节再处理。参数名 d_c、d_cq 分别对应公式里的 与 。
class MLA(nn.Module): """多头潜在注意力:K/V 联合压缩进隐向量 c_kv,用时再解压。"""
def __init__(self, d: int, n_heads: int, d_head: int, d_c: int, d_cq: int) -> None: super().__init__() self.n_heads, self.d_head, self.d_c = n_heads, d_head, d_c self.w_dkv = nn.Linear(d, d_c, bias=False) # 联合压缩 self.w_uk = nn.Linear(d_c, n_heads * d_head, bias=False) # K 解压 self.w_uv = nn.Linear(d_c, n_heads * d_head, bias=False) # V 解压 self.w_dq = nn.Linear(d, d_cq, bias=False) # Q 压缩 self.w_uq = nn.Linear(d_cq, n_heads * d_head, bias=False) # Q 解压 self.w_o = nn.Linear(n_heads * d_head, d, bias=False)
def forward(self, x: torch.Tensor) -> torch.Tensor: # x: [B, L, d],朴素版:显式还原全部 K/V B, L, _ = x.shape c_kv = self.w_dkv(x) # [B, L, d_c] k = self.w_uk(c_kv).view(B, L, self.n_heads, self.d_head).transpose(1, 2) v = self.w_uv(c_kv).view(B, L, self.n_heads, self.d_head).transpose(1, 2) q = self.w_uq(self.w_dq(x)).view(B, L, self.n_heads, self.d_head).transpose(1, 2) scores = q @ k.transpose(-1, -2) / math.sqrt(self.d_head) mask = torch.ones(L, L, dtype=torch.bool, device=x.device).tril() scores = scores.masked_fill(~mask, -torch.inf) o = scores.softmax(-1) @ v return self.w_o(o.transpose(1, 2).reshape(B, L, -1))然后是吸收版单步解码。缓存里只放 ,logits 用吸收后的隐空间 query 计算,value 侧先在隐空间聚合再投影:
@torch.no_grad() def absorbed_step(self, h_t: torch.Tensor, cache=None): """单 token 解码:只缓存 c_kv,K/V 不显式还原。h_t: [B, 1, d]""" c_kv = self.w_dkv(h_t) # [B, 1, d_c] cache = c_kv if cache is None else torch.cat([cache, c_kv], dim=1)
q_c = self.w_uq(self.w_dq(h_t)).view(-1, 1, self.n_heads, self.d_head) w_uk = self.w_uk.weight.view(self.n_heads, self.d_head, self.d_c)
# W_uk^T 被吸进 Q 侧:q_lat[i] = W_uk_i^T q_c_i,与 c_kv 同维 q_lat = torch.einsum("btha,hac->bthc", q_c, w_uk) # [B, 1, H, d_c] scores = torch.einsum("bthc,bsc->bths", q_lat, cache) / math.sqrt(self.d_head) attn = scores.softmax(-1)
# 先在隐空间加权聚合,再经 W_uv 各头切片上投影,等价于把 W_uv 并进 W_o z = torch.einsum("bths,bsc->bthc", attn, cache) # [B, 1, H, d_c] w_uv = self.w_uv.weight.view(self.n_heads, self.d_head, self.d_c) o = torch.einsum("bthc,hac->btha", z, w_uv) # [B, 1, H, d_head] return self.w_o(o.reshape(-1, 1, self.n_heads * self.d_head)), cache注意 w_uk.weight 的形状:PyTorch 的 nn.Linear(in, out) 权重是 [out, in],所以 view(H, d_head, d_c) 的第 块正好是 。验证两种算法在给定误差范围内一致,并数一数缓存:
torch.manual_seed(0)mla = MLA(d=128, n_heads=4, d_head=16, d_c=32, d_cq=48).eval()x = torch.randn(1, 10, 128)
ref = mla(x) # 朴素版整段前向cache = Nonefor t in range(10): # 吸收版逐 token 解码 out_t, cache = mla.absorbed_step(x[:, t : t + 1], cache) assert torch.allclose(out_t, ref[:, t : t + 1], atol=1e-5)
kv_elems = 2 * mla.n_heads * mla.d_head # 同配置 MHA 的每 token 每层缓存print(f"吸收版与朴素版逐位一致")print(f"每 token 每层缓存:MLA {mla.d_c} 元素,MHA {kv_elems} 元素,压缩 {kv_elems / mla.d_c:.0f} 倍")输出:
吸收版与朴素版逐位一致每 token 每层缓存:MLA 32 元素,MHA 128 元素,压缩 4 倍解耦 RoPE
还需要处理位置编码。EP.3 讲过,现代 LLM 几乎都用 RoPE,而把它直接施加到内容 K/Q 上,会妨碍刚才的矩阵吸收。
给 施加位置 的旋转 、给 施加位置 的旋转 之后,把 也展开,logits 变成
是随相对位置变化的块对角矩阵,正好卡在 与 之间。中间因子依赖相对位置,因而无法像前面那样预先合并为一个与位置无关的固定矩阵。朴素做法需要还原 K 后旋转,无法沿用前面的固定矩阵吸收。
DeepSeek 将位置信息从压缩通道中拆出。
这就是解耦 RoPE(decoupled RoPE):在原本的内容通道之外,额外开一条窄小的位置通道,专门承载 RoPE。对 query,每头从 投影出一段 维向量并旋转:
对 key,直接从输入投影出一个 维向量并旋转,所有头共享同一份:
然后拼接:,。由于内积对拼接向量是分段相加的,logits 自然拆成两项:
softmax 的缩放因子相应改成 。内容通道保持「可压缩、可吸收」,位置通道单独携带旋转,两者互不干扰。代价是缓存里除了 还要多存一份共享的 ,每 token 每层变成 个元素。
代码示例
在 MLA 上加两个投影:w_qr 从 生成每头的位置 query,w_kr 从输入直接生成共享的位置 key。RoPE 函数沿用 EP.3 的半区配对实现,这里直接重述:
def precompute_cos_sin(L: int, d_rope: int, base: float = 10000.0): inv_freq = base ** (-torch.arange(0, d_rope, 2).float() / d_rope) angles = torch.arange(L).float()[:, None] * inv_freq[None, :] # [L, d_rope/2] return angles.cos(), angles.sin()
def apply_rope(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor: # x: [..., d_rope],cos/sin: [..., d_rope/2],广播对齐 x1, x2 = x.chunk(2, dim=-1) return torch.cat([x1 * cos - x2 * sin, x2 * cos + x1 * sin], dim=-1)class MLADecoupledRoPE(MLA): """MLA + 解耦 RoPE:内容通道走低秩压缩,位置通道各头共享。"""
def __init__(self, d, n_heads, d_head, d_c, d_cq, d_rope) -> None: super().__init__(d, n_heads, d_head, d_c, d_cq) self.d_rope = d_rope self.w_qr = nn.Linear(d_cq, n_heads * d_rope, bias=False) # 每头的位置 q self.w_kr = nn.Linear(d, d_rope, bias=False) # 共享的位置 k
def forward(self, x: torch.Tensor, cos, sin) -> torch.Tensor: B, L, _ = x.shape c_kv, c_q = self.w_dkv(x), self.w_dq(x) k_c = self.w_uk(c_kv).view(B, L, self.n_heads, self.d_head).transpose(1, 2) v = self.w_uv(c_kv).view(B, L, self.n_heads, self.d_head).transpose(1, 2) q_c = self.w_uq(c_q).view(B, L, self.n_heads, self.d_head).transpose(1, 2)
k_r = apply_rope(self.w_kr(x), cos, sin) # [B, L, d_rope] q_r = apply_rope(self.w_qr(c_q).view(B, L, self.n_heads, self.d_rope), cos[:, None], sin[:, None]) k_r = k_r[:, :, None].expand(-1, -1, self.n_heads, -1).transpose(1, 2)
q = torch.cat([q_c, q_r.transpose(1, 2)], dim=-1) # [B, H, L, d_head + d_rope] k = torch.cat([k_c, k_r], dim=-1) scores = q @ k.transpose(-1, -2) / math.sqrt(self.d_head + self.d_rope) mask = torch.ones(L, L, dtype=torch.bool, device=x.device).tril() scores = scores.masked_fill(~mask, -torch.inf) o = scores.softmax(-1) @ v return self.w_o(o.transpose(1, 2).reshape(B, L, -1))吸收版也要相应调整:缓存里同时放 和已旋转的 ,内容分用 在隐空间算,位置分直接对缓存的 做内积:
@torch.no_grad() def absorbed_step(self, h_t: torch.Tensor, pos: int, cos, sin, cache=None): """单 token 解码:缓存 c_kv 与旋转后的 k_r。h_t: [B, 1, d]""" c_kv = self.w_dkv(h_t) # [B, 1, d_c] k_r = apply_rope(self.w_kr(h_t), cos[pos], sin[pos]) # [B, 1, d_rope] new = torch.cat([c_kv, k_r], dim=-1) # 进缓存的全部内容 cache = new if cache is None else torch.cat([cache, new], dim=1) c_cache, kr_cache = cache[..., : self.d_c], cache[..., self.d_c :]
c_q = self.w_dq(h_t) q_c = self.w_uq(c_q).view(-1, 1, self.n_heads, self.d_head) q_r = apply_rope(self.w_qr(c_q).view(-1, 1, self.n_heads, self.d_rope), cos[pos], sin[pos])
w_uk = self.w_uk.weight.view(self.n_heads, self.d_head, self.d_c) q_lat = torch.einsum("btha,hac->bthc", q_c, w_uk) # 内容分:吸收版 s_c = torch.einsum("bthc,bsc->bths", q_lat, c_cache) s_r = torch.einsum("bthr,bsr->bths", q_r, kr_cache) # 位置分:直接算 attn = ((s_c + s_r) / math.sqrt(self.d_head + self.d_rope)).softmax(-1)
z = torch.einsum("bths,bsc->bthc", attn, c_cache) w_uv = self.w_uv.weight.view(self.n_heads, self.d_head, self.d_c) o = torch.einsum("bthc,hac->btha", z, w_uv) return self.w_o(o.reshape(-1, 1, self.n_heads * self.d_head)), cache注意位置分的内积 s_r 直接用缓存的 kr_cache: 在入缓存时就已经按自己的位置旋转过了,读出来即用,这正是解耦的意义:它不参与低秩压缩,位置信息不会卡住吸收。验证带解耦 RoPE 的两种实现:
torch.manual_seed(0)L = 10cos, sin = precompute_cos_sin(L, d_rope=8)mla = MLADecoupledRoPE(d=128, n_heads=4, d_head=16, d_c=32, d_cq=48, d_rope=8).eval()x = torch.randn(1, L, 128)
ref = mla(x, cos, sin)cache = Nonefor t in range(L): out_t, cache = mla.absorbed_step(x[:, t : t + 1], pos=t, cos=cos, sin=sin, cache=cache) assert torch.allclose(out_t, ref[:, t : t + 1], atol=1e-5)
per_tok = mla.d_c + mla.d_ropeprint(f"吸收版与朴素版逐位一致")print(f"每 token 每层缓存:{per_tok} 元素:d_c={mla.d_c} + d_rope={mla.d_rope}")输出:
吸收版与朴素版逐位一致每 token 每层缓存:40 元素:d_c=32 + d_rope=8实际配置
最后把 DeepSeek-V2 的真实数字放进公式。它的注意力配置是 、、、、,共 层:
换成标准 MHA 的算法是 万,MLA 只有它的 1.8%;换成 GQA 的口径,相当于一个只有 2.25 组的 GQA,也只有 MQA 的 的 2.25 倍。这里的 1.8% 是固定头数、每头维度与层数后的理论比较。附录 D.2 另行训练了 16B 与 250B 两组 MoE 模型;它们的 MHA 基线缓存规模与上面的假设不同,对应缓存比例约为 14% 和 4%。小模型训练 1.33T token,大模型训练 420B token,结果如下:
| 规模 | 指标 | MHA | MLA |
|---|---|---|---|
| 16B | 每 token 缓存 | 110.6K | 15.6K |
| 16B | MMLU / BBH | 48.7 / 37.9 | 50.0 / 39.0 |
| 16B | C-Eval | 51.6 | 50.9 |
| 250B | 每 token 缓存 | 860.2K | 34.6K |
| 250B | MMLU / BBH | 57.5 / 46.6 | 59.0 / 50.7 |
| 250B | C-Eval | 57.9 | 59.2 |
MLA 在这些实验的多数指标上更好,小模型的 C-Eval 则略有下降。
还有两个工程细节。其一,论文在压缩隐向量后面加了额外的 RMSNorm,并在瓶颈处乘缩放因子,因为低秩压缩会改变层的输出尺度,这与 EP.2 讲的归一化思路一脉相承。上面的教学代码省略了这些归一化、额外缩放以及长上下文 YaRN 配置,验证的是简化结构中朴素前向与吸收计算的等价性。其二,吸收形式下 logits 是在 维的隐空间里算的,它换来的是显存与带宽,而不是更少的乘加。长上下文 decode 的瓶颈在搬运缓存,减少缓存搬运因此可能比减少乘加更有收益。
MLA 此后成了 DeepSeek 的标志性设计:V3、R1 沿用同一结构,Kimi K2 等模型也采用了它。它通过共享隐向量、保留各头投影,在压缩缓存的同时保留了各头不同的 K/V。
结语
本文沿着一条问题的线索走到了 MLA:自回归生成让历史 K/V 可以缓存,缓存却成了显存和带宽的双重瓶颈;MQA/GQA 用共享换缓存,在上面的实验中付出了性能代价;MLA 改用低秩联合压缩,让每个头仍有自己的 K/V,只是共享同一个隐向量;矩阵吸收用结合律避免显式还原历史 K/V;解耦 RoPE 则把位置信息请出压缩通道,补上了最后一块拼图。
MLA 的关键是压缩各头 K/V 的联合表示,再让注意力直接读取压缩后的缓存。理解这条计算路径,就能把低秩压缩、矩阵吸收和解耦 RoPE 连成一个完整方案。
下图把内容压缩、矩阵吸收与解耦 RoPE 合成一条解码路径,只展开第 个头。缓存中的 同时参与内容打分与 value 聚合;位置分支单独缓存已经旋转的 。图中 、 都可预先合并,因而不必显式还原历史内容 K/V。
参考资料
- Attention Is All You Need
- Fast Transformer Decoding: One Write-Head is All You Need
- GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints
- RoFormer: Enhanced Transformer with Rotary Position Embedding
- DeepSeekMoE: Towards Ultimate Expert Specialization in Mixture-of-Experts Language Models
- DeepSeek-V2: A Strong, Economical, and Efficient Mixture-of-Experts Language Model
- DeepSeek-V3 Technical Report
- DeepSeek-V2 推理代码
- small-language-model
- RethinkFun:DeepSeek V2