从零学大模型:Self-Attention —— 多头注意力
上一篇《Self-Attention —— 单头注意力》从一个 Attention Head(注意力头,以下简称 Head)的视角,跟踪了 Q、K、V、Causal Mask、Softmax 和 Value 加权求和。单个 Head 最终为每个 Token 位置产生一个 D_h 维输出;在 GPT-2 Small 中,对应的 shape 是 [T,64]。
但完整的 Self-Attention 需要产生 [T,768] 的 Attention 更新,才能与 GPT-2 Block 的 Residual Connection 主路径相加。中间缺少的部分正是 Multi-Head Attention(多头注意力):GPT-2 如何并行计算多个 Head,再把它们重新组合成一个 [T,768] 的输出?
为什么需要多个 Head
对于同一个 Token 位置,单个 Head 只会产生一组 Attention 权重。这组权重决定了当前位置从前文各个位置读取多少信息,然后用同一组权重对 Value 进行加权求和。
但在理解一句话时,当前位置往往需要同时参考多种线索。例如,当 GPT-2 读到:
1 | The cat sat on the mat because it was |
并准备预测下一个 Token 时,它可能需要同时关注:
it was,判断局部的语法和常见搭配;cat或mat,推测it指代的对象;because,理解前后内容之间的因果关系。
如果只有一个 Head,这些线索需要共同影响同一组 Attention 权重。Multi-Head Attention 则提供多组不同的投影视角:每个 Head 都可以形成自己的 Attention 权重并汇总上下文,最后再把各个 Head 的结果合并起来。
可以把两者的区别简单理解为:
1 | 单头:同一个位置 → 一组 Attention 权重 → 一份上下文结果 |
这些“局部搭配”、“指代关系”和“因果关系”只是帮助理解的假想视角,并不表示训练时会为每个 Head 预先指定固定职责。
为什么使用 12 个 Head?
GPT-2 论文将最小模型的 Hidden size 设为 768,OpenAI 官方实现进一步将 Head 数量设为 12,因此每个 Head 的维度为
768 / 12 = 64。《Attention Is All You Need》指出,多头注意力可以让模型同时关注不同位置和不同表示子空间的信息。原论文也将每个 Head 的维度设为 64,并说明缩小单个 Head 的维度后,多头计算的总成本可以与一个全维度单头保持在相近水平。
因此,
12 × 64应理解为 GPT-2 Small 采用的一组架构配置,而不是 Attention 公式推导出的唯一答案。在 Hidden size 固定时,更多 Head 意味着每个 Head 的维度更小,需要在 Head 数量与单个 Head 的表示宽度之间权衡,并不是越多越好。
从一个 Head 到完整 Self-Attention
第 l 个 Block 的 Attention 输入仍记为 Z_l [T,D],完整的 Attention 更新记为 A_l [T,D]:
1 | Z_l = LN_attn_l(X_l) [T,D] |
上一篇只展开了其中一个 Head,得到第 h 个 Head 的输出 O_h [T,D_h]。GPT-2 Small 同时计算 12 个这样的结果,再经过 Concat(拼接)和 Output Projection(输出投影)得到 A_l:
单头注意力只是 Multi-Head Attention 的一个局部视角,而不是独立的模型阶段。下面沿图中的数据流依次展开:先看 GPT-2 如何一次产生全部 Head 所需的 Q、K、V,再看各个 Head 如何并行计算,并通过 Concat 和 Output Projection 合并结果。
GPT-2 为什么使用 Fused QKV
Q、K、V 的线性投影都以 Z_l 为输入。Fused QKV(融合 QKV 投影)将这三次投影合并为一次:把三组权重和偏置沿输出维拼接,一次得到 [T,3D],再拆分为 Q、K、V。
这只改变参数和计算的组织方式:Q、K、V 仍分别使用,参数量和 Attention 公式不变。Fused QKV 的主要考虑是计算效率:把三次共享同一输入的投影合并为一次矩阵乘法,可以减少算子调度和对 Z_l 的重复读取,也更利于高效使用底层矩阵乘法内核。
上一篇从单个 Head 的视角分别写出了 Q、K、V 投影。把 12 个 Head 合在一起看,整体 Q、K、V 都是 [T,D]:
1 | Q = Z_l W_Q + b_Q [T,D] |
其中,W_Q 可以看成 12 个 [D,D_h] 的 W_Q^(h) 沿输出维拼在一起;W_K、W_V 同理。GPT-2 进一步把三组整体参数合并为一组 Fused QKV 参数:
1 | W_QKV = Concat(W_Q, W_K, W_V) [D,3D] |
于是,Q、K、V 可以通过一次线性投影同时得到:
1 | QKV = Z_l W_QKV + b_QKV [T,3D] |
对于 GPT-2 Small:
1 | [T,768] × [768,2304] + [2304] |
QKV 拆分与 Multi-Head View
GPT-2 Small 的 Hidden size 为 D = 768,Head 数量为 H = 12,所以每个 Head 的维度是:
1 | D_h = D / H = 768 / 12 = 64 |
得到 Fused QKV 后,图中的 Split(拆分)先沿最后一个维度取得 Q、K、V 三段,Multi-Head View(多头视图)再把每段组织成 12 个 64 维 Head:
1 | QKV [T,2304] |
这里并不是先把 Z_l [T,768] 切成 12 份,再把每一份交给一个 Head。每个 Head 都读取完整的 Z_l,但使用各自的 Q、K、V 投影参数,把输入映射到不同的 64 维表示子空间。
多个 Head 的并行计算
上一篇的单头公式对 h = 0, 1, ..., 11 都成立:
1 | O_h = softmax(Q_h K_h^T / sqrt(D_h) + M) V_h |
这里的“并行”是指,底层会把 12 个 Head 组织在一起计算,而不是按照 Head 0 → Head 1 → ... 的顺序逐个执行。
12 个 Head 使用相同的 Causal Mask 规则,但各自的 Q、K、V 以及由此得到的 Attention 权重通常不同。所有 Head 的输出合在一起是 [H,T,D_h];这让模型可以同时保留多组上下文读取结果,而不必把所有匹配关系压进一组权重。
Concat:恢复 Hidden 维度
每个 Head 都产生一个 [T,64] 的输出:
1 | O_0, O_1, ..., O_11 每个 [T,64] |
Concat 沿特征维依次拼接同一 Token 位置在 12 个 Head 中的输出:
1 | O_cat = Concat(O_0, O_1, ..., O_11) [T,12 × 64] = [T,768] |
Token 数量 T 没有改变。这里也不是把 12 个结果逐元素相加:相加仍然只会得到 64 维,Concat 则保留每个 Head 的 64 维结果,把它们组成一个 768 维向量。
以某个 Token 位置 i 为例:
1 | o_i^(0) [64] ─┐ |
Concat 本身没有训练参数,只负责重新组织各 Head 的输出。它恢复了 Hidden 维度,但还不是 GPT-2 最终使用的 Attention 更新。
Output Projection:组合多个 Head
Concat 之后已经得到 O_cat [T,768],但它只把 12 个 Head 的结果并排放置,并没有学习如何组合它们。如果直接将 O_cat 作为 Attention 更新,那么前 64 维只来自 Head 0,接下来的 64 维只来自 Head 1,以此类推。
GPT-2 使用训练得到的权重 W_O 和偏置 b_O 完成 Output Projection:
1 | A_l = O_cat W_O + b_O |
对应 shape 为:
1 | O_cat [T,768] |
为了理解这一次矩阵乘法如何组合多个 Head,可以重新标出 O_cat 中原有的 Head 边界:
1 | O_cat = [O_0 | O_1 | ... | O_11] |
再沿相同边界,将 W_O 中与这些输入维度对应的行分成 12 个 [64,768] 的子矩阵:
1 | [ W_O^(0) ] |
根据分块矩阵乘法:
1 | A_l |
每个 W_O^(h) 都将对应 Head 的 64 维输出映射到全部 768 个输出维度,因此每个 Head 都能影响任意一个输出维度,最终的 A_l 也就能够组合 12 个 Head 的结果。
这里的分块只是数学上的等价写法;GPT-2 实际仍使用一组完整的 W_O [768,768] 执行一次 Output Projection,而不是分别计算 12 次。
完整 shape 主线
将前面的步骤连起来,可以得到一次 GPT-2 Multi-Head Attention 的逻辑 shape:
| 阶段 | 逻辑 shape | GPT-2 Small(T=2) |
|---|---|---|
Attention 输入 Z_l |
[T,D] |
[2,768] |
Fused QKV 输出 QKV |
[T,3D] |
[2,2304] |
Q / K / V 多头视图 |
各自 [H,T,D_h] |
各自 [12,2,64] |
| Attention 分数 / 权重 | 各自 [H,T,T] |
各自 [12,2,2] |
| 各 Head 的输出 | [H,T,D_h] |
[12,2,64] |
Concat 结果 O_cat |
[T,D] |
[2,768] |
Output Projection A_l |
[T,D] |
[2,768] |
这条主线中,Token 数量始终为 T。Concat 将多个 Head 合并回 Hidden size D,Output Projection 保持 [T,D],并通过可学习参数组合多个 Head 的结果。
llama.cpp 实战
下面使用 llama.cpp b10435 和 GPT-2 Q8_0 模型,核对参数 shape 与运行时 Tensor,再对照源码确认完整计算路径。基础环境和模型准备见《LLM 如何逐个生成 Token?》。
进入 llama.cpp 目录,设置模型路径,并确保调试程序已经构建:
1 | cd llama.cpp |
核对多头参数
先从 GGUF 中读取 Hidden size、Head 数量,以及 Block 0 的 Fused QKV 和 Output Projection 参数的 shape:
1 | .venv/bin/gguf-dump "$GPT2_MODEL" --json | |
当前模型的输出为:
1 | { |
hidden_size: 768、head_count: 12 对应 D = 768、H = 12,因此 D_h = 64。
Fused QKV 的权重和偏置分别是 [768,2304] 与 [2304],对应 W_QKV [D,3D] 和 b_QKV [3D],其中 2304 = 3 × 768。Output Projection 的参数是 [768,768] 与 [768],对应 W_O [D,D] 和 b_O [D],输出维度仍保持为 768。
核对多头合并的运行时 shape
对 How are 运行一次回调,并只保留 Block 0 中与多头主线相关的节点:
1 | ./build/bin/llama-eval-callback \ |
裁剪后的输出如下:
1 | wqkv_b-0 = {2304, 2, 1, 1} |
回调中的 Vcur-0 先于 Kcur-0 出现,是计算图的实际调度顺序;逻辑上仍按 Q、K、V 阅读。
llama.cpp / GGML 显示的维度顺序与前文使用的逻辑 shape 不同,下面逐项对应。为简洁起见,表中省略末尾为 1 的维度:
| 运行时节点 | 运行时 shape | 逻辑含义 |
|---|---|---|
wqkv_b-0 |
{2304,2} |
Fused QKV [2,2304] |
Qcur-0 / Kcur-0 / Vcur-0 |
各自 {64,12,2} |
Q、K、V 的多头视图,各自 [12,2,64] |
kqv-0 |
{64,2,12} |
12 个 Head 的输出 [12,2,64] |
kqv_out-0 |
{768,2} |
Concat 后的 O_cat [2,768] |
Qcur-0、Kcur-0 和 Vcur-0 显示,Fused QKV 被组织成了 12 个 64 维 Head;kqv_out-0 的 {768,2} 则说明 12 个 Head 的结果已经合并回 768 维。
当前源码没有给 Output Projection 的结果设置独立、稳定的回调名,但可以用参数名定位 Block 0 的矩阵乘法和 bias 相加:
1 | ./build/bin/llama-eval-callback \ |
当前输出为:
1 | common_debug_cb_eval: node_28 = (f32) MUL_MAT(blk.0.attn_output.weight{768, 768, 1, 1}, kqv_out-0{768, 2, 1, 1}}) = {768, 2, 1, 1} |
第一行对应 O_cat [2,768] 乘以 W_O [768,768],第二行再加上 b_O [768],直接得到 Output Projection 的 A_0 [2,768]。node_28 和 node_29 是自动生成的节点名,可能随计算图变化;这里通过稳定的 blk.0.attn_output.weight 和 blk.0.attn_output.bias 识别这两个操作。
对比两个 Head 的 Attention 权重
继续保留 kq_soft_max-0 的数值:
1 | ./build/bin/llama-eval-callback \ |
kq_soft_max-0 的运行时 shape 是 {256,2,12,1}。输出按 Head 分组,每个 Head 包含 Query 0 和 Query 1 两行,每行前两个数对应本次输入的两个有效 Key 位置。下面取出 Head 0 和 Head 1。
对于 Query 0 / "How",Causal Mask 只允许读取第一个位置,所以两个 Head 的有效权重都是 [1.0000,0.0000]。更有区分度的是 Query 1 / " are":
| Head | 对 "How" 的权重 |
对 " are" 的权重 |
|---|---|---|
| Head 0 | 0.9534 | 0.0466 |
| Head 1 | 0.0049 | 0.9951 |
在 Block 0 的这次输入中,两个 Head 面对相同输入和相同可见范围,却得到明显不同的 Attention 权重。这直接显示它们不是对同一单头结果的简单复制;但只凭这两个数值,还不能给它们赋予固定的语言学含义。
对照源码
GPT-2 在 llama_model_gpt2::graph::graph() 中先调用 build_qkv(),再把 Q、K、V 和缩放因子传入 build_attn():
1 | auto [Qcur, Kcur, Vcur] = build_qkv(model.layers[il], cur, |
n_head_kv 表示 Key/Value Head 数;GPT-2 中它与 n_head 相同,都是 12,因此 Q、K、V 都按 12 个 Head 组织。
在 build_qkv() 中,GPT-2 进入 Fused QKV 分支。源码先完成一次矩阵乘法和 bias 相加,再通过三个带不同 offset 的 ggml_view_3d() 取得 Q、K、V:
1 | ggml_tensor * qkv = build_lora_mm(layer.wqkv, cur, layer.wqkv_s); |
ggml_view_3d() 没有再次计算投影,也没有复制出三份新结果;它通过 shape、stride 和 offset 解释 Fused QKV 中的不同区域,并把 Head 维显式组织出来。
关闭 Flash Attention 后,build_attn_mha() 会在包含 Head 维的 Tensor 上完成上一篇已经验证的 kq → kq_soft_max → kqv。得到所有 Head 的 kqv 后,同一函数继续完成 Head 合并:
1 | cur = ggml_permute(ctx0, kqv, 0, 2, 1, 3); |
这里没有一个名为 concat 的 GGML 节点。ggml_permute() 先把 Head 维移到适合拼接的位置,ggml_cont_2d() 再把结果整理成连续的二维 Tensor [D,T];它在逻辑上对应 Concat(O_0,...,O_11) [T,D]。
最后,build_attn() 使用 wo 和 wo_b 完成 Output Projection:
1 | ggml_tensor * cur = build_attn_mha(/* ... */); |
原理与源码可以汇总为:
| 原理概念 | GPT-2 Small 中的形式 | llama.cpp 中的入口 |
|---|---|---|
| Fused QKV | [T,768] → [T,2304] |
build_qkv() 中 build_lora_mm(layer.wqkv, cur, ...) |
| 拆分 Q / K / V | [T,2304] → 3 × [12,T,64] |
三个不同 offset 的 ggml_view_3d() |
| 12 个 Head 的 Attention 计算 | [12,T,64] → [12,T,T] → [12,T,64] |
build_attn_mha() 中 kq → kq_soft_max → kqv |
| Concat | [12,T,64] → [T,768] |
ggml_permute() + ggml_cont_2d() |
| Output Projection | [T,768] → [T,768] |
build_attn() 中 build_lora_mm(wo, ...) + bias |
这些 C++ 调用只是在构建 GGML 计算图,定义节点、依赖关系和 Tensor shape,并未立即执行数值计算。前面的回调输出才来自后端执行后的 Tensor;完整的建图与执行调用链留到后续文章。
小结
本文从完整 Self-Attention 的视角,说明了 GPT-2 如何并行计算多个 Head,并将它们重新组合为 Attention 更新:
- Multi-Head Attention 让 12 个 Head 使用各自的投影参数读取同一个
Z_l [T,768];它们是并行分支,不是 12 个先后阶段。 - GPT-2 用一次 Fused QKV 投影得到
[T,2304],再把 Q、K、V 分别组织成[12,T,64]。 - 12 个 Head 的结果经 Concat 回到
[T,768],Output Projection 再通过线性变换混合这些特征,得到完整的 Attention 更新A_l [T,768]。