逐行拆GPT-2源码:GPT2LMHeadModel到Attention,2026年依然有效的底层知识
【免费下载链接】gpt2项目地址: https://ai.gitcode.com/hf_mirrors/openai-community/gpt2
2019 年 OpenAI 发布 GPT-2 时,它因为"出于安全考虑暂不完整开源"而引发舆论争议,最终 15 亿参数的全量版本被放出的过程,几乎成了大模型行业最早的"开源大事件"。七年过去,GPT-4 之后的架构讨论早已转向 MoE、长上下文与多模态,但翻看openai-community/gpt2这个仅有 124M 参数的镜像仓库,你会发现:今天 Llama、Qwen、Mistral 在源码层面处理的每一个核心问题——自回归因果掩码、KV Cache、权重共享、残差路径的缩放初始化——在 GPT-2 的代码里都有最原初、最干净的答案。
社区里关于 GPT-2 的源码解读文章热度多年不减,从 2020 年的"GPT2LMHeadModel 阅读笔记"到 2025 年仍在更新的"GPT-2 复现"系列,足以说明这套代码的教材价值。本文不泛泛讲 Transformer 理论,而是把 Hugging Face transformers 中gpt2的类体系、Block/Attention/MLP的实现细节逐行过一遍,再对照本仓库的真实配置文件与权重结构,最后与今天的 Llama 架构做一次"跨时代"的源码级对照。
GPT2Config:一张写满"考古彩蛋"的配置表
一切从配置开始。仓库根目录的 config.json 就是一张活生生的 124M 模型体检表:
{ "activation_function": "gelu_new", "architectures": ["GPT2LMHeadModel"], "attn_pdrop": 0.1, "embd_pdrop": 0.1, "resid_pdrop": 0.1, "initializer_range": 0.02, "layer_norm_epsilon": 1e-05, "model_type": "gpt2", "n_ctx": 1024, "n_embd": 768, "n_head": 12, "n_layer": 12, "n_positions": 1024, "vocab_size": 50257 }对照GPT2Config的源码,可以看到它刻意保留了 GPT 系列最早期的命名习惯:n_embd、n_layer、n_head、n_positions,并通过attribute_map映射成现代 API 的hidden_size、num_hidden_layers、num_attention_heads、max_position_embeddings。理解这套映射,你就能看懂为什么GPT2Model.__init__里写的是self.embed_dim = config.hidden_size,而lm_head用的却是config.n_embd——两套命名指向同一个值 768。
vocab_size = 50257是另一个考古点:真实 BPE 词表是 50256,多出的 1 是文本结束符<|endoftext|>(token id 50256),bos_token_id与eos_token_id都指向它。仓库里的 vocab.json(50257 个 token 的 id 映射)与 50001 行的 merges.txt 共同定义了这套 byte-level BPE 词表——GPT-2 是第一个把 BPE 建在 UTF-8 字节级别上的主流模型,这也是它处理任意 Unicode 文本而不产生<unk>的原因。
配置里还有两个值得注意的开关:scale_attn_weights: true(注意力分数除以 √d_head)与scale_attn_by_inverse_layer_idx: false。后者在 GPT-2 论文的原始 TensorFlow 实现里是开启的——每层 attention 额外除以layer_idx + 1,但 Hugging Face 的 PyTorch 移植默认关闭。这类"论文与工程实现不完全一致"的细节,正是源码阅读最有价值的部分。
GPT2LMHeadModel:一个"瘦"模型壳,两个关键设计
GPT2LMHeadModel是整个推理入口,但它本身极简,__init__只有三行核心逻辑:
class GPT2LMHeadModel(GPT2PreTrainedModel): _tied_weights_keys = {"lm_head.weight": "transformer.wte.weight"} def __init__(self, config): super().__init__(config) self.transformer = GPT2Model(config) self.lm_head = nn.Linear(config.n_embd, config.vocab_size, bias=False) self.post_init()第一个设计是输出层与输入嵌入共享权重(weight tying)。lm_head是一个768 → 50257的无 bias 线性层,它的权重在保存时根本不落盘——_tied_weights_keys声明了lm_head.weight就是transformer.wte.weight,加载时自动从嵌入表拷贝。这既省下约 3.9 亿参数(对 124M 模型而言接近"第二个模型"的体积),也带来一个隐性的正则化效果。这个设计一直延续到今天:Llama 的tie_word_embeddings配置走的正是同一条路。
第二个设计藏在forward里,也就是社区文章反复提到的"labels 在模型内部移位":
if labels is not None: shift_logits = lm_logits[..., :-1, :].contiguous() shift_labels = labels[..., 1:].contiguous() loss_fct = CrossEntropyLoss() loss = loss_fct(shift_logits.view(-1, shift_logits.size(-1)), shift_labels.view(-1))自回归训练的目标是"用位置 1..i 的输入预测位置 i+1",所以代码把 logits 丢掉最后一个位置、labels 丢掉第一个位置,然后拍平做交叉熵。调用者甚至可以直接传labels = input_ids,因为移位发生在模型内部。很多刚入门的人在这里困惑"为什么 logits 比输入短 1",答案就是这行shift_logits = lm_logits[..., :-1, :]。
模型壳再往下,GPT2PreTrainedModel还有一个容易被忽略的初始化彩蛋:_init_weights里对每个c_proj.weight(Attention 与 MLP 的输出投影)使用std = initializer_range / sqrt(2 * n_layer)的缩放初始化。注释直接引用了 GPT-2 论文原话——这是针对残差路径随深度累积问题的修正,保证 12 层(乃至 48 层)堆叠后深层信号的方差不会爆炸。今天的 DeepNet、LLaMA 采用的各类深层缩放初始化,源头都可以追溯到这一行。
GPT2Model:词嵌入 + 位置嵌入,以及那条著名的残差路径
GPT2Model是主干,__init__结构一目了然:
self.wte = nn.Embedding(config.vocab_size, self.embed_dim) # 词嵌入 self.wpe = nn.Embedding(config.max_position_embeddings, self.embed_dim) # 位置嵌入 self.drop = nn.Dropout(config.embd_pdrop) self.h = nn.ModuleList([GPT2Block(config, layer_idx=i) for i in range(config.num_hidden_layers)]) self.ln_f = nn.LayerNorm(self.embed_dim, eps=config.layer_norm_epsilon)三个细节值得单独说:
- 可学习位置嵌入。
wpe是 1024×768 的可训练查表,位置信息是"学出来的"而不是公式算出来的。这与后来 Llama 的 RoPE 形成鲜明对照——RoPE 用旋转矩阵把相对位置信息注入 Q/K,而 GPT-2 直接把绝对位置"加"进 token 向量。理解这一点,就理解了后来围绕"绝对位置嵌入外推到 2048/4096 上下文会失效"的一系列工程讨论的起点。 - 位置编码的续接逻辑。当传入
past_key_values时,position_ids的起点不是 0 而是past_length:
if position_ids is None: past_length = past_key_values[0][0].size(-2) position_ids = torch.arange(past_length, input_shape[-1] + past_length, dtype=torch.long, device=device)这是 KV Cache 解码的第一个关键配套:每步只生成一个新 token,但它的位置编号必须接着历史序列往下排。 3.前向主循环。for i, (block, layer_past) in enumerate(zip(self.h, past_key_values))逐个执行 12 个GPT2Block,每个 block 接住上一步的layer_past(该层的 KV 缓存),最后统一过ln_f。整条前向路径除了 12 个 block 之间的顺序依赖外没有其他结构,这让 GPT-2 成了研究"层间通信"的纯净样本。
GPT2Block:pre-norm、两段残差、可选交叉注意力
GPT2Block是"一个 Transformer 层"的最小完整实现:
class GPT2Block(nn.Module): def __init__(self, config, layer_idx=None): hidden_size = config.hidden_size inner_dim = config.n_inner if config.n_inner is not None else 4 * hidden_size self.ln_1 = nn.LayerNorm(hidden_size, eps=config.layer_norm_epsilon) self.attn = GPT2Attention(config, layer_idx=layer_idx) self.ln_2 = nn.LayerNorm(hidden_size, eps=config.layer_norm_epsilon) if config.add_cross_attention: self.crossattention = GPT2Attention(config, is_cross_attention=True, layer_idx=layer_idx) self.ln_cross_attn = nn.LayerNorm(hidden_size, eps=config.layer_norm_epsilon) self.mlp = GPT2MLP(inner_dim, config)注意层序:ln_1 → attention → 残差 → ln_2 → MLP → 残差,这是标准的Pre-LayerNorm结构(先归一化再进子层),与 BERT 的 Post-LN 相对。Pre-LN 在今天几乎一统天下,因为它天然更利于深层训练的稳定性,而 GPT-2 是很早的实践者之一。
forward里的残差写法同样值得逐行看:
residual = hidden_states hidden_states = self.ln_1(hidden_states) attn_outputs = self.attn(hidden_states, layer_past=layer_past, ...) attn_output = attn_outputs[0] hidden_states = attn_output + residual # 第一条残差:Attention ... residual = hidden_states hidden_states = self.ln_2(hidden_states) feed_forward_hidden_states = self.mlp(hidden_states) hidden_states = residual + feed_forward_hidden_states # 第二条残差:MLP每条残差都显式保存前一步的hidden_states,计算完子层再加回来。这就是"每层两条残差路径"的含义——前面_init_weights里sqrt(2 * n_layer)的 2,正是来自这里。
另外inner_dim = config.n_inner if ... else 4 * hidden_size也是工程上常踩的坑:GPT-2 的 MLP 中间维度默认 4×768=3072,但允许通过n_inner覆盖。本仓库 config.json 未设置n_inner,即走默认 3072;而 distilgpt2 这类蒸馏模型就常显式改小n_inner。
GPT2Attention:从 768 到 12 个头再到 768 的完整旅程
这是整篇源码里最值得逐行读的部分。先看__init__中两个经典 buffer:
self.register_buffer( "bias", torch.tril(torch.ones((max_positions, max_positions), dtype=torch.uint8)).view( 1, 1, max_positions, max_positions ), ) self.register_buffer("masked_bias", torch.tensor(-1e4))bias是一个预先生成的 1024×1024 下三角矩阵,因果性在初始化时就固定成了常数,而不是每次 forward 现算。masked_bias则是老版本里用来填充被 mask 位置的常数。这两行是"把几何先验编码成 buffer"的教科书案例,也解释了为什么模型目录的忽略列表中总能看到attn.bias——它是运行时常量,不属于可训练权重。
然后是那个被无数文章讨论过的c_attn:
if self.is_cross_attention: self.c_attn = Conv1D(2 * self.embed_dim, self.embed_dim) self.q_attn = Conv1D(self.embed_dim, self.embed_dim) else: self.c_attn = Conv1D(3 * self.embed_dim, self.embed_dim) self.c_proj = Conv1D(self.embed_dim, self.embed_dim)这里的Conv1D不是卷积,而是一个权重转置过的线性层(forward里是torch.addmm(self.bias, x.view(-1, x.size(-1)), self.weight)),纯粹是为了对齐 OpenAI 原始 TensorFlow checkpoint 的权重排布。它的实际意义在于:c_attn一次线性变换直接输出 3×768=2304 维,Q、K、V 三段紧凑地拼在同一张矩阵里,随后用.split(self.split_size, dim=2)切开。也就是说,Q/K/V 共享一次矩阵乘,这是 GPT-2 推理性能优于"三个独立线性层"实现的原因之一,后来的 Llama 虽改用三个q_proj/k_proj/v_proj,但 fused 思想仍广泛存在于 CUDA kernel 层。
切分之后是_split_heads:
def _split_heads(self, tensor, num_heads, attn_head_size): new_shape = tensor.size()[:-1] + (num_heads, attn_head_size) tensor = tensor.view(new_shape) return tensor.permute(0, 2, 1, 3) # (batch, head, seq_length, head_features)把[batch, seq, 768]reshape 成[batch, seq, 12, 64]再转置成[batch, 12, seq, 64]——每个头 64 维(768/12)。这 12 个头的"分工"完全是数据驱动涌现的,源码层面没有任何区分,这也是后人做"注意力头可视化/剪枝"研究时的基础形状约定。
核心的_attn方法把因果掩码的应用写得非常清楚:
attn_weights = torch.matmul(query, key.transpose(-1, -2)) # [batch, 12, q_len, k_len] if self.scale_attn_weights: attn_weights = attn_weights / torch.full([], value.size(-1) ** 0.5, ...) # 除以 √64 query_length, key_length = query.size(-2), key.size(-2) causal_mask = self.bias[:, :, key_length - query_length : key_length, :key_length].to(torch.bool) mask_value = torch.finfo(attn_weights.dtype).min attn_weights = torch.where(causal_mask, attn_weights.to(attn_weights.dtype), mask_value) attn_weights = nn.functional.softmax(attn_weights, dim=-1) attn_output = torch.matmul(attn_weights, value)对[batch, 12, q_len, k_len]的注意力分数矩阵,因果 mask 保证第 i 行只对j ≤ i的列保留,其余填-inf(代码里用torch.finfo(...).min逼近),softmax 后这些位置权重归零。"只看过去、不看未来"就体现在这一行torch.where上,GPT-2 的整个自回归性质由此而来。
KV Cache 的实现也在forward里,总共四行:
if layer_past is not None: past_key, past_value = layer_past key = torch.cat((past_key, key), dim=-2) value = torch.cat((past_value, value), dim=-2) if use_cache is True: present = (key, value)每次生成只算新 token 的 Q/K/V,再把新的 K、V 沿序列维拼到历史缓存上,Q 则不需要缓存(它只跟"当前 token"有关)。这四行让推理复杂度从"每步重算全序列"降到"每步只算新增部分",是 GPT-2 源码里性价比最高的一段代码。今天主流框架的 KV Cache 管理、PagedAttention 的分页思想,逻辑起点都是这里的torch.cat。
最后,_merge_heads把[batch, 12, seq, 64]还原成[batch, seq, 768],过c_proj输出投影和resid_dropout,Attention 部分闭环。值得一提的还有reorder_and_upcast_attn:混合精度训练时,它会把 Q/K 上浮到 float32 计算点积和 softmax 再降回,避免 fp16 下注意力分数的精度损失——这个"在数值敏感点保留 fp32"的做法,至今仍是各类 mixed-precision 训练的标准手段。
GPT2MLP:fused 线性层 + 一个只属于 GPT-2 的激活函数
MLP 部分非常短:
class GPT2MLP(nn.Module): def __init__(self, intermediate_size, config): embed_dim = config.hidden_size self.c_fc = Conv1D(intermediate_size, embed_dim) # 768 → 3072 self.c_proj = Conv1D(embed_dim, intermediate_size) # 3072 → 768 self.act = ACT2FN[config.activation_function] self.dropout = nn.Dropout(config.resid_pdrop) def forward(self, hidden_states): hidden_states = self.c_fc(hidden_states) hidden_states = self.act(hidden_states) hidden_states = self.c_proj(hidden_states) hidden_states = self.dropout(hidden_states) return hidden_statesc_fc与c_proj同样是 fused 式Conv1D,中间维度 3072。而activation_function: "gelu_new"引出一个真正的"GPT-2 独家":gelu_new是论文里使用的 GELU 近似,Hugging Face 在activations.py里给了精确实现:
class NewGELUActivation(nn.Module): def forward(self, input): return 0.5 * input * (1.0 + torch.tanh(math.sqrt(2.0 / math.pi) * (input + 0.044715 * torch.pow(input, 3.0))))它与标准 GELU 的0.5x(1+erf(x/√2))数值上有微小差异,但对量化、对精度敏感的推理场景足以造成可观测的输出偏差。这也是社区里"用 Hugging Face 加载 OpenAI 原版权重、为什么输出和原版 TensorFlow 不完全一致"讨论的根源之一——gelu_new只是其中一个环节,scale_attn_by_inverse_layer_idx的开关状态是另一个。若想深究,仓库 onnx/config.json 明确写着"activation_function": "gelu_new"、"scale_attn_weights": true、"reorder_and_upcast_attn": false,导出模型时这些开关都会被原样继承。
权重即架构:用 safetensors 键名反推类结构
模型类代码与磁盘权重是严格一一对应的。以本仓库的model.safetensors为例(权重经 Git LFS 托管,仓库内是 134 字节的指针文件,真实体积 548MB),其键名天然构成一张类结构图:
transformer.wte.weight [50257, 768] 词嵌入(与 lm_head 共享) transformer.wpe.weight [1024, 768] 可学习位置嵌入 transformer.h.0.ln_1.weight/bias 第 0 层 Attention 前的 LayerNorm transformer.h.0.attn.c_attn.weight/bias [768, 2304] QKV 融合矩阵 transformer.h.0.attn.c_proj.weight/bias [768, 768] Attention 输出投影 transformer.h.0.ln_2.weight/bias 第 0 层 MLP 前的 LayerNorm transformer.h.0.mlp.c_fc.weight/bias [768, 3072] MLP 升维 transformer.h.0.mlp.c_proj.weight/bias [3072, 768] MLP 降维 transformer.h.1 ... transformer.h.11 其余 11 层同构 transformer.ln_f.weight/bias 末端 LayerNorm lm_head.weight (不落盘,加载时从 wte 拷贝)attn.c_attn的形状[768, 2304]恰好印证了"QKV 三段共享一次矩阵乘",mlp.c_fc的[768, 3072]印证了默认n_inner = 4 × n_embd。读源码时把类成员和权重键名对照着看,比单独读任何一方都快。
顺带一提,本仓库还保留了完整的多框架与推理导出产物:pytorch_model.bin、tf_model.h5、flax_model.msgpack、rust_model.ot覆盖四大框架,onnx/下则有decoder_model.onnx(无缓存版)、decoder_model_merged.onnx(权重融合版)与decoder_with_past_model.onnx(带 KV Cache 输入输出的版本),加上64.tflite、64-fp16.tflite、64-8bits.tflite三档 TFLite(8-bit 量化版)——"同一个模型,八种跑法",这套目录结构本身就是一篇完整的模型服务化实践笔记。
与 Llama 的源码级对照:七年演进,骨架未变
把 GPT-2 的源码和今天的 Llama(乃至 Qwen/Mistral)逐行对比,你会发现"变革"与"不变"同样惊人。下表是逐组件的对照:
| 组件 | GPT-2 | Llama(现代主流) | 变化本质 |
|---|---|---|---|
| 位置编码 | 可学习绝对位置wpe | RoPE 旋转位置编码 | 由"加进向量"变为"注入 Q/K",支持外推 |
| 归一化 | Pre-LN(LayerNorm) | Pre-LN(RMSNorm) | 去掉均值偏移,省算子、稳训练 |
| QKV 投影 | 单c_attnfused 2304 维 | 三个独立q/k/v_proj | 解耦便于张量并行与量化 |
| 注意力缩放 | √head_dim(可加层号缩放) | √head_dim(多版本实现) | 核心公式未变 |
| 因果掩码 | 预生成下三角 buffer | 每步现算/切片 | 做法更省显存,本质相同 |
| KV Cache | torch.cat拼接 +present返回 | past_key_values缓存类 | 逻辑一致,管理更系统化 |
| MLP | c_fc→gelu_new→c_proj | gate_proj×up_proj→SiLU→down_proj | 单支变 SwiGLU 门控双支 |
| 残差缩放 | std/√(2N)的c_proj初始化 | 部分模型引入额外缩放 | 同一问题的不同解法 |
| 输出头 | lm_head与wte共享权重 | lm_head可选共享(tie_word_embeddings) | 设计延续至今 |
最核心的结论是:自回归解码的主干——token 嵌入、位置信息、12 层 pre-norm 残差 Transformer、因果自注意力、逐 token 生成——在 Llama 里与 GPT-2 逐行对应。GPT-2 源码里关于 KV Cache 那四行torch.cat,在 DeepSeek、vLLM 的 PagedAttention 里演化成了更复杂的缓存管理,但"存 K/V、增量计算、避免全量重算"的动机一字未变。
这也正是"2026 年读 GPT-2 源码依然有效"的原因:它不教你最新最炫的算子,它教你的是一套自回归 Transformer 的最小完备心智模型——配置如何驱动结构、权重如何对应类、掩码如何保证因果、缓存如何加速解码。把这份源码读透,再去看任何现代大模型的论文与代码,你都像是在看一位老朋友换了新衣服。
如果你手头正好有这个仓库,建议按下面三步亲自走一遍:先用GPT2LMHeadModel.from_pretrained加载并跑一次generate;然后打开 config.json 对着GPT2Config源码逐字段核对;最后用model.transformer.h[0].attn打印各子模块与权重形状,亲手验证c_attn的[768, 2304]与_split_heads的 12×64 切分。七年前的代码,今天依然是最好的老师。
【免费下载链接】gpt2项目地址: https://ai.gitcode.com/hf_mirrors/openai-community/gpt2
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考