0. 上节课留了一笔账
L1 结束的时候,你手里有了一个打分器:拿两个向量做内积,得到一个合拍分。这个数告诉你”这两个词有多合拍”——正数意味着方向一致、互相支持;负数意味着方向相反、互相矛盾;零意味着没什么关系。
这个打分器已经过了三条约束的检验(分量可加、对称、旋转不偏心),也在机器上跑通了。看起来很完整。
但你现在要面对的不是一个词——是一整句话。
拿之前那个例子:“the bank was steep”。这句话有 4 个词。bank 要和 steep 合拍、要和 the 合拍、要和 was 合拍——3 个打分。然后 the 也要和其余 3 个词打分,was 也要,steep 也要。合在一起是 4 \times 3 = 12 对打分。
4 个词,12 对。你用 L1 的内积,一对一对地算——每对做一次逐分件乘再求和。12 次乘加。手算没问题。
现代模型的上下文窗口呢?100,000 个 token。100{,}000 \times 99{,}999 \approx 100 亿次内积。你的 CPU 一次乘加大约一纳秒——100 亿次就是 10 秒。每次前向传播 10 秒,训练要几千步。你自己算。
所以现在的问题是:不是内积不好——是逐对算这个做法在这种规模下不可行。你需要一种方法,把 100 亿次内积一次性做完。
1. 先解决小问题:50 个词做同一个变换
在解决”100 亿内积”之前,有一个更基础的问题需要处理。
L1 里说过,打分用的 q 和 k 不是原始词向量——它们是原始向量经过”坐标变换”后的版本。具体来说,每个词向量 x 要分别乘 W^Q 和 W^K,得到提问向量 q 和被查向量 k。再加一个 W^V,得到取回用的内容向量 v。
一个词做一次变换很简单:x 是 1\times d 的行向量,W 是 d\times d 的矩阵,x \cdot W 得到 1\times d 的输出。
但你有 50 个词。每个词都要做同样的变换——同一个 W,不同的 x。循环写 50 次?
不需要。你把 50 个词的向量摞成一个矩阵 X——50 行,每行一个词。然后一次矩阵乘法:
Q = X \cdot W^Q
结果是一个 50\times d 的矩阵,其中第 i 行 = X 的第 i 行(第 i 个词)乘 W 的结果。每一行独立过同一台机器——行与行互不干扰。
这就是矩阵乘法的第一种读法:批量变换。把一批向量排成矩阵的行,右乘一个变换矩阵,得到一批变换后的向量。行与行互不干扰——第 i 行的结果只取决于第 i 行的输入,跟其他行没有任何关系。
你可以验证这一点:把 X 的行打乱,输出只是同步打乱,逐位的数值不变(P1.a,repro_t2_scaled_dotprod.py)。这说明批量变换不制造行间关系——它只是”对每一行做同一件事”的高效写法。
投影搞定了:Q = XW^Q,K = XW^K,V = XW^V。三次批量变换,n 个词的提问向量、被查向量、内容向量全部到位。
2. 大问题:100 亿内积怎么一次做完
现在进入正题。
你有 n 个查询向量 Q(n 行,每行一个查询)和 n 个键向量 K(n 行,每行一个键)。你要算全部 n^2 对内积:Q 的第 1 行和 K 的每一行各做一次内积,然后 Q 的第 2 行和 K 的每一行各做一次……直到 Q 的第 n 行。
n = 50 的时候是 2500 次内积。n = 100{,}000 的时候是 100 亿次。
逐对循环写当然可以——但你注意一下这些内积的结构。第 i 行查询和第 j 行键的内积是:
s_{ij} = \sum_{a=1}^{d_k} Q_{ia} \cdot K_{ja}
把所有 n^2 个 s_{ij} 排成一张 n 行 n 列的表——第 i 行第 j 列就是第 i 个查询对第 j 个键的合拍分。
这张表有一个名字:它就是 Q \cdot K^\top 的乘积矩阵。
矩阵乘法 QK^\top 的第 (i,j) 格的定义就是”Q 的第 i 行与 K 的第 j 行的内积”——恰好是你逐对算的那个东西。你不需要循环。你写下一个矩阵乘法,整张表一次出来。
这就是矩阵乘法的第二种读法:两两配对表。读法一的产物是”一批变换后的向量”(还是向量);读法二的产物是”关系表”(每一格代表一对实体之间的关系强度)。同一个乘法运算,两种完全不同的语义——取决于你把输入的行理解为”待变换的个体”还是”待配对的实体”。
如果你觉得”矩阵乘法就是矩阵乘法,哪来的两种读法”——那你还没有体会到这种双重性带来的理解力提升。往下看一个数值例。
3. 数值例:亲手走一遍两种读法
取 X = [[1, 2], [3, 4]](2 个词,每个 2 维),W = [[0, 1], [1, 0]](2\times 2 的变换矩阵)。
读法一。 X \cdot W 的结果:
第一行:[1 \cdot 0 + 2 \cdot 1,\ 1 \cdot 1 + 2 \cdot 0] = [2,\ 1]
第二行:[3 \cdot 0 + 4 \cdot 1,\ 3 \cdot 1 + 4 \cdot 0] = [4,\ 3]
X \cdot W = \begin{bmatrix} 2 & 1 \\ 4 & 3 \end{bmatrix}
每一行是原来那行过了 W 这台机器后的结果。第一行 [1,2] 过了机器变成 [2,1];第二行 [3,4] 过了机器变成 [4,3]。行与行之间互不影响。
读法二。 取 Q = X \cdot W^Q 的输出当查询矩阵、K 当键矩阵,算 Q \cdot K^\top。假设 Q 的第一行是 q_1 = (0.28, 0.33),K 的第一行是 k_1 = (0.19, 0.04):
s_{11} = q_1 \cdot k_1 = 0.28 \times 0.19 + 0.33 \times 0.04 = 0.066
把 j 换成 2、3……一直算到 12,就填满了整张 4\times 4 的合拍表。这张表不需要循环——它就是一个矩阵乘法。
(机器验证:P2,全部 16 格与逐对手算的内积逐位对照,偏差 0。)
4. 三步合体:式 (1) 的完整诞生
现在你手里有了所有零件。串起来:
第一站,投影(读法一)。X 分别乘 W^Q、W^K、W^V,得到 Q、K、V。Q 是每个词的”提问”,K 是每个词的”标签”,V 是每个词的”内容”。
第二站,打分(读法二)。QK^\top 一次性算完全部配对的合拍分。除以 \sqrt{d_k}——这是 L3 讲的方差归一。
第三站,归一。softmax 逐行把分数变成份额。每一行是一组非负的数,加起来等于 1——第 i 行就是”第 i 个词对全部 n 个键的吸收配比”。
第四站,取回(读法一)。配比矩阵 A 乘 V:Z = A \cdot V。第 i 行 = 按配比混合全部 V 的行——第 i 个词”吸收”到的信息。
写成一net:
\mathrm{Attention}(Q,K,V)=\mathrm{softmax}\Big(\tfrac{QK^\top}{\sqrt{d_k}}\Big)V
每个零件你都亲手装过。这不是”学到了一个公式”——这是”你用三站流水线造了一台机器,然后给这台机器起了个名字”。
5. 形状自己会讲故事
最后看一眼形状。整条流水线从”每词一个向量”出发,到”每词一个向量”结束——形状闭合,所以注意力层可以一层层摞上去。
但中间经过了一张 n\times n 的方表。这张表是”全部配对”的记号形态,也是注意力最贵的部分。n = 100{,}000 时这张表有 100 亿格——你在一开始感受到的那个”100 亿次内积”的痛,没有消失,只是被矩阵乘法的高效实现压缩了。
矩阵乘法没有消除 n^2 的本质——它只是把 n^2 次内积写成了一个高效的机器指令序列。真正的规模化(FlashAttention、稀疏注意力、线性注意力)都是在这张表上做文章——但那不在本课范围内。
6. 乘法的两条定律
结合律成立:(AB)C = A(BC)(随机矩阵实测偏差 4\times 10^{-15})。所以 \mathrm{softmax}(AV) 里的组合顺序不影响结果——流水线怎么分段实现都行,数学唯一。
交换律不成立:AB \ne BA(随机方阵最大差 15.5)。X \cdot W 是”词过机器”,W \cdot X 是”机器过词”——两件不同的事。写注意力时把 QK^\top 手滑写成 KQ^\top,得到的不是同一张表的转置,而是另一台机器。乘法的顺序就是语义。
7. 练习
- 手算 X=\begin{bmatrix}1&2\\3&4\end{bmatrix}、W=\begin{bmatrix}0&1\\1&0\end{bmatrix} 的 XW,用行视角;然后把 X 的两行对调重算,确认输出只是同步对调。
- QK^\top 对角线上的 (i,i) 格是什么?它有什么几何含义?
- n = 100、d = 512、d_k = 64:投影要多少次乘法?打分表呢?哪个在 n 涨到 1000 时先爆炸?
8. 下一课
打分器和记号都齐了——但一切建立在”词向量的分量乖乖在 ±1 摇”的假设上。真实的 64 维向量一上来,分数就开始发疯:注意力把 79% 的权重压在一个随机错键上。会摇的向量——方差——是 L3 的全部内容。
(汇编说明:素材汇编自研究轨 T1/T2 实验日,断言见 repro_t2_scaled_dotprod.py 可独立重跑。)