RussellLuo

让思想在文字间徜徉

GPT-2 中 MLP 的位置

上一篇《Self-Attention —— 多头注意力》介绍了如何将 12 个 Attention Head 的结果重新组合为 A_l [T,768]A_l 再通过第一条 Residual Connection 与 Block 的输入 X_l 相加,得到已经包含上下文信息的 Hidden State H_l [T,768]

但在 GPT-2 Block 中,Self-Attention 后面还有一个 MLP(多层感知机)子层。既然 Self-Attention 已经让每个位置读取了上下文,为什么还需要这个 MLP?

Attention 与 MLP 的分工

Attention 和 MLP 都会更新 Hidden State,但它们处理信息的方式不同。

Attention 与 MLP 的功能对比

Self-Attention 的关键作用是让每个 Token 位置读取可见上下文。对于第 i 个位置,Self-Attention 先计算 Q_i 与各位置 Key 的匹配程度,再对位置 0 ... i 的 Value 加权求和。因此,该位置的输出会直接依赖多个可见位置。

MLP 不再读取其他位置,而是对每个位置已经包含上下文信息的 Hidden State 进行非线性特征变换。

因此,二者的侧重点可以概括为:Attention 主要在不同 Token 位置之间混合信息;MLP 主要在每个 Token 位置内部变换特征。

GPT-2 MLP 的整体结构

MLP 通常由输入层、一个或多个隐藏层和输出层组成。GPT-2 每个 Block 中的 MLP 采用一个隐藏层和一个输出层;按不计输入层的常见约定,这称为“两层 MLP”。

下面以 GPT-2 Small 为例。图中,T 表示 Token 数量,D = 768 表示 Hidden State 的维度,M = 3072 = 4D 表示 MLP 隐藏层的宽度。

GPT-2 MLP 的完整数据流

l 个 Block 中,H_l 先经过 LayerNorm 得到 MLP 输入 Z_l。隐藏层通过 Up Projection(升维投影)生成激活前的值 U_l,再经 GELU 得到输出 G_l。GELU 是逐元素激活函数,用于引入非线性。

输出层通过 Down Projection(降维投影)将 G_l 组合为 MLP 更新 M_l,该更新随后通过第二次 Residual Add 与 H_l 相加。这里,M_l 表示更新,与表示隐藏层宽度的 M 不同。

以上是 GPT-2 的具体结构。其他模型的前馈网络可以采用不同的激活函数或门控结构,并不都遵循“Up Projection → GELU → Down Projection”的串行流程。

为什么采用“扩展 → 非线性 → 再组合”

前面提到的 Up Projection 和 Down Projection 都是线性投影:把输入按固定权重加权求和,再加上偏置。

但模型需要学习的关系可能更复杂。例如,假设某个输出分量只应在两条线索同时出现时明显增大,其他情况下保持接近零。固定的加权相加无法准确表达这种关系。Up Projection 先组合输入,GELU 再对组合结果进行非线性变换,使 MLP 能够学习这类依赖特征组合的关系。

即使隐藏层保持 768 维,GELU 也能引入非线性。扩展到 3072 维,则提供了更多隐藏单元。每个单元使用各自的权重和偏置组合输入,再经过 GELU 产生一个数值。这样的扩展不是把输入复制四遍,也不意味着保存了四倍“知识”。

这些额外的非线性计算结果可供 Down Projection 组合,使 MLP 能表达更复杂的输入与输出关系。不过,加宽隐藏层也会增加参数量和计算量。

下图以单个 Token 位置为例,两列 3072 维向量分别表示隐藏层激活前后的数值。圆点表示向量分量,数量和连线仅作示意;颜色变化用于示意 GELU 前后各分量数值的变化。

GPT-2 MLP 的扩展、非线性与再组合

从 3072 维降回 768 维,并不会让前面的扩展失去意义。每个输出分量都可以综合多个非线性中间结果,因此输出虽然仍是 768 个数,却能表达更复杂的输入与输出关系。

单独看 Down Projection,它无法无损保留任意 3072 维输入。但 MLP 的目标是利用这些中间结果生成所需的更新,而非保存全部中间值。Down Projection 因此是在组合前面的计算结果,并不是把 Up Projection 的操作逆转回去。

隐藏层会学到什么?

Geva 等人的研究发现,一些隐藏单元(神经元)会对特定的文本模式产生较强响应,即遇到这类输入时,激活后的输出值较大。

对应到本文的 GPT-2 流程,隐藏单元的激活值就是 GELU 后的向量分量。可以把这类单元直观地看作训练得到的“模式探测器”,但这不意味着每个单元都对应一个固定概念;同一个单元也可能对多种文本模式产生响应。

Position-wise:逐位置计算,共享参数

GPT-2 的 MLP 也常被称为 FFN(Feed-Forward Network,前馈网络)或 Position-wise FFN。Position-wise 表示:在同一 Block 内,每个 Token 位置独立完成 MLP 变换,所有位置共享同一组参数。

H_l 中第 i 个位置的向量记为 h_(l,i) [D],这个位置经过 LayerNorm 和 MLP 后得到更新 m_(l,i) [D]

1
m_(l,i) = MLP_l(LN_ffn_l(h_(l,i)))    [D]

m_(l,i) 的计算只读取当前位置的 h_(l,i),而其中已包含 Attention 引入的上下文信息。因此,逐位置计算并不意味着忽略上下文。

实现中,T 个位置可以组织成矩阵批量计算,这不改变逐位置计算的含义。不同 Block 则使用各自独立的参数。

GPT-2 MLP 的三步计算

下面依次展开 Up Projection、GELU 和 Down Projection,公式省略权重和 bias 的层下标 l

Up Projection:从 768 维扩展到 3072 维

第一层线性投影使用 W_up [768,3072]b_up [3072]

1
2
3
4
U_l = Z_l W_up + b_up

[T,768] × [768,3072] + [3072]
→ [T,3072]

W_up 有 3072 列,每一列把 768 个输入值加权组合成一个中间值。因此,每个 Token 位置会得到 3072 个输出,组成 U_l [T,3072]

为什么中间维度是 3072?

GPT-2 技术报告表 2 给出的 117M 模型(通常称为 GPT-2 Small)隐藏维度为 D = 768;在 OpenAI 官方实现中,每个 Block 都以 nx * 4 作为 MLP 的中间维度。因此,GPT-2 Small 中有 M = 4D = 3072

四倍宽度并非 GPT-2 独有:《Attention Is All You Need》第 3.3 节的逐位置前馈网络采用 512 → 2048 → 512。它是一种常见的架构配置,并非所有 Transformer 都必须遵循,也不表示四倍维度在理论上最优。

GELU:在线性投影之间加入非线性

Up Projection 之后,GPT-2 对每个位置的 3072 个中间值逐元素应用 GELU(Gaussian Error Linear Unit)。GELU 原论文将其定义为 GELU(x) = xΦ(x),其中 Φ(x) 是标准高斯分布的累积分布函数。与 ReLU 根据输入正负进行硬截断不同,GELU 会根据输入值连续调节输出。

如果 t 表示 Token 位置、j 表示隐藏单元编号,可以写成:

1
G_l[t,j] = GELU(U_l[t,j])

GELU 不改变 Tensor shape,输入和输出都是 [T,3072];它只以非线性方式改变各元素的取值。

为什么两个线性层之间需要这样的非线性?如果去掉 GELU,那么 MLP 只剩两次连续的仿射变换:

1
2
(x W_up + b_up) W_down + b_down
= x (W_up W_down) + (b_up W_down + b_down)

即使中间维度扩展到 3072,两次投影仍可合并为一次从 768 维到 768 维的仿射变换。GELU 在两者之间引入非线性,使 MLP 能表达更复杂的映射。

为什么选择 GELU?

MLP 需要在线性投影之间加入非线性,但并不限定必须使用 GELU。原始 Transformer 使用 ReLU;GPT-2 则选择 GELU。

相比 ReLU,GELU 在零附近的变化更平滑;原论文也通过实验比较了 GELU、ReLU 和 ELU。因此,GELU 是一种有实验支持的架构选择,并非理论上唯一或必然最优的答案。

其他模型也可以采用不同的激活函数,或使用带 gate 的 FFN;后者还会改变内部结构。

Down Projection:回到 768 维

第二层线性投影使用 W_down [3072,768]b_down [768]

1
2
3
4
M_l = G_l W_down + b_down

[T,3072] × [3072,768] + [768]
→ [T,768]

Down Projection 将 3072 维中间结果重新组合成 768 维 MLP 更新 M_l。它必须回到 Hidden size D,因为接下来要通过第二次 Residual Add 与原来的 H_l [T,D] 逐元素相加:

1
X_(l + 1) = H_l + M_l    [T,D]

整个过程中 Token 数量始终为 T,只有特征维在 MLP 内部暂时从 D 扩展到 M,再回到 D

llama.cpp 实战

下面使用 llama.cpp b10435 和 GPT-2 Q8_0 模型,从 GGUF 参数、运行时 Tensor 和源码三个角度验证 MLP。基础环境和模型准备见《LLM 如何逐个生成 Token?》

进入 llama.cpp 目录,设置模型路径,并构建调试程序:

1
2
3
4
5
6
7
cd llama.cpp

GPT2_MODEL=models/QuantFactory/gpt2-GGUF/gpt2.Q8_0.gguf

cmake --build build \
--target llama-eval-callback \
--config Release -j

核对 FFN 参数

先从 GGUF 中读取 Hidden size、FFN 中间维度,以及 Block 0 的 LayerNorm、Up Projection 和 Down Projection 参数:

1
2
3
4
5
6
7
8
9
10
11
.venv/bin/gguf-dump "$GPT2_MODEL" --json |
jq '{
hidden_size: .metadata["gpt2.embedding_length"].value,
intermediate_size: .metadata["gpt2.feed_forward_length"].value,
ffn_norm_weight_shape: .tensors["blk.0.ffn_norm.weight"].shape,
ffn_norm_bias_shape: .tensors["blk.0.ffn_norm.bias"].shape,
ffn_up_weight_shape: .tensors["blk.0.ffn_up.weight"].shape,
ffn_up_bias_shape: .tensors["blk.0.ffn_up.bias"].shape,
ffn_down_weight_shape: .tensors["blk.0.ffn_down.weight"].shape,
ffn_down_bias_shape: .tensors["blk.0.ffn_down.bias"].shape
}'

当前模型的输出为:

1
2
3
4
5
6
7
8
9
10
{
"hidden_size": 768,
"intermediate_size": 3072,
"ffn_norm_weight_shape": [768],
"ffn_norm_bias_shape": [768],
"ffn_up_weight_shape": [768, 3072],
"ffn_up_bias_shape": [3072],
"ffn_down_weight_shape": [3072, 768],
"ffn_down_bias_shape": [768]
}

输出确认了 D = 768M = 3072,两组投影权重对应 768 → 3072 → 768。LayerNorm 不改变 Hidden size,其 weight 和 bias 均为 768 维;两次投影的 bias 则分别匹配各自的输出维度。

跟踪 MLP 的运行时 shape

How are 为输入运行调试程序,筛选 Block 0 的 MLP 相关输出:

1
2
3
4
5
./build/bin/llama-eval-callback \
-m "$GPT2_MODEL" \
--prompt 'How are' \
2>&1 |
rg 'common_debug_cb_eval: +(ffn_inp-0|ffn_norm-0|ffn_up-0|ffn_up_b-0|ffn_gelu-0|ffn_down-0|ffn_out-0|l_out-0) ='

筛选后的关键输出如下:

1
2
3
4
5
6
7
8
common_debug_cb_eval:                ffn_inp-0 = (f32)        ADD(node_24{768, 2, 1, 1}, inpL{768, 2, 1, 1}}) = {768, 2, 1, 1}
common_debug_cb_eval: ffn_norm-0 = (f32) ADD(norm_w-0{768, 2, 1, 1}, blk.0.ffn_norm.bias{768, 1, 1, 1}}) = {768, 2, 1, 1}
common_debug_cb_eval: ffn_up-0 = (f32) MUL_MAT(blk.0.ffn_up.weight{768, 3072, 1, 1}, ffn_norm-0{768, 2, 1, 1}}) = {3072, 2, 1, 1}
common_debug_cb_eval: ffn_up_b-0 = (f32) ADD(ffn_up-0{3072, 2, 1, 1}, blk.0.ffn_up.bias{3072, 1, 1, 1}}) = {3072, 2, 1, 1}
common_debug_cb_eval: ffn_gelu-0 = (f32) GELU(ffn_up_b-0{3072, 2, 1, 1}, }) = {3072, 2, 1, 1}
common_debug_cb_eval: ffn_down-0 = (f32) MUL_MAT(blk.0.ffn_down.weight{3072, 768, 1, 1}, ffn_gelu-0{3072, 2, 1, 1}}) = {768, 2, 1, 1}
common_debug_cb_eval: ffn_out-0 = (f32) ADD(ffn_down-0{768, 2, 1, 1}, blk.0.ffn_down.bias{768, 1, 1, 1}}) = {768, 2, 1, 1}
common_debug_cb_eval: l_out-0 = (f32) ADD(ffn_out-0{768, 2, 1, 1}, ffn_inp-0{768, 2, 1, 1}}) = {768, 2, 1, 1}

llama.cpp / GGML 按 {特征维, Token 维, ...} 显示运行时 Tensor,与前文使用的 [T,特征维] 顺序相反:

运行时节点 运行时 shape 逻辑含义
ffn_inp-0 {768,2} MLP 输入 H_0 [2,768]
ffn_norm-0 {768,2} LayerNorm 输出 Z_0 [2,768]
ffn_up-0 {3072,2} Up Projection 矩阵乘法结果,尚未加 b_up
ffn_up_b-0 {3072,2} 加上 b_up 后的完整输出 U_0 [2,3072]
ffn_gelu-0 {3072,2} 逐元素 GELU 输出 G_0 [2,3072]
ffn_down-0 {768,2} Down Projection 矩阵乘法结果,尚未加 b_down
ffn_out-0 {768,2} 加上 b_down 后的 MLP 更新 M_0 [2,768]
l_out-0 {768,2} Residual Add 输出 X_1 [2,768]

How are 对应两个 Token,因此从 ffn_inp-0l_out-0,Tensor 的第二维始终为 2。在每次 MUL_MAT 中,同一张权重矩阵分别作用于两个位置的向量,只改变特征维,不混合不同位置的信息。这正是 Position-wise 的运行时表现。

阅读这段输出时,重点关注等号左侧的节点名、操作类型和最右侧的 shape;中间的上游 Tensor 名称及自动编号可能随计算图变化。

对照 GPT-2 建图代码

GPT-2 在 llama_model_gpt2::graph::graph() 中先完成第一次 Residual Add,得到 MLP 输入 ffn_inp,随后依次执行 MLP 前的 LayerNorm、build_ffn() 和第二次 Residual Add:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// add the input
ggml_tensor * ffn_inp = ggml_add(ctx0, cur, inpL);
cb(ffn_inp, "ffn_inp", il);

// FF
{
cur = build_norm(ffn_inp,
model.layers[il].ffn_norm,
model.layers[il].ffn_norm_b,
LLM_NORM, il);
cb(cur, "ffn_norm", il);

cur = build_ffn(cur,
model.layers[il].ffn_up, model.layers[il].ffn_up_b, NULL,
NULL, NULL, NULL,
model.layers[il].ffn_down, model.layers[il].ffn_down_b, NULL,
NULL,
LLM_FFN_GELU, LLM_FFN_SEQ, il);
cb(cur, "ffn_out", il);
}

cur = ggml_add(ctx0, cur, ffn_inp);

按通用 build_ffn() 的参数顺序,关键实参如下:

build_ffn() 形参 GPT-2 传入的实参
up / up_b ffn_up / ffn_up_b
gate / gate_b / gate_s NULL / NULL / NULL
down / down_b ffn_down / ffn_down_b
type_op LLM_FFN_GELU
type_gate LLM_FFN_SEQ

updown 对应两次线性投影,type_op 选择 GELU。LLM_FFN_SEQ 只是 gate 的连接类型,本身不代表“无 gate”;真正的依据是 gategate_bgate_s 均为 NULL。因此,GPT-2 走的是无 gate 的 up → GELU → down 路径。

在通用的 build_ffn() 中,源码先构造 Up Projection 和 bias 节点:

1
2
3
4
5
6
7
ggml_tensor * tmp = up ? build_lora_mm(up, cur) : cur;
cb(tmp, "ffn_up", il);

if (up_b) {
tmp = ggml_add(ctx0, tmp, up_b);
cb(tmp, "ffn_up_b", il);
}

GPT-2 未传入 gate,因此 cur 直接接收 Up Projection 的结果 tmp,随后进入 GELU。

LLM_FFN_GELU 分支随后构造 ggml_gelu() 节点:

1
2
3
4
5
6
7
8
case LLM_FFN_GELU:
if (gate && type_gate == LLM_FFN_PAR) {
// GEGLU 路径
} else {
cur = ggml_gelu(ctx0, cur);
cb(cur, "ffn_gelu", il);
}
break;

最后再完成 Down Projection 和 bias:

1
2
3
4
5
6
7
8
if (down) {
cur = build_lora_mm(down, cur);
}

if (down_b) {
cb(cur, "ffn_down", il);
cur = ggml_add(ctx0, cur, down_b);
}

原理与源码可以汇总为:

原理概念 GPT-2 Small 中的形式 llama.cpp 中的入口
MLP Pre-Norm [T,768] → [T,768] build_norm(..., LLM_NORM, ...)
Up Projection [T,768] → [T,3072] build_ffn()build_lora_mm(up, cur) + bias
GELU [T,3072] → [T,3072] ggml_gelu()
无 gate 不存在额外 gate Tensor GPT-2 调用中的 gate/gate_b/gate_s == NULL
Down Projection [T,3072] → [T,768] build_lora_mm(down, cur) + bias
第二次 Residual Add H_l + M_l ggml_add(ctx0, cur, ffn_inp)

这些 C++ 调用用于构建 GGML 计算图,定义节点、依赖关系和 Tensor shape,并不立即执行数值计算。前面的回调输出才是计算图执行后的结果。

小结

本文介绍了 GPT-2 中 MLP 的作用与计算过程:

  • Attention 在不同 Token 位置之间混合信息;MLP 使用共享参数,逐位置对包含上下文信息的 Hidden State 进行非线性变换。
  • GPT-2 Small 使用无 gate 的 768 → 3072 → GELU → 768 MLP。中间的 3072 维提供临时的计算工作区,GELU 在两次线性投影之间引入非线性。
  • Down Projection 将非线性中间响应组合成与输入维度相同的 MLP 更新 M_l [T,768]

GPT-2 中 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 更新,才能与 Residual Connection 旁路保留的输入相加。GPT-2 如何并行计算多个 Head,再将它们组合成这一输出?这就是本篇要展开的 Multi-Head Attention(多头注意力)。

为什么需要多个 Head

对于同一个 Token 位置,单个 Head 只产生一组 Attention 权重,用于对各个可见位置的 Value 加权求和。

但在理解一句话时,当前位置往往需要同时参考多种线索。例如,当 GPT-2 读到:

1
The cat sat on the mat because it was

并准备预测下一个 Token 时,它可能需要同时关注:

  • it was,判断局部的语法和常见搭配;
  • catmat,推测 it 指代的对象;
  • because,理解前后内容之间的因果关系。

单个 Head 需要用同一组 Attention 权重综合这些线索。Multi-Head Attention 则让每个 Head 使用各自的投影参数,计算 Attention 权重并汇总上下文,最后合并各个 Head 的结果。

可以把两者的区别简单理解为:

1
2
单头:同一个位置 → 一组 Attention 权重 → 一份上下文结果
多头:同一个位置 → 多组独立的 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
2
Z_l = LN_attn_l(X_l)       [T,D]
A_l = Attention_l(Z_l) [T,D]

上一篇只展开了其中一个 Head,得到第 h 个 Head 的输出 O_h [T,D_h]。GPT-2 Small 同时计算 12 个这样的结果,再经过 Concat(拼接)和 Output Projection(输出投影)得到 A_l

GPT-2 Multi-Head Attention 的完整数据流

单头注意力是完整 Self-Attention 中的一个计算分支。下面沿图中的数据流,依次说明 QKV 投影、多个 Head 的计算,以及结果合并。

GPT-2 为什么使用 Fused QKV

Q、K、V 的线性投影都以 Z_l 为输入。Fused QKV(融合 QKV 投影)将三组权重和偏置沿输出维拼接,通过一次线性投影得到 [T,3D],再拆分为 Q、K、V。

这种组织方式不改变参数量和 Attention 公式,主要目的是提高计算效率:减少算子调度和对 Z_l 的重复读取,并更充分地利用底层矩阵乘法内核。

上一篇从单个 Head 的视角分别写出了 Q、K、V 投影。把 12 个 Head 合在一起看,整体 Q、K、V 都是 [T,D]

1
2
3
Q = Z_l W_Q + b_Q    [T,D]
K = Z_l W_K + b_K [T,D]
V = Z_l W_V + b_V [T,D]

其中,W_Q 可以看成 12 个 [D,D_h]W_Q^(h) 沿输出维拼在一起;W_KW_V 同理。GPT-2 进一步把三组整体参数合并为一组 Fused QKV 参数:

1
2
W_QKV = Concat(W_Q, W_K, W_V)    [D,3D]
b_QKV = Concat(b_Q, b_K, b_V) [3D]

于是,Q、K、V 可以通过一次线性投影同时得到:

1
QKV = Z_l W_QKV + b_QKV    [T,3D]

对于 GPT-2 Small:

1
2
[T,768] × [768,2304] + [2304]
→ QKV [T,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
2
3
QKV [T,2304]
→ Q / K / V 各自 [T,768]
→ Multi-Head View 各自 [12,T,64]

这里并不是先把 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 维并行计算。

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]

Concat 将 12 个 Head 的 64 维输出拼成一个 768 维向量,Token 数量 T 保持不变。若逐元素相加,结果仍然只有 64 维。

以某个 Token 位置 i 为例:

1
2
3
4
o_i^(0)  [64] ─┐
o_i^(1) [64] │
... ├─ Concat → o_cat_i [768]
o_i^(11) [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
2
3
4
O_cat    [T,768]
W_O [768,768]
b_O [768]
A_l [T,768]

为了理解这一次矩阵乘法如何组合多个 Head,可以重新标出 O_cat 中原有的 Head 边界:

1
O_cat = [O_0 | O_1 | ... | O_11]

再沿相同边界,将 W_O 中与这些输入维度对应的行分成 12 个 [64,768] 的子矩阵:

1
2
3
4
          [ W_O^(0)  ]
[ W_O^(1) ]
W_O = [ ⋮ ]
[ W_O^(11) ]

根据分块矩阵乘法:

1
2
A_l
= O_0 W_O^(0) + O_1 W_O^(1) + ... + O_11 W_O^(11) + b_O

每个 W_O^(h) 都将对应 Head 的 64 维输出映射到 768 维,因此 A_l 的每一维都可以融合多个 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
2
3
4
5
6
7
cd llama.cpp

GPT2_MODEL=models/QuantFactory/gpt2-GGUF/gpt2.Q8_0.gguf

cmake --build build \
--target llama-eval-callback \
--config Release -j

核对多头参数

先从 GGUF 中读取 Hidden size、Head 数量,以及 Block 0 的 Fused QKV 和 Output Projection 参数的 shape:

1
2
3
4
5
6
7
8
9
.venv/bin/gguf-dump "$GPT2_MODEL" --json |
jq '{
hidden_size: .metadata["gpt2.embedding_length"].value,
head_count: .metadata["gpt2.attention.head_count"].value,
qkv_weight_shape: .tensors["blk.0.attn_qkv.weight"].shape,
qkv_bias_shape: .tensors["blk.0.attn_qkv.bias"].shape,
output_weight_shape: .tensors["blk.0.attn_output.weight"].shape,
output_bias_shape: .tensors["blk.0.attn_output.bias"].shape
}'

当前模型的输出为:

1
2
3
4
5
6
7
8
{
"hidden_size": 768,
"head_count": 12,
"qkv_weight_shape": [768, 2304],
"qkv_bias_shape": [2304],
"output_weight_shape": [768, 768],
"output_bias_shape": [768]
}

hidden_size: 768head_count: 12 对应 D = 768H = 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
2
3
4
5
6
./build/bin/llama-eval-callback \
-m "$GPT2_MODEL" \
--prompt 'How are' \
--flash-attn off \
2>&1 |
rg 'common_debug_cb_eval: +(wqkv_b-0|Qcur-0|Kcur-0|Vcur-0|kqv-0|kqv_out-0) ='

裁剪后的输出如下:

1
2
3
4
5
6
wqkv_b-0       = {2304, 2, 1, 1}
Qcur-0 = {64, 12, 2, 1}
Vcur-0 = {64, 12, 2, 1}
Kcur-0 = {64, 12, 2, 1}
kqv-0 = {64, 2, 12, 1}
kqv_out-0 = {768, 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]

当前源码没有给 Output Projection 的结果设置独立、稳定的回调名,但可以用参数名定位 Block 0 的矩阵乘法及加上偏置的操作:

1
2
3
4
5
6
./build/bin/llama-eval-callback \
-m "$GPT2_MODEL" \
--prompt 'How are' \
--flash-attn off \
2>&1 |
rg 'common_debug_cb_eval: .*blk\.0\.attn_output\.(weight|bias)'

当前输出为:

1
2
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}
common_debug_cb_eval: node_29 = (f32) ADD(node_28{768, 2, 1, 1}, blk.0.attn_output.bias{768, 1, 1, 1}}) = {768, 2, 1, 1}

两行分别对应 O_cat W_O 和加上偏置 b_O,最终得到 A_0 [2,768]node_28node_29 是自动生成的节点名,可能随计算图变化,因此这里通过权重和偏置的参数名定位操作。

对比两个 Head 的 Attention 权重

继续保留 kq_soft_max-0 的数值:

1
2
3
4
5
6
./build/bin/llama-eval-callback \
-m "$GPT2_MODEL" \
--prompt 'How are' \
--flash-attn off \
2>&1 |
sed -n '/kq_soft_max-0 =/,/sum =/p'

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
2
3
4
5
6
7
auto [Qcur, Kcur, Vcur] = build_qkv(model.layers[il], cur,
n_embd_head, n_head, n_head_kv, il);

cur = build_attn(inp_attn,
model.layers[il].wo, model.layers[il].wo_b, model.layers[il].wo_s,
Qcur, Kcur, Vcur, nullptr, nullptr, nullptr,
1.0f/sqrtf(float(n_embd_head)), il);

n_head_kv 表示 Key/Value Head 数;GPT-2 Small 中它与 n_head 相同,都是 12,因此 Q、K、V 都按 12 个 Head 组织。

build_qkv() 中,GPT-2 进入 Fused QKV 分支。源码先完成一次矩阵乘法并加上偏置,再通过三个带不同偏移量的 ggml_view_3d() 取得 Q、K、V:

1
2
3
4
5
6
7
8
9
ggml_tensor * qkv = build_lora_mm(layer.wqkv, cur, layer.wqkv_s);
qkv = ggml_add(ctx0, qkv, layer.wqkv_b);

Qcur = ggml_view_3d(ctx0, qkv, n_embd_head, n_head, n_tokens,
/* ... */, 0);
Kcur = ggml_view_3d(ctx0, qkv, n_embd_head, n_head_kv, n_tokens,
/* ... */, ggml_row_size(qkv->type, n_embd_q));
Vcur = ggml_view_3d(ctx0, qkv, n_embd_head, n_head_kv, n_tokens,
/* ... */, ggml_row_size(qkv->type, n_embd_q + n_embd_kv));

ggml_view_3d() 没有再次计算投影,也没有复制出三份新结果;它通过 shape、步长和偏移量访问 Fused QKV 中的不同区域,并把 Head 维显式组织出来。

关闭 Flash Attention 后,build_attn_mha() 会在包含 Head 维的 Tensor 上完成上一篇已经验证的 kq → kq_soft_max → kqv。得到所有 Head 的 kqv 后,同一函数继续完成 Head 合并:

1
2
3
cur = ggml_permute(ctx0, kqv, 0, 2, 1, 3);
cur = ggml_cont_2d(ctx0, cur,
cur->ne[0]*cur->ne[1], cur->ne[2]*cur->ne[3]);

这里没有一个名为 concat 的 GGML 节点。ggml_permute() 先把 Head 维移到适合拼接的位置,ggml_cont_2d() 再把结果整理成连续的二维 Tensor {D,T};它在逻辑上对应 Concat(O_0,...,O_11) [T,D]

最后,build_attn() 使用 wowo_b 完成 Output Projection:

1
2
3
4
ggml_tensor * cur = build_attn_mha(/* ... */);

cur = build_lora_mm(wo, cur, wo_s);
cur = ggml_add(ctx0, cur, wo_b);

原理与源码可以汇总为:

原理概念 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] 三个不同偏移量的 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, ...) + 偏置

这些 C++ 调用负责构建 GGML 计算图,定义节点、依赖关系和 Tensor shape;数值计算由后端执行,前面的回调展示了执行结果。

小结

本文从完整 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]

GPT-2 中 Self-Attention 的位置

上一篇《Hidden State 如何流过 GPT-2 Block?》介绍了 Self-Attention 的输入和输出:LayerNorm 处理后的 Hidden State 进入 Self-Attention,产生的 Attention 更新再通过 Residual Connection 加回原 Hidden State。本文进一步展开它如何读取可见上下文。

GPT-2 使用多个 Attention Head(注意力头,以下简称 Head)并行计算。本文以 How are 中的 " are" 为例,跟踪单个 Head 从 Q、K、V 到 Value 加权求和的过程。

为什么需要 Self-Attention

先看两个结构相同的句子:

1
2
这种水果叫苹果,它很甜。
这家公司叫苹果,它生产手机。

两句话都有“苹果”和“它”,但上下文不同。“水果”和“公司”会影响“苹果”在当前句子中的表示;处理“它”时,也需要结合此前位置的信息。也就是说,一个位置的表示不能只包含当前 Token,还需要读取可见上下文。

回到本文使用的 "How are",在第一个 GPT-2 Block 之前,每个位置的 Initial Hidden State 由自己的 Token Embedding 与 Position Embedding 相加得到:

1
2
"How"   → x_(0,0) [D]
" are" → x_(0,1) [D]

x_(0,0)x_(0,1) 已经分别包含“当前是什么 Token”和“当前位于哪里”,但 " are" 还没有通过 Attention 读取 "How" 的信息。

Self-Attention 将上下文读取拆成三个部分:

  1. Causal Mask(因果掩码)决定当前位置可以读取哪些位置。
  2. Q、K 的匹配分数决定各个可见位置应该获得多大权重。
  3. 模型使用这些权重对 V 加权求和,得到包含上下文信息的新向量。

如果始终以相同权重汇总所有可见位置的 V,模型就无法根据输入调整关注重点。Self-Attention 会动态计算 Attention 权重,有选择地读取上下文。

为什么 Transformer 使用 Self-Attention?

处理序列时,模型需要在不同位置之间传递信息,常见做法有:

  • RNN(循环神经网络) 需要沿序列逐步传递状态,因此各位置的计算存在顺序依赖。
  • CNN(卷积神经网络) 可以并行计算,但使用局部卷积核时,每一层通常只能连接邻近位置,远距离位置需要经过多层才能建立联系。
  • Self-Attention 可以在一层内直接连接任意两个允许读取的位置,并对已经给定的序列并行计算各位置。

Transformer 以 Self-Attention 为核心,兼顾远距离信息交互与并行计算;GPT-2 也基于这一架构。

这里的“并行”是指一次前向计算可以同时处理多个位置;GPT-2 的自回归生成仍需逐个生成 Token。

Self-Attention 与单个 Head

上一篇从 Block 的整体视角,把 Self-Attention 写成一个 [T,D] → [T,D] 的子层。将 LayerNorm 的输出记为 Z_l,可以写成:

1
2
Z_l = LN_attn_l(X_l)       [T,D]
A_l = Attention_l(Z_l) [T,D]

其中,X_l [T,D] 是第 l 个 Block 的输入,A_l [T,D] 是完整的 Attention 更新。展开中间过程,可以看到多个 Head 与整体 Self-Attention 的关系:

GPT-2 Small 中多个 Attention Head 与单个 Head 的关系

GPT-2 Small 的 Hidden size 为 D = 768,包含 12 个 Head,因此每个 Head 的维度为:

1
D_h = D / 12 = 64

这里的 D / 12 只用于确定每个 Head 的特征宽度,不是把输入 Z_l 预先切成 12 份。12 个 Head 使用各自的投影参数,并行读取同一个 Z_l [T,768];每个 Head 都从完整的 Z_l 产生一个 O_h [T,64]

12 个 Head 的输出随后沿特征维合并回 [T,768];Token 数量 T 不变,也不是逐元素相加。本文只跟踪到 O_h

单头上下文读取流程

下面只观察第 h 个 Head,并以 " are" 位置的 Query 为例,沿图中箭头从上向下跟踪一次上下文读取:

Z_l 中“ are”位置向量通过一个 Attention Head 读取可见上下文

本例中,Attention 输入 Z_l [T,D] 包含两个位置向量:"How" 对应的 z_(l,0) [D]" are" 对应的 z_(l,1) [D]

为了与图中的符号对应,下面用下标 01 表示 Token 位置,用上标 (h) 表示当前观察的第 h 个 Head。

" are" 位置的读取过程可以分成四步:

  1. 计算 Q、K、V:当前 Head 使用三组线性变换,把 z_(l,0)z_(l,1) 分别投影为各自的 Query、Key 与 Value;其中 " are" 的 Query 记为 q_1^(h),后续沿它展开。
  2. 计算匹配分数q_1^(h) 分别与两个输入位置的 Key(k_0^(h)k_1^(h))做点积并缩放。
  3. 得到 Attention 权重:Causal Mask 先排除不允许读取的位置,Softmax 再把保留的分数转换成权重 p_(1,0)p_(1,1),分别对应 "How"" are"
  4. 读取 Value:将两个 Value(v_0^(h)v_1^(h))分别乘以对应的权重,再相加得到当前 Head 在 " are" 位置的输出 o_1^(h) [D_h]

在处理整段输入的一次前向计算中,Self-Attention 会为序列中的各个位置计算读取结果。“Self”表示 Q、K、V 来自同一段输入序列;“Causal”表示每个位置只能读取自身及此前位置。

Q、K、V

Q、K、V 都来自同一个 Z_l,但使用不同的投影参数,作用也不同:

  • Query:当前位置用什么特征发起查询。
  • Key:每个位置用什么特征与 Query 匹配。
  • Value:根据 Attention 权重参与加权求和的内容向量。

对第 h 个 Head,Q、K、V 的计算可以写成:

1
2
3
Q_h = Z_l W_Q^(h) + b_Q^(h)
K_h = Z_l W_K^(h) + b_K^(h)
V_h = Z_l W_V^(h) + b_V^(h)

Z_l W_Q^(h) 表示矩阵乘法;下文中,相邻书写的矩阵同样表示矩阵乘法。

同一个 Head 的所有 Token 位置共用这三组投影参数。参数通过训练得到;Q、K、V 则由各位置的当前输入计算,随输入变化。

三组投影参数及其输出的 shape 是:

1
2
3
W_Q^(h) / W_K^(h) / W_V^(h)  [D, D_h]
b_Q^(h) / b_K^(h) / b_V^(h) [D_h]
Q_h / K_h / V_h [T, D_h]

小写的 q_i^(h)k_i^(h)v_i^(h) 表示第 i 个 Token 位置在当前 Head 中的向量;大写的 Q_hK_hV_h 则把所有 T 个位置的向量按行放在一起。因此:

1
2
3
q_i^(h) = Q_h[i, :]
k_i^(h) = K_h[i, :]
v_i^(h) = V_h[i, :]

对于 "How are",图中跟踪的 q_1^(h) 就是 Q_h 中索引为 1 的行,也就是第二行。

Scaled Dot-Product Attention

Q、K、V 准备好后,当前 Head 会依次计算匹配分数、Attention 权重和 Value 加权结果。这一过程就是 Scaled Dot-Product Attention(缩放点积注意力)。

下面先沿 q_1^(h) 的路径逐步展开,再扩展到整个 Head 的矩阵形式。

原始匹配分数

一个 Query 与一个 Key 都是 D_h 维向量,二者的点积得到一个匹配分数:

1
score_h(i, j) = q_i^(h) · k_j^(h)

对于图中 " are" 位置的 q_1^(h)

1
2
score_h(1, 0) = q_1^(h) · k_0^(h)
score_h(1, 1) = q_1^(h) · k_1^(h)

分数越大,表示 Query i 与 Key j 在模型学到的特征空间中越匹配,但它还不是 Attention 权重。

从分数到 Attention 权重

原始分数要依次经过三个步骤:缩放 → Causal Mask → Softmax

第一步:缩放。 随着 D_h 增大,点积的典型幅度也会增大,容易使 Softmax 过度集中到少数位置。除以 sqrt(D_h) 可以控制分数的典型尺度:

1
scaled_score_h(i, j) = score_h(i, j) / sqrt(D_h)

GPT-2 Small 的单个 Head 为 D_h = 64,所以缩放因子是:

1
1 / sqrt(64) = 1 / 8

第二步:加入 Causal Mask。 GPT-2 是 Decoder-only 模型,使用自回归方式预测下一个 Token。计算位置 i 的表示时,只允许读取位置 0i,不能读取未来位置 j > i

对于当前的 "How are",Causal Mask 可以写成:

1
2
3
4
                    Key
"How" " are"
Query "How" 0 -∞
" are" 0 0

Causal Mask 会加到缩放后的分数上:允许读取的位置加 0,不改变分数;未来位置加 -∞,经过 Softmax 后权重变成 0。因此,q_0^(h) 只能读取 "How"q_1^(h) 可以读取 "How"" are"

扩展到更长序列时,可见区域同样呈下三角结构:每个位置只能读取自身及此前位置。

Causal Mask 能代替 Position Embedding 吗?

不能。Causal Mask 只规定“哪些位置可见”,不能告诉模型当前位置是第几个 Token。GPT-2 的 Position Embedding 通过训练得到,并以绝对位置编号查表;Self-Attention 再使用 Causal Mask 限制信息流向。二者作用不同,不能相互替代。

第三步:Softmax。 加入 Causal Mask 后,Softmax 沿每个 Query 对应的那一行计算,把可见位置的分数转换成总和为 1 的非负权重。对于 q_1^(h),得到的两个权重是:

1
[p_(1,0), p_(1,1)]

p_(1,0) 表示 " are""How" 读取信息的权重,p_(1,1) 表示它从自身读取信息的权重。

用 Attention 权重读取 Value

得到 Attention 权重后,当前 Query 使用这些权重对所有可见位置的 Value 做加权求和:

1
o_i^(h) = Σ_j p_(i,j) v_j^(h)

对于图中的 " are"

1
o_1^(h) = p_(1,0) v_0^(h) + p_(1,1) v_1^(h)

权重作用在 Value 上,而不是直接复制某个 Token,也不是对 Key 做加权求和。计算结果 o_1^(h) [D_h] 就是当前 Head 在 " are" 位置的输出。

整个 Head 的矩阵形式

上面只跟踪了 q_1^(h) 的一条路径。实际计算会同时处理所有 Query 位置:

1
2
3
S_h = Q_h K_h^T / sqrt(D_h) + M
P_h = softmax(S_h)
O_h = P_h V_h

K_h^T 表示 K_h 的转置。K_h [T,D_h] 转置后变为 K_h^T [D_h,T],因此 Q_h [T,D_h]K_h^T [D_h,T] 相乘会得到 [T,T] 的分数矩阵。

其中:

  • M [T,T] 是前面 Causal Mask 表格的矩阵形式。
  • S_h [T,T] 是原始匹配分数经过缩放并加上 M 后的分数矩阵。
  • P_h [T,T] 是 Attention 权重,Softmax 沿每一行计算。
  • O_h [T,D_h] 是当前 Head 的输出。

位置级符号与矩阵中的元素一一对应:

1
2
3
4
5
(Q_h K_h^T)[i,j]  = score_h(i, j) = q_i^(h) · k_j^(h)
S_h[i,j] = score_h(i, j) / sqrt(D_h) + M[i,j]
P_h[i,:] = softmax(S_h[i,:])
P_h[i,j] = p_(i,j)
O_h[i,:] = o_i^(h)

q_1^(h) 的 Attention 权重对应 P_h 的第二行,输出 o_1^(h) 对应 O_h 的第二行。

将三步合并,可得 Scaled Dot-Product Attention 的公式:

1
O_h = softmax(Q_h K_h^T / sqrt(D_h) + M) V_h

最小数值例子

下面继续使用 "How are" 的两个位置(T = 2),并将 Head 维度简化为 D_h = 4,把分数、Causal Mask、Softmax 和 Value 加权连起来。

为便于手算,这里只观察一个 Head,省略下标 h,并取 Q = K。GPT-2 Small 的实际 D_h64,示例数值不代表真实运行结果。

QV 的两行以及 K^T 的两列,都依次对应 "How"" are"。设:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Q = [
[1, 0, 1, 1],
[1, 1, 0, 1]
]

K^T = [
[1, 1],
[0, 1],
[1, 0],
[1, 1]
]

V = [
[1, 0, 1, 0],
[0, 1, 0, 1]
]

Q 的每一行与 K^T 的每一列做点积,得到原始匹配分数:

1
2
3
4
Q K^T = [
[3, 2],
[2, 3]
]

再除以 sqrt(D_h) = sqrt(4) = 2,得到缩放后的分数:

1
2
3
4
Q K^T / sqrt(4) = [
[1.500, 1.000],
[1.000, 1.500]
]

Query 行和 Key 列均按 "How"" are" 的顺序排列,Causal Mask 为:

1
2
3
4
M = [
[0, -∞],
[0, 0]
]

M 加到缩放后的分数上,未来位置对应的分数变为 -∞

1
2
3
4
S = Q K^T / sqrt(4) + M = [
[1.500, -∞],
[1.000, 1.500]
]

接着逐行计算 P = softmax(S)" are" 对应的第二行计算结果是:

1
softmax([1.000, 1.500]) ≈ [0.378, 0.622]

得到 Attention 权重矩阵:

1
2
3
4
P = [
[1.000, 0.000],
[0.378, 0.622]
]

最后用这些权重对 V 加权求和," are" 位置的输出是:

1
2
O_1 ≈ 0.378 [1, 0, 1, 0] + 0.622 [0, 1, 0, 1]
= [0.378, 0.622, 0.378, 0.622]

对应的矩阵结果为:

1
2
3
4
O ≈ [
[1.000, 0.000, 1.000, 0.000],
[0.378, 0.622, 0.378, 0.622]
]

"How" 位置只能读取自己的 V_0" are" 位置则从 V_0V_1 读取信息。至此,一个 Head 的 Causal Self-Attention 计算就完成了。

单头 shape 主线

推理时,训练得到的投影参数保持不变。当前输入 Z_l 先生成 Q_hK_hV_h,再计算匹配分数、Attention 权重和输出 O_h。因此,Attention 权重是运行时计算的结果,不是模型文件中的固定参数。

Causal Mask 稍有不同:它的因果规则固定,但具体 Tensor 由当前序列长度和位置关系确定。

前文的 W_Q^(h)W_K^(h)W_V^(h) 及其偏置,是按单个 Head 展开的逻辑写法;GPT-2 如何组织多个 Head 的参数留到下一篇。

这条单头计算路径的逻辑 shape 如下:

阶段 逻辑 shape GPT-2 Small(T=2
Attention 输入 Z_l [T,D] [2,768]
Q_h / K_h / V_h 各自 [T,D_h] 各自 [2,64]
Attention 分数 S_h / 权重 P_h 各自 [T,T] 各自 [2,2]
Head 输出 O_h [T,D_h] [2,64]

O_h 对应整体视图中的一条 Head 分支,还不是最终的 Attention 更新 A_l [T,D]

llama.cpp 实战

下面使用 llama.cpp b10435 和 GPT-2 Q8_0 模型,从真实运行中选出 Block 0 / Head 0,验证单头公式。基础环境和模型准备见《LLM 如何逐个生成 Token?》gguf-dump 的安装方式见《文本如何变成 Token ID?》

进入 llama.cpp 目录,设置模型路径,并构建本篇使用的调试程序:

1
2
3
4
5
6
7
cd llama.cpp

GPT2_MODEL=models/QuantFactory/gpt2-GGUF/gpt2.Q8_0.gguf

cmake --build build \
--target llama-eval-callback \
--config Release -j

确认单个 Head 的逻辑维度

先从 GGUF 元数据读取 Hidden size 和 Head 数,并计算单个 Head 的维度:

1
2
3
4
5
6
7
8
9
.venv/bin/gguf-dump "$GPT2_MODEL" --json |
jq '
(.metadata["gpt2.embedding_length"].value) as $d |
(.metadata["gpt2.attention.head_count"].value) as $h |
{
hidden_size: $d,
head_count: $h,
head_dim: ($d / $h)
}'

当前模型的输出为:

1
2
3
4
5
{
"hidden_size": 768,
"head_count": 12,
"head_dim": 64
}

每个 Head 的维度为 768 / 12 = 64。后续选取 Block 0 / Head 0,按单头逻辑 shape [T,64] 理解 Q、K、V 和输出。

查看运行时 shape

How are 为输入,筛选 Block 0 的 Attention 中间 Tensor:

1
2
3
4
5
6
./build/bin/llama-eval-callback \
-m "$GPT2_MODEL" \
--prompt 'How are' \
--flash-attn off \
2>&1 |
rg 'common_debug_cb_eval: +(Qcur-0|Kcur-0|Vcur-0|kq-0|kq_soft_max-0|kqv-0) ='

本次实验使用 --flash-attn off 保留 kqkq_soft_maxkqv 三个中间 Tensor,便于逐步检查单头计算过程。

经过裁剪的真实输出如下:

1
2
3
4
5
6
Qcur-0         = {64, 12, 2, 1}
Kcur-0 = {64, 12, 2, 1}
Vcur-0 = {64, 12, 2, 1}
kq-0 = {256, 2, 12, 1}
kq_soft_max-0 = {256, 2, 12, 1}
kqv-0 = {64, 2, 12, 1}

节点名末尾的 -0 表示 Block 0,不表示 Head 0。对于 Q、K、V,{64,12,2,1} 可以记为 {head_dim, head_count, T, 1};前三个维度依次表示每个 Head 的特征数、Head 数和 Token 数。Head 0 就是在 Head 数所在的第二个维度取索引 0

分数 Tensor 的维度顺序不同:在 {256,2,12,1} 中,第三个维度 12 才是 Head 数,因此 Head 0 是该维度中索引为 0 的切片。后面的数值实验都只读取这个切片。

运行时输出采用 GGML 的维度顺序,与前文使用的逻辑 shape 写法不同。只取 Head 0 后,可以按计算顺序理解为:

运行时 Tensor 单头逻辑含义 单头逻辑 shape
Qcur-0 / Kcur-0 / Vcur-0 Q_h / K_h / V_h 各自 [2,64]
kq-0 Q_h K_h^T 的原始分数 有效区域 [2,2]
kq_soft_max-0 缩放、Causal Mask、Softmax 后的 P_h 有效区域 [2,2]
kqv-0 P_h V_h [2,64]

llama.cpp 会先把本轮 Kcur-0Vcur-0 写入 KV Cache,再让 Attention 读取 KV Cache 中的 K、V 视图。当前实验从空 KV Cache 开始处理 How are,因此前两个有效位置就是本轮产生的 K、V,对应这里的 K_hV_h

kq-0 中的 256 来自当前 KV Cache 的对齐视图容量。本轮只有 How are 两个位置有效,其余槽位会被 Causal Mask 排除;这里的 256 不表示输入变成了 256 个 Token。

验证单头 Attention 计算

分数与 Attention 权重。 保留 kq-0kq_soft_max-0 的 Tensor 数值:

1
2
3
4
5
6
./build/bin/llama-eval-callback \
-m "$GPT2_MODEL" \
--prompt 'How are' \
--flash-attn off \
2>&1 |
sed -n '/kq-0 =/,/sum =/p; /kq_soft_max-0 =/,/sum =/p'

从输出中取出 Head 0 的两个 Query,并为每个 Query 只保留前两个 Key 位置:

Query kq 原始分数 kq_soft_max 权重
Query 0 / How [3.7358, -13.7144] [1.0000, 0.0000]
Query 1 / are [-1.3936, -25.5417] [0.9534, 0.0466]

kq 是尚未缩放的 Q/K 点积分数。GPT-2 Small 的 kq_scale = 1/8,所以 " are" 的两个可见分数先变成:

1
2
[-1.3936 / 8, -25.5417 / 8]
≈ [-0.1742, -3.1927]

两个位置对 Query 1 都可见,Softmax 后得到:

1
[0.9534, 0.0466]

Query 0 虽然在 kq 中也有两个原始分数,但 " are" 对它来说是未来位置,Causal Mask 会把第二项排除,因此最终权重为 [1.0000, 0.0000]。这也说明二者职责不同:Q/K 点积决定匹配分数,Causal Mask 决定哪些分数可以参与 Softmax。

Value 加权。 继续查看 Vcur-0kqv-0

1
2
3
4
5
6
./build/bin/llama-eval-callback \
-m "$GPT2_MODEL" \
--prompt 'How are' \
--flash-attn off \
2>&1 |
sed -n '/Vcur-0 =/,/sum =/p; /kqv-0 =/,/sum =/p'

将 Head 0 的两个 Value 各保留前 3 维,并列出 Query 1 的两个权重:

1
2
3
4
5
v_0^(0) = [-0.1252,  0.0577, 0.0428, ...]   # "How"
v_1^(0) = [ 0.2463, -0.0901, 0.2779, ...] # " are"

p_(1,0) = 0.9534
p_(1,1) = 0.0466

按照单头公式:

1
2
3
o_1^(0)
= 0.9534 × v_0^(0) + 0.0466 × v_1^(0)
≈ [-0.1079, 0.0508, 0.0538, ...]

kqv-0 中 Query 1 对应位置的前 3 维是:

1
[-0.1080, 0.0509, 0.0538, ...]

用保留 4 位小数的数值手算,结果与 kqv-0 的前 3 维相差约 0.0001。两者在舍入误差范围内一致,与前面的 Value 加权公式相符。

改变输入。 最后把输入扩展为三个 Token How are you,再次查看 kq_soft_max-0

1
2
3
4
5
6
./build/bin/llama-eval-callback \
-m "$GPT2_MODEL" \
--prompt 'How are you' \
--flash-attn off \
2>&1 |
sed -n '/kq_soft_max-0 =/,/sum =/p'

输出的第一个 Head 切片包含三个 Query;只保留前三个 Key 位置:

1
2
3
Query 0 / "How"  = [1.0000, 0.0000, 0.0000]
Query 1 / " are" = [0.9534, 0.0466, 0.0000]
Query 2 / " you" = [0.8849, 0.0563, 0.0588]

Query 0 只能读取位置 0,Query 1 只能读取位置 0~1,Query 2 才能读取全部三个位置。前两个 Query 的权重与 How are 实验相同,说明新增的未来 Token 没有影响此前位置的 Attention 权重。

对照源码

GPT-2 在 llama_model_gpt2::graph::graph() 中先调用 build_qkv(),再把 Q、K、V 和 1/sqrt(D_h) 传入 build_attn()

1
2
3
4
5
6
7
auto [Qcur, Kcur, Vcur] = build_qkv(model.layers[il], cur,
n_embd_head, n_head, n_head_kv, il);

cur = build_attn(inp_attn,
model.layers[il].wo, model.layers[il].wo_b, model.layers[il].wo_s,
Qcur, Kcur, Vcur, nullptr, nullptr, nullptr,
1.0f/sqrtf(float(n_embd_head)), il);

build_qkv() 产生包含全部 Head 的 QcurKcurVcur,随后由 build_attn() 继续处理。

build_attn() 再通过 cpy_k()cpy_v() 把当前 K、V 写入 KV Cache,并用 get_k()get_v() 取得 KV Cache 视图,交给 build_attn_mha()

关闭 Flash Attention 后,build_attn_mha() 中与单头公式对应的核心操作是:

1
2
3
4
5
6
ggml_tensor * kq = ggml_mul_mat(ctx0, k, q);

kq = ggml_soft_max_ext(ctx0, kq, kq_mask, kq_scale,
hparams.f_max_alibi_bias);

ggml_tensor * kqv = ggml_mul_mat(ctx0, v, kq);

逐项对应如下:

数学计算 llama.cpp / GGML
Q_h K_h^T ggml_mul_mat(k, q)kq
缩放 + Causal Mask + Softmax ggml_soft_max_ext(..., kq_mask, kq_scale, ...)kq_soft_max
P_h V_h ggml_mul_mat(v, kq)kqv

build_attn_mha() 中的这三步沿 Head 维分别计算,取出 Head 0 后即可与前文的单头公式对应。

源码没有为缩放、Causal Mask 和 Softmax 分别创建三个节点。ggml_soft_max_ext() 同时接收 kq_scalekq_mask,在一个操作中完成这三步。上述调用只是在构建 GGML 计算图,真正的数值由后端执行。

小结

本文以 "How are" 中的 " are" 为例,从单个 Head 的视角说明了 Self-Attention 如何读取可见上下文:

  • 一个 Attention Head 用 Query 与 Key 计算分数,缩放后用 Causal Mask 排除未来位置,再通过 Softmax 得到 Attention 权重,最后对 Value 加权求和。
  • Q、K、V、Attention 权重和 Head 输出都由当前输入动态产生;训练得到并保存在模型文件中的是投影参数,而不是固定的 Attention 结果。
  • GPT-2 Small 并行计算 12 个 Head;单个 Head 输出 O_h [T,64],还不是完整的 Attention 更新 A_l [T,768]

GPT-2 中 12 个 Transformer Block 的位置

在上一篇《Token ID 如何变成向量?》中,How are 对应的 Token Embedding 与 Position Embedding 相加,得到 shape 为 [2,768] 的 Initial Hidden State(初始隐藏状态)。它是第一个 GPT-2 Block 的输入,但还不是模型最终用于预测下一个 Token 的表示。

接下来,Hidden State 会依次流过 12 个 GPT-2 Block。本文将 Self-Attention 和 MLP 看作黑盒,重点说明每个 Block 的 Pre-Norm、Residual Connection,以及 Block 之间的连接方式。最后用 llama.cpp 源码和运行结果验证这条数据流。

GPT-2 Block

Embedding 只完成了输入表示的初始化。真正结合上下文、逐层更新每个 Token 表示的工作,主要发生在这 12 个 Block 中。

一个 GPT-2 Block 包含两个子层:

  1. Self-Attention(自注意力),结合可见的上下文更新每个位置的表示。
  2. MLP(多层感知机),分别更新每个位置的表示。

GPT-2 使用 Causal Self-Attention(因果自注意力)

这里的 Causal 表示每个位置只能读取自身和此前的位置,不能读取后续位置。

Self-Attention 和 MLP 都采用 Pre-Norm 和 Residual Connection(残差连接),完整数据流如下:

Hidden State 流过一个 GPT-2 Block 的数据流

图中,黑色主路径经过 LayerNorm 和子层,分别产生 Attention 更新 A_l 和 MLP 更新 M_l;蓝色 Residual Connection 旁路则保留各子层进入 LayerNorm 前的状态。在每个阶段,两条路径都在 + 处逐元素相加。

一个 Block 的完整数据流可以分为两个依次执行、结构相同的阶段:

  1. Attention 阶段:以 X_l 为输入,经过 LayerNorm 和 Self-Attention 得到更新 A_l;再将 A_l 与原始 X_l 相加,得到 H_l
  2. MLP 阶段:以 H_l 为输入,经过 LayerNorm 和 MLP 得到更新 M_l;再将 M_l 与原始 H_l 相加,得到 X_(l + 1)

记第 l 个 Block 的输入为 X_l,其数据流可以写成:

1
2
3
4
5
A_l       = Attention_l(LN_attn_l(X_l))
H_l = X_l + A_l

M_l = MLP_l(LN_ffn_l(H_l))
X_(l + 1) = H_l + M_l

Hidden State

Hidden State 可以理解为模型在当前层对整段 Token 序列的内部表示。对于包含 T 个 Token、Hidden size 为 D 的输入,它的 shape 是:

1
[T, D]

每个 Token 位置对应一个 D 维向量。GPT-2 Small 的 D = 768,因此 How are 对应的 Hidden State 为:

1
2
3
"How"  的表示 ─┐
├─ Hidden State [2, 768]
" are" 的表示 ─┘

Hidden State 有三个特点:

  • 同一位置的向量会随着 Block 的计算不断更新。
  • 向量逐渐包含当前位置及其可见上下文的信息。
  • Block 输入和输出的 Hidden size 都是 768,因此多个 Block 可以首尾相接。

x_(l,i) 表示第 l 个 Block 输入中第 i 个 Token 位置的 Hidden 向量。它是一个包含 D 个数的一维向量:

1
x_(l,i) [D] = [x_(l,i,0), x_(l,i,1), ..., x_(l,i,D-1)]

T 个位置的向量按照 Token 顺序纵向排列,就得到整个 Hidden State X_l

1
2
3
4
5
6
X_l [T,D] = [
x_(l,0) ← 第 0 个 Token 位置的 D 维向量
x_(l,1) ← 第 1 个 Token 位置的 D 维向量
...
x_(l,T-1) ← 第 T - 1 个 Token 位置的 D 维向量
]

其中,l 表示 Block 层编号,i 表示 Token 在序列中的位置,最后一个下标表示向量内部的维度编号。

Self-Attention 会在遵守因果约束的前提下,让每个位置从可见位置读取信息;MLP 则对每个位置分别做进一步变换。虽然内部计算不同,但两个子层的输出 shape 都是 [T,D],因此可以与 Residual Connection 分支逐元素相加。

Pre-Norm

LayerNorm(层归一化)对每个 Token 的 Hidden 向量 x 单独进行归一化,再做缩放和平移:

1
LN(x) = γ ⊙ (x - μ) / sqrt(σ² + ε) + β

其中,μσ² 分别是当前 Hidden 向量的均值和方差;γβ 分别是训练得到的 weight 和 bias; 表示逐元素相乘。ε 是一个固定的很小正数,用于避免方差过小时分母接近 0。

LayerNorm 不会改变 Token 数量或 Hidden size:

1
2
输入  [T, D]
输出 [T, D]

为什么需要 LayerNorm?

Hidden State 经过不同 Block 后,数值的整体偏移和尺度可能发生变化。LayerNorm 的作用不是增加新的上下文信息,而是让 Attention 和 MLP 获得数值尺度相对可控的输入。

GPT-2 使用 Pre-Norm:LayerNorm 位于 Attention 或 MLP 之前。GPT-2 原论文将其描述为把 LayerNorm 移到每个子 Block 的输入。对一个 Block 来说,顺序是:

1
2
X_l → LayerNorm → Attention → Residual Add
H_l → LayerNorm → MLP → Residual Add

Pre-Norm 可以写成 x + Sublayer(LN(x)):原状态 x 沿 Residual Connection 旁路保留,子层根据归一化后的输入计算更新。相比将相加结果再归一化的 Post-Norm LN(x + Sublayer(x)),Pre-Norm 保留了不经过 LayerNorm 的直接路径,也为训练时的梯度传播提供了更直接的通道。

Pre-Norm 为什么更容易训练?

Xiong 等人的研究表明,在其理论与实验设置下,Pre-Norm Transformer 初始化时的梯度比 Post-Norm 更稳定,对 Learning Rate Warm-up 的依赖也更小。这解释了 Pre-Norm 为什么通常更容易训练,但不表示它在所有场景下都一定优于 Post-Norm。

上述优势主要体现在训练阶段。推理时,模型使用训练好的参数进行前向计算。每个 Block 有两组 LayerNorm 参数,分别用于 Attention 和 MLP。

Residual Connection

Residual Connection 不直接用子层输出替换原来的 Hidden State,而是保留原状态,再加入子层算出的更新:

1
2
新表示 = 原状态 + 子层更新
x_new = x + F(x)

这里的 F(x) 表示 LayerNorm 和子层共同算出的更新。在前面的 Block 数据流中,A_lM_l 分别是两个子层产生的 F(x),而 X_lH_l 则沿对应的 Residual Connection 旁路直接保留。

两种写法对比如下:

1
2
无 Residual Connection:x_new = F(x)
有 Residual Connection:x_new = x + F(x)

两者的区别在“保持原状态不变”时最容易看出来:

  • 无 Residual Connection:如果模型希望得到 x_new ≈ x,就必须让整个子层学会 F(x) ≈ x,也就是由子层重新产生一份接近输入的表示。
  • 有 Residual Connection:原状态 x 已经通过旁路直接到达输出。子层不需要重新生成 x,只需让更新 F(x) ≈ 0,就可以得到 x_new ≈ x

用一个简单的线性变换说明:如果 F(x) = Wx,没有 Residual Connection 时,需要学到 W ≈ II 表示单位矩阵)才能保持输入;使用 Residual Connection 后,只需让 W ≈ 0,旁路就会自动保留 x

Residual Connection 并不要求更新始终很小;F(x) ≈ 0 只是无需修改原状态时的特殊情况。

Residual Connection 为什么有利于深层训练?

He 等人在图像识别网络的实验中发现,对于不使用 Residual Connection、只把上一层输出交给下一层的网络,增加深度后训练误差可能反而升高;加入 Residual Connection 后,较深的网络获得了更低的训练误差。这项实验结果支持了前面的设计直觉:让恒等路径直接存在、由子层学习额外变化,有利于深层网络的训练。

无论子层最终加入什么变化,它的输出都必须与原状态具有相同的 shape,才能进行逐元素相加:

阶段 原理 shape
输入 Hidden State X_l [T,D]
Attention LayerNorm 输出 [T,D]
Attention 更新 A_l [T,D]
第一次 Residual Add 结果 H_l [T,D]
MLP LayerNorm 输出 [T,D]
MLP 更新 M_l [T,D]
输出 Hidden State X_(l + 1) [T,D]

12 个 Block

GPT-2 Small 包含 12 个串联的 Transformer Block。本文沿用 llama.cpp 从 0 开始的编号方式,将它们依次记作 Block 0Block 11

12 个 GPT-2 Block 的层间数据流

为什么要堆叠多个 Block?

多个 Block 串联后,每个位置都会反复读取可见上下文,并经过多轮非线性变换。后续 Block 可以继续组合和更新前面形成的表示,使模型能够表达更复杂的关系。

不过,更深并不意味着效果一定更好。GPT-2 论文比较了 12、24、36 和 48 层的四种模型配置,并观察到模型整体容量增加时,多项任务的表现随之提升。但这些配置同时增加了层数、Hidden State 的维度和总参数量,因此不能把提升单独归因于 Block 数量。

后续的 Scaling Laws 研究进一步指出,模型的损失主要随模型规模、训练数据量和训练计算量变化;在研究覆盖的较大范围内,宽度与深度等具体形状的影响相对较小。因此,Block 数量应被理解为训练前结合实验效果与计算预算确定的架构超参数,而不是越多越好。

这 12 个 Block 的计算骨架相同,但每层都有各自训练得到的 LayerNorm、Attention 和 MLP 参数。

这 12 个 Block 的串联过程可以写成:

1
X_(l + 1) = Block_l(X_l),  l = 0, 1, ..., 11

每一层接收上一层的输出,再生成新的 Hidden State。最后一个 Block 的输出还要经过 Final LayerNorm 和 LM Head,才会变成用于预测下一个 Token 的 logits。

llama.cpp 实战

下面使用 llama.cpp b10435 和 GPT-2 Q8_0 模型,从 GGUF 参数、GPT-2 建图源码和运行时 Tensor 三个角度验证 Block 数据流。基础环境和模型准备见《LLM 如何逐个生成 Token?》

进入 llama.cpp 目录,设置模型路径,并构建本篇使用的调试程序:

1
2
3
4
5
6
7
cd llama.cpp

GPT2_MODEL=models/QuantFactory/gpt2-GGUF/gpt2.Q8_0.gguf

cmake --build build \
--target llama-eval-callback \
--config Release -j

验证 12 个 Block 的独立参数

先用 gguf-dump 查看模型的 Block 数量和每层的 Tensor 数量,再将 Block 0 和 Block 11 的 Tensor 按参数组归类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.venv/bin/gguf-dump "$GPT2_MODEL" --json |
jq '
.metadata["gpt2.block_count"].value as $block_count |
def block_tensors($i):
[.tensors | keys[] | select(startswith("blk.\($i)."))];
def parameter_groups($i):
[block_tensors($i)[] | sub("\\.(weight|bias)$"; "")] | unique;
{
block_count: $block_count,
tensor_count_per_block: [
range(0; $block_count) as $i |
block_tensors($i) | length
],
block_0_groups: parameter_groups(0),
block_11_groups: parameter_groups(11)
}'

当前模型的输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
"block_count": 12,
"tensor_count_per_block": [
12, 12, 12, 12, 12, 12,
12, 12, 12, 12, 12, 12
],
"block_0_groups": [
"blk.0.attn_norm",
"blk.0.attn_output",
"blk.0.attn_qkv",
"blk.0.ffn_down",
"blk.0.ffn_norm",
"blk.0.ffn_up"
],
"block_11_groups": [
"blk.11.attn_norm",
"blk.11.attn_output",
"blk.11.attn_qkv",
"blk.11.ffn_down",
"blk.11.ffn_norm",
"blk.11.ffn_up"
]
}

block_count 验证了当前模型包含 12 个 Block,tensor_count_per_block 的各项均为 12,说明每层都有 12 个参数 Tensor。命令去掉 Tensor 名称末尾的 .weight.bias 后,得到每层的 6 个参数组:

GGUF 参数组 Block 中的概念
attn_norm Attention 前的 LayerNorm
attn_qkvattn_output Attention
ffn_norm MLP 前的 LayerNorm
ffn_upffn_down MLP

每个参数组都包含 weight 和 bias,因此 6 个参数组对应 12 个 Tensor。Block 0 和 Block 11 具有相同的参数组结构,但完整名称分别以 blk.0.blk.11. 开头,说明两层的计算骨架相同,却读取各自独立的参数。

Residual Add 没有训练参数,因此不会出现在 GGUF 参数列表中;下一节将从源码中的 ggml_add() 确认它在 Block 数据流中的位置。

从源码确认 Block 的计算路径

GPT-2 的建图入口是 llama_model_gpt2::graph::graph()。其中,Block 层循环的主路径如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
for (int il = 0; il < n_layer; ++il) {
cur = build_norm(inpL,
model.layers[il].attn_norm,
model.layers[il].attn_norm_b,
LLM_NORM, il);

// self-attention
{
cur = build_attn(/* ... */);
}

ggml_tensor * ffn_inp = ggml_add(ctx0, cur, inpL);

cur = build_norm(ffn_inp,
model.layers[il].ffn_norm,
model.layers[il].ffn_norm_b,
LLM_NORM, il);

cur = build_ffn(cur, /* ... */);

cur = ggml_add(ctx0, cur, ffn_inp);

// input for next layer
inpL = cur;
}

这些代码位置可以与前面的 Block 数据流逐项对应。表中的运行时节点将在下一节用于检查实际计算结果:

原理中的表示 llama.cpp 代码 运行时节点
输入 Hidden State X_l 循环开始时的 inpL inpL,或上一层的 l_out-(l-1)
LN(X_l) cur = build_norm(inpL, ...) attn_norm-l
Attention 更新 A_l cur = build_attn(...) —(无独立回调节点名)
H_l = X_l + A_l ffn_inp = ggml_add(ctx0, cur, inpL) ffn_inp-l
LN(H_l) cur = build_norm(ffn_inp, ...) ffn_norm-l
MLP 更新 M_l cur = build_ffn(cur, ...) ffn_out-l
X_(l + 1) = H_l + M_l 第二次 ggml_add(),随后执行 inpL = cur l_out-l

两次 build_norm(..., LLM_NORM, ...) 都位于对应子层之前,验证了 GPT-2 的 Pre-Norm 结构。两次 ggml_add() 则分别对应 Attention 和 MLP 之后的 Residual Add。

cur 是建图时复用的临时变量,在不同步骤中指向不同 Tensor,需结合当前操作判断其含义。

用运行时输出验证 shape 与层间连接

使用 llama-eval-callback 运行 How are,只保留 Block 0 和 Block 1 的关键节点:

1
2
3
4
./build/bin/llama-eval-callback \
-m "$GPT2_MODEL" \
--prompt 'How are' 2>&1 |
rg 'common_debug_cb_eval: +(attn_norm|ffn_inp|ffn_norm|ffn_out|l_out)-(0|1) ='

为了突出数据流,下面只保留了节点名称、输入关系和 shape:

1
2
3
4
5
6
7
8
9
10
11
attn_norm-0                                   = {768, 2}
ffn_inp-0 = ADD(...{768, 2}, inpL{768, 2}) = {768, 2}
ffn_norm-0 = {768, 2}
ffn_out-0 = {768, 2}
l_out-0 = ADD(ffn_out-0,
ffn_inp-0) = {768, 2}

attn_norm-1 = {768, 2}
ffn_inp-1 = ADD(...{768, 2}, l_out-0{768, 2}) = {768, 2}
...
l_out-1 = {768, 2}

这些节点是 llama-eval-callback 输出的运行时中间 Tensor,不是前面 blk.0.attn_norm.weightblk.0.attn_norm.bias 这类模型参数。节点名中的 -0-1 分别表示 Block 0 和 Block 1,它们与原理概念的对应关系见上一节表格。ADD 输入中的 ...{768, 2} 表示没有独立回调节点名的 Attention 更新,这里只保留其 shape。

GGML 将 Hidden 维度放在前面,因此 {768,2} 对应原理部分的 [2,768]ffn_inp-0l_out-0 分别是两次 Residual Add 的实际结果,参与相加的 Tensor 与结果都保持相同 shape。Block 0 的 l_out-0 又出现在 Block 1 第一次 Residual Add 的输入中,直接验证了“当前层输出成为下一层输入”。

小结

本文介绍了 Hidden State 在 GPT-2 Block 中的更新过程:

  • Hidden State 以 [T,D] 的 shape 流过 Block;Attention 和 MLP 会更新其中的表示,但不会改变 Token 数量和 Hidden size。
  • Attention 和 MLP 都采用 Pre-Norm 和 Residual Connection:先对输入进行 LayerNorm,再由子层计算更新,最后与原状态逐元素相加。
  • GPT-2 Small 串联了 12 个计算骨架相同、参数各自独立的 Block;每个 Block 的输出都会成为下一个 Block 的输入。

在上一篇《文本如何变成 Token ID?》中,How are 经过分词后得到 Token ID 序列 [2437, 389]。但 Token ID 只是词表中的整数编号,不能直接参与 GPT-2 的向量和矩阵计算。

本文先打开 GPT-2 的模型黑盒,了解 Embedding(嵌入)的位置;再说明 Token Embedding 与 Position Embedding 如何组成 Initial Hidden State(初始隐藏状态);最后用 llama.cpp 查看实际参数和计算结果。

GPT-2 内部的整体流程

GPT-2 接收 Token ID 序列,依次经过 Embedding、12 个 Transformer Block 和 LM Head,最终得到 logits:

GPT-2 从 Token ID 到 logits 的内部流程

本文聚焦图中蓝色的 Embedding。为简化整体流程,图中未单独画出 Final LayerNorm;采样位于 GPT-2 模型之外,将在后续文章中介绍。

从 Embedding 到 Initial Hidden State

GPT-2 分别根据 Token ID 和 Position ID 查询两张 Embedding Table,再将查到的向量逐元素相加,得到 Initial Hidden State,也就是第一个 GPT-2 Block 的输入:

Token Embedding 与 Position Embedding 相加得到 Initial Hidden State

图中的符号含义如下:

符号 含义 GPT-2 Small / 本文示例
V 词表大小 50257
D Hidden / Embedding size 768
C 最大位置数 1024
T 当前输入的 Token 数量 2

Token Embedding

Token ID 是 Token 在词表中的整数编号。例如:

1
2
"How"  → 2437
" are" → 389

数字 2437 的大小不表示 "How" 的语义,也不能把相邻的 Token ID 理解为语义相近。Token ID 在模型中的作用更像数组下标:模型用它从 Token Embedding Table 中取出一个向量。

Embedding Table 与查表

设 Token Embedding Table 为 E_token,它包含 V 行,每行 D 个数。对于 [2437, 389],模型取出编号为 2437389 的两行,按输入顺序组成 [2, 768] 的 Token Embedding:

根据 Token ID 取出参数表中的对应行,按输入顺序组成 Token Embedding

设第 i 个 Token ID 为 id_i,查表过程可以写成:

1
X_token[i] = E_token[id_i]

这个过程称为 Embedding Lookup(Embedding 查表)。

两种 shape 表示

shape(形状)表示 Tensor(张量)各个维度的大小。本文原理部分与 llama.cpp / GGML 使用不同的维度顺序,同一个 Token Embedding 可表示为:

表示方式 维度顺序 通用 shape How are 示例
本文原理部分 Token 数量、Hidden 维度 [T, D] [2, 768]
llama.cpp / GGML Hidden 维度、Token 数量 {D, T} {768, 2}

Position Embedding

Token Embedding 只由 Token ID 决定。同一个 Token 无论出现在第几个位置,都会查到同一个 Token Embedding 向量。

但文本顺序会影响含义。例如:

1
2
dogs chase cats
cats chase dogs

两个句子包含相同的单词,但顺序不同,追逐者和被追逐者也随之交换。

为了让模型知道每个 Token 位于序列中的什么位置,GPT-2 还会查询 Position Embedding Table。对于同一个 Token ID id,它出现在位置 0 和位置 2 时,Token Embedding 相同,但加入的 Position Embedding 不同:

1
2
E_token[id] + E_position[0]
E_token[id] + E_position[2]

因此,同一个 Token 出现在不同位置时,会得到不同的 Initial Hidden State。

GPT-2 Small 的 Position Embedding Table 包含 1024 个位置,每个位置对应一个 768 维向量,即 [C, D] = [1024, 768]。根据 T 个 Position ID 查表,得到 [T, D] 的 Position Embedding。

How are 是一段没有历史上下文的新输入,因此两个 Token 的位置编号依次是 01

输入位置 Token ID Token Position ID
0 2437 How 0
1 389 are 1

Position ID 和 Token ID 是两套不同的编号:

  • Token ID 用来查询 Token Embedding Table,表示“当前是什么 Token”。
  • Position ID 用来查询 Position Embedding Table,表示“当前位于哪里”。

GPT-2 的 Position Embedding 通过训练得到,并使用绝对位置编号。这里的“绝对”表示每个位置使用自己的编号,例如 0、1、2

Initial Hidden State

Token Embedding 和 Position Embedding 的 shape 相同,GPT-2 将它们逐元素相加:

1
2
3
Token Embedding        [T, D]
+ Position Embedding [T, D]
= Initial Hidden State [T, D]

对序列中的第 i 个位置:

1
X_0[i] = E_token[id_i] + E_position[pos_i]

下面用 3 维向量演示相加过程。数值仅用于说明,不是 GPT-2 的真实参数:

1
2
3
4
Token Embedding       [ 0.20, -0.10,  0.50]
Position Embedding [ 0.01, 0.02, -0.03]
---------------------
Initial Hidden State [ 0.21, -0.08, 0.47]

GPT-2 Small 实际使用 768 维向量。对于包含两个 Token 的 How are,相加前后的 shape 都是 [2, 768]

为什么选择相加?

相加可以理解为给 Token 向量加上一个由位置决定的偏移。同一个 Token 出现在不同位置时,就会得到不同的输入表示。

两张 Embedding Table 和后续网络会在训练中共同调整,让模型学会利用相加后的表示预测下一个 Token。相加是一种简单的组合方式,同时还能保持向量维度不变。

Embedding 参数从哪里来

Token Embedding Table 和 Position Embedding Table 都是训练得到的模型参数。推理框架从模型文件中加载它们,用于查表。

从训练到查表的过程可以概括为:

1
2
3
4
5
6
7
训练得到的两张 Embedding Table
↓ 保存
模型文件
↓ 加载
运行时参数 Tensor
↓ 按 Token ID / Position ID 查表
Token Embedding / Position Embedding

本文实战使用的模型文件格式是 GGUF。转换为 GGUF 后,GPT-2 中的两个 Embedding 参数对应为:

1
2
transformer.wte → token_embd.weight
transformer.wpe → position_embd.weight

GGUF 用元数据保存模型架构和配置,用 Tensor 保存 Embedding Table 等训练参数。

llama.cpp 实战

下面使用 llama.cpp b10435 和 GPT-2 Q8_0 模型,查看 GGUF 中的 Embedding 参数、运行时操作与 shape,以及实际 Tensor 数值。

基础环境准备见《LLM 如何逐个生成 Token?》gguf-dump 的安装方式见《文本如何变成 Token ID?》

进入 llama.cpp 目录,设置模型路径,并构建本篇使用的工具:

1
2
3
4
5
6
7
cd llama.cpp

GPT2_MODEL=models/QuantFactory/gpt2-GGUF/gpt2.Q8_0.gguf

cmake --build build \
--target llama-eval-callback \
--config Release -j

查看 GGUF 中的 Embedding 参数

模型文件名中的 Q8_0 表示部分参数采用 Q8_0 量化格式保存,具体 Tensor 的类型仍需查看 GGUF。

使用 gguf-dump 读取模型元数据和两个 Embedding Tensor,再用 jq 只保留本篇需要的字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.venv/bin/gguf-dump "$GPT2_MODEL" --json |
jq '{
model: {
architecture: .metadata["general.architecture"].value,
blocks: .metadata["gpt2.block_count"].value,
context: .metadata["gpt2.context_length"].value,
embedding: .metadata["gpt2.embedding_length"].value
},
tensors: {
token_embedding: (
.tensors["token_embd.weight"] | {shape, type}
),
position_embedding: (
.tensors["position_embd.weight"] | {shape, type}
)
}
}'

整理后的输出如下(只调整了 JSON 的换行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"model": {
"architecture": "gpt2",
"blocks": 12,
"context": 1024,
"embedding": 768
},
"tensors": {
"token_embedding": {
"shape": [768, 50257],
"type": "Q8_0"
},
"position_embedding": {
"shape": [768, 1024],
"type": "F32"
}
}
}

这份静态 GGUF 数据验证了前面的模型配置:

  • token_embd.weight 的 GGML shape 是 {768,50257},数学语义是 [50257,768]
  • position_embd.weight 的 GGML shape 是 {768,1024},数学语义是 [1024,768]
  • Token Embedding Table 使用 Q8_0,Position Embedding Table 使用 F32,说明同一 GGUF 文件中的 Tensor 可以使用不同类型。

查看 Embedding 的计算过程与 shape

gguf-dump 用于查看静态参数,llama-eval-callback 则用于观察运行时 Tensor。下面以 How are 为输入,筛选 Embedding 阶段的三个节点:

1
2
3
4
./build/bin/llama-eval-callback \
-m "$GPT2_MODEL" \
--prompt 'How are' 2>&1 |
rg 'common_debug_cb_eval: +(embd|pos_embd|inpL)'

整理后的关键输出如下:

1
2
3
4
5
6
7
8
9
common_debug_cb_eval: embd = (f32) GET_ROWS(
token_embd.weight{768, 50257, 1, 1},
inp_tokens{2, 1, 1, 1}) = {768, 2, 1, 1}
common_debug_cb_eval: pos_embd = (f32) GET_ROWS(
position_embd.weight{768, 1024, 1, 1},
leaf_5{2, 1, 1, 1}) = {768, 2, 1, 1}
common_debug_cb_eval: inpL = (f32) ADD(
embd{768, 2, 1, 1},
pos_embd{768, 2, 1, 1}) = {768, 2, 1, 1}

其中 leaf_5 对应 Position ID 输入。

这三个节点分别对应:

  1. GET_ROWS(token_embd.weight, inp_tokens) 根据 [2437, 389] 查询 Token Embedding。
  2. GET_ROWS(position_embd.weight, leaf_5) 根据 [0, 1] 查询 Position Embedding。
  3. ADD(embd, pos_embd) 把两个 {768,2} Tensor 相加,得到 {768,2}inpL

inpL 就是进入第一个 GPT-2 Block 的 Initial Hidden State。日志还显示,查表结果 embd 的类型是 F32。

查看 Embedding 与 Initial Hidden State 的数值

llama-eval-callback 还会在节点信息后打印 Tensor 数值。下面使用 -A 6 保留每个目标节点后面的六行:

1
2
3
4
5
./build/bin/llama-eval-callback \
-m "$GPT2_MODEL" \
--prompt 'How are' 2>&1 |
rg -A 6 --context-separator '' \
'common_debug_cb_eval: +(embd|pos_embd|inpL) ='

整理后的数值部分如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
embd:
[
[-0.0483, 0.0604, 0.0587, ..., 0.1476, 0.0712, 0.0738],
[ 0.0760, 0.0564, 0.0491, ..., 0.0599, -0.0559, 0.0839]
]

pos_embd:
[
[-0.0188, -0.1974, 0.0040, ..., -0.0430, 0.0283, 0.0545],
[ 0.0240, -0.0538, -0.0948, ..., 0.0342, 0.0102, -0.0002]
]

inpL:
[
[-0.0671, -0.1370, 0.0627, ..., 0.1045, 0.0995, 0.1283],
[ 0.1000, 0.0026, -0.0458, ..., 0.0941, -0.0457, 0.0837]
]

每个 Tensor 都有两行,分别对应序列位置 0"How" 和位置 1" are"。每行实际包含 768 个值,调试输出只展示前 3 个和后 3 个。

不同计算后端的最后几位可能略有差异,但不影响这里的逐元素相加关系。

对照 Embedding 源码

GPT-2 在 load_arch_tensors() 中声明两个参数 Tensor:

1
2
3
4
5
6
7
tok_embd = create_tensor(
tn(LLM_TENSOR_TOKEN_EMBD, "weight"),
{n_embd, n_vocab}, 0);

pos_embd = create_tensor(
tn(LLM_TENSOR_POS_EMBD, "weight"),
{n_embd, n_ctx_train}, 0);

代入当前 GPT-2 Small 的配置:

1
2
tok_embd → {768, 50257}
pos_embd → {768, 1024}

llama_model_gpt2::graph::graph() 中,Embedding 主路径是:

1
2
3
4
5
6
7
inpL = build_inp_embd(model.tok_embd);

ggml_tensor * inp_pos = build_inp_pos();

pos = ggml_get_rows(ctx0, model.pos_embd, inp_pos);

inpL = ggml_add(ctx0, inpL, pos);

其中,通用的 build_inp_embd() 为 Token ID 创建输入 Tensor,再构造查表节点:

1
2
3
4
inp->tokens = ggml_new_tensor_1d(
ctx0, GGML_TYPE_I32, ubatch.n_tokens);

cur = ggml_get_rows(ctx0, tok_embd, inp->tokens);

将源码与运行结果对应起来:

1
2
3
4
5
6
7
8
9
10
inp_tokens {2}
→ ggml_get_rows(token_embd.weight)
→ embd {768,2}

positions {2}
→ ggml_get_rows(position_embd.weight)
→ pos_embd {768,2}

embd {768,2} + pos_embd {768,2}
→ inpL {768,2}

这里的 ggml_get_rows()ggml_add() 用于构造计算图节点,数值计算在执行计算图时完成。llama-eval-callback 展示的是节点执行后的 Tensor 类型、shape 和部分数值。

小结

本文以 [2437, 389] 为例,说明了 Token ID 如何变成 GPT-2 的输入向量:

  • Token ID 只是词表中的整数编号;GPT-2 将它作为下标,查询训练得到的 Token Embedding Table。
  • GPT-2 同时根据 Position ID 查询 Position Embedding,为每个 Token 加入位置信息。
  • Token Embedding 与 Position Embedding 逐元素相加,得到 Initial Hidden State。
0%