问题:KV cache 成了推理的瓶颈

自回归推理时,每生成一个 token 都要和全部历史 token 做注意力,所以历史的 K、V 必须缓存下来。MHA 里每个头都有自己的 K、V,按 DeepSeek-V3 的维度(128 个头,K 192 维、V 128 维)算,一个 token 在一层里就要存 40960 个元素,61 层合计约 4.8 MB。KV cache 的大小直接决定了一张卡能同时服务多少请求、能支持多长的上下文;而 decode 阶段每一步都要把这些 cache 从显存完整读一遍,它又决定了 decode 的速度。

MQA、GQA 的办法是减少 KV 头的数量:让多个 query 头共用一组 K、V,cache 立刻缩小几倍到上百倍。代价也很直接——K、V 的表达能力跟着头数一起被砍掉了,模型效果会打折扣。

于是问题变成:能不能只缓存很少的东西,却仍然让每个头看到各不相同的 K、V?

核心思想:缓存一个能还原所有头的 latent

MLA(Multi-head Latent Attention)的回答来自 LoRA 式的低秩分解。传统注意力直接用 W_{Q}、W_{K}、W_{V} 从输入 X 得到 Q、K、V:

Q = X W_{Q} \quad K = X W_{K} \quad V = X W_{V}

MLA 在中间插入一个“先压缩、再展开”的步骤:

c_{Q} = X W_{DQ} \qquad Q = c_{Q} W_{UQ}

c_{KV} = X W_{DKV} \qquad K = c_{KV} W_{UK} \qquad V = c_{KV} W_{UV}

D 表示 down(压缩),U 表示 up(展开)。压缩后的低维张量叫 latent:c_{KV} 只有 512 维,却能通过 W_{UK}、W_{UV} 展开成 128 个头各不相同的 K、V。

关键在于缓存什么。展开后的 K、V 完全由 c_{KV} 决定,所以只需要缓存 c_{KV} 本身,需要时再展开。GQA/MQA 是“少存几个头”,MLA 是“存一个可以还原所有头的低秩表示”:

每 token 每层的 KV cache 大小对比

图里 MLA 的 576 比 512 多出来的 64 维是 RoPE 部分,原因见后文的 RoPE 一节。先忽略 RoPE,看看“只存 latent”之后注意力怎么算。

同一个注意力,两种算法:Normal 与 Absorb

Normal:先展开,再做 MHA

最直接的做法是把 cache 里的 c_{KV} 展开成每个头的 K、V,然后照常做 MHA。下面用 W_{qb} 表示 W_{UQ},W_{kb}、W_{vb} 分别表示 W_{UK}、W_{UV}(合起来就是代码里的 kv_b_proj):

\begin{aligned} \text{MHA 路径} &= \text{softmax}\left( \frac{QK^{T}}{\sqrt{k}} \right) V \\ &= \text{softmax}\left( \frac{c_{q} W_{qb} (c_{KV} W_{kb})^T}{\sqrt{k}} \right) (c_{KV} W_{vb}) \end{aligned}

这条路径叫 Normal 模式。它在效果上就是 MHA,cache 也确实只存了 latent。问题是每次计算都要把全部 S 个历史 token 的 latent 重新展开一遍,展开的代价随 KV 长度 S 增长。

Absorb:把上投影吸收到 q 和输出上

矩阵乘法满足结合律,可以换一个结合顺序:

\begin{aligned} \text{MQA 路径} &= \text{softmax}\left( \frac{QK^{T}}{\sqrt{k}} \right) V \\ &= \text{softmax}\left( \frac{c_{q} W_{qb} (c_{KV} W_{kb})^T}{\sqrt{k}} \right) c_{KV} W_{vb} \\ &= (\text{softmax}\left( \frac{(c_{q} W_{qb} W_{kb}^T) c_{KV}^T}{\sqrt{k}} \right) c_{KV}) W_{vb} \end{aligned}

最后一行里,W_{kb} 被“吸收”进了 query:先算 \tilde{q} = c_{q} W_{qb} W_{kb}^{T}(每个头一个 512 维向量),再直接和 c_{KV} 做点积;W_{vb} 则挪到注意力之后,先对 latent 做加权求和,再展开到每个头。

这时所有头的 K 和 V 都是同一份 c_{KV}——这正是 MQA 的形状:128 个 query 头,1 个 KV 头。这条路径叫 Absorb 模式。它数学上与 Normal 完全等价,却只需要把上投影乘在 T 个新 token 上,而不是 S 个历史 token 上。

谁更省:看 T 和 S 的比例

两种模式交换的是两笔开销:

Normal 与 Absorb 的计算量权衡

于是有了推理里的分工:

RoPE 为什么要单独拆出来

上面的推导有个前提:Q 和 K 之间只隔着与位置无关的矩阵。RoPE 打破了这个前提。给 query 位置 i、key 位置 j 加上旋转矩阵 R_i、R_j 之后:

q_i^{T} k_j = (R_i\, q_i)^{T} (R_j\, W_{UK}^{T} c_j) = q_i^{T}\, R_{j-i}\, W_{UK}^{T}\, c_j

W_{UK} 和 query 之间夹了一个随 j-i 变化的旋转矩阵,没法再预先把 W_{UK} 吸收进 q;要算这一项,就只能对每个历史 token 展开 K,Absorb 的意义就没了。

DeepSeek 的解法叫 decoupled RoPE:把每个头的 q、k 拆成两段——

两段的点积相加就是完整的注意力分数。这就是 cache 里每个 token 是 512 + 64 = 576 维的原因。DeepSeek-V2 论文中的原始流程图如下:

mla 流程

Normal(MHA)路径的完整数学流程:

c_{Q} = X W_{DQ} Q_{nope} = c_{Q} W_{UQ} Q_{rope} = \text{RoPE}(c_{Q} W_{QR}) c_{KV} = X W_{DKV} K_{nope} = c_{KV} W_{UK} V = c_{KV} W_{UV} K_{rope} = \text{RoPE}(X W_{KR}) Q = \text{Concat}(Q_{nope}, Q_{rope}) K = \text{Concat}(K_{nope}, K_{rope}) \text{MHA} = \text{softmax}(\frac{QK^T}{\sqrt{k}})V

注意 K_{nope} 不加 RoPE,K_{rope} 只有一份,拼接时在所有头之间广播。对应的流程图:

MLA normal 流程

decode 阶段走的 Absorb(MQA)路径只改动 nope 段,rope 段原样保留:

c_{Q} = X W_{DQ} Q_{nope} = c_{Q} W_{UQ} Q_{rope} = \text{RoPE}(c_{Q} W_{QR}) Q = \text{Concat}(Q_{nope} W_{UK}^{T}, Q_{rope}) c_{KV} = X W_{DKV} K_{rope} = \text{RoPE}(X W_{KR}) K = \text{Concat}(c_{KV}, K_{rope}) V = c_{KV} W_{UV} \text{MQA} = \text{softmax}(\frac{QK^T}{\sqrt{k}})V

这里的 K 只有一个头:所有 query 头共享同一个 576 维的 K。最后一行是简写,实际计算时先用 c_{KV}(即 K 的前 512 维)做加权求和,再乘 W_{UV},与上一节 MQA 路径公式的最后一行一致。缩放因子 \sqrt{k} 仍按原始的 192 维计算,因为吸收不改变点积的值。对应的流程图:

MLA absorb 流程

计算量分析

用 T 表示 q_len,S 表示 kv_len(含前缀),单位为乘加次数,单层、不切 TP:

\begin{aligned} &\text{两条路径共有的部分} \\ &\quad \text{flops\_q\_a\_and\_kv\_a}: \quad T \times 7168 \times (1536 + 512 + 64) \\ &\quad \text{flops\_qb}: \quad T \times 1536 \times 128 \times 192 \\ &\quad \text{flops\_oproj}: \quad T \times 128 \times 128 \times 7168 \\ \\ &\text{Normal(MHA)路径} \\ &\quad \text{flops\_kb}: \quad S \times 512 \times 128 \times 128 \\ &\quad \text{flops\_vb}: \quad S \times 512 \times 128 \times 128 \\ &\quad \text{flops\_mha}: \quad 128 \times (T \times S \times 192 + T \times S \times 128) \\ \\ &\text{Absorb(MQA)路径} \\ &\quad \text{flops\_absorb\_kb}: \quad T \times 128 \times 128 \times 512 \\ &\quad \text{flops\_absorb\_vb}: \quad T \times 128 \times 512 \times 128 \\ &\quad \text{flops\_mqa}: \quad 128 \times (T \times S \times 576 + T \times S \times 512) \end{aligned}

定性结论:

SGLang 中的实现

SGLang 里 DeepSeek V2/V3 的 MLA 层是 DeepseekV2AttentionMLA(srt/models/deepseek_v2.py)。它本身只负责初始化和分派,两条路径的前向分别放在 srt/models/deepseek_common/attention_forward_methods/ 下的 mixin 里:forward_mha.py(Normal)和 forward_mla.py(Absorb),另有 ROCm、CPU、NPU 的变体。

SGLang 中一层 MLA 的两条路径

代码符号与数学符号的对应

读代码时最容易绕晕的是两套命名。代码按“合并后的 GEMM”命名,数学按“语义”命名,对应关系如下:

代码 数学 说明
q_lora_rank / kv_lora_rank c_Q / c_{KV} 的维度 1536 / 512
qk_nope_head_dim / qk_rope_head_dim / v_head_dim nope 段 / rope 段 / v 的每头维度 128 / 64 / 128
fused_qkv_a_proj_with_mqa [W_{DQ}, W_{DKV}, W_{KR}] 三个 down 投影合成一个 GEMM,输出 1536 + 512 + 64
q_b_proj [W_{UQ}, W_{QR}] 每头输出 192 = 128 + 64
kv_b_proj [W_{UK}, W_{UV}] 每头输出 256 = 128 + 128,Normal 用
w_kc / w_vc W_{UK} / W_{UV} 从 kv_b_proj 拆出来的逐头矩阵,Absorb 用
kv_a_layernorm 之后的 latent c_{KV} 缓存的前 512 维
k_pe k^{rope} 缓存的后 64 维

checkpoint 里 q_a_proj 和 kv_a_proj_with_mqa 仍是两个张量,加载时(deepseek_common/deepseek_weight_loader.py)先各自缓存,两者都到齐后沿输出维 torch.cat 成 fused_qkv_a_proj_with_mqa 的权重。前向时这次 GEMM 的结果按 [q_lora_rank, kv_lora_rank + qk_rope_head_dim] 切开,后一段再切成 c_KV 和 k_pe。V2-Lite 这类 q_lora_rank is None 的模型没有 q 压缩,走 q_proj + kv_a_proj_with_mqa 两个独立投影。

两套权重从一份 checkpoint 而来

Normal 需要 kv_b_proj 这个大 GEMM,Absorb 需要逐头的 w_kc、w_vc 做 batched matmul。它们是同一份权重的两种视图,在 post_load_weights 里派生:

w_kc, w_vc = w.unflatten(0, (-1, qk_nope_head_dim + v_head_dim)).split(
    [qk_nope_head_dim, v_head_dim], dim=1)   # 均为 [H, 128, 512]
# 之后 w_vc 转置为 [H, 512, 128];w_kc 逻辑形状不变,只改成列主序存储

Absorb 时 torch.bmm(q_nope.transpose(0, 1), self.w_kc) 把 [H, T, 128] 乘成 [H, T, 512],注意力之后再用 w_vc 乘回 [T, H·128]。fp8 checkpoint 会在这里先反量化或转成 per-tensor scale,另有 bmm_fp8、DeepGEMM masked grouped GEMM 等变体,但逻辑上都是这两次 bmm。

KV cache:一份 576 维的 latent

MLA 的 cache 由 MLATokenToKVPool(srt/mem_cache/memory_pool.py)管理。与 MHA 的 pool 分别存 K、V 不同,它每层只有一个 buffer,形状是 [size + page_size, 1, kv_lora_rank + qk_rope_head_dim]:中间那个 1 就是“只有一个 KV 头”。写入用 set_mla_kv_buffer(layer, loc, cache_k_nope, cache_k_rope),把归一化后的 c_KV 和已经加过 RoPE 的 k_pe 拼进同一行;读出用 get_mla_kv_buffer 再拆回两段。索引与分配和其他 pool 完全一样,详见显存池。

两条路径读写的是同一份 cache,这是 Normal / Absorb 能逐 batch 自由切换的前提。在模型里它们对应两个 RadixAttention 实例:

这个设计也带来一个副作用:所有头共享同一份 latent,attention TP 无法再按头切分 KV,每个 rank 都持有完整副本,DCP 正是为此沿 token 维度切分 cache。

路径选择:按 batch 决定,而不是按阶段

前面说“prefill 用 Normal、decode 用 Absorb”是一个近似。SGLang 实际上对每个 batch调用 dispatch_attn_forward_method,返回一个 AttnForwardMethod(deepseek_common/attention_forward_methods/forward_methods.py),再由 forward_prepare / forward_core 分派。规则按 attention backend 注册在 deepseek_common/attention_backend_handler.py 中。fa3、flashinfer、flashmla 共用的规则大致是:

batch 情况 结果
decode、投机解码的 verify / draft extend、CUDA graph 中 MLA(Absorb)
普通 extend,前缀为 0 MHA_ONE_SHOT;总长度超过单块容量时 MHA_CHUNKED_KV
普通 extend,前缀 ≥ SGLANG_CHUNKED_PREFIX_CACHE_THRESHOLD(默认 8192) 同上
普通 extend,0 < 前缀 < 阈值 MLA(Absorb)

三个 MHA 变体都是 Normal,区别在于前缀怎么处理:

短前缀走 Absorb 是工程上的取舍:为一小段前缀多做一次读出和展开得不偿失,并不是 flops 上的精确分界。不同 backend 的规则也不完全相同,例如 triton 只在前缀为 0 时走 MHA,trtllm_mla 在 extend 时倾向 MHA_CHUNKED_KV。

一个容易踩的细节:k_pe 的广播

Normal 路径里 k_pe 只有一份,而 attn_mha 要的是每个头一份完整的 192 维 K。_concat_and_cast_mha_k 负责把 k_nope [S, H, 128] 和广播后的 k_pe [S, 1, 64] 拼成 [S, H, 192],并按需转成 fp8;对常见的 128 头 / 128 / 64 维组合有专门的 concat_mla_k CUDA kernel。Absorb 路径则完全没有这一步,q_pe 和 k_pe 直接拼在 576 维的末尾——这也是 Absorb 在 decode 上更轻的原因之一。

shape 速查

以 DeepSeek-V3 为例,T 为 q_len,S 为 kv_len,均省略 batch 维:

小结

MLA 的价值在于把“缓存多少”和“每个头看到什么”拆开了:cache 里只存一个 576 维的低秩 latent,体积接近 MQA;而通过 W_{UK}、W_{UV} 的展开,每个头仍然拥有不同的 K、V,表达能力接近 MHA。矩阵乘法的结合律让同一个注意力有两种算法:Normal 把上投影乘在 KV 侧,适合 T\approx S 的 prefill;Absorb 把上投影吸收进 q 和输出,适合 T\ll S 的 decode。decoupled RoPE 则让位置编码绕开 latent,保住了吸收的可能。SGLang 把这些落实为一份共享的 MLATokenToKVPool、两套由 kv_b_proj 派生的权重视图,以及按 batch 选择路径的 AttnForwardMethod 分派。