DeepSeek V4 源码剖析

第3章 Compressor:滑窗与 KV 几何压缩

作者 杨艺韬 · 8,871 字 · 发布于 · 更新于

"compress_ratios": [128, 128, 4, 128, 4, ...] —— DeepSeek-V4-Pro config.json

V4 的 KV cache 不是把所有 token 都原样记下来,而是逐层决定"这一层值得记多细"——61 层里 30 层记得细(每 4 个 token 一组),31 层记得粗(每 128 个一组)。


3.1 引子:1M context 的 KV 几何崩塌

如果 V4 不做任何压缩,按 head_dim=512 + 61 layers + 1M tokens 算,每个序列的 KV cache 是:

KV=61×1,048,576×512×2 B (BF16)≈65.5 GB\text{KV} = 61 \times 1{,}048{,}576 \times 512 \times 2 \text{ B (BF16)} \approx 65.5 \text{ GB}

单序列 65.5 GB——一个 H100 80 GB 卡只能跑 1 个并发,而且还没算 Q 投影、激活、模型权重的占用。这是不可能商用的。

V4 的解决方案不是"换更高密度的硬件",而是重新设计 KV cache 的几何结构。每层的 KV 被切成两段:

flowchart LR
  subgraph KVCache["每层 KV cache 几何"]
    direction LR
    Win["滑窗段<br/>window_size=128"]
    Comp["压缩段<br/>max_seq_len // ratio"]
    Win -.物理拼接.-> Comp
  end
  subgraph Window["滑窗段(前 128 槽位)"]
    W1["最近 128 token 的 KV<br/>环形覆盖"]
  end
  subgraph Compressed["压缩段(max_seq_len/ratio 槽位)"]
    C1["每 ratio 个 token 压成 1 组<br/>对长距离信息做语义归约"]
  end

这个双段结构的物理存储就是 Attention.__init__ 里的:

kv_cache_size = args.window_size + (args.max_seq_len // self.compress_ratio if self.compress_ratio else 0)
self.register_buffer("kv_cache", torch.zeros(args.max_batch_size, kv_cache_size, self.head_dim))

kv_cache_size 把两段加在一起。比如 1M context、ratio=4:128 + 1048576/4 = 262272 槽位。每槽 head_dim=512 BF16,每层占 268 MB;61 层占 ~16.4 GB。

如果 ratio=128 呢?128 + 1048576/128 = 8320 槽位,每层占 8.5 MB;61 层占 ~520 MB。

V4 通过让每一层独立选择 ratio(主模型 61 层里 30 层取 4、31 层取 128),把整条序列的 KV 压到 4.16 G 个元素——BF16 下 8.3 GB;按技术报告的混合存储格式(RoPE 的 64 维用 BF16、其余 448 维用 FP8,不计 scale)约 4.7 GB,相比 dense 的 65.5 GB 少了约 8 倍(混合格式下约 14 倍)。这还没算 Indexer 自带的那份缓存,§3.10 会把这本账逐字节算清楚。


3.2 Compressor 类全景

V4 的 Compressor 类(inference/model.py)的 __init__ 完整摆出来:

class Compressor(nn.Module):
    def __init__(self, args: ModelArgs, compress_ratio: int = 4, head_dim: int = 512, rotate: bool = False):
        super().__init__()
        self.dim = args.dim
        self.head_dim = head_dim
        self.rope_head_dim = args.rope_head_dim
        self.nope_head_dim = head_dim - args.rope_head_dim
        self.compress_ratio = compress_ratio
        self.overlap = compress_ratio == 4
        self.rotate = rotate
        coff = 1 + self.overlap
        self.ape = nn.Parameter(torch.empty(compress_ratio, coff * self.head_dim, dtype=torch.float32))
        self.wkv = Linear(self.dim, coff * self.head_dim, dtype=torch.float32)
        self.wgate = Linear(self.dim, coff * self.head_dim, dtype=torch.float32)
        self.norm = RMSNorm(self.head_dim, args.norm_eps)
        self.kv_cache: torch.Tensor = None  # 由 Attention 注入

        # decode-phase incremental state
        self.register_buffer("kv_state",
            torch.zeros(args.max_batch_size, coff * compress_ratio, coff * self.head_dim, dtype=torch.float32),
            persistent=False)
        self.register_buffer("score_state",
            torch.full((args.max_batch_size, coff * compress_ratio, coff * self.head_dim), float("-inf"), dtype=torch.float32),
            persistent=False)
        self.freqs_cis: torch.Tensor = None

把它拆成五块:

  • 静态参数:wkv、wgate、ape、norm——决定如何把 token 压缩成"压缩 KV"
  • 运行时 KV cache:self.kv_cache ——压缩后的 KV 写入这里(注意是被 Attention 注入的,Compressor 自己不分配)
  • decode 增量状态:kv_state 与 score_state ——decode 阶段累积尚未"凑齐 ratio 个"的 KV
  • rotate 标志:是否用 Hadamard 变换(仅 Indexer 的 Compressor 才用)
  • overlap 标志:是否启用"重叠窗口压缩"(仅 ratio=4 才启用)

接下来按 prefill / decode 两条路径分别拆。


3.3 prefill 路径:一次性把整段 token 压完

prefill 阶段(start_pos == 0)的 Compressor.forward 处理整段 KV:

# inference/model.py:316 起
def forward(self, x: torch.Tensor, start_pos: int):
    assert self.kv_cache is not None      # :317——kv_cache 必须由外层先挂好
    bsz, seqlen, _ = x.size()
    ratio, overlap, d, rd = self.compress_ratio, self.overlap, self.head_dim, self.rope_head_dim
    dtype = x.dtype
    x = x.float()
    kv = self.wkv(x)                # [B, S, coff*d]
    score = self.wgate(x)            # [B, S, coff*d]

    if start_pos == 0:
        should_compress = seqlen >= ratio
        remainder = seqlen % ratio
        cutoff = seqlen - remainder
        offset = ratio if overlap else 0

        # 为 decode 预存状态:overlap 时把最后一个完整组存成"前一窗口";凑不齐 ratio 的尾段存进"当前窗口"
        if overlap and cutoff >= ratio:
            self.kv_state[:bsz, :ratio] = kv[:, cutoff-ratio : cutoff]
            self.score_state[:bsz, :ratio] = score[:, cutoff-ratio : cutoff] + self.ape
        if remainder > 0:
            kv, self.kv_state[:bsz, offset : offset+remainder] = kv.split([cutoff, remainder], dim=1)
            self.score_state[:bsz, offset : offset+remainder] = score[:, cutoff:] + self.ape[:remainder]
            score = score[:, :cutoff]      # 注意:这一行在 if 里面——凑不齐一组时才需要截断

        kv = kv.unflatten(1, (-1, ratio))
        score = score.unflatten(1, (-1, ratio)) + self.ape

        if overlap:
            kv = self.overlap_transform(kv, 0)
            score = self.overlap_transform(score, float("-inf"))

        # 用 softmax(score) 加权求和 → 压缩 KV
        kv = (kv * score.softmax(dim=2)).sum(dim=2)

这一段的代数过程:

  1. wkv(x) 把每个 token 投到 coff * head_dim(如果 overlap=True,coff=2,相当于每个 token 准备了"两套"压缩贡献)
  2. wgate(x) 算门控分数
  3. score + ape——加上 absolute position embedding(每个 ratio-相对位置一组)
  4. unflatten(1, (-1, ratio))——把 [B, S] 重排成 [B, S/ratio, ratio],每 ratio 个 token 聚成一组
  5. softmax(score, dim=2)——在组内做 softmax 归一
  6. (kv * softmax_score).sum(dim=2)——加权求和,得到每组的"压缩 KV"

最终 kv 形状是 [B, S/ratio, head_dim]——每 ratio 个原始 token 被压成 1 组压缩 KV。

3.3.1 absolute position embedding (ape) 是什么

self.ape: [ratio, coff * head_dim] 是一个可学习的 embedding 表,给 ratio 内每个相对位置一个偏置。

为什么需要?因为 Compressor 在做"组内加权求和"时,ratio 内的 token 顺序信息会丢失——softmax + sum 是对称的。如果不加位置偏置,"3 4 5 6" 和 "6 5 4 3" 会得到相同的压缩 KV,这显然不对。

ape 提供的就是"组内的相对位置感"——给第 i 个位置的 score 加一个偏置 ape[i],让顺序信息进入 softmax 概率分布。

3.3.2 overlap = True 的特殊处理

ratio=4 的层启用 overlap=True。压缩槽的个数并没有变(还是 S / ratio 个),变的是每个槽往回看多远:每个槽吃进 2 × ratio = 8 个 token,与前一个槽共享其中 4 个。

看 overlap_transform(inference/model.py:307)就一目了然——输入 [b, s/r, r, 2d],输出 [b, s/r, 2r, d]:

new_tensor[:, :, ratio:] = tensor[:, :, :, d:]      # 后 4 槽 = 本组 4 个 token 的「后 d 维」
new_tensor[:, 1:, :ratio] = tensor[:, :-1, :, :d]   # 前 4 槽 = 前一组 4 个 token 的「前 d 维」

也就是:

原始 token: [t0, t1, t2, t3, t4, t5, t6, t7, t8, ...]

不 overlap (ratio=128 的层):
  slot 0: softmax 加权 t0..t127
  slot 1: softmax 加权 t128..t255

overlap (ratio=4 的层):
  slot 0: softmax 加权 (空, 空, 空, 空) + (t0..t3 的后 d 维)     ← 第 0 槽没有前一组,用 0 / -inf 填
  slot 1: softmax 加权 (t0..t3 的前 d 维) + (t4..t7 的后 d 维)
  slot 2: softmax 加权 (t4..t7 的前 d 维) + (t8..t11 的后 d 维)
  ...

coff = 1 + overlap = 2 让 wkv / wgate 输出双倍维度:同一个 token 因此准备了两套贡献——[:d] 那套在"它作为前一组成员"时被用,[d:] 那套在"它作为本组成员"时被用。同一个 token 在两个相邻槽里用的是不同的投影,这也是 overlap 比"简单地把窗口拉长到 8"更强的地方。

为什么要 overlap?因为 ratio=4 的层是稀疏注意力的主要工作层——overlap 让"组与组的边界处的信息"不会丢失(边界 token 同时出现在两个相邻槽)。代价是 wkv / wgate 的参数与计算量翻倍,但压缩段的槽位数、也就是 KV cache 的大小一点没变。


3.4 decode 路径:增量压缩与状态累积

decode 阶段(start_pos > 0),每次只送 1 个 token,Compressor 必须增量地累积 KV,每 ratio 步触发一次"压缩落盘":

# inference/model.py:343 起(承接 §3.3 那段 start_pos == 0 的分支)
else:  # start_pos > 0 (decode)
    should_compress = (start_pos + 1) % self.compress_ratio == 0
    score += self.ape[start_pos % ratio]

    if overlap:                                    # ratio == 4 的层
        self.kv_state[:bsz, ratio + start_pos % ratio] = kv.squeeze(1)
        self.score_state[:bsz, ratio + start_pos % ratio] = score.squeeze(1)
        if should_compress:
            # 把累积了 ratio 个 token 的状态做一次 softmax 加权求和
            kv_state = torch.cat([self.kv_state[:bsz, :ratio, :d], self.kv_state[:bsz, ratio:, d:]], dim=1)
            score_state = torch.cat([self.score_state[:bsz, :ratio, :d], self.score_state[:bsz, ratio:, d:]], dim=1)
            kv = (kv_state * score_state.softmax(dim=1)).sum(dim=1, keepdim=True)
            # 把"当前窗口"滚动成"前一窗口"
            self.kv_state[:bsz, :ratio] = self.kv_state[:bsz, ratio:]
            self.score_state[:bsz, :ratio] = self.score_state[:bsz, ratio:]
    else:                                          # ratio == 128 的层,单 buffer
        self.kv_state[:bsz, start_pos % ratio] = kv.squeeze(1)
        self.score_state[:bsz, start_pos % ratio] = score.squeeze(1)
        if should_compress:
            kv = (self.kv_state[:bsz] * self.score_state[:bsz].softmax(dim=1)).sum(dim=1, keepdim=True)

注意这个 if/else 的层级:should_compress 的判断嵌在 overlap 分支内部——overlap 的层走双 buffer(前一窗 + 当前窗),非 overlap 的层走单 buffer。两条分支的压缩公式因此不同:前者要先 cat 出"跨窗口"的 2×ratio 条,后者直接对 ratio 条做 softmax。

这段代码的状态机非常微妙:

  • kv_state 维度是 [B, 2*ratio, 2*head_dim](overlap 时)
  • 前 ratio 个槽位是"上一窗口的累积 KV"
  • 后 ratio 个槽位是"当前窗口的累积 KV"
  • 每 ratio 步,触发 should_compress=True,把累积的 KV 做一次 softmax 加权求和,写入 self.kv_cache[start_pos // ratio]
  • 同时,"当前窗口" 滚动成 "上一窗口",给下一轮腾出空间

这种"前一窗口 + 当前窗口"的双 buffer 设计是 overlap 模式的关键——确保压缩 KV 的"边界平滑"在 decode 阶段也能保持与 prefill 数学一致。

3.4.1 数学等价性

prefill 一次性算完 + decode 逐 token 算完,最终得到的压缩 KV 序列必须完全相同。否则模型在"先 prefill 长 prompt → 后 decode" 与 "纯 decode" 这两种方式下会出现行为漂移。

V4 的工程师通过 kv_state 与 score_state 的精确状态机,让 prefill 和 decode 的输出在数学上等价(浮点求和次序不同,末位仍可能有差异,但不会有系统性偏移)。这一点是稀疏 attention 工业化的硬性要求——prefill 和 decode 走不同 codepath 但必须输出同一个结果。


3.5 per-layer compress_ratio 的工程哲学

V4 的 compress_ratios 是个长度 62 的整数数组——num_hidden_layers=61(主 attention 层)+ num_nextn_predict_layers=1(MTP 层)= 62。config.json 实测分布:值仅 3 种(0 / 4 / 128),其中 128 出现 31 次、4 出现 30 次、0 仅 1 次(在数组最末——对应 MTP 层):

[128, 128, 4, 128, 4, 128, 4, ..., 4, 128, 4, 128, 4, 128, 4, 0]
   ↑   ↑                                              ↑    ↑
 第0层 第1层                                       第60层  第61层(MTP)

读这串数字的关键是理解ratio 的语义:

  • ratio = 0:本层不压缩,KV 不进入 kv_cache 的压缩段——只走滑窗
  • ratio = 4:每 4 个 token 压一组——稀疏注意力的"主力工作层",启用 Indexer
  • ratio = 128:每 128 个 token 压一组——极端高压缩,主要用于"远距离粗略回忆"

V4 团队没有选择"全 ratio=4"或"全 ratio=128"的均匀方案,而是选交替混合:

flowchart LR
  subgraph 模型层级
    direction TB
    L0["第 0-1 层<br/>ratio=128"]
    L1["第 2 层<br/>ratio=4"]
    L2["第 3 层<br/>ratio=128"]
    L3["第 4 层<br/>ratio=4"]
    L4["..."]
    L5["第 60 层 (主模型末层)<br/>ratio=4"]
    L6["第 61 层 (MTP)<br/>ratio=0"]
  end
  L0 -->|粗略远记忆| L1
  L1 -->|精细稀疏选择| L2
  L2 -->|粗略远记忆| L3
  L3 -->|精细稀疏选择| L4
  L4 --> L5
  L5 -->|主模型 dense 输出| Out["主 head 输出"]
  L5 -.MTP 副 head.-> L6
  L6 -->|MTP 纯滑窗 128 槽| MTPOut["MTP head 输出"]

这种"粗 → 细 → 粗 → 细 → 不压缩"的交替结构,体现了一个工程直觉:

  1. 粗层(ratio=128):保留远距离的"主旨信息"——比如 1M context 中前 100K token 在讲什么主题
  2. 细层(ratio=4):在 token 之间做精细的注意力交互——稀疏 attention 在这里发挥作用
  3. 无压缩段的层(ratio=0,仅 MTP 层):注意这里的 0 不是"不压缩所以看得最全"——kv_cache_size = window_size + (max_seq_len // ratio if ratio else 0) 代入 0 只剩 window_size=128 个槽位,if self.compress_ratio: 也不成立,Compressor 与 Indexer 都不会被构造(inference/model.py:466、inference/model.py:473)。ratio=0 的层是纯滑窗注意力,注意力半径只有 128 个 token。源码注释把这件事写在 RoPE 那一段:disable YaRN and use base rope_theta in pure sliding-window attention(inference/model.py:478)

这种设计让模型同时具备"远距离粗略记忆"和"近距离精细推理"两种能力,在 1M context 下保持表现。

3.5.1 layer-wise 不均匀的工程动机

如果所有层都 ratio=4,KV cache 在 1M context 下要 16 GB(按 §3.1 的算式)。如果都 ratio=128,KV cache 只要约 520 MB,但远距离信息被过度压缩,模型在长 context 任务上会塌陷。

V4 的混合方案给出了一个"成本 / 能力" 的最优解——主模型 61 层中 31 层 ratio=128 节省显存(远距粗略),30 层 ratio=4 保证精度(稀疏选取)。MTP 层(第 62 个元素)的 ratio=0 是最省的那一档:它连压缩段都不分配,只留 128 槽的滑窗——主模型本身的 61 层并没有 ratio=0 的层。

3.5.2 这串数字会演化吗

V4 Preview 版的 compress_ratios 是一个"工程现状",不是不可调的常数。GA 版本可能会基于训练 / 推理实测调整这串数字。本专栏的预测是:数字的整体模式(粗 / 细交替 + MTP 层 0)大概率会保留——但这只是预测,官方没有给出过承诺。同一份技术报告里的 V4-Flash 就是另一种排法:前两层用纯滑窗注意力,其后才 CSA / HCA 交替(技术报告第 4.2.1 节)。


3.6 状态机的双 buffer 推演(一个具体例子)

decode 阶段的 kv_state / score_state 双 buffer 状态机最难理解。我们用一个具体的例子推演——假设 ratio=4、overlap=True(coff=2),从 start_pos=0 开始 decode 8 个 token(真实推理里 start_pos == 0 走的是 prefill 分支,decode 从 start_pos = prompt 长度 起步;这里取 0 只是为了让 start_pos % ratio 的下标好读,模运算的节奏与真实情形完全一致):

初始状态(prefill 完成后留下的状态):

kv_state    = [前一窗口的 4 槽位] + [当前窗口的 4 槽位]
score_state = [前一窗口的 4 槽位] + [当前窗口的 4 槽位]  (初始 -inf)

start_pos=0:第 1 个新 token 进来

should_compress = (0+1) % 4 != 0    → False
kv_state[ratio + 0 % 4] = kv_state[4] = 新 token 的 kv  (overlap 模式下写"当前窗口"的第 0 槽)
score_state[4] = score + ape[0]

start_pos=1:第 2 个 token

should_compress = (1+1) % 4 != 0    → False
kv_state[5] = 新 token 的 kv
score_state[5] = score + ape[1]

start_pos=2:第 3 个 token

should_compress = (2+1) % 4 != 0    → False
kv_state[6] = 新 token 的 kv
score_state[6] = score + ape[2]

start_pos=3:第 4 个 token,触发压缩!

should_compress = (3+1) % 4 == 0    → True
kv_state[7] = 新 token 的 kv  (先存)
score_state[7] = score + ape[3]

# 然后做压缩
# 注意 cat 的细节:取 kv_state 前 4 个的"前 d 维" + 后 4 个的"后 d 维"
kv_pool    = cat([kv_state[:4, :d], kv_state[4:, d:]])   # [8, d]
score_pool = cat([score_state[:4, :d], score_state[4:, d:]])

# softmax 加权求和
new_compressed_kv = (kv_pool * score_pool.softmax(dim=0)).sum(dim=0)

# 写入 kv_cache 的下一个槽位
self.kv_cache[start_pos // ratio = 0] = new_compressed_kv

# 滚动:"当前窗口" 变成 "前一窗口",给下一轮腾空间
kv_state[:4]    = kv_state[4:]
score_state[:4] = score_state[4:]

start_pos=4-7:第 5-8 个 token 重复上述循环,第 8 个 token 时再次触发压缩,写入 kv_cache[1]。

这个状态机看似复杂,但核心思想很简单——每次 decode 都把新 KV 累积到"当前窗口",每 ratio 步触发一次压缩并把"当前"滚成"前一"。cat 的"前一窗前 d 维 + 当前窗后 d 维"的拼接方式是 overlap 模式的特殊处理——它对应 prefill 阶段 overlap_transform 函数的逻辑。

数学上能保证:prefill 一次性算 8 个 token 与 decode 逐个算 8 个 token,最终 kv_cache[0]、kv_cache[1] 落到同一个值(浮点求和次序不同,末位可能有差异)。这就是 V4 实现"prefill 与 decode 数学等价"的全部秘密。


3.7 V4 KV 几何与操作系统虚拟内存的对应关系

V4 的 KV 几何如果用一个熟悉的类比,最贴切的对应是操作系统的虚拟内存分级:

OS 虚拟内存 V4 KV 几何
L1 / L2 cache 滑窗段(最近 128 token)
主存 压缩段 ratio=4 部分
交换分区 / 磁盘 压缩段 ratio=128 部分
只有 L1、没有主存的进程 MTP 层 ratio=0 的纯滑窗 KV

先说清楚类比的边界:ratio=4 与 ratio=128 分属不同的层,不是同一层里的两级存储;每一层只有"滑窗 + 一种 ratio 的压缩段"两段。在这个前提下,类比在几个层面成立:

容量与精度的反比关系:滑窗段容量小(128 槽)但逐 token 保真;压缩段容量大(百万 token / ratio)但每槽是一组 token 的加权和。读取时两段走的是同一次 sparse_attn,额外成本花在写入端(Compressor 的加权求和),不在读取端。

分组粒度:每 ratio 个 token 压成一组的设计,相当于"页大小 = ratio"。与 OS 换页不同的是,token 并不是"被滑窗淘汰后才进入压缩段"——每凑满一组就立即压缩写入,最近 128 个 token 同时存在于滑窗段和(已凑满的)压缩组里。

层级粒度:L1 / L2 / 主存的粒度逐级变粗(cache line → 页面 → segment),V4 的 KV 几何粒度也逐级变粗(单 token 滑窗 → 4 token 一组 → 128 token 一组)。

TLB 类比:Indexer 在 V4 中扮演的角色类似 TLB(页表查找单元)——它快速给压缩 KV 打分,告诉主 attention "应该去查哪些压缩 KV"。

这个类比对工程师建立直觉特别有用:当你想搞清楚"V4 的某层在做什么内存层级的事",把它对应到虚拟内存的层级就一目了然。第 4 章会展开 Indexer 这个"TLB"的内部工作机制。


3.8 Compressor 在 Attention 与 Indexer 中的双重身份

V4 的源码里有两套 Compressor:

  • 一套在 Attention 里,给主 attention 用:self.compressor = Compressor(args, self.compress_ratio, self.head_dim),rotate=False,head_dim=512,ratio=4 和 ratio=128 的层都有,输出写进 Attention.kv_cache 的压缩段
  • 一套在 Indexer 里,给稀疏选取的 score net 用:self.compressor = Compressor(args, compress_ratio, self.head_dim, True),rotate=True,这里的 self.head_dim 是 index_head_dim=128,只在 ratio=4 的层有,输出写进 Indexer 自己的 kv_cache(inference/model.py:399)

代码是同一个类,差别在维度(512 对 128)和 rotate 标志。rotate 决定 forward 末尾走哪条量化路径:

if self.rotate:
    kv = rotate_activation(kv)
    fp4_act_quant(kv, fp4_block_size, True)
else:
    act_quant(kv[..., :-rd], 64, scale_fmt, scale_dtype, True)
  • Attention 的 Compressor:只对非 RoPE 的 448 维做 FP8 量化,RoPE 的 64 维保持 BF16(保留位置精度)
  • Indexer 的 Compressor:先做 Hadamard 旋转,再对全部 128 维做 FP4 量化(更激进的压缩)

两者传的最后一个参数都是 inplace=True:参考实现里这是"量化再反量化回 BF16"的 QAT 模拟(inference/kernel.py 的 docstring:inplace=True does fused quant+dequant back to BF16),缓存里实际存的仍是 BF16(pin 版 FP8 这一路还有个细节:中间那次 Cast 的目标是 BF16,数值上等于原样返回,见第 14 章 §14.6;FP4 那一路是真取整)。

为什么 Indexer 的 Compressor 走 FP4?因为 Indexer 的输出只是"打分",最终 topk 选取之后并不直接参与 attention 输出——它的精度损失对最终结果影响小,可以更激进地压。Hadamard 旋转把信息"打散"到所有维度,让 FP4 量化的精度损失更均匀,进一步提升精度 / 容量比。

这是 V4 的一个微妙工程:两套同源模块用不同精度——同一个类、维度不同、参数独立训练、量化策略不同。第 4 章会展开 Indexer 的设计,Hadamard + FP4 的精度链路见 §4.7;FP4/FP8 格式本身见第 12 章。


3.9 Compressor 与 attention sink 的协同

第 2 章讲到 V4 的 attn_sink 是稀疏注意力的"兜底参数"——当稀疏选取找不到合适 KV 时,允许这一步的注意力总和小于 1。Compressor 的设计与 attn_sink 形成了一对互补的"安全网":

  • Compressor 提供"长距离主旨记忆":在 ratio=4 的层上,压缩 KV 只有被 Indexer 选进 top-1024 的那部分才会被 attention 看到;但相邻的 ratio=128 层不经过 Indexer,每个 query 都能看到全部已凑满的压缩组(get_compress_topk_idxs),所以即便某一层的 top-1024 选错了,"这段 context 的整体语义"仍然有一条不依赖 Indexer 的通路
  • attn_sink 提供"什么都不选的安全出口":如果 Indexer 选到的 KV 都不相关,sink 接住这一步 attention 的注意力质量

两者一起构成 V4 稀疏注意力的"双重防御":宏观信息(Compressor)+ "不必硬选"的出口(attn_sink)。可以把这个设计理解为"哪怕 Indexer 训练得不完美,模型也不至于崩溃"——这是本专栏的解读,技术报告没有这样表述设计目标。

这里也可以引申一个观察:V4 在每个稀疏化决策处都有数值冗余兜底——

  • 滑窗 KV 提供"近距离精确记忆"——保证当稀疏选取出错时,至少近期 token 不会丢
  • Compressor 提供"远距离粗略记忆"——保证整体语义不丢
  • attn_sink 提供"零选择"出口——不相关的 KV 不必分到权重
  • MTP 层 ratio=0 反过来把冗余降到最低:它只留 128 槽滑窗,副 head 的长距离信息全部来自主模型交过来的 hidden state

前三重冗余叠加,意图是让 V4 在 Indexer 选取出错时不至于失去全部上下文(本专栏没有做过相关实测)。这是稀疏路径与 dense 路径"工程纪律"上的最大差别。


3.10 KV cache 的字节级账本

把 V4 Pro 跑 1M context 的 KV cache 占用按字节级算清楚,理解显存压力的真实分布。

给定参数:

  • n_layers = 61
  • max_seq_len = 1,048,576(1M 部署设定;参考实现 ModelArgs 默认 4096,inference/config.json 也没有设这个字段,跑 1M 要自己在配置里加上)
  • window_size = 128
  • head_dim = 512
  • 假设所有层都 BF16 存(每槽 2 bytes × 512 = 1 KB)

单层占用 = (window_size + max_seq_len // ratio) × head_dim × 2 bytes

ratio 槽位数 单层 (BF16) 单层 (混合格式:RoPE 64 维 BF16 + 其余 FP8)
0 128(只有滑窗) 0.13 MiB 0.07 MiB
4 262,272 256 MiB 144 MiB
128 8,320 8.13 MiB 4.57 MiB

V4 Pro 的真实 compress_ratios 配置(主模型 61 层:30 层 ratio=4 + 31 层 ratio=128;ratio=0 那一项是数组第 62 个元素,属于 MTP 层,不计入主模型):

30 层 × 256.1 MiB = 7684 MiB
31 层 × 8.13 MiB  =  252 MiB
──────────────────────────
总计 ≈ 7936 MiB ≈ 8.3 GB / 序列(BF16)
                ≈ 4.7 GB / 序列(按技术报告的混合格式存,不计 scale)

这还只是主 attention 的缓存。30 个 ratio=4 的层上,Indexer 另有一块自己的 kv_cache(inference/model.py:399,形状 [max_batch_size, max_seq_len // 4, index_head_dim=128],没有滑窗段):

30 层 × 262,144 × 128 × 2 bytes = 1920 MiB ≈ 2.0 GB / 序列(BF16)
主 attention + Indexer ≈ 9856 MiB ≈ 9.6 GiB ≈ 10.3 GB / 序列(BF16)

第 15 章 §15.13 算 TP 下每卡的 KV 时用的就是这个含 Indexer 的 9.6 GiB。

对比 V3.2 的 KV cache(MLA,每 token 每层都存、不压缩:kv_lora_rank=512 + qk_rope_head_dim=64 共 576 维):

61 层 × 1,048,576 × 576 × 2 bytes = 73.7 GB / 序列(BF16)

只算主 attention,8.3 / 73.7 = 11.3%,与 README 那句"1M 上下文下 KV cache 只需 DeepSeek-V3.2 的 10%"在量级上一致(§1.15 用元素数算过同一笔账)。注意口径不同:技术报告 Figure 1 的 10% 是"估算的累计 KV cache",V4 一侧按技术报告第 2.3.4 节的混合格式(RoPE 维 BF16、其余 FP8)存,报告没有逐项写明是否含 Indexer 缓存;这里是两边都按全 BF16、都不含 Indexer 的粗算。V4 这边加上 Indexer 的 2.0 GB 后是 14%,但 V3.2 的 DSA 同样带 lightning indexer 的缓存(本专栏 pin 树里没有 V3.2 源码,未计入),所以 14% 也不能直接拿来和 10% 比;§1.15 按 V3.2 论文的口径把两边的 indexer 缓存都粗估进去,比值约 11.5%。

当 batch_size=8 时:

V3.2:8 × 73.7 = 590 GB —— KV 本身就远超单机显存 V4:8 × 8.3 = 66 GB(混合格式下约 37 GB;都未含 Indexer 缓存)—— 落回"和权重抢显存"的正常量级

需要说清楚的是:这不代表 V4 Pro 能单卡跑——1.6T 权重本身就是 864.7 GB 的 safetensors,无论如何都要多机多卡。KV 从 590 GB 降到几十 GB 的意义在于,长上下文高并发时显存瓶颈从 KV 挪回了权重,而权重是可以靠 TP/EP 摊薄的固定成本,KV 不行。第 19 章会讨论这对推理引擎部署架构的影响。


3.11 Compressor 与 Mamba / 状态空间模型的对比

V4 的 Compressor 与 Mamba2、xLSTM 等"状态空间模型"在思想上有共同点——把序列信息压成定长状态——但工程实现完全不同:

维度 Mamba2 / SSM V4 Compressor
状态形式 隐状态向量 (recurrent) 压缩 KV (非 recurrent)
更新机制 A · h_{t-1} + B · x_t softmax(score) · kv 加权求和
访问模式 必须按序访问历史 可以随机访问任何压缩组
训练复杂度 O(n) (selective scan) O(n)(每个 token 都过 wkv/wgate,产出 n / ratio 组)
推理复杂度 O(1) per token O(n / ratio) per token
长上下文能力 状态可能"遗忘"细节 压缩 KV 保留所有 ratio 组

V4 的 Compressor 选择非 recurrent:每个压缩组独立计算、可以并行化、可以随机访问。这与 Transformer 的"每个 token 独立 KV"哲学一致——只是把粒度从"每个 token" 放到"每 ratio 个 token"。

代价是:相比 Mamba 的 O(1) per-token 推理,V4 的 O(n/ratio) 推理在超长上下文下成本仍然线性增长。但 V4 通过 ratio=4-128 的非均匀混合,把这个常数压得很低。要注意的是,整体 KV 占用是 ratio=4 的层在主导(§3.10:30 层共 7684 MiB,31 个 ratio=128 的层合计才 252 MiB),ratio=128 的层几乎不占显存。

这种"非 recurrent 压缩 KV"的选择,让 V4 保留了 Transformer 的全部并行优势,同时让 KV cache 按一个很小的斜率线性增长。这是 SSM 路线与 Transformer 路线之间的一个工程妥协——V4 没有放弃 Transformer,而是给它配了一套"分级 KV 内存"。


3.12 一段动手实验:自己实现一个 mini Compressor

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

class MiniCompressor(nn.Module):
    """简化版 Compressor,不含 overlap,便于理解核心逻辑"""
    def __init__(self, dim=512, head_dim=128, ratio=4):
        super().__init__()
        self.ratio = ratio
        self.head_dim = head_dim
        self.wkv = nn.Linear(dim, head_dim)
        self.wgate = nn.Linear(dim, head_dim)
        self.ape = nn.Parameter(torch.zeros(ratio, head_dim))
        self.norm = nn.LayerNorm(head_dim)

    def forward(self, x):
        # x: [B, S, dim], assume S % ratio == 0
        B, S, _ = x.shape
        kv = self.wkv(x)                                  # [B, S, head_dim]
        score = self.wgate(x)                             # [B, S, head_dim]

        kv = kv.view(B, S // self.ratio, self.ratio, -1)
        score = score.view(B, S // self.ratio, self.ratio, -1) + self.ape

        weights = score.softmax(dim=2)
        compressed = (kv * weights).sum(dim=2)            # [B, S/ratio, head_dim]
        return self.norm(compressed)


# 测试
mc = MiniCompressor()
x = torch.randn(2, 16, 512)
out = mc(x)
print(out.shape)   # 应该是 [2, 4, 128]

跑通这个 mini 版本后,再回看 V4 源码里 overlap 的处理,会觉得"原来不过是给 wkv / wgate 加倍输出维度,前后两半各管一段重叠区"。


3.13 Compressor 在 Tensor Parallel 多卡下的状态分布

以 8 卡 TP(Tensor Parallel)部署 V4 Pro 为例,每张卡持有 1/8 的 attention head——n_heads / world_size = 16 个 local heads。Compressor 的状态分布需要回答一个问题:kv_state 与 kv_cache 是按 head 切分的吗?

读源码可以确认:Compressor 的状态不按 head 切分。

# Attention 类中
self.register_buffer("kv_cache", torch.zeros(args.max_batch_size, kv_cache_size, self.head_dim))

self.head_dim 是 512(不是 n_heads × head_dim)——所有 head 共享同一组 KV(V4 是 num_key_value_heads=1 的 MQA-style)。这意味着:

  • 每张 TP 卡上的 kv_cache 是完整的、未切分的 ——[B, kv_cache_size, 512]
  • 每张卡上的 Compressor 是完整的副本 ——所有 rank 跑相同的压缩计算
  • 这浪费了 KV cache 的并行性,但省了通信 ——稀疏 attention 不需要在 TP rank 间同步 KV

这种"KV 全副本、Q/O 张量并行"的混合切分,是 MQA-style 模型在 TP 下的标准做法。它的工程权衡是:

  • ✅ KV cache 的访问不需要 all-reduce
  • ✅ Compressor 的压缩计算不需要跨 rank 同步(wkv / wgate 是普通 Linear,不切分);Indexer 则不同,它的 wq_b / weights_proj 按 index_n_heads 切分,各 rank 算完部分打分后要 dist.all_reduce(index_score) 再取 top-k(inference/model.py:423)
  • ❌ KV cache 内存被冗余 8 倍——V4 的 KV 已经被压到约 9.6 GiB / 序列(含 Indexer 缓存,§3.10),8 份就是约 77 GiB,是否可接受要看并发(第 15 章 §15.13 有这笔账)

如果换成 GQA(多个 KV head),KV 切分到不同 rank 是合理的,但通信开销会上升。V4 选 MQA + 完整副本,是把"通信简单 + 实现简单"放在了"显存效率"之上。

第 15 章会展开 ColumnParallelLinear / RowParallelLinear 在 V4 中的具体使用,以及 MoE 的 expert parallel 如何与 attention 的 TP 配合。


3.14 Compressor 的工程师 FAQ

写到这里,读者最容易问的几个问题集中回答:

Q1: 为什么 Compressor 不是 attention 而是 gated pooling?

attention 是"query 主动查 KV",gated pooling 是"输入自带门控权重做加权平均"。Compressor 的输入是 hidden state x(不是 query),目标是把 ratio 个 token 压成 1 组——它没有 query 的概念。用 gated pooling(softmax(score) × kv)比用 attention 简单得多,参数也少。

如果用 attention 做压缩,你必须给 Compressor 配一个"压缩 query"——这要么是固定的(学不到 token 自适应)、要么是从输入算出来的(多一组参数)。V4 的 gated pooling 是更经济的选择。

Q2: ape (absolute position embedding) 与主 attention 的 RoPE 是什么关系?

是两套不同的位置编码:

  • 主 attention 的 RoPE:作用于完整序列的 1M token,提供"全局位置感"
  • Compressor 的 ape:仅作用于"压缩组内部"的 ratio 个相对位置,提供"组内位置感"

两者在源码里是分开的——ape: [ratio, coff*head_dim],freqs_cis: [max_seq_len, rope_head_dim/2] 复指数。Compressor 在做完 softmax 加权求和后,会再用 apply_rotary_emb 给压缩 KV 套上 RoPE——这时位置是"压缩组对应的 token 位置"(freqs_cis[:cutoff:ratio])。

Q3: 为什么 ratio=4 启用 overlap,ratio=128 不启用?

overlap 是为了"组与组的边界平滑"。当 ratio=4 时,4 个 token 一组,组与组的边界很近——模型经常需要"跨组"的相关性。overlap 让每个压缩槽实际覆盖 8 个 token(本组 4 个 + 前一组 4 个),相邻槽因此共享 4 个 token,减少边界处的信息损失。

当 ratio=128 时,128 个 token 一组——组内本身已经是"粗粒度记忆",组间边界平滑没那么重要。也不启用 overlap。

工程上还可以算一笔成本账:overlap 让 wkv / wgate 的输出维度翻倍(coff=2),参数与投影计算量也翻倍(每层多约 7.3M 参数,见 Q5),但 KV cache 的槽位数一点没变(§3.3.2)。这笔代价在 ratio=4 的层上换来的是边界平滑;ratio=128 的组里已有 128 个 token,边界 token 的占比小得多,同样的代价换来的收益也小——这是本专栏的推断,源码只写了 self.overlap = compress_ratio == 4,没有说明理由。

Q4: kv_state 与 score_state 必须是 FP32 吗?

源码里这两个 buffer 显式指定 dtype=torch.float32:

self.register_buffer("kv_state", torch.zeros(..., dtype=torch.float32), ...)
self.register_buffer("score_state", torch.full(..., float("-inf"), dtype=torch.float32), ...)

是的。注意 wkv / wgate 这两个 Linear 在 Compressor.__init__ 里也显式写了 dtype=torch.float32(源码注释说明:checkpoint 里存的是 bf16,加载到内存后升成 fp32),forward 开头还有一行 x = x.float() 并注明 "compression need fp32"。原因是压缩是"softmax 加权求和",权重来自 wgate 的输出——BF16 只有 7 位尾数,在 softmax 前后会放大相对误差,而这条路径的结果要直接进 KV cache 长期留存,误差没有被后续层稀释的机会。

Q5: Compressor 的参数量大不大?

分两种层算(overlap 只在 ratio=4 的层开启,coff 因此是 2 或 1):

ratio=4 的层(30 层,coff=2,head_dim=512)

  • wkv: 7168 × 1024 = 7.34M
  • wgate: 7168 × 1024 = 7.34M
  • ape: 4 × 1024 = 4096,norm: 512
  • 小计 ≈ 14.7M / 层 → 30 层 ≈ 441M

ratio=128 的层(31 层,coff=1,head_dim=512)

  • wkv + wgate: 2 × 7168 × 512 = 7.34M
  • ape: 128 × 512 = 65536,norm: 512
  • 小计 ≈ 7.4M / 层 → 31 层 ≈ 230M

主 attention 的 Compressor 合计约 670M。再加上 Indexer 自带的那套(head_dim = index_head_dim = 128,只在 30 个 ratio=4 层上有,每层 2 × 7168 × 256 ≈ 3.67M)约 110M,两者一共 不到 0.8B——占 1.6T 总参的 0.05%。

Compressor 用万分之五的参数让 V4 的 KV cache 掉到 V3.2 的十分之一——这是一笔极划算的工程交易。


3.15 Compressor 与 dense MLA 的"性能转折点"

V4 的 Compressor + sparse 路径不是无脑优于 dense MLA。在某些 context 长度下 dense 反而可能更快。下面按结构做定性推演——分段边界(8K、64K、256K)是本专栏的估计,不是实测数据,技术报告也没有给出这样的分段:

Context ≤ 8K:

dense MLA 的 KV cache 才几百 MB,attention FLOPs 也可承受。而 V4 在这个长度上仍然要付出固定成本:每层多两个 7168→512/1024 的投影(Compressor 的 wkv/wgate)、Indexer 的一整套打分、以及 sparse kernel 的 gather 开销。短上下文下这些固定成本没有被摊薄,V4 的架构红利拿不到。具体慢多少取决于实现与硬件,本专栏不给未经实测的倍数。

Context = 16K - 64K:

dense 的 KV cache 开始变大(GB 量级),FLOPs 也开始凸显。Compressor 开始拿到节省,固定成本逐渐被摊薄,两者的差距在这一段收窄。

Context = 64K - 256K:

V4 的 Compressor 优势开始拉开——dense 的 O(n2)O(n^2) 在这个范围变得越来越不经济;而 V4 ratio=4 层的主 attention 每个 query 只看 window_size + index_topk = 128 + 1024 个条目,不随 n 增长(Indexer 的打分仍随 n/4 线性增长,ratio=128 层的 attention 也随 n/128 线性增长,只是斜率小得多)。

Context = 256K - 1M:

dense 几乎不可用——KV cache 爆、FLOPs 爆。只有 V4 这类压缩 / 稀疏路线还能跑,速度对比意义不大。

Context > 1M(如果未来扩到):

V4 当前不支持 > 1M——max_position_embeddings=1048576 是硬上限。如果未来扩到 2M / 4M,按本专栏的推测,Compressor 的层级化压缩(多层 ratio)会让 V4 这类架构仍然可行,dense 则很难。

部署建议:

如果你的产品场景大部分 context < 8K,V4 未必是最优选——同等质量要求下的 dense 模型(如 Qwen3-32B dense)可能更快,需要按自己的负载实测。按上面的推演,V4 的甜区在 64K 以上。

理解这条曲线让你做"模型选择"的决策更清醒——不是"V4 在所有场景都最快",而是"V4 在长上下文场景压倒性优势"。


3.16 Compressor 与 fine-tune 的兼容性

V4 fine-tune 时 Compressor 的几个特殊考虑:

考虑 1:Compressor 参数也要 fine-tune

如果 fine-tune 时冻结 Compressor,新数据分布的"压缩重要性" 与预训练学到的可能不一致——Compressor 输出与下游 attention 不匹配。建议 fine-tune 时 Compressor 也参与梯度更新,但用比 attention 更小的 lr。

考虑 2:compress_ratios 不能改

ratio 是写在 config 的 per-layer 数组——fine-tune 不应该改这串数字。改了等于改 KV cache 几何,预训练得到的 Compressor 参数也没法直接沿用:ape 的形状就是 [ratio, coff * head_dim],overlap 与否还决定 wkv / wgate 的输出维度,换了 ratio 连权重都加载不进来。

考虑 3:fine-tune 数据的长度分布

如果 fine-tune 数据全是短 prompt(< 4K),Compressor 的远距离压缩能力得不到训练。fine-tune 后模型在短 prompt 上可能有提升,在长 prompt 上反而退化(因为 Compressor 知识被"洗掉")。建议 fine-tune 数据保留一定比例长 prompt。

考虑 4:与 LoRA 的协同

如果用 LoRA 做 fine-tune(不 full fine-tune),Compressor 是否也加 LoRA 是个选择题。本专栏的倾向是不给 Compressor 加 LoRA:它的 wkv / wgate 本来就是 7168 → 512/1024 的窄投影,参数只占全模型的万分之五(Q5),直接全量微调的成本很低,没必要再套一层低秩近似。LoRA 集中在 attention 的 wq_b / wo_b 等位置即可。这是经验性建议,没有官方依据。

这 4 个考虑让 fine-tune V4 时 Compressor 不被破坏——保留预训练学到的"如何压缩长距离信息"能力。


3.17 延伸阅读

  • DeepSeek-V3.2-Exp 模型卡——DSA(DeepSeek Sparse Attention)的最早期描述
  • Mamba 论文(arXiv:2312.00752)/ Mamba2 论文(arXiv:2405.21060)—— 状态空间模型的"长距离记忆压缩"思路与 V4 Compressor 有平行性
  • Linformer / Performer 等"低秩注意力"工作——把 attention 做成 O(n) 的早期尝试
  • 本专栏第 4 章:Indexer 是 Compressor 的下游——把压缩 KV 用作"稀疏 KV 选择"的输入

3.18 本章小结

  • V4 的 KV cache 是"滑窗 + 压缩段"的双段几何,不是单一的连续 KV
  • Compressor 的核心是"按 ratio 把多个 token 压成 1 组" + "ape 给组内位置感"
  • prefill 与 decode 的两条 codepath 必须数学等价——kv_state / score_state 双 buffer 是关键
  • per-layer 的非均匀 compress_ratios(粗 / 细 / 不压缩交替)是 V4 长上下文成本可控的真正秘诀
  • Compressor 在 V4 中有两个身份:一个给 Attention 主路(FP8 + 不旋转),一个给 Indexer score net(FP4 + Hadamard 旋转)

第 4 章我们进入 Indexer——V4 稀疏注意力的"score net",看它怎么用一个独立的 KV 视角,给压缩 KV 打分并选取 top-1024。