注意力机制的直觉表述是"每个 token 审视序列中所有 token,按相关度取信息"。如果直接照这个直觉写代码,会得到一个 $O(n^2)$ 次内积调用的双重循环。Transformer 论文真正值钱的一步,是把这个循环改写成两个矩阵乘法。这篇把改写过程摊开,并算清它为什么不只是语法糖。
循环版:概念正确,工程失败
最忠实的实现:对 query 索引 $i$ 循环,对 key 索引 $j$ 循环,逐对算 $s_{ij}=q_i^\top k_j/\sqrt d$,逐行 softmax,再逐对加权求和 $o_i=\sum_j p_{ij}v_j$。
# 循环版:n² 次 Python 级内积调用
for i in range(n):
for j in range(n):
s[i, j] = q[i] @ k[j] / sqrt(d)
p[i] = softmax(s[i])
o[i] = sum(p[i, j] * v[j] for j in range(n))
问题不在 FLOPs——总乘加次数和矩阵版完全一样——而在调度粒度。每次内积只有 $d$ 次乘加,Python 循环体的每次迭代都要付解释器开销、类型检查、内存寻址;$d=64$ 时这些开销是计算本身的几百倍。更要命的是数据局部性:$q_i$ 和 $k_j$ 每次都从内存重新取,CPU/GPU 的缓存体系完全失效。这个版本 $n=512$ 就要跑秒级,而它的数学内容一行不多。
矩阵版:把循环压进 GEMM
把 query 堆成矩阵 $Q\in\mathbb{R}^{n\times d}$(第 $i$ 行是 $q_i^\top$... 注意堆叠后 $q_i$ 作为行向量),key 堆成 $K\in\mathbb{R}^{n\times d}$,看 $QK^\top$ 的第 $(i,j)$ 元:
$$ (QK^\top)_{ij} = \sum_{a=1}^{d} Q_{ia}K_{ja} = q_i^\top k_j. $$
双重循环的每个元素,就是矩阵乘对应位置的那一个数。 没有近似、没有技巧,纯粹是记号重组。于是整个注意力变成四行:
S = Q @ K.T / sqrt(d) # (n,n),所有 pairwise 分数一次算完
P = softmax(S, axis=-1) # 逐行归一化
O = P @ V # (n,d),按权重混合 value
循环没了,$n^2d$ 次乘加被打包成一次 GEMM(general matrix multiply)。GEMM 是数值计算里被优化得最狠的算子:分块、向量化、tensor core 的 $4\times4$ 乘加阵列,全部只对矩阵乘生效。同一个运算,循环版吃不到其中任何一项。
复杂度没有变,为什么还是快
要诚实:矩阵化不改变大 O。FLOPs 账两头都是 $2n^2d$(QKᵀ)+ $2n^2d$(PV)量级,后面复杂度的专篇会细算。真正的收益是常数项,而且常数大得惊人——在 GPU 上,优化后的 GEMM 对 naive 循环的加速比轻松到 $10^3\sim10^4$。这个模式在深度学习里反复出现:先把运算表述成少数几个大矩阵乘,再让硬件专长接管。反向传播那篇里注意力反向的六个 GEMM,前向的 $Q=XW^Q$ 等三个投影,同一个模式的第三次出现。
矩阵化还有一个理论副产品:$QK^\top$ 让我们能用线性代数的工具分析注意力——秩(下一篇之后的专篇)、谱、条件数,这些概念在循环视角下完全不可见。概念向工程妥协一次(接受矩阵记号),换来的是整个工具箱。
小结
自注意力的矩阵化是"概念 → 线性代数"的一次标准翻译:$(QK^\top)_{ij}=q_i^\top k_j$ 让 $n^2$ 次内积坍缩成一个 GEMM,softmax 保持逐行,PV 是第二个 GEMM。大 O 没变,但常数项三个数量级的差距决定了这条路线能在 GPU 上跑起来。记住这个翻译的副产品:一旦写成矩阵,秩、谱、方差这些分析工具就都解锁了——后面几篇会逐个用上。