当前位置:首页 > 编程开发 > 正文

线性注意力到底省了什么:从 O(n²) 到固定状态,120 行代码跑通

约 7273 字 · 预计阅读 19 分钟
一句话总结:线性注意力省掉的不是计算,是「必须记住全部历史」这个前提——把注意力查表变成对一个固定大小状态的读和写,显存从随长度线性增长变成常数;代价是容量有上限、旧信息会被覆盖。本文用 120 行 NumPy 把递推、delta 覆写、逐通道遗忘门、块内并行四个版本全部实现并跑过,每一步都给了真实输出。

上周写 Kimi Linear 时留了一句没展开:KV cache 减 75% 是结构决定的。这篇把「结构」两个字拆开,用能跑的代码讲清楚。

不涉及训练,只讲推理时那个状态是怎么算的。看懂之后,你再看任何线性注意力或状态空间模型的论文,公式部分就不会卡住了。

前置条件

Python 3.9+
numpy(本文实测环境 2.5.4)

不需要 GPU、不需要 PyTorch。所有代码在 CPU 上跑,序列长度用 6 是为了方便打印,换成 100 万只是算得慢一点,逻辑不变。

第一步:先说清楚全注意力贵在哪

标准自注意力对每个位置 t,算出它对所有位置的注意力权重:

Attention(Q, K, V) = softmax(Q·Kᵀ / √d) · V

麻烦在于 Q·Kᵀ 是个 n×n 矩阵。n 是序列长度,n = 100 万时这个矩阵有 1 万亿个元素。虽然实践中没人真的把 n×n 全算出来(flash attention 用分块避免物化),但自回归生成时必须缓存历史的 K 和 V,因为每生成一个新 Token 都要和前面所有 token 算一遍。

这个缓存就是 KV cache,大小是 长度 × 层数 × 2 × d。它是线性增长的,但基数大,所以到了长上下文就吃光显存。

具体感受一下。单层、dk = 4、FP32、只算 K 和 V:

上下文长度 全注意力缓存 线性注意力状态
8,1920.25 MB0.000046 MB
131,0724.00 MB0.000046 MB
1,048,57632.00 MB0.000046 MB

右边那一列是常数,因为它根本不随长度变。这是同一维度下(dk=4, dv=3)的对比,放大到真实的 d=128 也是同一个规律:左边继续涨,右边还是那个数。

第二步:把 softmax 拿掉,矩阵乘法就能换序

线性注意力的全部秘密在一个代数事实:矩阵乘法满足结合律。

原来的顺序是先算 Q·Kᵀ(n×n),再乘 V。如果去掉 softmax,表达式就变成:

(φ(Q) · φ(K)ᵀ) · V  =  φ(Q) · (φ(K)ᵀ · V)

右边括号里那个 φ(K)ᵀ · V 的形状是 dk × dv,跟序列长度完全无关。也就是说,可以先把所有 K 和 V 压成一个固定大小的矩阵,之后每次查询只需要用 Q 去乘它。

这个 dk × dv 的矩阵,就是「状态」。

代价也很清楚:softmax 不只是归一化,它带来的是「查询能动态选择关注谁」的能力。去掉它之后,注意力权重退化成内积,模型失去了根据上下文调整关注点的机制。后面所有的改进(门控、delta 规则)本质上都在想办法把这个能力部分补回来。

第三步:最朴素的实现,两种写法验证

先写逐 token 递推版,这是最直观的形式:

import numpy as np

def linear_attn_recur(Q, K, V):
    """逐 token 递推:每读一个 token 就更新状态,再读出来"""
    T, dk = K.shape
    dv = V.shape[1]
    S = np.zeros((dk, dv))          # 状态,与长度无关
    outs = []
    for t in range(T):
        S = S + np.outer(K[t], V[t])   # 写入:k 和 v 的外积累加
        outs.append(S.T @ Q[t])        # 读出:用 q 去查状态
    return np.array(outs)

再写一个等价的矩阵版本,利用因果性做前缀和:

def linear_attn_cumsum(Q, K, V):
    KV = np.einsum('ti,tj->tij', K, V)   # [T, dk, dv]
    S = np.cumsum(KV, axis=0)            # 前缀和 = 每个位置的状态
    return np.einsum('tkv,tk->tv', S, Q)

跑一遍对比(T=6, dk=4, dv=3,随机数据):

递推 vs cumsum 最大误差: 8.88e-16

8.88e-16 是双精度浮点的舍入误差量级,可以认为完全一致。这个验证很重要:它说明「逐 token 递推」和「前缀和并行」是同一个数学对象的两种算法。前者好理解,后者好在 GPU 上并行。

第四步:只累加会发生什么

上面那版有个隐藏问题。S = S + outer(k, v) 只加不减,同一个 key 反复写入时,值会一直叠加。

用一组手算就能看到后果:让 key 固定为 [1,0,0,0],先写 v = [5,0,0],再写 v = [9,0,0]。

K1 = np.array([1.0, 0, 0, 0])
S = np.zeros((4, 3))
for v in (np.array([5.0, 0, 0]), np.array([9.0, 0, 0])):
    S = S + np.outer(K1, v)
print((S.T @ K1).round(4))

输出:

[14.  0.  0.]

读回来是 14,也就是 5 + 9。同一个位置的旧值没有被替换,只是叠在了一起。

模型里的后果是:事实更新时(比如「预算从 500 万调到 800 万」),状态里同时存着新旧两个数字,读出来是个混合体。这就是早期线性模型「记得住大意、记不准细节」的根源。

第五步:delta 规则,写入误差而不是数值

解法叫 Delta 规则,思路是:写入之前先看看这个位置现在是什么,只写差值。

def linear_attn_delta(Q, K, V, beta=1.0):
    S = np.zeros((K.shape[1], V.shape[1]))
    outs = []
    for t in range(len(Q)):
        err = V[t] - S.T @ K[t]              # 当前状态在这里读出多少
        S = S + beta * np.outer(K[t], err)   # 只写入「目标值 - 现值」
        outs.append(S.T @ Q[t])
    return np.array(outs)

同样跑那个例子:

delta 覆写后读出: [9. 0. 0.]      ← 旧值被替换
纯累加后读出:     [14. 0. 0.]     ← 旧值叠加进来

9 而不是 14,说明覆写成功。这就是 delta 这个名字的由来:写的是变化量(delta),不是绝对值。

放到公式里看更清楚。原来的纯累加是 S_t = S_{t-1} + k_t·v_tᵀ,delta 版是:

S_t = (I - β·k_t·k_tᵀ)·S_{t-1} + β·k_t·v_tᵀ

那个 (I - β·k·kᵀ) 就是「先沿 k 方向擦掉旧的,再写新的」。β 控制擦除强度,β = 1 且 k 是单位向量时,写完读回来严格等于 v。

第六步:KDA 把遗忘门从一个数变成一串数

有了覆写还不够。状态容量固定,塞满了怎么办?得能主动遗忘。

最早的做法是加一个标量衰减 α:

S_t = α·S_{t-1} + β·k_t·(v_t - S_{t-1}ᵀ·k_t)ᵀ

α 是一个数,所有 key 通道一起按同一个速度衰减。问题在于真实文本里信息密度是不均匀的:有些特征需要长期保留(比如产品名),有些很快就该忘(比如「你好」)。

KDA(Kimi Delta Attention)把 α 从标量升成 dk 维向量,每个 key 通道一个遗忘率:

def kda(Q, K, V, alpha, beta=1.0):
    """alpha: [T, dk],每个时间步、每个 key 通道独立的遗忘率"""
    T, dk = K.shape
    S = np.zeros((dk, V.shape[1]))
    outs = []
    for t in range(T):
        S = S * alpha[t][:, None]        # 逐通道衰减(关键的一行)
        err = V[t] - S.T @ K[t]
        S = S + beta * np.outer(K[t], err)
        outs.append(S.T @ Q[t])
    return np.array(outs), S

注意 alpha[t][:, None] 这个 reshape:alpha[t] 是长度 dk 的向量,转成 [dk, 1] 之后才能和 [dk, dv] 的状态逐行相乘,实现「每一行(也就是每一个 key 通道)用自己那个衰减率」。

实测(T=6, dk=4, dv=3, α 全取 0.9):

KDA 输出 (6, 3) 状态 (4, 3) | 含 NaN: False

形状正常,没有数值爆炸。真实的 α 不是常数,由输入经过一个小线性层加 sigmoid 算出来,这样模型自己决定哪些通道该忘得快。

第七步:块内并行,这是能上 GPU 的关键

到这里所有版本都是逐 token 循环。Python 里循环 100 万次要跑几分钟,GPU 又最讨厌顺序依赖,所以必须改成并行。

办法是把序列切成块(chunk),块内用矩阵乘法一次算完,块与块之间只传状态:

def linear_attn_chunkwise(Q, K, V, C=2):
    outs = []
    S = np.zeros((K.shape[1], V.shape[1]))
    for start in range(0, len(Q), C):
        q, k, v = Q[start:start+C], K[start:start+C], V[start:start+C]
        n = len(q)
        # 块内:只算块内之前的 key,用下三角 mask
        mask = np.tril(np.ones((n, n)))
        intra = (mask * (q @ k.T)) @ v
        # 块外:用进入本块时的状态
        inter = q @ S
        outs.append(intra + inter)
        S = S + k.T @ v        # 块结束,更新状态
    return np.vstack(outs)

验证它和逐 token 递推是否等价:

chunk-wise(块=2)vs 递推最大误差: 1.33e-15

也是浮点误差级别,等价。

这个改写是整个工程落地的命门。块大小 C 决定了并行度:C 越大,块内矩阵乘法越大、GPU 越饱和,但块内那部分退化成近似全注意力(要算 C×C 的 mask 矩阵),显存优势被削弱。Kimi Linear 选的是 32,这个数不是随便定的。

顺带说,KDA 带门控之后这个改写没那么直白,因为门控让状态更新变成非线性。公开的解法是把它重写成仿射变换 S_t = S_{t-1}·T_t + H_t,这样块内就能用下三角线性方程组求解。这也是为什么 KDA 的工程实现比数学描述复杂得多。

图 1:左边是全注意力的缓存随长度线性涨,右边是线性注意力只读写一个固定状态;下方一列是四个实现版本的递进

坑清单

按我自己踩到的顺序排,第一条最容易中。

1. einsum 的维度标注搞混,报广播错误。

写 np.einsum('tij,tj->ti', S, Q) 时,S 是 [T, dk, dv]、Q 是 [T, dk],j 在两边指的是不同维度,直接报 operands could not be broadcast。改成 'tkv,tk->tv' 才对。教训是 einsum 的下标一定和实际形状对应着写,别图省事复用字母。

2. 忘了 [:, None],逐通道衰减变成整体缩放。

S * alpha[t] 会尝试广播 [dk, dv] * [dk],numpy 默认按最末一维对齐,实际变成「按 dv 维乘」,语义完全错了还不报错。必须显式 alpha[t][:, None] 变成 [dk, 1]。

3. 以为非因果版本能直接用。

(Kᵀ·V) 这种矩阵形式不带因果掩码,会把未来 token 的信息算进去。训练时如果直接用它,模型在训练阶段就能偷看答案,loss 会异常低,上线后崩。因果必须靠前缀和或分块 mask 实现。

4. 用纯累加版验证,误以为 delta 没用。

如果只测「不同 key 写不同值」,纯累加和 delta 的结果一样,看不出差别。必须专门构造「同一个 key 写两次」的用例,才能暴露覆写是否生效。我第一版验证就漏了这个,白跑一轮。

5. 拿 dk×dv 的玩具尺寸估算真实显存,差了三个数量级。

dk=4、dv=3 时状态只有 46 字节,看着像白送。真实模型 d=128、32 个头,状态是 128 × 128 × 4 字节 × 32 头 ≈ 2MB 每个请求每层,几十层叠起来也是几百 MB。省是省,但不是零。

收尾

这 120 行的价值不在于能替代生产实现,而在于把「线性注意力到底省了什么」这件事变得可验证。我跑完最大的收获是两点:

一是省的东西很具体:省掉的是「必须保留全部历史」这个约束,换来一个常数大小的状态。这不是压缩,是换了一种记忆方式。

二是代价也很具体:容量固定意味着必然丢信息,覆写只是让丢得更聪明。所以所有这类模型都是混合结构,留几层全注意力做精确检索。谁要是说「纯线性注意力全面超越全注意力」,那基本可以判断他没跑过长上下文检索任务。

想继续往下走,两条路。一条是往上:看 Mamba-2 和 Gated DeltaNet 的论文,对比门控形式的不同。一条是往横:去 Kimi Linear 的仓库读 vLLM 集成那部分,看状态对齐在工程上到底怎么处理。想知道这些改动在真实吞吐上值多少钱,就得自己压一遍,压测方法那篇给了完整的分位测法。

参考资料

目录导航
  • 前置条件
  • 第一步:先说清楚全注意力贵在哪
  • 第二步:把 softmax 拿掉,矩阵乘法就能换序
  • 第三步:最朴素的实现,两种写法验证
  • 第四步:只累加会发生什么
  • 第五步:delta 规则,写入误差而不是数值
  • 第六步:KDA 把遗忘门从一个数变成一串数
  • 第七步:块内并行,这是能上 GPU 的关键
  • 坑清单
  • 收尾
    • 参考资料
  • 相关文章:

  • Kimi Linear 深度解读:KV cache 砍掉 75%、1M 上下文快 6 倍,线性注意力第一次真能用了2026-10-31 08:41:49
  • Kimi K3 深度评测:2.8 万亿参数的开源模型,到底追上了闭源多少2026-10-31 08:41:49
  • 发表评论

    ◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。