☰
深入解析GPT-2核心组件:多头注意力、LayerNorm与MLP的C实现
2026/10/26 4:48:13 网站建设 项目流程

1. 从 llm.c 小书第三篇说起:为什么我们要死磕 GPT-2 的每一行

很多人第一次看到 llm.c 这个项目时的反应都差不多:一个用纯 C 语言写的、不依赖 PyTorch 的大模型训练与推理实现,代码量不大,却能把 GPT-2 完整跑起来。前两篇小书我们聊了环境搭建、张量内存布局、前向传播的整体骨架,到了第三篇,就绕不开整个 Transformer 里最“玄学”也最核心的部分——注意力机制,以及它周围那几块看似不起眼、实则决定训练能否收敛的组件:LayerNorm和MLP。

我写这一系列笔记的初衷很简单:现在大多数人调模型都是import torch然后model.train(),一旦 loss 不降就抓瞎,根本不知道梯度在内部经历了什么。而 llm.c 这种“把每一层都摊开给你看”的实现,恰好是补课的最佳材料。GPT-2 的结构其实不复杂,堆叠的 decoder block 里就三样东西:多头自注意力、LayerNorm、前馈网络(也就是 MLP)。把这三块吃透,你再看任何现代大模型的结构图,都会有一种“哦,还是这套东西换了个马甲”的淡定。

这篇内容适合两类人:一类是已经能跑通 llm.c、但对着attention那几十行 C 代码发懵的实践者;另一类是想从零理解 GPT-2 内部机理、又不想被框架封装挡住视线的好奇者。我会把多头注意力机制的原理、LayerNorm 的归一化细节、MLP 的通道扩张逻辑,以及它们在 C 代码里怎么落地,一层层拆开讲。中间会穿插我自己踩过的坑,比如为什么 LayerNorm 的 epsilon 不能乱设、为什么注意力缩放因子是1/sqrt(head_dim)而不是别的、为什么 MLP 要先升维再降维。这些细节,才是决定你“跑得起来”还是“跑得对”的分水岭。

2. 多头自注意力机制原理:把一句话拆成多个视角去看

2.1 从单头到多头:为什么要“多此一举”

先说单头注意力。给定一个序列的输入表示,我们通过三个线性变换得到 Query、Key、Value 三组向量。核心动作就一个:用 Query 和所有 Key 做点积,算出每个位置对当前位置的“关注程度”,softmax 归一化后,再对 Value 加权求和。用一句话概括就是——当前位置的输出,是所有位置信息按相关性强弱加权混合的结果。

那为什么还要多头?我一开始也觉得这是不是有点“为了复杂而复杂”。后来在 llm.c 里逐行对照才想明白:单个注意力头只能学到一种“相关性模式”。比如一个头可能专注于捕捉主谓关系,另一个头可能专注于捕捉指代关系,还有一个头可能在看相邻词。如果只有一个头,softmax 之后只有一组权重分布,模型就被迫把所有关系压缩到同一个表示空间里,表达能力受限。

多头自注意力机制的做法是:把embedding_dim切成num_heads份,每一份独立做一次注意力计算,最后把结果拼接起来再过一个输出投影。这样每个头在自己的子空间里学习不同的关注模式,模型整体的表达能力就上来了。GPT-2 small 用的是 12 个头,每个头维度 64,总 embedding 维度 768。这个 768 = 12 × 64 的关系,在 C 代码里体现为内存布局上的切分,非常关键。

2.2 缩放因子 1/sqrt(d_k):一个容易被忽略的数值稳定细节

点积注意力的公式里有个缩放因子,写出来是:

Attention(Q, K, V) = softmax(Q·K^T / sqrt(d_k)) · V

很多人背公式时会把sqrt(d_k)直接略过,觉得它无关紧要。但我在 llm.c 里做数值对照实验时发现,去掉这个缩放,softmax 的输出会变得极其“尖锐”——几乎所有的概率质量都集中在一个位置上,梯度接近于零,训练直接卡死。

原因其实不复杂:假设 Q 和 K 的每个分量都是均值 0、方差 1 的独立随机变量,那么它们点积之后的方差就是d_k。当d_k = 64时,点积结果的量级大概在 ±8 左右,再大一点就会到 ±20。softmax 对输入的尺度非常敏感,输入值一大,指数运算就会把大的更大、小的趋近于零,分布退化成 one-hot。除以sqrt(d_k)相当于把方差重新拉回到 1 附近,让 softmax 工作在一个“温和”的区间里。

提示:这个缩放因子不是可学习的参数,是固定的。在 C 实现里通常预先算好1.0f / sqrtf(head_dim)存成一个常量,避免每次前向都做一次开方。

2.3 因果掩码:GPT 为什么不能“偷看未来”

GPT 是自回归模型,预测第 t 个 token 时只能看到前 t 个位置,不能看到后面的。这个约束在注意力里靠因果掩码实现:在 softmax 之前,把上三角部分(即 j > i 的位置)的注意力分数设成一个极大的负数,softmax 之后这些位置的概率就变成 0。

llm.c 里这块处理得很直接,通常是在计算完Q·K^T之后,用一个循环把非法位置填成-INFINITY或者一个足够大的负数(比如-1e9)。这里有个坑:如果你用-INFINITY,某些平台上exp(-inf)会返回 NaN,导致整个 softmax 崩掉。我实测下来,用-1e9这种“足够负但有限”的值更稳,因为exp(-1e9)在 float32 下就是 0,不会产生 NaN。

另一个细节是掩码的时机。必须在 softmax 之前加,不能 softmax 之后再乘一个 0/1 矩阵。因为 softmax 是全局归一化的,你先算完再置零,分母里已经包含了未来位置的贡献,结果就错了。这个错误很隐蔽,loss 会降但降不到应有的水平,很多人会误以为是学习率问题。

3. LayerNorm 在 GPT-2 里的位置与实现细节

3.1 Pre-LN 还是 Post-LN:一个影响训练稳定性的选择

原始 Transformer 论文用的是 Post-LN,也就是LayerNorm(x + Sublayer(x)),残差相加之后再归一化。但 GPT-2 用的是Pre-LN,即x + Sublayer(LayerNorm(x)),先归一化再进子层,残差连接保持“干净”。

这个改动看起来只是顺序调换,实际影响很大。Post-LN 在深层网络里梯度容易爆炸或消失,需要精心设计 warmup 策略才能训起来。Pre-LN 把归一化放在子层入口,梯度可以通过残差路径直接回传,训练稳定性明显更好,对 warmup 的依赖也小很多。llm.c 里实现的是 Pre-LN,你在看代码时会发现 LayerNorm 出现在注意力和 MLP 之前,而不是之后。

3.2 归一化的计算过程:均值、方差、缩放平移

LayerNorm 对每个 token 的 embedding 向量独立做归一化,不跨 batch、不跨序列位置。具体步骤是:

  1. 计算该向量所有元素的均值mean
  2. 计算方差var
  3. 归一化:(x - mean) / sqrt(var + eps)
  4. 缩放平移:gamma * normalized + beta

其中gamma和beta是可学习参数,维度等于 embedding 维度。eps是为了防止方差为 0 时除零,GPT-2 里通常取1e-5。

在 C 实现里,这一步需要两次遍历:第一次求均值和方差,第二次做归一化和缩放。也可以一次遍历用 Welford 算法在线更新,但 llm.c 为了代码清晰,用的是两次遍历。这里要注意数值精度:如果你用 float32 累加一个 768 维向量的平方和,误差还可以接受;但如果维度上千,最好用 double 累加或者 Kahan 求和,否则方差算出来偏差会累积。

注意:eps的位置是加在方差上,不是加在标准差上。写成sqrt(var + eps)和sqrt(var) + eps是两回事,后者在 var 很小时会产生巨大误差。我见过有人写错这一行,训练初期 loss 直接飙到 NaN。

3.3 为什么 LayerNorm 对 GPT-2 如此关键

有人会问,BatchNorm 不也能归一化吗?为什么 Transformer 系列清一色用 LayerNorm?核心原因是序列长度可变。BatchNorm 在 batch 维度上统计,不同样本的序列长度不一样,padding 会污染统计量;而且推理时如果 batch size 为 1,BatchNorm 的统计量根本没法算。LayerNorm 对每个样本独立处理,与 batch 大小、序列长度都无关,天然适配 NLP 场景。

另外,LayerNorm 的缩放平移参数让网络在归一化之后仍能恢复一定的表达能力。如果只有归一化没有 gamma/beta,相当于强制每层输出都是标准正态,会损失信息。这两个参数虽然少,但不可或缺。

4. MLP 前馈网络:注意力之后的“思考”环节

4.1 先升维再降维:4 倍扩张的由来

GPT-2 每个 block 里,注意力之后接一个 MLP。结构是两层线性变换加一个激活函数:

MLP(x) = Linear2(GELU(Linear1(x)))

其中Linear1把维度从 768 升到 3072(4 倍),Linear2再降回 768。这个 4 倍扩张比例是原始 Transformer 论文定下来的,后续大部分模型都沿用。为什么是 4 倍?业界的普遍解释是:注意力负责在序列维度上混合信息,MLP 负责在每个位置的特征维度上做非线性变换。升维到 4 倍,相当于给模型一个更宽的特征空间去做非线性组合,表达能力强很多;如果保持同维,MLP 的容量就不够,模型性能会明显下降。

我在 llm.c 里做过一个小实验:把扩张比例从 4 改成 2,参数量少了,训练 loss 下降明显变慢,最终收敛水平也差一截。改成 8 呢,参数量翻倍,收益却不成比例。所以 4 倍是个性价比很高的经验值。

4.2 GELU 激活:比 ReLU 更平滑的选择

GPT-2 用的激活函数是 GELU,不是 ReLU。GELU 的公式是x * Φ(x),其中 Φ 是标准正态分布的累积分布函数。实际实现里常用近似:

GELU(x) ≈ 0.5 * x * (1 + tanh(sqrt(2/π) * (x + 0.044715 * x^3)))

相比 ReLU 在负半轴直接截断为 0,GELU 在负半轴有一个平滑的过渡,梯度不会突然消失。这个平滑性对深层网络的训练稳定性有帮助。在 C 里实现 GELU,tanh和pow都是标准库函数,但要注意pow(x, 3)比x*x*x慢不少,llm.c 里一般直接写成乘法。

提示:如果你在移植到其他平台时发现没有tanh,可以用(exp(2x)-1)/(exp(2x)+1)替代,但要注意 x 很大时exp会溢出,需要做截断保护。

4.3 MLP 与注意力的分工:一个负责“看”,一个负责“想”

我习惯把注意力理解成“信息收集”,MLP 理解成“信息加工”。注意力让每个位置从其他位置拿信息,MLP 则对拿到的信息做深度处理。两者交替堆叠,模型就能既看到全局上下文,又有足够的非线性容量去建模复杂关系。

从参数量看,MLP 其实占了每个 block 的大头。以 GPT-2 small 为例,注意力的 QKV 投影加输出投影大概是4 * 768 * 768 ≈ 2.36M参数,而 MLP 是768*3072*2 ≈ 4.72M,正好是注意力的两倍。所以你说 MLP 是“配角”,那是低估它了,它才是参数量的主力。

5. 在 llm.c 里落地:从公式到 C 代码的关键步骤

5.1 内存布局:为什么 head 的切分方式很重要

llm.c 里张量通常是一维数组加形状描述。对于注意力,Q、K、V 的形状是[batch, seq_len, num_heads, head_dim]。这个布局决定了你在做多头计算时,怎么用指针偏移去访问每个头的数据。

常见做法是把num_heads * head_dim展平,然后第 h 个头的 Q 就是从h * head_dim开始、长度head_dim的一段。这样每个头的计算是连续的,缓存友好。如果你把布局搞成[batch, num_heads, seq_len, head_dim],虽然逻辑上更直观,但跨头访问时步长变大,性能会受影响。llm.c 选择了前者,牺牲一点可读性换性能,这是 C 实现的典型取舍。

5.2 前向传播的完整流程

一个 block 的前向大致是:

  1. 保存输入x作为残差
  2. x = LayerNorm(x)
  3. 计算 Q、K、V 投影
  4. 对每个头做缩放点积注意力,加因果掩码
  5. 拼接多头输出,过输出投影
  6. x = 残差 + 注意力输出
  7. 保存x作为新残差
  8. x = LayerNorm(x)
  9. 过 MLP(升维、GELU、降维)
  10. x = 残差 + MLP输出

每一步都要保存中间结果,因为反向传播需要。llm.c 里会预先分配好这些中间缓冲区,避免运行时频繁 malloc。这个“预分配”思路在 C 实现里很常见,也是它比动态图框架快的原因之一。

5.3 反向传播里最容易出错的地方

反向传播这块,我踩过的坑主要集中在 LayerNorm 和 softmax 的梯度上。

LayerNorm 的反向涉及均值、方差、归一化值三者的梯度耦合,公式比较长。一个常见的错误是忘记gamma的梯度,或者把beta的梯度算错。我的建议是先用数值梯度检验(有限差分)验证一遍,确认无误再上大规模训练。

Softmax 的反向有个经典陷阱:当某个位置的 softmax 输出接近 0 或 1 时,梯度会非常小,容易下溢。llm.c 里通常会在 softmax 前减去最大值(max subtraction)来提升数值稳定性,这个技巧在反向时也要对应处理。

注意:因果掩码位置在反向传播时梯度必须为 0。如果你在前向用-1e9掩码,反向时这些位置的梯度自然接近 0,但最好显式置零,避免累积误差。

6. 常见问题与排查技巧实录

6.1 训练 loss 不降或震荡,怎么定位

遇到 loss 不降,我一般按这个顺序排查:

排查项可能原因验证方法
学习率太大导致震荡,太小导致不降试 1e-4、3e-4、1e-3 三档
LayerNorm eps太小导致除零,太大导致归一化失效打印方差最小值,确认 eps 合理
注意力缩放忘记除以 sqrt(d_k)检查 softmax 输出是否过于尖锐
因果掩码掩码时机错误或值不当检查掩码后 softmax 上三角是否为 0
梯度爆炸深层网络梯度累积打印每层梯度范数

这个表是我自己调试时总结的,基本能覆盖八成问题。剩下两成通常是数据问题或者初始化问题。

6.2 数值精度问题:float32 够不够

GPT-2 small 用 float32 训练是够的,但要注意几个敏感点:LayerNorm 的方差累加、softmax 的指数运算、以及损失函数的 log。如果发现 loss 偶尔出现 NaN,优先检查这几处。混合精度训练时,LayerNorm 和 softmax 通常要保持 float32,否则精度损失会很明显。

6.3 多头注意力的头数选择

头数不是越多越好。头数增加,每个头的维度就减小,单头的表达能力下降。GPT-2 small 用 12 头、每头 64 维,是个平衡点。如果你把头数加到 16,每头只有 48 维,性能反而可能下降。经验法则是保持每头维度在 64 左右,头数根据总维度调整。

6.4 实操心得:先小后大,先单层后多层

我强烈建议在调试 llm.c 时,先用 1 层、小维度(比如 128)、短序列(比如 16)跑通,确认前向反向都对,再逐步放大。直接上完整 GPT-2 调试,一旦出错你根本不知道是哪一层的问题。这个“缩小规模验证”的思路,在实现任何复杂模型时都适用。

另外,把中间张量的统计量(均值、方差、最大值、最小值)打印出来,和 PyTorch 的参考实现逐层对比,是定位问题最快的方法。llm.c 本身不提供这些打印,你需要自己加,但这几行代码能省你几天时间。

7. 注意力机制的变体与延伸思考

7.1 从自注意力到通道注意力、时序注意力

多头自注意力机制的核心思想是“按相关性加权”,这个思想可以迁移到很多场景。通道注意力机制(比如 SE 模块)就是在通道维度上做类似的事:给每个通道算一个权重,然后加权。时序注意力机制则是在时间维度上做加权,常用于序列预测。理解了 QKV 这套框架,再看这些变体,你会发现它们只是换了“在哪个维度上算相关性”。

7.2 效率优化:KV Cache 与稀疏注意力

GPT-2 推理时,如果每次都重新算所有位置的 K、V,计算量会随序列长度平方增长。KV Cache 的思路是缓存已算过的 K、V,新 token 只算自己的 Q,然后和缓存的 K、V 做注意力。llm.c 的推理部分就实现了这个优化,能显著降低长序列的延迟。

稀疏注意力则是另一个方向:不是所有位置都互相看,只保留一部分连接。这在超长序列场景下很有用,但会损失一部分表达能力,需要根据任务权衡。

7.3 我个人的一点体会

写 llm.c 小书这一系列,最大的收获不是学会了用 C 写 Transformer,而是被迫把每个公式都推导了一遍、每个数值都验证了一遍。以前用框架时觉得理所当然的东西,比如 LayerNorm 的 eps、注意力的缩放、GELU 的近似,自己实现时才发现每一个都有讲究。这种“从抽象到具体”的过程,对理解模型本质帮助极大。

如果你也在啃 llm.c,我的建议是别急着跑通就完事,把每个中间结果打印出来,和参考实现对一遍。对上了,你是真懂了;对不上,那正是你该花时间的地方。注意力机制、LayerNorm、MLP 这三块吃透,GPT-2 对你来说就不再是黑盒了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询