从多头共享到潜变量:DeepSeek的MLA在低秩投影与按需解压中重新定义 KV-Cache

发布于
10分钟
4,185
en / zh语言

引言

在大语言模型繁荣的时代,硬件资源依然是绕不过去的“天花板”——特别是 GPU 显存有限,如何在有限资源下让模型拥有更长的上下文、更快的推理速度,一直是工程与研究领域关注的焦点。除了常见的量化、剪枝,越来越多人也将目光投向 “减少推理时 KV-Cache 占用” 这个方向。

本文将先回顾 MHA(Multi-Head Attention)、MQA(Multi-Query Attention)与 GQA(Grouped-Query Attention)在共享或减少 K/V 方面的思考与取舍,然后聚焦于最新的 MLA(Multi-Head Latent Attention)。不同于前者仅在“共享 K/V”层面做文章,MLA 通过低秩投影与按需解压的组合,让 KV-Cache 不再直接存储多头 K/V,而是改为 “潜变量” 形式;在此基础上,它还能进一步使用“合并矩阵”的技巧,在推理阶段仅依赖极少的显存来完成注意力计算。

需要说明的是,MLA 在实际落地时往往还需兼容 RoPE(Rotary Position Embedding)这一位置编码方案;但为了让读者先理解 MLA 的核心(低秩投影本身),我们会在 介绍完 MLA 的主干思路 后,再探讨 RoPE 与 MLA 的结合方式。希望这样的安排能使你感受到 MLA 设计背后的层次与巧思。

特别感谢:本文借鉴了苏剑林的博客缓存与效果的极限拉扯:从MHA、MQA、GQA到MLA中的一些想法,向苏建林老师致敬。


1. 为什么要减少 KV-Cache?

1.1 长序列推理中显存的“隐形杀手”

自回归 Transformer 每生成一个新 Token,都需要回顾之前所有 Token 的 Key / Value (K,VK, V) 做注意力;这部分历史 K/V 在推理阶段被缓存下来,即所谓 KV-Cache。如果序列长度为 LL、注意力头数为 hh,单头维度为 dkd_kdvd_v,则 KV-Cache 的量级约为

L×h×(dk+dv),L \times h \times (d_k + d_v),(1)

并随 LL 线性增长。当上下文长度轻松达到数千到上万时,KV-Cache 成为显存占用的主要瓶颈,比模型本身的激活值更为庞大。

1.2 显存与带宽的双重约束

当序列过长,“一张卡”往往无法容纳全部 KV-Cache,需要多卡或多机分担。但跨卡和跨机的带宽远低于卡内带宽,导致推理速度显著变慢。简而言之,如果能在单卡或少卡上实现更长的上下文,就能节省通信开销,提升推理吞吐量。这也是近年在 MQA / GQA 以及 MLA 等思路上不断迭代的根源。


2. MHA → MQA → GQA:多头注意力中 K/V 的简化历程

在聚焦 MLA 之前,让我们先快速对多头注意力(MHA)以及后来的共享式注意力(MQA、GQA)做一个小结,为后续对比 MLA 奠定基础。

2.1 多头注意力(MHA)的原点

2.1.1 经典注意力公式

在 Transformer 中,一段序列 x1,,xl\mathbf{x}_1,\dots,\mathbf{x}_l 会映射到多组 (Q,K,V)(Q, K, V) 来计算注意力。以第 ss 个注意力头为例,令隐藏维度为 dd

qi(s)=xiWq(s),ki(s)=xiWk(s),vi(s)=xiWv(s).\mathbf{q}_i^{(s)} = \mathbf{x}_i \,\mathbf{W}_q^{(s)},\quad \mathbf{k}_i^{(s)} = \mathbf{x}_i \,\mathbf{W}_k^{(s)},\quad \mathbf{v}_i^{(s)} = \mathbf{x}_i \,\mathbf{W}_v^{(s)}.(2)

此时若进行自回归计算,第 tt 步的注意力分数往往写作

αt,i(s)=qt(s)ki(s),其中 it.\alpha_{t,i}^{(s)} = \mathbf{q}_t^{(s)} \,\mathbf{k}_i^{(s)\top}, \quad\text{其中 } i \le t.(3)

在推理时,为加速计算,我们把已经算出的 ki(s),vi(s)\mathbf{k}_i^{(s)},\mathbf{v}_i^{(s)} 暂存于显存里,供后续 Token 使用,这部分显存缓存就称为 KV-Cache

2.1.2 显存压力

由于 MHA 中每个 Head 都独立存储 K、V,若 Head 数 hh 较大,就得存下 hh 份相同长度的 Key/Value,显存消耗惊人。为此,研究者开始思考:是不是能让多头注意力在 K/V 层面也做一些共享合并压缩

2.2 MQA:极端共享 K/V

MQA(Multi-Query Attention)在 2019 年提出,核心是让所有 Head 只用一套 K/V:

ki=xiWk,vi=xiWv,\mathbf{k}_i = \mathbf{x}_i \mathbf{W}_k,\quad \mathbf{v}_i = \mathbf{x}_i \mathbf{W}_v,(4)

而各 Head 仍可有不同 qi(s)\mathbf{q}_i^{(s)}。这样,KV-Cache 只需存 1 份 K/V,显存占用是原来的 1h\tfrac{1}{h}。在 PaLM、StarCoder 等模型中有使用。不过由于 K、V 过度共享,有些任务上的精度可能下降,需要额外训练技巧弥补。

2.3 GQA:分组共享

如果觉得 MQA 太极端,可以用 GQA(Grouped-Query Attention)折中:把 hh 个 Head 划分为 gg 组,同一组内共享 K/V;这样 KV-Cache 缩减到原先的 g/hg/h,保留一定多样性。LLaMA2-70B、ChatGLM2/3 等是这一路线的代表。

2.4 小对比:MHA / MQA / GQA

方案KV-Cache 存储是否共享 K/V显存省多头灵活度
MHAhh各 Head 独立低 (基线)
MQA存 1 份所有 Head 完全共享
GQAgg分组共享中等较高

无论是 MQA 还是 GQA,都还处在“是否共享 K/V”这个思路。MLA 则从根本上改变“推理时存什么”:它将大多数 K/V 信息转移到一个潜变量里,并在需要时才“还原”出来。下面让我们首先看看没有 RoPE 干扰时,MLA 如何实现低秩投影与按需解压。


3. MLA 的核心:低秩投影与按需还原(不含 RoPE)

3.1 基本思路:改“存多头 K/V”为“存低维潜变量”

MLA(Multi-Head Latent Attention)里,训练阶段依然会对输入做投影,得到各个 Head 的 Key、Value。但它引入一个低秩潜变量 ci\mathbf{c}_i,在推理时只需要缓存这个维度远小于 dd 的向量,就能借助一套“矩阵合并”来临时得到多头的 K、V。如果只考虑不带位置编码(RoPE)的核心公式,MLA 的训练过程可以描述为:

ci=xiWc(低秩投影, WcRd×dc),\mathbf{c}_i = \mathbf{x}_i \mathbf{W}_c\quad (\text{低秩投影, } \mathbf{W}_c \in \mathbb{R}^{d \times d_c}),(5)

并且对每个 Head ss,定义解压矩阵 Wkc(s),Wv(s)\mathbf{W}_{kc}^{(s)}, \mathbf{W}_v^{(s)},使得

ki(s)=ciWkc(s),vi(s)=ciWv(s).\mathbf{k}_i^{(s)} = \mathbf{c}_i \mathbf{W}_{kc}^{(s)}, \quad\mathbf{v}_i^{(s)} = \mathbf{c}_i \mathbf{W}_v^{(s)}.(6)

这样一来,若 Head 数再多,也只是训练时会显式生成多份 K/V;但在推理时,我们不必缓存所有 ki(s),vi(s)\mathbf{k}_i^{(s)}, \mathbf{v}_i^{(s)},而只要保留潜变量 ci\mathbf{c}_i。需要计算时,通过合并矩阵乘法就能“还原”出各头的 K、V。由此可见,MLA 很大程度上摆脱了“多头数 hh 与 KV-Cache 成正比”这件事。

3.2 动态解压:显存怎么省?

推理中,每当生成第 tt 个 Token,需要与历史 i<ti < t 的 Key 做点积 qt(s)ki(s)\mathbf{q}_t^{(s)} \mathbf{k}_i^{(s)\top}。传统方法下要在显存中直接拿 ki(s)\mathbf{k}_i^{(s)},但在 MLA 里我们“合并”了

ki(s)=ciWkc(s)    qt(s)ki(s)=(xtWq(s))(ciWkc(s)).\mathbf{k}_i^{(s)}=\mathbf{c}_i\,\mathbf{W}_{kc}^{(s)}\implies\mathbf{q}_t^{(s)} \mathbf{k}_i^{(s)\top}=(\mathbf{x}_t \mathbf{W}_q^{(s)}) (\mathbf{c}_i \mathbf{W}_{kc}^{(s)})^\top.(7)

根据矩阵乘法,我们可以做如下合并:

(xtWq(s))(ciWkc(s))=xt(Wq(s)Wkc(s))(ci)=xtWmerged(s)(ci)(\mathbf{x}_t \mathbf{W}_q^{(s)}) (\mathbf{c}_i \mathbf{W}_{kc}^{(s)})^\top = \mathbf{x}_t (\mathbf{W}^{(s)}_q \mathbf{W}^{(s)\top}_{kc}) (\mathbf{c}_i)^\top = \mathbf{x}_t \mathbf{W}_{\mathrm{merged}}^{(s)} (\mathbf{c}_i)^\top(8)

从而只需要在显存中保留 ci\mathbf{c}_i,而无需分别保存 ki(s)\mathbf{k}_i^{(s)}。显然,如果 ciRdc\mathbf{c}_i \in \mathbb{R}^{d_c}dch×dkd_c \ll h \times d_k,那么KV-Cache 的占用就从原先按 h×dkh \times d_k 线性增长,变成按 dcd_c 成本增长

3.3 低秩投影如何大幅压缩存储?

有些读者可能会好奇,这种低秩投影压缩率有多大?取一个示例,如果原模型维度 d=4096d=4096、多头数 h=32h=32、单头 dk=128d_k=128;那么传统 MHA 每个 Token 要存下 32×128=409632 \times 128 = 4096 维 Key(加上 Value 也是同量级),而 MLA 可以把潜变量 ci\mathbf{c}_i 设成 512 维,显存需求就从 4096 缩减到 512,几乎减少 8 倍。在某些更极端场景,压缩比例可高达几十甚至数百倍。

当然,这只是一个不带位置编码的美好设想。实际上,Transformer 常用的 RoPE(Rotary Position Embedding)会影响 K、Q 的投影方式,使单纯“潜变量 + 合并矩阵”无法简单替代。为了让读者先理解 MLA 的低秩核心,我们暂不展开 RoPE;接下来会从一个“相册系统”的比喻帮助你理清 MLA 的运作流程,然后再单独讨论 RoPE 问题。


4. 从智能相册系统看 MLA

在说完 MLA 的数学公式后,或许你对“潜变量 ci\mathbf{c}_i + 投影矩阵”仍然停留在抽象层面。下面让我们用一个生活化的类比:把“Token”比做“拍摄的照片”,把“多头注意力”比做“给照片套滤镜”,而把“KV Cache”看作“相册存储空间”,展示 MLA 如何在这个体系里实现“压缩存储”与“动态解压”。

4.1 拍照存储:低秩投影

想象每次拍照(即处理一个 Token xi\mathbf{x}_i),我们并不把它保存为“多头滤镜后的完整分辨率原图”,而是只保留一个“体积小但保留关键信息”的智能缩略图(潜变量 ci\mathbf{c}_i)。类似地,若原始图像分辨率是 4096,对应 MLA 里 d=4096d=4096,缩略图可能只有 512,这会带来约 1/8 甚至更高的压缩比例。
在数学上,这就是

ci=xiWc,WcR4096×512,\mathbf{c}_i =\mathbf{x}_i \,\mathbf{W}_c, \quad \mathbf{W}_c \in \mathbb{R}^{4096 \times 512},(9)

扮演了“拍照时做一次低秩投影”的作用。

4.2 浏览照片:实时动态解压

当我们要“浏览”某张照片,并试图把它变成“某种滤镜版本”时,可以理解为要计算特定的 Key/Value(比如复古滤镜、HDR 滤镜,对应不同 Head 的 K、V)。在 MLA 中,这个过程相当于

ki(s)=ciWkc(s),vi(s)=ciWv(s),\mathbf{k}_i^{(s)} = \mathbf{c}_i \,\mathbf{W}_{kc}^{(s)}, \quad \mathbf{v}_i^{(s)} = \mathbf{c}_i \,\mathbf{W}_v^{(s)},(10)

s也就是把缩略图 ci\mathbf{c}_i 投影到你想要的滤镜。因此,哪怕有十几种滤镜(多头),我们也不必重复存十几份滤镜版原图,而是只存一张缩略图(ci\mathbf{c}_i),在“浏览”时按需解压。这样,就达成了“相册容量极度压缩”的目标。

4.3 动态解压的数学对应:按需还原

在实际推理中,“合并矩阵”的过程就像“把滤镜的计算提前或合并到查看行为”中,用公式来说:

qt(s)ki(s)=(xtWq(s))(ciWkc(s))xtWmerged(s)ci.\mathbf{q}_t^{(s)} \mathbf{k}_i^{(s)\top} = \bigl(\mathbf{x}_t \mathbf{W}_q^{(s)}\bigr) \bigl(\mathbf{c}_i \mathbf{W}_{kc}^{(s)}\bigr)^\top \approx \mathbf{x}_t \,\mathbf{W}_{\mathrm{merged}}^{(s)}\,\mathbf{c}_i^\top.(11)

换句话说,滤镜参数 Wkc(s)\mathbf{W}_{kc}^{(s)} 不再需要静态保存在相册中,而是只在查看时临时计算。最终,我们的显存里只保留这个“压缩向量ci\mathbf{c}_i,由此大幅度减少存储成本。

通过这个相册类比,你会发现 MLA 本质上避免了对每个注意力头都存一整份 Key/Value,而是将 K/V 的大部分信息浓缩到一个低秩潜变量里,让“滤镜”(多头投影矩阵)在推理时即时生效。


5. RoPE 的挑战:为何要再加“位置贴纸”?

到这里,MLA 的低秩投影与按需解压思路已相对清晰。不过,在真实的 Transformer 应用中,常见的 RoPE(Rotary Position Embedding)会为 Key、Query 添加一个位置相关的旋转矩阵 Ri\mathbf{\mathcal{R}}_i,以便实现相对位置的建模。但这会打破前文的“把所有头的 K 都只存潜变量”做法。简单来说,RoPE 需要显式知道某个 Token 的位置索引 ii,并且这种位置索引会影响点积公式里的“旋转相减”性质。

5.1 RoPE:拍摄时间与 GPS 坐标

若再回到“相册系统”的比喻,可以把 RoPE 理解成“拍摄时间和 GPS 坐标”:除了基本图像信息(ci\mathbf{c}_i)之外,我们还需要单独存一张小贴纸,用来记录照片的拍摄地点或时间。这样,在对不同照片进行对比或排序时,就能根据“时间差 / 距离差”进行更准确的比较。
一旦我们尝试把拍摄时间直接混进“缩略图”里,会让相对位置的计算变得复杂或失效。这就是为什么 MLA 在面对 RoPE 时,往往把 Key/Query 的维度分成两部分:一部分来自共享潜变量,另一部分仍显式乘以 RoPE 矩阵,以保留位置信息。

5.2 分治策略:ci\mathbf{c}_i + RoPE 小维度

在数学层面,为了保留旋转矩阵 Ri\mathbf{\mathcal{R}}_i 的相减性质,MLA 会把 ki(s)\mathbf{k}_i^{(s)}(以及 qi(s)\mathbf{q}_i^{(s)})写成:

ki(s)=(ciWkc(s))压缩区    (xiWkrRi)位置区,\mathbf{k}_i^{(s)}=\underbrace{\bigl(\mathbf{c}_i \,\mathbf{W}_{kc}^{(s)}\bigr)}_{\text{压缩区}} \;\oplus\;\underbrace{\bigl(\mathbf{x}_i \,\mathbf{W}_{kr} \,\mathbf{\mathcal{R}}_i\bigr)}_{\text{位置区}},(12)

从而,在 KV-Cache 中只多出一个较小的“位置区”,而主部分的存储成本仍维持在潜变量 ci\mathbf{c}_i 上,这就巧妙地把“低秩投影”与“RoPE 相对位置”结合起来。


6. MLA 的综合优势:存储革命、灵活查询、时空保真

经过前面两节的拆分介绍,我们可以再次回到一个全局视角,看看 MLA 在“存储革命”“动态解压”和“时空保真”三个层面所带来的提升。

从存储角度看,原本多头注意力在推理阶段必须为每个 Head 保留独立 K/V;而在 MLA 中,大部分信息用低秩向量 ci\mathbf{c}_i 表示,尺寸极小,显存可以减少数倍甚至数十倍。真正做到了一种“拍照时直接存缩略图”的思路,节省了相册(KV-Cache)空间。

从计算角度看,MLA 借助“按需解压”,只在每次计算点积时根据 ci\mathbf{c}_i 恢复 Key/Value(或者更进一步地把 Key 合并到 Query 一侧),显存-计算的折中比单纯缩放 Head 更灵活。而且在长序列场景中,带宽消耗往往才是主要瓶颈,所以 MLA 的“KV 缓存减量”常能带来显著推理提速。

从位置角度看,如果我们需要兼容相对位置编码(RoPE),可以让 Key/Query 的一部分维度显式乘上 Ri\mathbf{\mathcal{R}}_i,对应“拍摄时间”的小贴纸;主维度仍由 ci\mathbf{c}_i 提供语义特征。这样保证了时空信息依然得以保真,不会因全部压缩到 ci\mathbf{c}_i 而丢失。


7. 工程视角:落地 MLA 时需注意的要点

7.1 显存 VS. 推理速度

如果你的应用上下文非常长(数千乃至上万 Token),MLA 的潜变量压缩思路能在不损失过多效果的前提下,显著减少 KV-Cache 占用,从而在单卡上就能跑更多 Token 或更大 batch size,进而提升吞吐量。

7.2 RoPE 维度调参

当把 K 分成 “ci\mathbf{c}_i 区 + RoPE 区”,RoPE 区若定得过小,超长序列时相对位置信息可能不够充分;过大则会拉高显存占用,抵消 MLA 的收益。工程中往往通过实验来平衡这两者。

7.3 数值误差与精度

MLA 在推理阶段会做矩阵合并,精度较高时没问题,但在 BF16/FP16 下可能出现一些顺序误差积累。通常仍在可接受范围;若应用对精度极端敏感,可考虑混合精度或高精度 accumulators。


8. 整体总结与展望

MLA(Multi-Head Latent Attention)并不是单纯地“把 K/V 做低秩分解”,它更进一步地在推理阶段只缓存一个潜变量 ci\mathbf{c}_i,通过按需解压矩阵合并来还原各头所需的 K/V,大幅减少 KV-Cache。若再配合分治策略来处理 RoPE,那些相对位置信息也不会因为潜变量的介入而丢失。

从工程角度,MLA 带来的显存优势在长上下文推理中极为明显,能够拓展单卡或少卡可以承载的 Token 数量与并发规模。至于 RoPE 的兼容方式以及分治维度怎么选,还需结合任务需求与模型大小来调优。将这一思路推广到其他位置编码(如 ALiBi、NTK Scaling)或其他特殊场景,则是后续可能的研究方向。

无论如何,MLA 无疑已经在“如何减少 KV-Cache”这一问题上,给出了新颖且高效的答案。或许在不久的未来,我们会见到更多结合 MLA 与其他注意力优化方案的模型出现,让大模型的推理在硬件资源有限的条件下继续爆发潜能。


附录:核心公式与对应场景

在此列出 MLA 方案中几个最关键的公式与它们在“智能相册系统”类比中的对应,方便你回顾与对照。

  1. 低秩投影

    ci=xiWc\mathbf{c}_i = \mathbf{x}_i \mathbf{W}_c (13)
    \quad\updownarrow\quad(14)
    拍照时直接存缩略图,减少体积 dcd.\text{拍照时直接存缩略图,减少体积 } \propto \frac{d_c}{d}.(15)
  2. 动态解压

    ki(s)=ciWkc(s),vi(s)=ciWv(s)\mathbf{k}_i^{(s)} = \mathbf{c}_i \mathbf{W}_{kc}^{(s)}, \quad \mathbf{v}_i^{(s)} = \mathbf{c}_i \mathbf{W}_v^{(s)}(16)
    \quad\updownarrow\quad(17)
    浏览时根据缩略图 + 不同滤镜生成多种版本\text{浏览时根据缩略图 + 不同滤镜生成多种版本}(18)
  3. 按需还原(推理时合并矩阵)

    \quad\updownarrow\quad(20)
    把滤镜的计算“合并”到需要时,节省存储\text{把滤镜的计算“合并”到需要时,节省存储}(21)
  4. RoPE 分治

    ki(s)=(ciWkc(s))    (xiWkrRi)\mathbf{k}_i^{(s)} =\bigl(\mathbf{c}_i \mathbf{W}_{kc}^{(s)}\bigr)\;\oplus\;\bigl(\mathbf{x}_i \mathbf{W}_{kr}\mathbf{\mathcal{R}}_i\bigr)(22)
    \quad\updownarrow\quad(23)
    把拍摄时间或 GPS 坐标这类信息单独存放\text{把拍摄时间或 GPS 坐标这类信息单独存放}(24)

通过以上回顾,相信你已对 MLA 从低秩投影按需还原再到与 RoPE 协作的全过程有了更完整的理解。正如它的名字所暗示,MLA 既保留了“多头注意力”的强大表达力,又将主要存储负担隐含在一个更小的潜变量里,使长序列推理跨越了显存障碍。


9. 一个最小化 MLA 实现

为了让读者对 MLA 的核心原理有更多“落地感”,下面我们展示一个最小可运行的 MLA MoE-Transformer 实现,并点到为止地解释其中和“潜变量”“按需解压”“RoPE”密切相关的部分。 在参数类ModelArgs 中,attn_impl: Literal["naive", "absorb"] = "absorb" 参数的选择决定了KV-Cache是使用传统的存储 K、V (naive) 还是存储在 MLA 中定义的潜变量 (absorb)。功能完整代码详见 DeepSeek Official Repo

完整代码如下:

import math
from dataclasses import dataclass
from typing import Tuple, Optional, Literal

import torch
import torch.nn.functional as F
from torch import nn

@dataclass
class ModelArgs:
    max_batch_size: int = 8
    max_seq_len: int = 4096 * 4
    vocab_size: int = 102400
    dim: int = 2048
    inter_dim: int = 10944
    moe_inter_dim: int = 1408
    n_layers: int = 2
    n_dense_layers: int = 1
    n_heads: int = 16
    # moe
    n_routed_experts: int = 64
    n_shared_experts: int = 2
    n_activated_experts: int = 6
    n_expert_groups: int = 1
    n_limited_groups: int = 1
    score_func: Literal["softmax", "sigmoid"] = "softmax"
    route_scale: float = 1.
    # mla
    q_lora_rank: int = 0
    kv_lora_rank: int = 512
    qk_nope_head_dim: int = 128
    qk_rope_head_dim: int = 64
    v_head_dim: int = 128
    # rope
    original_seq_len: int = 4096
    rope_theta: float = 10000.0
    rope_factor: float = 40
    beta_fast: int = 32
    beta_slow: int = 1
    mscale: float = 1.
    # kv-cache
    attn_impl: Literal["naive", "absorb"] = "absorb"

@dataclass
class RMSNorm(nn.Module):
    def __init__(self, dim: int, eps: float = 1e-6):
        super().__init__()
        self.dim = dim
        self.eps = eps
        self.weight = nn.Parameter(torch.ones(dim))

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        rms = torch.sqrt(torch.mean(x ** 2, dim=-1, keepdim=True) + self.eps)
        return x / rms * self.weight

def linear(x: torch.Tensor, weight: torch.Tensor, bias: Optional[torch.Tensor] = None) -> torch.Tensor:
    return F.linear(x, weight, bias)


class Linear(nn.Module):
    def __init__(self, in_features: int, out_features: int, bias: bool = False):
        super().__init__()
        self.in_features = in_features
        self.out_features = out_features
        self.weight = nn.Parameter(torch.empty(out_features, in_features))
        nn.init.xavier_normal_(self.weight)
        if bias:
            self.bias = nn.Parameter(torch.empty(out_features))
            nn.init.zeros_(self.bias)
        else:
            self.register_parameter("bias", None)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return linear(x, self.weight, self.bias)


def precompute_freqs_cis(args: ModelArgs) -> torch.Tensor:
    dim = args.qk_rope_head_dim
    seqlen = args.max_seq_len
    beta_fast = args.beta_fast
    beta_slow = args.beta_slow
    base = args.rope_theta
    factor = args.rope_factor

    def find_correction_dim(num_rotations, dim, base, max_seq_len):
        return dim * math.log(max_seq_len / (num_rotations * 2 * math.pi)) / (2 * math.log(base))

    def find_correction_range(low_rot, high_rot, dim, base, max_seq_len):
        low = math.floor(find_correction_dim(low_rot, dim, base, max_seq_len))
        high = math.ceil(find_correction_dim(high_rot, dim, base, max_seq_len))
        return max(low, 0), min(high, dim - 1)

    def linear_ramp_factor(min_val, max_val, dim):
        if min_val == max_val:
            max_val += 0.001
        linear_func = (torch.arange(dim, dtype=torch.float32) - min_val) / (max_val - min_val)
        ramp_func = torch.clamp(linear_func, 0, 1)
        return ramp_func

    freqs = 1.0 / (base ** (torch.arange(0, dim, 2, dtype=torch.float32) / dim))
    if seqlen > args.original_seq_len:
        low, high = find_correction_range(beta_fast, beta_slow, dim, base, args.original_seq_len)
        smooth = 1 - linear_ramp_factor(low, high, dim // 2)
        freqs = freqs / factor * (1 - smooth) + freqs * smooth

    t = torch.arange(seqlen)
    freqs = torch.outer(t, freqs)
    freqs_cis = torch.polar(torch.ones_like(freqs), freqs)
    return freqs_cis


def apply_rotary_emb(x: torch.Tensor, freqs_cis: torch.Tensor) -> torch.Tensor:
    dtype = x.dtype
    x = torch.view_as_complex(x.float().view(*x.shape[:-1], -1, 2))
    freqs_cis = freqs_cis.view(1, x.size(1), 1, x.size(-1))
    y = torch.view_as_real(x * freqs_cis).flatten(3)
    return y.to(dtype)


class MLA(nn.Module):
    """
    Multi-Headed Attention Layer (MLA).
    """
    def __init__(self, args: ModelArgs):
        super().__init__()
        self.dim = args.dim
        self.n_heads = args.n_heads
        self.n_local_heads = self.n_heads  # 简化,不再做并行分割
        self.q_lora_rank = args.q_lora_rank
        self.kv_lora_rank = args.kv_lora_rank
        self.qk_nope_head_dim = args.qk_nope_head_dim
        self.qk_rope_head_dim = args.qk_rope_head_dim
        self.qk_head_dim = args.qk_nope_head_dim + args.qk_rope_head_dim
        self.v_head_dim = args.v_head_dim
        self.attn_impl = args.attn_impl

        # Q
        if self.q_lora_rank == 0:
            self.wq = Linear(self.dim, self.n_heads * self.qk_head_dim)
        else:
            self.wq_a = Linear(self.dim, self.q_lora_rank)
            self.q_norm = RMSNorm(self.q_lora_rank)
            self.wq_b = Linear(self.q_lora_rank, self.n_heads * self.qk_head_dim)

        # K,V
        self.wkv_a = Linear(self.dim, self.kv_lora_rank + self.qk_rope_head_dim)
        self.kv_norm = RMSNorm(self.kv_lora_rank)
        self.wkv_b = Linear(
            self.kv_lora_rank, self.n_heads * (self.qk_nope_head_dim + self.v_head_dim)
        )

        # Output
        self.wo = Linear(self.n_heads * self.v_head_dim, self.dim)

        self.softmax_scale = self.qk_head_dim ** -0.5
        if args.max_seq_len > args.original_seq_len:
            mscale = 0.1 * args.mscale * math.log(args.rope_factor) + 1.0
            self.softmax_scale *= mscale * mscale

        # 根据 attn_impl 来注册不同缓存
        if self.attn_impl == "naive":
            self.register_buffer(
                "k_cache",
                torch.zeros(args.max_batch_size, args.max_seq_len, self.n_local_heads, self.qk_head_dim),
                persistent=False
            )
            self.register_buffer(
                "v_cache",
                torch.zeros(args.max_batch_size, args.max_seq_len, self.n_local_heads, self.v_head_dim),
                persistent=False
            )
        else:
            self.register_buffer(
                "kv_cache",
                torch.zeros(args.max_batch_size, args.max_seq_len, self.kv_lora_rank),
                persistent=False
            )
            self.register_buffer(
                "pe_cache",
                torch.zeros(args.max_batch_size, args.max_seq_len, self.qk_rope_head_dim),
                persistent=False
            )


    def forward(self, x: torch.Tensor, start_pos: int, freqs_cis: torch.Tensor, mask: Optional[torch.Tensor]):
        bsz, seqlen, _ = x.size()
        end_pos = start_pos + seqlen

        if self.q_lora_rank == 0:
            q = self.wq(x)
        else:
            q = self.wq_b(self.q_norm(self.wq_a(x)))
        q = q.view(bsz, seqlen, self.n_local_heads, self.qk_head_dim)
        q_nope, q_pe = torch.split(q, [self.qk_nope_head_dim, self.qk_rope_head_dim], dim=-1)
        q_pe = apply_rotary_emb(q_pe, freqs_cis)

        kv = self.wkv_a(x)
        kv, k_pe = torch.split(kv, [self.kv_lora_rank, self.qk_rope_head_dim], dim=-1)
        k_pe = apply_rotary_emb(k_pe.unsqueeze(2), freqs_cis)

        if self.attn_impl == "naive":
            # naive 模式下,直接投影 kv
            q = torch.cat([q_nope, q_pe], dim=-1)
            kv = self.wkv_b(self.kv_norm(kv))  # (bsz, seqlen, n_heads*(qk_nope_head_dim + v_head_dim))
            kv = kv.view(bsz, seqlen, self.n_local_heads, self.qk_nope_head_dim + self.v_head_dim)

            k_nope, v = torch.split(kv, [self.qk_nope_head_dim, self.v_head_dim], dim=-1)
            k = torch.cat([k_nope, k_pe.expand(-1, -1, self.n_local_heads, -1)], dim=-1)

            # 写入 cache
            self.k_cache[:bsz, start_pos:end_pos] = k
            self.v_cache[:bsz, start_pos:end_pos] = v

            scores = torch.einsum("bshd,bthd->bsht", q, self.k_cache[:bsz, :end_pos]) * self.softmax_scale

            if mask is not None:
                scores += mask.unsqueeze(1)

            scores = scores.softmax(dim=-1, dtype=torch.float32).type_as(x)
            out = torch.einsum("bsht,bthd->bshd", scores, self.v_cache[:bsz, :end_pos])

        else:
            # absorb 模式
            # 将 kv_norm(kv) 存进 kv_cache, 同时将 k_pe 存进 pe_cache
            wkv_b = self.wkv_b.weight
            # q_nope 先和 wkv_b 的前 qk_nope_head_dim 行做乘积
            # 并将结果暂存在 q_nope
            # 如果要对 q_nope 做矩阵乘法,需要先转下维度
            wkv_b = wkv_b.view(self.n_local_heads, -1, self.kv_lora_rank)

            # 将 kv_norm(kv) 写进 kv_cache
            self.kv_cache[:bsz, start_pos:end_pos] = self.kv_norm(kv)
            # 将 k_pe 写进 pe_cache
            self.pe_cache[:bsz, start_pos:end_pos] = k_pe.squeeze(2)

            q_nope = torch.einsum(
                "bshd,hdc->bshc",
                q_nope,
                wkv_b[:, :self.qk_nope_head_dim]  # 只取前 qk_nope_head_dim
            )

            scores = (
                torch.einsum("bshc,btc->bsht", q_nope, self.kv_cache[:bsz, :end_pos]) +
                torch.einsum("bshr,btr->bsht", q_pe, self.pe_cache[:bsz, :end_pos])
            ) * self.softmax_scale

            if mask is not None:
                scores += mask.unsqueeze(1)

            scores = scores.softmax(dim=-1, dtype=torch.float32).type_as(x)

            # 计算最终输出
            out = torch.einsum("bsht,btc->bshc", scores, self.kv_cache[:bsz, :end_pos])
            out = torch.einsum(
                "bshc,hdc->bshd",
                out,
                wkv_b[:, -self.v_head_dim:]  # 取 v_head_dim 对应部分
            )

        out = self.wo(out.flatten(2))
        return out


class MLP(nn.Module):

    def __init__(self, dim: int, inter_dim: int):
        super().__init__()
        self.w1 = Linear(dim, inter_dim)
        self.w2 = Linear(inter_dim, dim)
        self.w3 = Linear(dim, inter_dim)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.w2(F.silu(self.w1(x)) * self.w3(x))


class Expert(nn.Module):
    """
    Expert layer for Mixture-of-Experts (MoE) models.
    """

    def __init__(self, dim: int, inter_dim: int):
        super().__init__()
        self.w1 = Linear(dim, inter_dim)
        self.w2 = Linear(inter_dim, dim)
        self.w3 = Linear(dim, inter_dim)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.w2(F.silu(self.w1(x)) * self.w3(x))


class Gate(nn.Module):
    """
    Gating mechanism for routing inputs in a mixture-of-experts (MoE) model.
    """

    def __init__(self, args: ModelArgs):
        super().__init__()
        self.dim = args.dim
        self.topk = args.n_activated_experts
        self.n_groups = args.n_expert_groups
        self.topk_groups = args.n_limited_groups
        self.score_func = args.score_func
        self.route_scale = args.route_scale
        self.weight = nn.Parameter(torch.empty(args.n_routed_experts, args.dim))
        nn.init.xavier_normal_(self.weight)
        self.bias = nn.Parameter(torch.zeros(args.n_routed_experts)) if self.dim == 7168 else None

    def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
        scores = linear(x, self.weight, self.bias)

        if self.score_func == "softmax":
            scores = scores.softmax(dim=-1, dtype=torch.float32)
        else:
            scores = scores.sigmoid()

        original_scores = scores
        if self.n_groups > 1:
            scores = scores.view(x.size(0), self.n_groups, -1)
            if self.bias is None:
                group_scores = scores.amax(dim=-1)
            else:
                group_scores = scores.topk(2, dim=-1)[0].sum(dim=-1)
            indices_groups = group_scores.topk(self.topk_groups, dim=-1)[1]
            mask = torch.zeros_like(scores[..., 0]).scatter_(1, indices_groups, True)
            scores = (scores * mask.unsqueeze(-1)).flatten(1)

        indices = torch.topk(scores, self.topk, dim=-1)[1]
        weights = original_scores.gather(1, indices)
        if self.score_func == "sigmoid":
            weights /= weights.sum(dim=-1, keepdim=True)
        weights *= self.route_scale
        return weights.type_as(x), indices


class MoE(nn.Module):
    """
    Mixture-of-Experts (MoE) module.
    """

    def __init__(self, args: ModelArgs):
        super().__init__()
        self.dim = args.dim
        self.n_routed_experts = args.n_routed_experts
        self.n_activated_experts = args.n_activated_experts
        self.gate = Gate(args)

        self.experts = nn.ModuleList([
            Expert(args.dim, args.moe_inter_dim) for _ in range(self.n_routed_experts)
        ])
        self.shared_experts = MLP(args.dim, args.n_shared_experts * args.moe_inter_dim)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        shape = x.size()
        x = x.view(-1, self.dim)
        weights, indices = self.gate(x)
        y = torch.zeros_like(x)

        counts = torch.bincount(indices.flatten(), minlength=self.n_routed_experts).tolist()
        for i in range(self.n_routed_experts):
            if counts[i] == 0:
                continue
            expert = self.experts[i]
            idx, top = torch.where(indices == i)
            y[idx] += expert(x[idx]) * weights[idx, top, None]

        z = self.shared_experts(x)
        return (y + z).view(shape)


class Block(nn.Module):
    """
    Transformer block combining attention and feed-forward layers.
    """

    def __init__(self, layer_id: int, args: ModelArgs):
        super().__init__()
        self.attn = MLA(args)
        self.ffn = MLP(args.dim, args.inter_dim) if layer_id < args.n_dense_layers else MoE(args)
        self.attn_norm = RMSNorm(args.dim)
        self.ffn_norm = RMSNorm(args.dim)

    def forward(self, x: torch.Tensor, start_pos: int, freqs_cis: torch.Tensor,
                mask: Optional[torch.Tensor]) -> torch.Tensor:
        x = x + self.attn(self.attn_norm(x), start_pos, freqs_cis, mask)
        x = x + self.ffn(self.ffn_norm(x))
        return x


class Transformer(nn.Module):
    def __init__(self, args: ModelArgs):
        super().__init__()
        self.max_seq_len = args.max_seq_len
        self.embed = torch.nn.Embedding(args.vocab_size, args.dim)
        self.layers = torch.nn.ModuleList()
        for layer_id in range(args.n_layers):
            self.layers.append(Block(layer_id, args))
        self.norm = RMSNorm(args.dim)
        self.head = Linear(args.dim, args.vocab_size)

        self.register_buffer("freqs_cis", precompute_freqs_cis(args), persistent=False)

    @torch.inference_mode()
    def forward(self, tokens: torch.Tensor, start_pos: int = 0):
        seqlen = tokens.size(1)
        h = self.embed(tokens)
        freqs_cis = self.freqs_cis[start_pos:start_pos + seqlen]

        mask = None
        if seqlen > 1:
            mask = torch.full((seqlen, seqlen), float("-inf"), device=tokens.device).triu_(1)

        for layer in self.layers:
            h = layer(h, start_pos, freqs_cis, mask)

        h = self.norm(h)[:, -1]
        logits = self.head(h)
        return logits


if __name__ == "__main__":
    torch.manual_seed(0)
    args = ModelArgs()
    x = torch.randint(0, args.vocab_size, (2, 128))
    model = Transformer(args)
    logits = model(x)
    print(logits.shape)  # (batch_size, vocab_size)
引用
@misc{zhang2025mla,
  author = {Zhang, Yihua},
  title  = {从多头共享到潜变量:DeepSeek的MLA在低秩投影与按需解压中重新定义 KV-Cache},
  year   = {2025},
  url    = {https://normaluhr.github.io/zh/p/mla}
}