加载预训练权重
从零训练一个 1.24 亿参数模型,是预算问题;加载一份已经公开的 checkpoint,只该是周二下午的工作。这节课会把 GPT-2 风格的预训练权重从 safetensors 文件装进第 35 课那套完全一致的架构里,一步一步走通参数名映射,再用一次 sanity generation 证明加载真的生效。没有网络、没有第三方 loader、也没有黑盒魔法。
类型: Build 语言: Python 前置要求: 第 19 阶段第 30-36 课 预计时间: ~90 分钟
学习目标
- 用
safetensorsPython 库读 safetensors 文件,并检查里面每个 tensor 的名字与形状。 - 把每一个预训练参数名映射到第 35 课 GPT 模型里的本地参数。
- 正确处理两套命名约定之间的差异:发布版 GPT-2 的
wte/wpe/h.N.attn.c_attn/c_proj、mlp.c_fc/c_proj,以及本课程本地模型的tok_embed/pos_embed/blocks.N.attn.qkv/out_proj、mlp.fc1/fc2。 - 在任何权重赋值发生前,先检测并拒绝 shape mismatch,并给出清晰错误。
- 用加载后的权重生成一小段 continuation,确认 token 分布真的来自加载好的参数,而不是随机初始化。
问题背景
公开 checkpoint 从来不是按你的架构命名打包的。它们带的是原始实现自己的参数名。比如预训练文件里有:
transformer.h.0.attn.c_attn.weight,形状 (2304, 768)
而你的模型可能期待的是:
blocks.0.attn.qkv.weight,形状同样是 (2304, 768),或者你的 nn.Linear 内部权重还把矩阵按另一种方向存了。于是同一块参数会同时有三种身份:名字、形状、字节布局。loader 的职责,就是把三者重新对齐。
一个瞎 copy 的 loader,会把对的 tensor 装进错的槽里,最后模型吐垃圾。一个遇到 shape mismatch 就偷偷跳过、但什么也不记的 loader,会让你完全不知道哪块权重没落下。本课的 loader 走的是硬路径:每次赋值都记日志,每个 shape 都先验证,最后给你一份 LoadReport,明白告诉你命中了哪些、漏了哪些、炸了哪些。
核心概念
flowchart LR
SF[safetensors 文件<br/>gpt2-stub.safetensors] --> R[读取器<br/>safe_open]
R --> N[参数名迭代器]
N --> M[名称映射器<br/>预训练 -> 本地]
M --> S[形状检查]
S -- 匹配 --> A[赋值张量<br/>在 torch.no_grad 下]
S -- 不匹配 --> E[记录不匹配<br/>不赋值]
A --> RP[LoadReport]
E --> RP
RP --> G[生成<br/>健全性检查样本]name mapper 本质上就是一个 str -> str 的函数。shape check 只是一个 if。真正赋值时放在 torch.no_grad() 下,避免 autograd 跟踪加载动作。最后 report 把每个名字的命运都记下来。
GPT-2 的命名约定
公开 GPT-2 权重常见名字如下:
| 预训练名 | 形状 | 含义 |
|---|---|---|
wte.weight | (50257, 768) | Token embedding |
wpe.weight | (1024, 768) | Position embedding |
h.N.ln_1.weight | (768,) | 第 N 个 block 的 LayerNorm 1 scale |
h.N.ln_1.bias | (768,) | 第 N 个 block 的 LayerNorm 1 shift |
h.N.attn.c_attn.weight | (768, 2304) | 融合 QKV 线性层权重 |
h.N.attn.c_attn.bias | (2304,) | 融合 QKV 线性层 bias |
h.N.attn.c_proj.weight | (768, 768) | Attention 输出投影 |
h.N.attn.c_proj.bias | (768,) | Attention 输出投影 bias |
h.N.ln_2.weight | (768,) | LayerNorm 2 scale |
h.N.ln_2.bias | (768,) | LayerNorm 2 shift |
h.N.mlp.c_fc.weight | (768, 3072) | MLP fc1 权重 |
h.N.mlp.c_fc.bias | (3072,) | MLP fc1 bias |
h.N.mlp.c_proj.weight | (3072, 768) | MLP fc2 权重 |
h.N.mlp.c_proj.bias | (768,) | MLP fc2 bias |
ln_f.weight | (768,) | 最终 LayerNorm scale |
ln_f.bias | (768,) | 最终 LayerNorm shift |
这里有两个坑最需要小心:
c_attn、c_proj、c_fc这些线性层的矩阵布局,与nn.Linear.weight默认期待的方向是转置关系,加载时要转置。- LM head 根本不在文件里,因为它通过
wte做 weight tying。只要wte装好了,head 本身应该通过 alias 指过去。
本地模型的命名约定
这套课程里的模型名字更偏描述式:
| 本地名 | 含义 |
|---|---|
tok_embed.weight | Token embedding |
pos_embed.weight | Position embedding |
blocks.N.ln1.scale | 第 N 个 block 的 LayerNorm 1 scale |
blocks.N.ln1.shift | LayerNorm 1 shift |
blocks.N.attn.qkv.weight | Fused QKV |
blocks.N.attn.qkv.bias | Fused QKV bias |
blocks.N.attn.out_proj.weight | Attention 输出投影 |
blocks.N.attn.out_proj.bias | 输出投影 bias |
blocks.N.ln2.scale | LayerNorm 2 scale |
blocks.N.ln2.shift | LayerNorm 2 shift |
blocks.N.mlp.fc1.weight | MLP fc1 |
blocks.N.mlp.fc1.bias | MLP fc1 bias |
blocks.N.mlp.fc2.weight | MLP fc2 |
blocks.N.mlp.fc2.bias | MLP fc2 bias |
final_ln.scale | 最终 LayerNorm scale |
final_ln.shift | 最终 LayerNorm shift |
映射关系是固定函数,本课会直接把它做成 dict,并按层展开。
Stub Fixture
真实 GPT-2 权重大约 0.5 GB。本课不会下载它,而是在首次运行时生成一个小型 safetensors fixture:命名完全遵守 GPT-2 习惯,但层数和宽度会缩成便于本地验证的版本(比如 12 层、d_model=192)。这样 fixture 的结构足以打通 loader 的所有路径。换成真实文件时,loader 本身不用改。
cc-weight-remap动手构建
code/main.py 会实现:
- 一份本地版的第 35 课
GPTModel,保证本课自洽 make_pretrained_to_local(num_layers):按层展开 name mapload_safetensors(model, path):遍历名字、映射、检查 shape、对 conv1d 风格权重做转置,在torch.no_grad()下赋值,并返回LoadReportmake_stub_safetensors(path, cfg):按预训练命名习惯生成 fixture 文件- 一个 demo:首次运行时生成
outputs/gpt2-stub.safetensors,构造一个 fresh model,先从随机初始化下生成一段,再加载 stub,再生成一段,并验证两者不同,说明加载真的改变了模型
运行方式:
python3 code/main.py输出包括:fixture 路径、逐参数加载日志、LoadReport 摘要、加载前的 continuation、加载后的 continuation,以及一条故意注入的坏 tensor 所触发的 shape mismatch,用来覆盖失败路径。
Stack
safetensors:负责磁盘格式与流式读取torch:负责模型和赋值数学- 不用
transformers,不用huggingface_hub,不做任何网络调用
生产里常见的三个模式
先验证整个文件,再开始任何赋值。 正确流程是:打开文件、列出所有 tensor 名、dtype、shape,跑完整 name mapping 和 shape check,只有全部通过后才真正写进模型。半加载模型就是静默失败制造机。
每次赋值都记 source name 和 destination name。 出问题时,这份 log 会直接告诉你哪块 tensor 被写到了哪里,不然你只能去读 hexdump。LoadReport 至少应该跟踪:loaded、missing、unexpected、shape_mismatch。
LM head 是 weight tying alias,不是独立 copy。 标准写法是:model.lm_head.weight = model.tok_embed.weight。若你把 embedding 再 copy 一份给 head,绑定就断了,参数数目悄悄翻倍。
实际使用
- 只要 safetensors 文件遵守 GPT-2 命名规范,这个 loader 就能直接用。真实 GPT-2 small / medium / large / xl 只是在 config 上不同,代码不需要变。
- 同样的套路也能扩展到 LLaMA、Mistral、Qwen,只要更新 name map 即可。shape check 和 report 逻辑完全不变。
- 加载后做一次 sanity generation,是最快的门槛测试:若加载后样本和随机初始化时几乎没区别,那就是 loader 根本没真正改到模型。
练习
- 给 loader 加一个
dtype参数,加载时可选强转成bfloat16、float16或float32。验证float32模型降成bfloat16后仍能生成。 - 加一个
expected_layers参数,若 checkpoint 里的h.N索引与模型num_layers不符,就直接拒绝加载。 - 把 loader 接回第 35 课的 generation 函数,打印“随机初始化样本 vs 加载权重样本”的并排对比。
- 再补一条导出路径:把当前模型 state 按预训练命名规范写回新 safetensors 文件。再 round-trip 一次,验证 report 中 shape mismatch 为 0。
- 扩展
NAME_MAP去适配 LLaMA 风格命名(无 bias、RMSNorm、fused qkv),并用你自己生成的 stub LLaMA fixture 复跑 loader。
关键术语
| 英文 | 大家嘴上怎么说 | 它实际指什么 |
|---|---|---|
| Name map | “Key remapping” | 从预训练 tensor 名到本地参数名的映射函数,通常是一张按层展开的字典 |
| Shape mismatch | “Bad shape” | tensor 名能映射上,但预训练张量形状与本地参数不一致;loader 会拒绝赋值并记录 |
| Transpose-on-load | “Conv1d layout” | GPT-2 发布权重里 attention / MLP projection 的矩阵布局与 nn.Linear 期待方向相反,因此加载时要转置 |
| Weight tying alias | “Shared LM head” | 让 model.lm_head.weight = model.tok_embed.weight,head 与 embedding 共用同一块存储 |
| Load report | “Coverage summary” | 一个 dataclass,汇总 loaded、missing、unexpected、shape_mismatch;它就是你判断加载是否成功的证据 |
延伸阅读
- 第 19 阶段第 35 课:接收这些权重的模型架构
- 第 19 阶段第 36 课:产出同形 checkpoint 的训练 loop
- 第 10 阶段第 11 课:内存紧张时对已加载权重做量化
- 第 10 阶段第 13 课:围绕加载与推理的一整条 LLM pipeline