跟着LAP啥都学 跟着LAP啥都学

第 4 章 组装 GPT-2

      +

      本章导读:将第 3 章的多头注意力装配进 Transformer 块,堆叠 12 层得到 完整的 GPT-2 架构,并核算其参数构成——零件已在第 2、3 章备齐,本章负责总装。

      学完本章你将能够:

      1. 说明 GPT_CONFIG 七个超参的含义与出处;

      2. 实现 LayerNorm、GELU、FeedForward 与快捷连接,并解释各自的设计理由;

      3. 组装 TransformerBlock 与 GPTModel,理解「形状守恒」的意义;

      4. 独立核算 124M 参数的每一项构成。

      产出与代码:chapters/chapter04_gpt/(include/gpt.h、src/main.cpp; 注意力复用第 3 章 attention.h,分词器复用第 2 章 bpe_tokenizer.h)。

      4.1 为什么从 GPT-2 学起:七个超参

      组装 LLM 要回答的第一个问题不是「怎么写代码」,而是「参照哪个模型」。 候选是 GPT-2 与 GPT-3:前者最大 15 亿参数,后者 1750 亿。按规模直觉似乎 应该选后者,但学习实现的判断依据不是规模,而是三个条件:架构是否一致、 权重是否可得、硬件是否负担得起。逐项对比:

      维度 GPT-2 GPT-3

      架构

      基准架构

      与 GPT-2 基本相同

      参数量

      最大 15 亿

      1750 亿

      权重公开

      ✅ 已公开(第 5 章要加载)

      ❌ 截至本教程撰写时未公开

      运行条件

      单台笔记本即可

      需 GPU 集群(单张 V100 训练需 355 年)

      三个条件中,GPT-3 只占第一个:其架构与 GPT-2 基本相同,最大的差别只是 规模。在小模型上学会的实现,可以原样迁移到大模型;而「权重公开」与 「单机可跑」两条,只有 GPT-2 满足。因此本教程从 GPT-2 学起。

      一个考据细节:GPT-2 论文标注的参数量是 1.17 亿,后来被修正为 1.24 亿。 本教程采用修正值,4.7 节的参数核算会回到这个数字。

      参数(parameter)是模型中可训练的权重,训练中通过调整它们来最小化 损失函数——一个 2048×2048 的权重矩阵就是 419 万个参数。本章的任务是 把这样的参数组织成一个 1.24 亿级的架构。定义它只需要七个超参:除 n_layers 是本章引入的新成员外,其余六个都 来自第 2、3 章:

      struct GPTConfig {
          int64_t vocab_size = 50257;       // BPE 词表(第 2 章)
          int64_t context_length = 1024;    // 最大可观察的词元数
          int64_t emb_dim = 768;            // 每个词元的嵌入向量维度
          int64_t n_heads = 12;             // 注意力头数(第 3 章)
          int64_t n_layers = 12;            // Transformer 块数
          double drop_rate = 0.1;           // dropout 比例
          bool qkv_bias = false;            // Q/K/V 投影是否加偏置(GPT-2 官方为 true,本教程取 false)
      };

      跨章约定:qkv_bias。本章沿用现代 LLM 的通行做法取 false;第 5 章 加载 OpenAI 官方权重前必须改回 true——官方权重包含 Q/K/V 偏置,不改则 张量形状对不上。现在记住这条约定,第 5 章遇到形状不匹配的报错时,先检查它。

      实现策略是「先搭骨架,再换真件」:先写一个 DummyGPTModel,层归一化 与 Transformer 块都用空壳占位,但完整数据流(六步 forward:词元嵌入
      位置嵌入 → 相加 → dropout → N 个块 → 最终归一化 → 输出头)先跑通, 输入输出形状锁定为 [2, 4, 50257]。真实版只把三个占位换成真件,forward 代码一行不改。本教程的 C++ 版直接从真件讲起,但「先锁形状、再填内容」的 做法值得记住:骨架始终可运行,零件换错时能立刻定位到刚改的那一处。

      本章的组装流水线——先造零件,再总装:

      Diagram

      4.2 层归一化(LayerNorm)

      深层网络训练有两大障碍:梯度消失与梯度爆炸,表现为训练不稳定、loss 曲线 迟迟不降。层归一化(LayerNorm)的对策是把每层的激活值强行调整成 均值 0、方差 1,以稳定各层输入的分布。GPT-2 及现代 Transformer 把它用在 多头注意力和 FFN 之前,以及最终输出层之前。

      计算分两步。先沿特征维 d 算每个样本自身的均值和方差:

      \[\mu = \frac{1}{d}\sum_{k=1}^{d} x_k, \qquad \sigma^{2} = \frac{1}{d}\sum_{k=1}^{d} (x_k - \mu)^{2}\]

      再标准化,并用两个可训练参数做仿射还原(γ 对应代码里的 scale, β 对应 shift,ε 防止除零):

      \[\mathrm{LayerNorm}(x) = \gamma \odot \frac{x - \mu}{\sqrt{\sigma^{2} + \varepsilon}} + \beta\]
      class LayerNormImpl : public torch::nn::Module {
      public:
          explicit LayerNormImpl(int64_t emb_dim) {
              scale_ = register_parameter("scale", torch::ones(emb_dim));
              shift_ = register_parameter("shift", torch::zeros(emb_dim));
          }
          torch::Tensor forward(torch::Tensor x) {
              auto mean = x.mean(-1, /*keepdim=*/true);
              auto var  = x.var({-1}, /*unbiased=*/false, /*keepdim=*/true);  // 有偏方差!
              auto norm_x = (x - mean) / torch::sqrt(var + eps_);             // eps_ = 1e-5
              return scale_ * norm_x + shift_;
          }
          torch::Tensor scale_, shift_;
          double eps_ = 1e-5;
      };

      这里有一个看似矛盾的设计:数据刚被归一化成均值 0 方差 1,代码里又乘上 scale、加上 shift 两个可训练参数——归一化不就被破坏了吗?恰恰 相反。归一化在稳定分布的同时,也抹掉了层原本学到的表示尺度。这两个 参数的初值是 1 和 0,此时输出与输入完全相同;训练中模型可以自行学习 各维度合适的缩放与偏移,需要就用,不需要就保持初值。

      四个实现细节需要逐一交代:

      • scale/shift 为什么可训练:如上——归一化抹掉了层学到的表示尺度, 这两个可训练参数(初值 1 和 0)让模型有能力恢复它;

      • 有偏方差(unbiased=false,分母用 n 而非 n−1):三条理由—— ① 嵌入维度很大(768),n 与 n−1 的差别可忽略;② 与 GPT-2 的归一化层 兼容;③ 原始 GPT-2 用 TensorFlow 实现,这是 TF 的默认行为。第三条属于 历史原因,但第 5 章要正确加载 OpenAI 预训练权重,必须与上游一致—— 在工程上,与上游一致常常比数学上的优雅更重要;

      • dim=-1 + keepdim=true 是固定搭配:三维张量 [batch, tokens, emb] 的特征维编号是 2,写 -1 统一表示「最后一维」,代码不必在 2D/3D 输入 之间切换;keepdim 保留长度为 1 的维度,结果才能与原张量广播相减;

      • eps(1e−5):加在方差上防止除零。

      LayerNorm vs BatchNorm:前者沿特征维度归一化(每个样本独立), 后者沿批次维度归一化(依赖批次统计量)。LLM 的 batch size 常受硬件与 用例限制,推理时甚至 batch=1;LayerNorm 不受批次大小影响,在分布式 训练与资源受限部署下更灵活稳定。LLM 一律采用 LayerNorm,原因就在这里。

      实测:随机正态输入经 LayerNorm 后每行 mean ≈ 0、var ≈ 1。下图展示上式 的实际效果——原始激活带均值与方差偏移,归一化后两者都被消除:

      04 layernorm effect
      Figure 1. 公式图像:LayerNorm 前后(同一条 12 维激活向量,沿特征维归一化)

      4.3 GELU 与 FeedForward

      GELU:负输入不全置零

      LLM 的激活函数有一条演进线:ReLU(传统标配,负输入置 0,0 点有尖锐 拐角)→ GELU(GPT-2 所用,结合高斯分布,平滑)→ SwiGLU(现代 LLM 常用,sigmoid 门控线性单元)。演进方向一致:曲线越平滑,优化越好做。

      ReLU 有一个已知缺陷:对负输入输出恒为 0,接收负输入的神经元不再参与 学习——这就是 dying ReLU。GELU(Gaussian Error Linear Unit,2016 年由 Hendrycks 与 Gimpel 提出)的处理更温和:对负输入输出小的非零值—— 这些神经元仍参与学习,只是贡献较小。

      GELU 的精确定义是 \(\mathrm{GELU}(x) = x \cdot \Phi(x)\),Φ 为标准高斯分布的累积分布函数。 直观理解:按输入的数值大小加权,而不是像 ReLU 那样按符号一刀切。 精确式与实际使用的 tanh 拟合近似如下——原始 GPT-2 就是用这个近似训练的:

      \[\mathrm{GELU}(x) = x \cdot \Phi(x) = \frac{x}{2}\left[1 + \mathrm{erf}\!\left(\frac{x}{\sqrt{2}}\right)\right]\]
      \[\mathrm{GELU}(x) \approx \frac{x}{2}\left[1 + \tanh\!\left( \sqrt{\tfrac{2}{\pi}} \left(x + 0.044715\, x^{3}\right)\right)\right]\]
      torch::Tensor forward(torch::Tensor x) {
          auto c = torch::sqrt(torch::tensor(2.0 / 3.14159265358979323846));
          return 0.5 * x * (1.0 + torch::tanh(c * (x + 0.044715 * torch::pow(x, 3))));
      }

      0.044715 不是物理常数,是拟合得到的经验系数。两条曲线几乎重合,但都与 ReLU 有本质区别:负区间不再硬性置零。

      数值对照:GELU(1.5) ≈ 1.3996,而 ReLU(1.5) = 1.5——GELU 在正区间略微 压低大值。与 ReLU 相比,GELU 的优势有两点:

      1. 平滑带来更好的优化:ReLU 在 0 点的尖锐拐角让优化变难,深度网络中 尤其明显;GELU 全程平滑,允许参数做更细微的调整;

      2. 负输入的神经元不死:ReLU 对负输入输出恒 0,神经元停止参与学习; GELU 输出小的非零值,这部分神经元仍能贡献梯度。

      04 gelu vs relu
      Figure 2. 公式图像:GELU(精确与 tanh 近似)与 ReLU 的对比

      FeedForward:先扩 4 倍,再缩回

      FeedForward(FFN)是逐位置的两层 MLP(Expand → GELU → Contract),是 Transformer 块中参数量最大的子模块,结构却只有两层:第一层把 768 维扩到 3072 维(正好 4 倍),过一层 GELU,第二层再缩回 768:

      explicit FeedForwardImpl(const GPTConfig& cfg) {
          layers = register_module("layers", torch::nn::Sequential());
          layers->push_back(torch::nn::Linear(cfg.emb_dim, 4 * cfg.emb_dim));  // 768 → 3072
          layers->push_back(GELU());
          layers->push_back(torch::nn::Linear(4 * cfg.emb_dim, cfg.emb_dim));  // 3072 → 768
      }

      「先扩 4 倍再缩回」的反瓶颈结构乍看是浪费,实际有两点理由:① 先扩展到 高维空间做非线性变换,再压缩回来——高维空间给了非线性变换更大的余地, 模型能表达更复杂的函数;② 输入输出维度一致,堆叠多层时不需要调整 维度。「维度不变」是本章反复出现的主题:FFN 保持 768、TransformerBlock 保持 768、整个 Transformer 栈保持 768,只有最后的输出头把 768 映射到 50257。形状不变,层才能任意堆叠——GPT-2 从 1.24 亿到 15 亿,结构代码不改 一行。写成公式(\(W_1\) 即「Expand」,\(W_2\) 即「Contract」):

      \[\mathrm{FFN}(x) = W_2\, \mathrm{GELU}(W_1 x + b_1) + b_2, \qquad W_1 \in \mathbb{R}^{4d \times d},\; W_2 \in \mathbb{R}^{d \times 4d}\]

      这里藏着一个 C++ 翻译陷阱:不能写成 Sequential(Linear(…​), GELU(), Linear(…​)) 的单表达式构造—— C++ 函数参数求值顺序未指定(GCC 从右向左),会导致第二个 Linear 先于第一个初始化,RNG 顺序与 Python 版对不上,数值验证全部失真。 它的隐蔽之处在于:代码能编译、能运行、输出形状完全正确,只是数值悄悄 错了。所以必须像上面那样逐个 push_back,用语句顺序强制求值顺序。这是 把 Python 代码翻译成 C++ 时最容易踩的坑之一。

      4.4 快捷连接:对抗梯度消失

      梯度消失指反向传播时梯度逐层变小,导致早期(浅层)网络难以有效 训练:网络加深本应带来更强的表达能力,浅层训不动,这份收益就拿不到。

      快捷连接(skip connection,又称残差连接,源自视觉领域的 ResNet)的 机制是:跳过一个或多个层,把一层的输出直接加到后续层的输出上——为 梯度提供一条更短的替代路径,反向传播时不必逐层衰减。

      main.cpp 的 demo_shortcut 用同一个 5 层 MLP 做了对照实验,梯度绝对值 均值对比(节选)如下:

      层           无快捷连接      有快捷连接     提升倍数
      layers.0     0.0002017      0.2217        ≈ 1099×
      layers.1     0.0001201      0.2069        ≈ 1723×
      layers.2     0.0007152      0.3290        ≈ 460×
      layers.4     0.0050496      1.3259        ≈ 263×

      浅层梯度相差 1099 倍:无快捷连接时,浅层梯度掉到 1e-4 量级,这正是梯度 消失;有快捷连接时,各层梯度稳定在 0.2–1.3,深浅层几乎一致。快捷连接 成为超大规模模型核心构建块的依据不是理论偏好,而是实测到的数量级 差距。

      实现上有一个细节:残差相加要求两端形状一致,x.shape == layer_output.shape 不满足时(如演示用的最后一层故意降维 3→1)自动跳过快捷连接。 Transformer 块的两个子块各配一条,见 4.5 节。

      4.5 TransformerBlock:两个子模块的分工

      前几节备齐了零件:LayerNorm、GELU、FFN、残差连接,加上第 3 章的多头 注意力。现在把它们装进一个盒子——Transformer 块,GPT-2 的标准构件。 理解它的钥匙是两个子模块的分工:

      • 多头注意力做跨位置的信息混合:识别输入序列中元素之间的关系,让第 3 个 词的表示里包含第 1 个词的信息;

      • 前馈网络做逐位置的特征变换:在每个位置上独立地对表示做深加工。

      两者缺一不可:只有注意力没有 FFN,词与词完成了交流,但每个位置的表达 深度不足;只有 FFN 没有注意力,每个位置的表达足够深,但位置之间不交换 信息。

      Pre-LN 版的块只讲两句话——归一化前置、残差相加收尾,注意力子块与前馈 子块各来一遍:

      \[x' = x + \mathrm{Attn}\!\left(\mathrm{LN}(x)\right), \qquad x'' = x' + \mathrm{FFN}\!\left(\mathrm{LN}(x')\right)\]

      对着公式读两遍,有两个最容易看错的地方:LN 在分支内部、残差加在 分支输出上——归一化作用于主干信号,被变换的是副本,加回去的也是 变换后的副本。与下面代码逐行对应:

      torch::Tensor forward(torch::Tensor x) {
          // 注意力子块:Pre-LayerNorm + 快捷连接
          auto shortcut = x;
          x = norm1->forward(x);
          x = att->forward(x);            // 第 3 章的 MultiHeadAttention
          x = drop_shortcut->forward(x);
          x = x + shortcut;               // 残差
      
          // 前馈子块:同样一套
          shortcut = x;
          x = norm2->forward(x);
          x = ff->forward(x);
          x = drop_shortcut->forward(x);
          x = x + shortcut;
          return x;
      }

      实测:输入 [2, 4, 768] → 输出 [2, 4, 768]。形状不变是 Transformer 块的 契约,也是「块可以无限堆叠」的前提:12 块摞起来是 GPT-2 small,48 块摞 起来是 GPT-2 xl,结构代码一个字不用改。物理维度不变,但内容被重新 编码:每个输出向量都整合了整个输入序列的上下文(即第 3 章的上下文 向量),且输出向量与输入向量一一对应——这使块能直接用于各种序列到序列 任务。

      Pre-LayerNorm vs Post-LayerNorm:归一化放在子层之前(GPT-2 与现代 Transformer,✅ 训练效果更好——主干信号不经子层变换)还是之后(最初的 Transformer,❌ 通常导致较差的训练效果)——本教程用 Pre-LN。块内完整 顺序要读准:Norm → 子层 → Dropout → 加残差——dropout 作用在分支 输出上,残差加在最后(不是 dropout 加在残差结果上)。这两者差一层 括号,语义完全不同。

      4.6 总装:GPTModel 与生成循环

      零件齐了,开始总装。GPTModel 的 forward 数据流固定六步:词元嵌入
      位置嵌入 → 相加 → dropout → 12 个 Transformer 块 → 最终归一化 → 输出头。前五步都已出现,最后一步是新的——输出头,把隐藏表示映射到 词表上:

      torch::Tensor forward_hidden(torch::Tensor in_idx) {   // [batch, seq_len] 词元 ID
          auto tok_embeds = tok_emb->forward(in_idx);        // (b, T, 768)  词元嵌入
          auto pos_embeds = pos_emb->forward(torch::arange(seq_len));  // (T, 768) 位置嵌入
          auto x = tok_embeds + pos_embeds;                  // 广播相加(第 2 章 2.7)
          x = drop_emb->forward(x);
          x = trf_blocks->forward(x);                        // 12 个 TransformerBlock
          x = final_norm->forward(x);
          return x;                                          // (b, T, 768) 隐藏表示
      }
      torch::Tensor forward(torch::Tensor in_idx) {
          return out_head->forward(forward_hidden(in_idx));  // (b, T, 50257) logits
      }

      输出头把 768 维隐藏表示映射到词表 V=50257,softmax 之后就是下一个词元的 概率分布——语言模型「预测下一个词」的全部数学就这一条:

      \[P(w \mid x_{1:T}) = \mathrm{softmax}\!\left(W_{\mathrm{out}}\, h_T\right)_w, \qquad W_{\mathrm{out}} \in \mathbb{R}^{V \times d}\]

      forward_hidden 与 forward 分离不是炫技:第 6 章分类微调要 替换输出头、只取隐藏表示接分类层——这个接口就是那时留下的。 总装时多留一个接口,微调时就少改一处结构。

      最后是生成循环 generate_text_simple——贪心解码,每轮固定四步: 截断上下文 → 无梯度前向 → 取最后位置 argmax → 拼回序列:

      for (int64_t i = 0; i < max_new_tokens; ++i) {
          auto idx_cond = idx.index({Slice(), Slice(-context_size, None)});  // 截断上下文
          torch::Tensor logits;
          { torch::NoGradGuard no_grad;
            logits = model->forward(idx_cond); }
          logits  = logits.index({Slice(), -1, Slice()});      // 只取最后一个位置的 logits
          auto idx_next = torch::argmax(torch::softmax(logits, -1), -1, /*keepdim=*/true);
          idx = torch::cat({idx, idx_next}, 1);                // 拼回序列,进入下一轮
      }

      循环里有两处防御性设计,外加一步推理前置操作,都为同一件事服务:让循环 能无限跑下去。

      • 上下文截断:idx_cond 在上下文超过 1024 时只取最后 1024 个词元—— 不截断会因位置嵌入越界而崩溃;

      • NoGradGuard:推理不需要梯度,省显存省计算;

      • eval 模式:生成前调用 model→eval(),禁用 dropout 等仅训练期使用 的随机组件,保证推理结果确定。

      这个 softmax 其实是冗余的——softmax 保持各分数的大小顺序, 所以对 softmax 后的概率取 argmax 与直接对 logits 取 argmax 结果完全相同。 这里保留它只是为了展示「logits → 概率」的完整过程。但注意:第 5 章引入 温度/Top-k 采样后,这步就不能省了——采样需要的是概率分布而非分数。

      第 5 章会把它升级为温度 + Top-k 采样;现在先用 argmax 最简版。

      4.7 参数核算:163M 与 124M

      总装的最后一件事是核对参数量。这里会先出现两个看似矛盾的数字:同一个 模型,一次打印 1.63 亿,另一次打印 1.24 亿。main.cpp 的 demo_gpt_model 输出完整账目,全部数字可用第 0 章 §F 的 named_parameters 遍历逐项核对:

      模块 参数量 说明

      tok_emb(词元嵌入)

      50257 × 768 = 38,597,376

      最大单项

      pos_emb(位置嵌入)

      1024 × 768 = 786,432

      12 × TransformerBlock

      12 × 7,085,568 = 85,026,816

      见下表

      final_norm

      2 × 768 = 1,536

      out_head(输出头)

      768 × 50257 = 38,597,376

      无偏置

      单个 TransformerBlock(7,085,568):

      子模块 参数量

      MHA:Q/K/V(无偏置)+ out_proj(含偏置)

      3×589,824 + 590,592 = 2,360,064

      FFN:768→3072 + 3072→768(各含偏置)

      2,362,368 + 2,360,064 = 4,722,432

      LayerNorm ×2

      2 × 1,536 = 3,072

      实测输出:

      Total number of parameters: 163,009,536            ← 全部参数(含输出头)
      Number of trainable parameters considering weight tying: 124,412,160
      Total size of the model: 621.83 MB                 ← 163,009,536 × 4 字节
      
      [练习 4.1] FeedForward 参数量 = 4,722,432, MultiHeadAttention 参数量 = 2,360,064
      [练习 4.2] 公式估算 vs 实际(124M): 163009536 (实际 163009536)
        GPT-2 medium (emb=1024, layers=24, heads=16): 总参数 ...,weight tying 后 ...
        GPT-2 large (emb=1280, layers=36, heads=20):  ...
        GPT-2 xl (emb=1600, layers=48, heads=25):    ...

      三个数字的口径各不相同:

      • 163,009,536:模型实际参数量——输出头(38,597,376)占了近四分之一;

      • 124,412,160:考虑 weight tying(输出头与词元嵌入共享同一份权重 ——(b, T, 768) 的隐藏状态乘以 tok_emb.weight.T 就是 logits)后的 独立参数量,这正是 GPT-2 small 名字里「124M」的来历;

      • 621.83 MB:按 float32 存全部 163M 参数的显存/磁盘占用。

      一份权重两处使用,参数账立刻少四分之一——论文里的数字与「实例化数一遍」 的数字对不上,原因就在这里。

      为什么共享一份矩阵是合理的?tok_emb.weight 的每一行是一个词元的语义 向量;输出头把它转置后使用,相当于「用同一个词元的语义向量给该词元 打分」——输入侧和输出侧共用同一个词表上的同一套语义表示,两边的 空间天然对齐,本来就不该是两套坐标系。训练时这份矩阵从两处获得梯度 (作为嵌入被查找、作为分类器被乘),两个信号互相校准同一个表示。收益是 省掉 50257×768 ≈ 38.6M 参数(约占全部参数的 24%)。第 5 章加载 OpenAI 权重时的最后一步 out_head→weight = assign(…​, params("wte")) 就是在 显式还原这个共享。

      [练习 4.1] 还给出一个重要结论:FFN 参数量(4.72M)几乎是注意力(2.36M) 的两倍——Transformer 的容量大头在前馈网络,不在注意力。这与 「Attention is all you need」留下的第一印象相反。[练习 4.2] 的公式估算与 实测逐位一致;medium/large/xl 三档的参数量也由同一公式打印 (355M / 774M / 1.56B)。

      4.8 首次生成:乱码是预期行为

      编译并运行本章程序:

      ./scripts/run.sh chapter04_gpt

      运行后应看到:「Hello, I am」经 BPE 编码成 [15496, 11, 314, 716],贪心 解码 6 个新词元:

      encoded_tensor.shape: [1, 4]
      Output length: 10
      Decoded text: "Hello, I am <乱码词元>"      ← 未训练模型的预期行为

      输出是乱码,而且这是预期行为——随机初始化的模型只会产生形状正确、 语义无意义的词元,它还没有学过任何语言。

      这段乱码同时验证了三件事:形状链全部打通、贪心解码循环工作正常、权重 能在 GPU 上前后向流动。架构正确不等于能生成语言——把乱码变成连贯文本, 是第 5 章预训练的任务。

      本章小结

      • 七个超参唯一确定一个 GPT 架构。学实现选 GPT-2 的三个理由:与 GPT-3 同架构、权重公开、笔记本能跑。实现采用「先占位骨架、后换真件」的方法, forward 数据流保持不变。

      • 三个关键组件:LayerNorm(有偏方差与 GPT-2 兼容;scale/shift 提供 可学习的仿射还原)、GELU(平滑激活,负输入不全置零)、快捷连接(缓解 梯度消失,实测提升两个数量级以上)。

      • TransformerBlock = 注意力(跨位置信息混合)+ FFN(逐位置特征变换), 各配 Pre-LayerNorm 与残差连接,形状守恒。

      • 参数三数字互证:163,009,536(全部)、124,412,160(权重共享口径)、 621.83 MB(float32 存储);FFN 容量约为注意力的两倍。

      • 未训练模型输出无意义内容是预期行为:架构正确不等于能够生成文本。

      自测清单

      能不回看就答出这些,本章就算过关:

      1. 能说出选 GPT-2 不选 GPT-3 的三个理由;qkv_bias 有什么跨章约定

      2. DummyGPTModel 输出 [2, 4, 50257];TransformerBlock 输出 [2, 4, 768]

      3. GELU(1.5) ≈ 1.3996,与 ReLU 的 1.5 形成对照

      4. 参数三数字:163,009,536 / 124,412,160 / 621.83 MB

      5. 能不看表复述 FFN(4.72M) ≈ 2×MHA(2.36M) 的结论

      6. 贪心解码 4 + 6 = 10 个词元,输出乱码是预期行为

      7. 能说出 FeedForward 单表达式构造为什么在 C++ 里是坑

      8. 能说出 unbiased=false 与 Pre-LN 的理由;scale/shift 为什么可训练

      9. 能写出块内顺序 Norm → 子层 → Dropout → 加残差,并说清 dropout 与残差谁在外

      习题

      1. 把 GPTConfig 改成 emb_dim=1024, n_layers=24, n_heads=16 (medium 规模),先跑 [练习 4.2] 公式估出参数量,再实际实例化验证 (显存不够就用估算值,不必强行实例化)。

      2. 在 generate_text_simple 里把 argmax 换成 torch::multinomial 随机采样,观察输出稳定性的变化——这就是第 5 章解码策略的入口。

      3. 数一数 GPTModel 里的三处 dropout (drop_emb / 每块的 drop_shortcut / MHA 内部),运行时输出已给出答案。

      4. 把 LayerNormImpl 的 unbiased=false 改成 true,对随机输入重跑 实测,观察 mean/var 偏差变化——再想想为什么第 5 章加载官方权重前 必须改回去。

      拓展阅读