Skip to content

第 11 章 Gated DeltaNet:从矩阵状态理解递推 ​

生成文本时,如果把历史信息压进一个固定大小的矩阵,状态就不必随着序列变长而不断扩张。但这样一来,新信息该写到哪里,旧信息又该怎样读取和修正?Gated DeltaNet 用一套递推规则解决这些问题。理解它,需要先看矩阵怎样更新,再看这种记忆方式与 softmax 注意力有哪些区别。

本章沿固定提交的 Gated DeltaNet README 与估算脚本 展开。核心模块写在 README 的代码块里,独立 Python 文件则负责内存估算与绘图。阅读时应先看 README 中的模块,再看估算脚本,不能只按文件数量判断实现范围。完整含卷积的 Qwen3.5 模块在第 20 章继续解释。Gated Delta Networks 原论文 是递推规则的研究来源。

11.1 一个 key 对应一行读出方向 ​

先只看一个 batch 样本中的一个头。令 key/query 宽度为 dk,value 宽度为 dv。状态 St∈Rdk×dv 可以看作一个不断改变的线性映射。给定列向量 kt∈Rdk,预测 value 为 S⊤kt;按 PyTorch 行向量写法就是 k_t @ S。读出 yt=qt⊤St,得到长度 dv 的行向量。一般的 key 会对状态的多行加权读取;只有选取坐标方向这样的特殊 key,才会直接读出某一行。

先考虑简单外积累加 St=St−1+ktvt⊤。展开后,qt⊤St=∑i≤t(qt⊤ki)vi⊤。这与未归一化的线性注意力相似:每段历史的贡献由 query 与 key 的内积决定。不过,同一个 key 多次出现时,它对应的记录会不断叠加。如果后一次信息是要纠正前一次,简单相加就未必合适。

11.2 Delta rule 用预测误差修正记忆 ​

以下先按每个头使用标量写入门来推导。先用遗忘系数衰减旧状态,即乘以 αt,再读出旧状态对当前 key 的预测:

S~t=αtSt−1,v^t=S~t⊤kt.

再用当前 value 减去预测值,得到需要修正的误差。把误差乘以写入强度 βt,沿当前 key 的方向更新:

St=S~t+βtkt(vt−v^t)⊤,yt=St⊤qt.

本章示例把 α,β 约束到 (0,1);它们是从输入学习到的门,不是人为固定的学习率。α 决定整体保留多少旧记忆,β 决定当前误差修正多强。两者作用不同:把 β 设为零只停止写入,状态仍会按 α 衰减。

这条规则还可以从瞬时平方误差理解。把 kt 当常量,目标为 12∥S⊤kt−vt∥2,对 S 的梯度是 kt(S⊤kt−vt)⊤。一次梯度步正好给出 delta 更新。这里的梯度解释针对状态更新:模型在前向过程中修正记忆矩阵,并没有调用优化器去改权重。模型参数本身仍通过反向传播训练。

先遗忘、再预测、按误差写入、最后读出
可切换原始尺寸,滚动查看细节

查看大图

尺寸读取中

适合窗口:完整显示图片,不放大超过原始尺寸。 按 Esc 关闭。

取 k=[1,0]⊤,v=[3,4]⊤、α=1,β=1。无论旧状态第一行是什么,更新后第一行恰好是 [3,4],第二行不变。因为 k⊤k=1,再次沿同一个 key 读出,就精确得到新 value。这解释了源码为何对 key 作 L2 归一化。若把 key 放大 10 倍,更新中的新 value 写入项 βkv⊤ 放大 10 倍,而旧记忆纠正项 −βkk⊤S~ 放大 100 倍。两个部分的缩放不同,所以整个增量并不是统一放大某个倍数。此时也不能再把 β=1 理解为一次覆盖。

11.3 源码每一步的形状与顺序 ​

README 的示例从 X:[B,T,D] 生成 Q、K、V、输出 gate 和更新门。拆头后 Q/K/V 为 [B,H,T,d],这里为了易读令 dk=dv=d。这里还有一处需要与前面的公式区分:为方便推导,前文把写入门当作标量;固定 README 则为每个 value 通道分别生成写入门,逐通道缩放预测误差。遗忘门仍是每个头一个标量。

Q 作 L2 归一化后再除以 d,K 只作 L2 归一化。S = x.new_zeros(B,H,d,d) 与输入保持设备和 dtype。

时间循环里,S = S * a_t 必须放在 kv_mem 的计算之前。这样,当前 value 比较的是衰减后的预测,而不是衰减前的旧预测。(S * k_t.unsqueeze(-1)).sum(dim=-2) 沿 key 维求和,得到 [B,H,dv] 预测;更新外积需要 k_t.unsqueeze(-1) * delta.unsqueeze(-2),产生 [B,H,dk,dv]。如果把两个 unsqueeze 颠倒,得到的外积方向也会颠倒。状态恰好是方阵时,形状检查可能放过这个错误,程序能运行却不代表更新方向正确。

输出沿 query 方向读取,所有时间结果堆叠后转成 [B,T,H,dv]。模块随后做 RMSNorm 和 SiLU 输出门,再合并头、dropout、输出投影。输出 gate 调节的是已经读出的表示,不负责写入记忆,因此与 α、β 两种状态门不能互换。普通“gated attention”只是给 softmax 注意力输出加门,仍保留 T×T 点积矩阵;这里没有这个矩阵。

README 为了集中说明递推,省略了局部卷积。这个简化接口也不会跨 forward 调用保存状态:每次调用都重新建立零矩阵,因此若只喂入最后一个 token,就无法利用之前的历史。流式实现需要返回本次的最终状态,在下次调用时把它作为初始状态;切换到不同请求时,还要重置状态,避免混入另一条序列的记忆。Qwen3.5 的 cache 正是为此增加另一套协议。

11.4 为什么可以分块,为什么仍不能称为任意 softmax 的等价替代 ​

先沿用前面标量写入门的简化设定,将递推改写为

St=AtSt−1+Ut,At=αt(I−βtktkt⊤),Ut=βtktvt⊤.

从零状态展开有

St=∑i=1t(∏j=ti+1Aj)Ui.

乘积从右到左依时间顺序作用,先发生的更新先乘,不能任意交换。这里能分块,是因为仿射映射的组合满足结合律:可以先合并一段更新,再与另一段合并,但每段内部的时间顺序必须保留。

对于 README 中逐 value 通道的写入门,需要按状态的列分别理解这种仿射更新,不能直接把向量门代入上面的单一左乘矩阵。学习时可以先用慢速展开核对标量版本,再研究 chunk kernel 如何利用块内的三角结构。

固定大小的状态把历史压缩到同一个矩阵空间,不同历史可能得到相同状态。它没有逐个保存历史 key/value,因此之后无法再随 query 对每个历史 token 重新分配 softmax 权重。这是压缩记忆的表达限制,不能靠修正某个实现 bug 消除。混合模型保留部分全局注意力层,用两种记忆方式承担不同职责,具体效果仍需训练与质量评估。

11.5 存储与计算的边界 ​

这里只比较推理时保留的状态,设每个元素占用的字节数为 s。每层矩阵状态占 BHdkdvs 字节;等宽时为 BHd2s。普通 MHA 为 2BTHds,两式相等时 T=d/2。因此非常短的序列上,固定矩阵状态未必更小。简化递推计算量随 T 线性增长,约 O(BTHdkdv);“线性”针对序列长度,不代表对头宽也线性。

plot_memory_estimates_gated_deltanet.py 用配置计算 MHA 和固定状态、再按混合比例相加。曲线来自公式估算,没有计入卷积缓存、模型权重或训练反向传播所需的中间状态,也不代表 GPU 显存实测。训练还需要保存、重算或按块组织中间量,因此“推理状态不随序列增长”不能直接推成“训练内存也不随序列增长”。

11.6 真实验证与补充练习 ​

完整程序 examples/architectures/architecture_checks.py 的 delta() 用 6 个 token、3 维 key/value,对照逐步递推与逐项展开。原稿报告的 float64 最大误差为 4.44×10−16;单位 key、β=1 的覆盖测试误差为 0。原稿记录的环境为 Python 3.12.14、torch 2.6.0+cpu、seed 1949、单线程。原稿将准确命令和全部数值指向 qa/architectures/algebra_checks.json,但当前交付目录未包含这份日志,暂时无法核验这些历史输出。本轮只核对了测试代码,没有重新执行,也没有运行论文训练实验。

题目。 ① α=0 时状态还包含什么?② β=0 时是不是输出也必为零?③ 将公式中的 v - prediction 改成 v 会发生什么?④ 为什么 Q/K 不等宽于 V 时更容易检查转置错误?

答案。 ① 只剩当前写入 βkv⊤。② 不是,仍可从衰减后的旧状态读取。③ 变成带遗忘的外积累加,失去针对当前 key 的误差纠正。④ 正确状态应为 dk×dv;一旦误转置,矩阵形状通常立即报错,方阵反而容易掩盖错误。

参考资料 ​

  • 固定提交 ch04/08_deltanet/README.md 中的模块、比较片段与绘图说明。
  • Yang 等,Gated Delta Networks:https://arxiv.org/abs/2412.06464。
  • 完整 Qwen3.5 helper 的 chunk/recurrent 实现与卷积状态见第 20 章。

保留各书来源与许可 · 示例运行条件以正文为准