问题:Softmax Attention 的两笔账

\boldsymbol{S} = \frac{\boldsymbol{Q} \boldsymbol{K}^\top}{\sqrt{d}} \in \mathbb{R}^{n \times n}, \qquad \boldsymbol{A} = \mathrm{softmax}(\boldsymbol{S}), \qquad \boldsymbol{O} = \boldsymbol{A} \boldsymbol{V} \in \mathbb{R}^{n \times d}

推理时,softmax attention 有两笔账都随序列长度 T 增长:

根源在 softmax 的归一化:o_t 依赖 q_t 与所有 k_j 的打分之和,任何一个历史 token 都不能提前丢掉。Linear attention 的出发点是:如果能把历史压缩成一个与 T 无关的东西,这两笔账就都不存在了。

核心抽象:把历史压缩成一个状态矩阵

结合律

去掉 softmax 后,矩阵乘法可以按结合律重排:(QK^\top)V = Q(K^\top V)。

考虑完整的 MHA,Q, K, V 的 shape 都是 [B, T, H, D]。计算 K^\top V 时,B, H 是 batch 维,D 是自由维,T 是被收缩掉的维度。结果的 shape 为 [B, H, D, D],计算量 BTHD^2:复杂度里的 T^2 变成了 D^2。

递归形式:Attention 变成 RNN

下文统一用行向量约定:q_t, k_t \in \mathbb{R}^{1\times d_k},v_t \in \mathbb{R}^{1\times d_v},状态 S \in \mathbb{R}^{d_k \times d_v}。因果版本的输出为

o_t=\sum_{j=1}^{t} (q_t k_j^\top)\, v_j = q_t \sum_{j=1}^{t} k_j^\top v_j = q_t S_t, \qquad S_t = S_{t-1} + k_t^\top v_t

S_t 就是 K^\top V 的前缀和。因果 attention 于是变成一个以 S_t 为隐状态的 RNN:每步写入一个外积、读出一次矩阵乘,状态大小恒为 d_k \times d_v。

KV Cache 与状态矩阵

把 S 看成联想记忆

S 是一个 key→value 的线性联想存储:写入是外积 k^\top v,读取是用 key 去乘 S。如果写入过的 key 两两正交且单位长,k_i S = v_i 可以精确读回。

问题也在这里:d_k 维空间最多容纳 d_k 个正交 key,token 数远超 d_k 后写入必然互相串扰。而且纯累加让所有历史等权叠加,没有任何机制去忘掉过时的信息,或改写同一个 key 下的旧值。

所以 linear attention 真正要回答的问题是:容量固定的记忆,应该怎么写、怎么忘。 下面所有变体都是对这个问题的不同回答。

演化:从累加到 Gated Delta Rule

统一递归形式

\boxed{ \begin{aligned} \hat{v}_t &= k_t \,(\gamma_t S_{t-1}) &&\text{(先衰减,再用当前 key 读出旧值)}\\ \Delta_t &= v_t - \hat{v}_t &&\text{(真实值与记忆预测值的误差)}\\ S_t &= \gamma_t S_{t-1} + \alpha_t \, k_t^\top v_t + \beta_t \, k_t^\top \Delta_t &&\text{(统一状态更新)}\\ o_t &= q_t \, S_t &&\text{(用 query 读出输出)} \end{aligned} }

模型/方法 \gamma_t \alpha_t \beta_t 退化后的更新
最早的线性 Attention 1 1 0 S_t = S_{t-1} + k_t^\top v_t(纯累加)
加入遗忘门(RetNet / GLA / Mamba2 一类) \gamma_t 1 0 S_t = \gamma_t S_{t-1} + k_t^\top v_t(带衰减的累加)
DeltaNet 1 0 \beta_t S_t = S_{t-1} + \beta_t k_t^\top (v_t - k_t S_{t-1})(误差驱动)
Gated DeltaNet \gamma_t 0 \beta_t S_t = \gamma_t S_{t-1} + \beta_t k_t^\top (v_t - k_t\, \gamma_t S_{t-1})(遗忘 + 误差驱动)

注意 Gated DeltaNet 的预测值 \hat v_t 用的是衰减之后的状态——SGLang kernel 里也是先 h *= exp(g),再 v -= h·k。

为什么 delta rule 更好

两个角度理解:

Gated Delta Rule 一步更新

矩阵(并行)形式

把递归在整段序列上展开,可以得到等价的矩阵形式(M 是因果 mask,M^- 是严格下三角 mask,\Gamma 是累积衰减 mask;为简洁省略 \beta,可视作并入 k, v):

模型/方法 矩阵形式
Softmax Attention (\exp(QK^\top) \odot M)V
最早的线性 Attention (QK^\top \odot M)V
加入遗忘门 (QK^\top \odot \Gamma)V
DeltaNet (QK^\top \odot M)(I + KK^\top \odot M^-)^{-1}V
Gated DeltaNet (QK^\top \odot \Gamma)(I + KK^\top \odot \Gamma^-)^{-1}V

\Gamma_{i,j} = \begin{cases} \displaystyle \prod_{\tau=j+1}^{i} \gamma_\tau, & i > j \\ 1, & i = j \\ 0, & i < j \end{cases}

那个下三角逆矩阵 (I + KK^\top \odot M^-)^{-1} 不是额外加的归一化,而是 delta rule 递归在多步展开后的闭式解:每一步的误差 \Delta_t 依赖前面所有步写入的结果,把这条依赖链解开就是一个下三角线性方程组。它的实际用途是让 prefill 可以按块并行(见下文 chunkwise 一节)。

Gated DeltaNet

Gated DeltaNet(GDN,Qwen3-Next / Qwen3.5 的 linear 层)把更新写成更紧凑的形式:

S_t = \alpha_t \left(I - \beta_t k_t^\top k_t\right) S_{t-1} + \beta_t k_t^\top v_t, \qquad \alpha_t = e^{g_t} \in (0, 1],\quad \alpha_t \text{ 是每个 head 一个标量}

展开括号:

\boxed{ S_t=\alpha_t S_{t-1} -\alpha_t\beta_t k_t^\top k_t S_{t-1} +\beta_t k_t^\top v_t }

(I - \beta_t k_t^\top k_t) 是一个“部分 Householder 变换”:只沿 k_t 方向把旧记忆收缩 \beta_t,其余方向不动。这也是 q、k 必须做 L2 归一化的原因:\lVert k_t\rVert = 1 时这个矩阵沿 k_t 方向的特征值是 1-\beta_t \in [0, 1),递归不会发散。

门的参数化沿用 Mamba2:

g_t=-\exp(A_{\log})\cdot\operatorname{softplus}(a_t+dt_{\mathrm{bias}})

\beta_t=\operatorname{sigmoid}(b_t)

A_log、dt_bias 是每个 v-head 一个的可学习参数,a_t, b_t 由输入投影 in_proj_ba 给出(每个 v-head 各一个数)。\exp(A_{\log}) 决定这个 head 的基础衰减速率,\operatorname{softplus}(a_t+dt_{\mathrm{bias}}) 是数据相关的“步长”,两者相乘再取负保证 g_t \le 0。

\underbrace{S' = \alpha S_{t-1}}_{\text{遗忘}} \quad\longrightarrow\quad \underbrace{\hat v=kS'}_{\text{读取旧值}} \quad\longrightarrow\quad \underbrace{\delta=\beta(v-\hat v)}_{\text{计算修正}} \quad\longrightarrow\quad \underbrace{S_t=S'+k^\top\delta}_{\text{更新记忆}} \quad\longrightarrow\quad \underbrace{o=qS_t}_{\text{输出}}

一个与 kernel 语义一致的逐 token 参考实现:

def gated_delta_rule_ref(q, k, v, g, beta, S=None):
    # q, k: [b, h, t, dk]   v: [b, h, t, dv]
    # g: [b, h, t](log 空间衰减,≤ 0)   beta: [b, h, t]
    b, h, t, dk = k.shape
    q = F.normalize(q, dim=-1) * dk ** -0.5   # 先 L2Norm 再乘 scale
    k = F.normalize(k, dim=-1)
    if S is None:
        S = k.new_zeros(b, h, dk, v.shape[-1])
    outs = []
    for i in range(t):
        q_i, k_i, v_i = q[:, :, i], k[:, :, i], v[:, :, i]
        S = S * g[:, :, i].exp()[..., None, None]                 # ① 衰减
        v_hat = torch.einsum("bhk,bhkv->bhv", k_i, S)             # ② 预测
        delta = beta[:, :, i, None] * (v_i - v_hat)               # ③ 误差
        S = S + torch.einsum("bhk,bhv->bhkv", k_i, delta)         # ④ 写回
        outs.append(torch.einsum("bhk,bhkv->bhv", q_i, S))        # ⑤ 读出
    return torch.stack(outs, dim=2), S

GDN 的 v-head 数可以多于 k-head 数(GVA,类似 GQA 反过来:多个 v-head 共享一组 q/k),状态按 v-head 存,形状是 [H_V, d_v, d_k]。

KDA:逐通道的遗忘门

Kimi Delta Attention(KDA,Kimi-Linear 的 linear 层)只改了一处:遗忘门从每个 head 一个标量变成每个 head 一个长度 d_k 的向量。

S_t = \left(I - \beta_t k_t^\top k_t\right) \mathrm{Diag}(\alpha_t)\, S_{t-1} + \beta_t k_t^\top v_t, \qquad \alpha_t \in (0, 1]^{d_k}

S 的第 i 行只被 key 的第 i 个通道写入和读出,所以 \mathrm{Diag}(\alpha_t) 按行衰减,等于让不同特征通道拥有不同的记忆“半衰期”:有的通道几乎永久保留,有的通道每几个 token 就清空。这是 GLA 式细粒度门控与 delta rule 的结合。上面的参考实现只需改一行:g 变成 [b, h, t, dk],衰减写成 S * g[:, :, i].exp()[..., None]。

遗忘门粒度:GDN vs KDA

门的参数化与 GDN 同构,只是 f_t 变成了向量,并且由低秩投影得到以控制参数量:

f_t = W_{f_b}\,(W_{f_a}\, x_t) \in \mathbb{R}^{H \cdot d_k},\qquad g_t = -\exp(\texttt{A\_log}[h]) \cdot \mathrm{softplus}(f_t + \texttt{dt\_bias})

其中 f_a_proj: hidden → head_dim、f_b_proj: head_dim → H·head_dim;A_log 每个 head 一个,dt_bias 每个通道一个([H \cdot d_k])。部分模型(如 Kimi K3,config 中的 gate_lower_bound=-5)改用 “safe gate”:g = \text{lower\_bound} \cdot \mathrm{sigmoid}(\exp(\texttt{A\_log}) \cdot (f + \texttt{dt\_bias})),把衰减限制在一个有界区间内(见下文 Kimi K3 一节)。

Gated DeltaNet KDA
遗忘门 每 head 一个标量 \alpha_t 每 head 一个 d_k 维向量 \alpha_t
门的来源 in_proj_ba 直接投影 f_a_proj → f_b_proj 低秩投影
头数 v-head 可多于 k-head(GVA) q/k/v 头数相同
输出门 RMSNormGated(o, z),z 来自 in_proj_qkvz RMSNorm(o) ⊙ sigmoid(g),g 来自低秩 g_a_proj → g_b_proj(K3 改为满秩 g_proj)
搭配的 full attention 层 Gated Attention(GQA) MLA
SGLang 模型 Qwen3GatedDeltaNet(models/qwen3_next.py) KimiDeltaAttention(models/kimi_linear.py)

代价是门张量从 [T, H] 变成 [T, H, d_k],chunk kernel 里的衰减也不能再当作一个标量 mask 提出来,需要把 k 按累积衰减逐通道缩放(kernel 里的 kg)。好在状态递推的结构没变,SGLang 的 KDA prefill 直接复用了 GDN 的块间递推 kernel chunk_gated_delta_rule_fwd_h,只是传入向量门 gk。

两种计算形态:chunkwise prefill 与 recurrent decode

逐 token 的递归在 decode 时刚好合适(每步本来就只有一个 token),但用在 prefill 上就是 T 步串行,GPU 吃不满。矩阵形式虽然完全并行,却又回到了 T^2。chunkwise 算法折中两者:把序列切成大小 C=64 的块(fla/chunk.py 中的 CHUNK_SIZE)。

串行步数从 T 降到 T/64,总计算量是 O(T\,C\,d + T\,d^2)。

Chunkwise 并行

这个形态在 serving 上有一个副产品:每个块边界的状态 h 都会被物化出来,等于免费拿到了“前缀长度为 64 的倍数处”的状态快照,prefix cache 就是靠它来保存中间状态的(见下文)。

decode 时没有 chunk,直接调用 fused recurrent kernel(fused_recurrent_gated_delta_rule_packed_decode / fused_recurrent_kda_packed_decode,或者非 packed 的 fused_sigmoid_gating_delta_rule_update)。它在一个 kernel 里完成门计算和上图五步更新,并原地改写 pool 里的状态。

一层 GDN / KDA 的完整数据流

delta rule 只是一层的核心,GDN 和 KDA 在它前后都包了一样的骨架:

一层 GDN / KDA 的数据流

有两个容易被忽略的组件:

结果是每个 linear 层有两份与序列长度无关的状态:conv state(最近 K-1=3 步的卷积输入)和 SSM state S。

实例:Kimi K3 的 KDA 权重如何对上计算流程

Kimi K3 权重精算 列出了一个 KDA 层的 20 个张量。光看名字和形状,很难说清哪个张量对应上面公式里的哪个符号,也看不出参数量都花在了哪里。这一节把它们逐个挂回计算流程。

K3 的 KDA 配置:96 个 head,d_k = d_v = 128,投影宽度 96 \times 128 = 12288,短卷积核 4,use_full_rank_gate=true,gate_lower_bound=-5。

先分角色,再对形状

20 个张量里有 6 个(input_layernorm、post_attention_layernorm 以及 *_res_norm / *_res_proj)是每层都有的公共 norm 和 Attention Residual 权重,MLA 层也有,与 KDA 无关。剩下 14 个 self_attn.* 按它们在递推里干什么可以分成四组:

另外有几步没有任何权重:q/k 的 L2Norm、d^{-1/2} 缩放、状态递推本身。递推是纯计算,可学习的东西全在它的输入(q、k、v、β、α)和输出端。

Kimi K3 KDA 权重与计算流程

写成公式(行向量,省略 head 下标;\hat{\cdot} 表示 L2 归一化):

\begin{aligned} q_t, k_t, v_t &= \mathrm{SiLU}\big(\mathrm{Conv}_4(x\,W_{q,k,v}^\top)\big)_t &&\texttt{q/k/v\_proj},\ \texttt{q/k/v\_conv1d}\\ \beta_t &= \sigma(x_t W_b^\top) \in (0,1)^{96} &&\texttt{b\_proj}\\ f_t &= (x_t W_{f_a}^\top)\, W_{f_b}^\top \in \mathbb{R}^{96\times 128} &&\texttt{f\_a\_proj},\ \texttt{f\_b\_proj}\\ g_t &= -5\cdot\sigma\!\big(e^{\texttt{A\_log}[h]}\,(f_t + \texttt{dt\_bias})\big),\quad \alpha_t = e^{g_t} &&\texttt{A\_log},\ \texttt{dt\_bias}\\ S_t &= (I-\beta_t \hat k_t^\top \hat k_t)\,\mathrm{Diag}(\alpha_t)\,S_{t-1} + \beta_t \hat k_t^\top v_t &&\text{无权重}\\ o_t &= d^{-1/2}\,\hat q_t S_t &&\text{无权重}\\ y_t &= \big(\mathrm{RMSNorm}_{w}(o_t)\odot\sigma(x_t W_g^\top)\big)\,W_o^\top &&\texttt{o\_norm},\ \texttt{g\_proj},\ \texttt{o\_proj} \end{aligned}

权重 形状 / dtype 对应操作 产物
q_proj / k_proj / v_proj [12288, 7168] BF16 ×3 x W^\top,按 head 切成 [96, 128] 卷积前的 q、k、v
q_conv1d / k_conv1d / v_conv1d [12288, 1, 4] F32 ×3 每通道 4 个 tap 的因果 depthwise 卷积,后接 SiLU 进入 delta rule 的 q、k、v
b_proj [96, 7168] BF16 \sigma(x W_b^\top) \beta,每 head 一个
f_a_proj [128, 7168] BF16 低秩下投影到 128 维 遗忘门的瓶颈表示
f_b_proj [12288, 128] BF16 上投影回每 head 每通道 原始门 f,[96, 128]
dt_bias [12288] F32 加到 f 上,逐通道 每个通道的基础门值
A_log [128] F32(只用前 96) e^{\texttt{A\_log}[h]} 作 sigmoid 的斜率,逐 head 与上面一起得到 g、\alpha=e^g
g_proj [12288, 7168] BF16 满秩输出门,\sigma(\cdot) 与 o 逐元素相乘的门
o_norm [128] F32 head 内 RMSNorm 的缩放,96 个 head 共享 归一化后的 o
o_proj [7168, 12288] BF16 拼接各 head 后投影回 hidden 层输出 [7168]

把这 14 项加起来是每层 443,740,416,乘 69 层正好是精算总览里的 30,618,088,704,说明没有遗漏或重复的张量。

逐个看:每个权重控制什么

把 KDA 当成一块容量固定的记忆,q/k/v 决定“按什么地址、写什么内容”,其余小张量是三个旋钮:写多少(β)、忘多少(α)、读多少(输出门)。

KDA 的三个旋钮

q / k / v 与短卷积:地址和内容。 k_t 是写入地址,v_t 是写入内容,q_t 是读取地址,角色与 softmax attention 相同;区别是 k、v 不进 KV cache,而是当场压进 S。q/k/v_conv1d 让每个通道看到当前和前 3 个 token 的投影(4 个 tap 的因果卷积),补上 linear 层没有 RoPE、分不清相邻顺序的缺口。

b_proj → β:这一步写多少。 b_proj 是 [96, 7168],每个 head 一行:对当前 token 的 hidden 做一次点积再取 sigmoid,就是这个 head 在这一步的写入强度。它的效果可以精确写出来。记衰减后的状态为 S'、旧值 \hat v = \hat k_t S',写入后再用同一个 key 读回:

\hat k_t S_t = \hat k_t S' + \beta_t\,(\hat k_t \hat k_t^\top)(v_t - \hat v) = (1-\beta_t)\,\hat v + \beta_t\, v_t

这里用到了 \lVert \hat k_t\rVert = 1。所以 β 是新旧值之间的插值系数:β≈0 时这个 token 对记忆没有影响,β=1 时这个 key 下的旧值被完全改写,与 \hat k_t 正交的方向始终不动。如果把 β 固定为 1,每个 token 都会强行覆盖自己 key 方向上的记忆;有了 b_proj,模型可以按内容决定哪些 token 值得占用有限的容量。β 缩放的是整个 rank-1 更新,所以每个 head 一个数就够了。

f_a_proj / f_b_proj / dt_bias / A_log → α:每个通道忘多少。 这四个张量合起来算一个逐通道的门:

z_t = \underbrace{(x_t W_{f_a}^\top)\, W_{f_b}^\top}_{f_t} + \texttt{dt\_bias},\qquad g_t = -5\cdot\sigma\big(e^{\texttt{A\_log}[h]}\cdot z_t\big),\qquad \alpha_t = e^{g_t}

用“每步半衰期” \ln 2/|g|(记忆衰减到一半要经过多少个 token)来读最直观,即图中右侧的曲线:

o_norm + g_proj:读多少。 读出 o_t = d^{-1/2}\hat q_t S_t 没有 softmax 那样的归一化,幅度随记忆里写了多少、写了什么而漂移。o_norm 先在每个 head 内做 RMSNorm(权重 [128],96 个 head 共享),把幅度拉回统一尺度;再乘 \sigma(x_t W_g^\top),逐通道决定当前 token 要用多少读出结果,门接近 0 时这一层对残差几乎没有贡献。输出门只看 x_t,不参与记忆的读写。K3 的 MLA 层也有一个同形状的 g_proj(见权重精算的 MLA 表),两种 attention 层都在 o_proj 之前乘一个 sigmoid 输出门。

每层公共的 6 个张量不属于 KDA。 input_layernorm、post_attention_layernorm 是 attention / MLP 输入前的 RMSNorm。self_attention_res_*、mlp_res_* 是 Attention Residuals 的参数:*_res_proj [1, 7168] 是一个 H\to 1 的打分向量,*_res_norm 是打分前的 RMSNorm,两者一起给前面各个 block 的残差快照打分,再按 softmax 权重混合,代替普通的残差相加。

几个从形状里读出来的细节

在 SGLang 里的落地

模型类是 KimiK3DeltaAttention(models/kimi_k3.py),checkpoint 的张量名和运行时模块并不一一对应,load_weights 在加载时做了合并:

在 SGLang 中的实现

以下路径都相对于 python/sglang/srt/。

混合层结构

纯 linear 模型的召回能力不够,所以实际模型都是混合结构:

模型层只负责投影和门控,真正的 attention 计算交给 layers/radix_linear_attention.py 里的 RadixLinearAttention。它带着 layer_id、头数/维度、conv 权重、A_log、dt_bias 等信息,把 mixed_qkv, a, b 交给当前的 attention backend。这和 full attention 层的 RadixAttention 是对称的。

状态存在哪里:两种显存

混合模型的服务形态

图中的账说明了取舍:每个请求的固定状态约等于 3K token 的 KV。所以短请求在 linear 层上反而更“贵”,长上下文才是混合模型的主场。

backend 路由与两条路径

layers/attention/hybrid_linear_attn_backend.py 中的 HybridLinearAttnBackend 持有两个子 backend,用 _is_full_attn(layer_id) 按层路由:full attention 层走普通 backend(FlashInfer / FA3 / MLA 等),linear 层走 GDNAttnBackend / KDAAttnBackend(layers/attention/linear/,都继承 MambaAttnBackendBase)。

metadata 只在每个 batch 开始时准备一次,主要包括:

以 GDN 为例,两条路径的分工如下:

kernel 抽象

具体 kernel 被再抽象一层:

prefix cache 与投机解码

这两个特性都会撞上同一个事实:状态是对整段前缀的压缩,不能切开。KV cache 可以按 token 截断复用;而 S_t 只对应前缀长度恰好为 t 的位置,任意截断点的状态都需要事先存下来。

小结

Linear attention 的核心是把 softmax attention 随长度增长的 KV cache,换成一个 d_k\times d_v 的固定状态,attention 因此变成 RNN。随之而来的问题是固定容量的记忆怎么写、怎么忘:

落到 Kimi K3 的权重上,可学习参数几乎全在递推的两端:99% 是搬运内容的 q/k/v/g/o 大矩阵,决定写入与遗忘的 β、α 只占不到 1%,而递推本身没有权重。

工程上,chunkwise 算法让 prefill 能并行,recurrent kernel 让 decode 只需常数开销。SGLang 用“full attention 层 per-token KV + linear 层 per-request 状态 slot”的两种显存来服务混合模型;代价是状态不可切分,prefix cache 和投机解码都要靠显式的状态快照来补。