专栏DeepSeek-V4 模型结构·序列3 / 8
7 min学习

DeepSeek-V4 模型结构(2):序列维度(中),Lightning Indexer 与稀疏选择

压缩之后 1M 上下文仍有 26 万个条目,每个 query 只看其中 1024 个。从 DeepSeek-V3.2 的 DSA 说起:indexer 的打分公式为什么长这样、ReLU 和 FP4 从哪来、它怎么用主注意力的分布来训练;再看 V4 把索引对象从 token 换成压缩块之后改了什么,以及一层 CSA 从头到尾的完整维度。

目录8 节

对应论文 §2.3.1 的后半,公式 (13) 到 (17)。上一篇把 KV 压成了每 4 个 token 一条,这一篇讲 CSA 名字里的 "Sparse":每个 query 只对其中 top-kk 条做注意力,选哪些由 lightning indexer 决定。indexer 来自 DeepSeek-V3.2 的 DSA,V4 论文只写了公式,训练方法要回 V3.2 的报告里找。

文本 token复制成 4 份,进入 4 条残差流(mHC 的 hc_mult = 4)残差流 ×4每条 d 维重复 29 次CSA 与 HCA 交错第 2 – 59 层,偶数层 CSA、奇数层 HCA每层注意力后接一个 MoEmHC:每个子层一次读、一次写 + 混:sigmoid,4 条流压成 1 条:2·sigmoid,输出分给 4 条流:Sinkhorn 20 轮,双随机三者都由当前 4 条流的内容动态生成logits最后一次只读:4 条流压成 1 条4 条流的最终状态 + 下一个 token 的 embeddingmHC 读算子 A:对 4 条流做 sigmoid 加权求和,得到子层的 d 维输入读 AmHC 读算子 A:对 4 条流做 sigmoid 加权求和,得到子层的 d 维输入读 AmHC 读算子 A:对 4 条流做 sigmoid 加权求和,得到子层的 d 维输入读 AmHC 读算子 A:对 4 条流做 sigmoid 加权求和,得到子层的 d 维输入读 AmHC 读算子 A:对 4 条流做 sigmoid 加权求和,得到子层的 d 维输入读 AmHC 读算子 A:对 4 条流做 sigmoid 加权求和,得到子层的 d 维输入读 AmHC 读算子 A:对 4 条流做 sigmoid 加权求和,得到子层的 d 维输入读 AmHC 读算子 A:对 4 条流做 sigmoid 加权求和,得到子层的 d 维输入读 AmHC 读算子 A:对 4 条流做 sigmoid 加权求和,得到子层的 d 维输入读 AmHC 写算子 C 与混合矩阵 B:子层输出按 C 分到 4 条流,同时 4 条流按双随机矩阵 B 互相混合写 C · 混 BmHC 写算子 C 与混合矩阵 B:子层输出按 C 分到 4 条流,同时 4 条流按双随机矩阵 B 互相混合写 C · 混 BmHC 写算子 C 与混合矩阵 B:子层输出按 C 分到 4 条流,同时 4 条流按双随机矩阵 B 互相混合写 C · 混 BmHC 写算子 C 与混合矩阵 B:子层输出按 C 分到 4 条流,同时 4 条流按双随机矩阵 B 互相混合写 C · 混 BmHC 写算子 C 与混合矩阵 B:子层输出按 C 分到 4 条流,同时 4 条流按双随机矩阵 B 互相混合写 C · 混 BmHC 写算子 C 与混合矩阵 B:子层输出按 C 分到 4 条流,同时 4 条流按双随机矩阵 B 互相混合写 C · 混 BmHC 写算子 C 与混合矩阵 B:子层输出按 C 分到 4 条流,同时 4 条流按双随机矩阵 B 互相混合写 C · 混 BmHC 写算子 C 与混合矩阵 B:子层输出按 C 分到 4 条流,同时 4 条流按双随机矩阵 B 互相混合写 C · 混 B词表 129280Token Embedding129280 × 7168Heavily Compressed Attention:每 128 个 token 压成一个 KV 条目,不做稀疏选择HCA第 0、1 层 · m′ = 128前三层的 MoE 按 token id 查表决定 expert,权重仍由 router 打分Hash-MoE第 0–2 层的 FFN · 384 选 6Compressed Sparse Attention:每 4 个 token 压成一个条目,indexer 选 top-k 个条目,加 128 个滑窗条目CSA偶数层 · ×30 · m = 4sqrt(softplus) 打分,无辅助损失偏置路由,SwiGLU clamp,FP4 expertDeepSeekMoE384 选 6 + 1 shared每 128 个 token 压成一个条目,全部条目都看HCA奇数层 · ×29 · m′ = 128同上DeepSeekMoE384 选 6 + 1 shared最后一层是 CSACSA第 60 层 · 收尾同上DeepSeekMoE第 60 层最终归一化RMSNorm不与 embedding 共享权重LM Head→ 129280多 token 预测模块:一个完整的 decoder block,注意力只有滑窗分支,有自己的 4 流残差和输出头MTP 层 ×1滑窗注意力 + MoE · 自带 mHC
序列 · CSA序列 · HCA序列 · 滑窗深度 · mHC宽度 · DeepSeekMoE零件
你现在在这里:全部偶数层的 CSA。

为什么压缩之后还要选

CSA 把条目数除以了 4,但 1M 上下文下仍有 218=2621442^{18} = 262144 条。128 个 query 头对 26 万条 512 维向量做注意力,每 token 每层 2×128×262144×512342 \times 128 \times 262144 \times 512 \approx 34 GFLOP,61 层的一半是 CSA,光注意力就 1 TFLOP 一个 token。HCA 靠 128 倍的压缩率把条目压到 8192 条,能全看;CSA 要保留细粒度,只能选。

选的原则和 DSA 一样:用一个便宜的打分器给每对 (query, 条目) 算一个分数,只对 top-kk 做真正的注意力。便宜到什么程度决定了它能不能用:打分器本身是 O(n)O(n) 每 query,要比主注意力低一到两个数量级才划算。

DSA 复习:lightning indexer 长什么样

V3.2 的 indexer 给 query token tt 和前文 token ss 打分:

It,s=j=1HIwt,jIReLU(qt,jIksI).I_{t,s} = \sum_{j=1}^{H^I} w^I_{t,j}\cdot \operatorname{ReLU}\big(\mathbf{q}^I_{t,j}\cdot \mathbf{k}^I_s\big).

拆开看它为什么长这样:

  • 它是一个小号的多头注意力打分,没有 softmax。 HIH^I 个头(V3.2 和 V4 都是 64),每头的 query qt,jI\mathbf{q}^I_{t,j} 和 key ksI\mathbf{k}^I_s 维度 128。主注意力有 128 头 × 512 维,indexer 是 64 头 × 128 维,内积的算术量是主注意力的 1/81/8
  • key 只有一份,所有头共享。 和主注意力的 MQA 一样,indexer 的 ksI\mathbf{k}^I_s 不分头,缓存每 token 128 个数。
  • ReLU 而不是 exp。 论文说是为了吞吐。ReLU 让负内积直接归零、不需要跨条目的归一化,每个分数可以独立算,也不需要维护 max 和 sum。
  • 头之间用 wt,jIw^I_{t,j} 加权求和。 权重由 query token 决定(wtI=htWw\mathbf{w}^I_t = \mathbf{h}_t W^w),可以为负,相当于让 query 决定这次检索听哪几个头的。64 个头压成一个标量分数,top-kk 只需要在一个分数上做。
  • FP8 / FP4。 打分不需要精度,V3.2 用 FP8,V4 进一步到 FP4。

给定 It,:I_{t,:},取 top-kk 的条目集合 St\mathcal{S}_t,主注意力只算 St\mathcal{S}_t 里的:核心注意力的复杂度从 O(n2)O(n^2) 降到 O(nk)O(nk),indexer 仍是 O(n2)O(n^2) 但常数小得多。

它怎么训练。 indexer 的输出不进主模型的损失,它有自己的目标:模仿主注意力的分布。V3.2 的做法分两阶段。

  1. 稠密热身。 冻结主模型,保持稠密注意力,只训 indexer。对每个 query,把主注意力所有头的分数加起来、沿序列做 L1 归一化得到目标分布 pt,:p_{t,:},最小化 KL(pt,:Softmax(It,:))\mathrm{KL}(p_{t,:} \,\|\, \operatorname{Softmax}(I_{t,:}))。1000 步、21 亿 token。
  2. 稀疏训练。 打开 top-kk 选择,全部参数一起训。indexer 的 KL 目标只在选中的集合 St\mathcal{S}_t 上算;indexer 的输入 ht\mathbf{h}_t 从计算图里 detach,主模型只收语言模型损失的梯度,indexer 只收 KL 的梯度。两者互不干扰。

V4 沿用这个框架:先用稠密注意力训 1T token(Pro 更长),在序列长度 64K 时引入稀疏,引入时「先用一个短阶段热身 indexer」。

V4 的改动:索引对象从 token 变成压缩块

V4 的 indexer(公式 (13) – (17))和 V3.2 的形式相同,变的是 key 和 query 的来源。

key 是压缩块。 上一篇的压缩算子再做一份,头维换成 cI=128c^I = 128,产生 KICompRn/m×cIK^{\text{IComp}} \in \mathbb{R}^{n/m \times c^I}。也就是说 indexer 有自己的 WaKV,WbKV,WaZ,WbZW^{aKV}, W^{bKV}, W^{aZ}, W^{bZ}(各 7168 × 128)和自己的位置偏置,和主注意力的压缩是两套参数、同一种算法。给每个压缩块一个 128 维的「索引键」,缓存每 4 个 token 128 个数。

query 从共享的 latent 出来。 公式 (13)、(14):

ctQ=htWDQR1536,qtI=ctQWIUQR64×128.\mathbf{c}^Q_t = \mathbf{h}_t W^{DQ} \in \mathbb{R}^{1536}, \qquad \mathbf{q}^I_t = \mathbf{c}^Q_t W^{IUQ} \in \mathbb{R}^{64 \times 128}.

ctQ\mathbf{c}^Q_t 就是主注意力 query 的低秩 latent。V3.2 的论文只写 qI\mathbf{q}^I 「由 ht\mathbf{h}_t 得到」,V4 明确写成从 ctQ\mathbf{c}^Q_t 展开(V3.2 的开源实现里其实已经是这样做的):indexer 的 query 矩阵是 1536 × 8192 而不是 7168 × 8192,两路 query 共享底层表示。

打分和选择。 公式 (15) – (17) 和 DSA 一样,只是 ss 的取值范围是压缩块编号 s<t/ms < \lfloor t/m \rfloor,因果性以块为单位:query tt 只能看已经完整压缩的块,自己所在的块看不到。选 top-1024 个块(Flash 512),每块 4 个 token,覆盖 4096 个原始 token;V3.2 是选 2048 个 token。论文说 top-kk 减小是有意的,为了让短文本和中等长度上也更快。

1536,和主注意力共用7168所有前文 token× 716864 头 × 128,RoPE,Hadamard + FP464 个头权重(标量)压缩成每 4 token 一条 × 128,同款重叠压缩对每个块一个分数top-1024块编号从压缩 cache 取1024 条 × 512
lightning indexer(公式 13–17)。它有自己的一套压缩键(128 维,比主注意力的 512 维小),打分只做一次 ReLU 加权和,在 FP4 里算;输出的是块编号,主注意力按编号去压缩 cache 里取条目。

官方代码里的 indexer

python
class Indexer(nn.Module):
    def forward(self, x, qr, start_pos, offset):
        q = self.wq_b(qr)                                  # qr 是 1536 维的 c^Q,→ 64 头 × 128
        q = q.unflatten(-1, (self.n_local_heads, self.head_dim))
        apply_rotary_emb(q[..., -rd:], freqs_cis)          # 后 64 维 RoPE
        q = rotate_activation(q)                           # 随机 Hadamard 旋转
        fp4_act_quant(q, fp4_block_size, True)             # 模拟 FP4 量化
        self.compressor(x, start_pos)                      # 自己的压缩算子 → kv_cache,同样旋转 + FP4
        weights = self.weights_proj(x) * (self.softmax_scale * self.n_heads ** -0.5)   # w^I_t,乘 128^-0.5 · 64^-0.5
        index_score = torch.einsum("bshd,btd->bsht", q, self.kv_cache[:bsz, :end_pos // ratio])
        index_score = (index_score.relu_() * weights.unsqueeze(-1)).sum(dim=2)          # Σ_h w_h ReLU(q_h · k)
        # 因果 mask:块 s 只有在 s < (t+1)//ratio 时可见
        topk_idxs = index_score.topk(min(self.index_topk, end_pos // ratio), dim=-1)[1]
        return topk_idxs + offset                          # offset 跳过 cache 里前面的滑窗段

论文没写的细节:

  • Hadamard 旋转。 query 和索引键在量化前都做一次随机 Hadamard 变换(fast_hadamard_transform,尺度 1/1281/\sqrt{128})。旋转是正交的,不改变内积,但把能量摊匀到所有维度,去掉离群通道,FP4 的量化误差小得多。这是 QuaRot 一系的做法。
  • FP4 是模拟的。 推理代码里 fp4_act_quant(..., True) 是原地量化再反量化回 BF16,注释说他们做了 QAT,真实部署用 FP4 内核。
  • 打分的缩放。 权重 ww 乘了 1281/2641/2128^{-1/2} \cdot 64^{-1/2},前者是内积的标准缩放,后者是 64 个头求和的归一化。
  • indexer 的 key 也带 RoPE。 压缩键的后 64 维用块首位置做旋转,query 用自己的位置。两者用同一个 θ=160000\theta = 160000 的 YaRN 版本,和主注意力压缩层一致。
  • top-kk 不够时。 前文不足 1024 个块时取全部;序列开头几个 token 连一个完整块都没有,只靠滑窗分支。

一层 CSA 的完整维度

把上一篇和这一篇拼起来,一层 CSA 从输入到输出(Pro 的数):

压缩分支(写入一次,所有 query 共用)lightning indexer(只决定看哪 1024 个块)同一个:128 头 × 512128 条被选中的 1024 条块编号71687168 → 1536RMSNorm15361536 → 128 × 512逐头 RMSNorm+ 后 64 维 RoPE7168 → 512(单头)RMSNorm + RoPE(64)非 rope 维存 FP8滑窗 cache最近 128 条 × 512重叠压缩 4 → 1:7168 → 2 × 512RMSNorm + RoPE(块首位置)θ = 160000,YaRN ×16压缩 cache条 × 512indexer query1536 → 64 头 × 128,RoPE,FP4indexer 压缩每 4 token 一条 × 128,FP4top-1024 个块MQA 核心注意力128 头 × (128 + 1024) 条K = V,每头一个 sink输出后 64 维反旋转 RoPE(−t)让贡献只依赖相对距离分组投影16 组,每组 4096 → 102416384 → 7168
一层 CSA(Pro 维度)。只有一条 512 维的 KV 向量,既当 K 又当 V,所有 128 个 query 头共用;推理时真正缓存的是右侧两个 cache 框加 indexer 自己的压缩键。核心注意力的每个 query 看 128 条滑窗 + 1024 条被选中的压缩条目。

按官方代码 Attention.forward 的顺序:

  1. query。 ht\mathbf{h}_t (7168) → wq_a → 1536 → RMSNorm → wq_b → 128 × 512 → 逐头 RMSNorm(无权重)→ 后 64 维 RoPE。
  2. 单条 KV。 ht\mathbf{h}_twkv → 512 → RMSNorm → 后 64 维 RoPE → 非 rope 的 448 维按 64 维一组做 FP8 量化。写入滑窗 cache(环形,128 条)。
  3. 压缩。 Compressor 每 4 个 token 产生一条 512 维条目,写入压缩 cache。
  4. 索引。 Indexer 产生 top-1024 个块编号;和 128 个滑窗位置拼成一个长度 1152 的索引数组。
  5. 核心注意力。 sparse_attn(q, kv, attn_sink, topk_idxs, scale):按索引从 cache 里 gather 1152 条,128 个头各做一次 softmax,分母加 sink,K 和 V 是同一份。
  6. 输出。 后 64 维反旋转,分 16 组 4096 → 1024,拼成 16384 → wo_b → 7168。

每 token 每层 CSA 的核心注意力算术量是 2×128×1152×5121512 \times 128 \times 1152 \times 512 \approx 151 MFLOP,和上下文长度无关。indexer 的打分是 2×64×(n/4)×1282 \times 64 \times (n/4) \times 128,1M 时约 4.3 GFLOP,在 FP4 里算。

参数账

一层 CSA(Pro):

部件参数
wq_a 7168 × 153611.0M
wq_b 1536 × 65536100.7M
wkv 7168 × 5123.7M
压缩算子 4 × 7168 × 51214.7M
indexer wq_b 1536 × 819212.6M
indexer weights_proj 7168 × 640.5M
indexer 压缩算子 4 × 7168 × 1283.7M
wo_a 16 × 4096 × 102467.1M
wo_b 16384 × 7168117.4M
合计≈ 331M

HCA 少了 indexer 的三项,压缩算子只有两个矩阵,约 307M。61 层注意力合计 19.8B,全部激活。

术语坑

  • kk 的单位变了。 V3.2 的 top-2048 是 token,V4 的 top-1024 是压缩块,每块 4 个 token。
  • indexer 的压缩和主注意力的压缩是两套参数。 同一种算法,不同的维度(128 对 512),不同的量化(FP4 对 FP8)。
  • indexer 的梯度不进主模型。 沿用 V3.2:输入 detach,只训 KL。V4 论文没有重复说,按 V3.2 理解。
  • index_topksliding_window 是加起来的。 核心注意力看 1024 + 128 条。

下一篇

第 3 篇讲剩下的部分:HCA 为什么不用 indexer、两种注意力怎么交错、滑窗分支为什么必须有、K = V 带来的位置编码问题怎么用反旋转解决、attention sink,以及 1M 上下文的 KV cache 和 FLOPs 的账。