数据截至 (上游 commit f1e2ace65149)
01 · train_gpt2.c:CPU 参考实现
这一章讲什么: 全仓库最该先读的文件。
train_gpt2.c(1182 行)用纯 C + 少量 OpenMP 写完 GPT-2 的训练:模型怎么住进内存、前向逐层算什么、反向怎么手工推、AdamW 怎么更新、main 循环长 什么样。读完它,后面 CUDA 版的一切优化你都知道在优化什么。
1. 它要解决的小问题
不用任何框架,「训练 GPT-2 一步」到底是哪几步数学?
PyTorch 里这是 loss = model(x, y); loss.backward(); opt.step() 三行。llm.c 把这三行展开成
1182 行——但没有一行是浪费的:每个层的前向和反向各一个函数,加模型定义、checkpoint 读取、
主循环,仅此而已。
2. 模型在 C 里怎么住:一块大数组 + 一把指针
2.1 16 个参数张量
GPT-2 的全部参数被列在一个 struct 里(ParameterTensors,train_gpt2.c:536-553):
| 张量 | 形状 | 含义 |
|---|---|---|
wte | (V, C) | token 嵌入(同时兼任最后的分类器,见 §3.3) |
wpe | (maxT, C) | 位置嵌入 |
ln1w/b、ln2w/b、lnfw/b | (L, C) / (C) | 各 LayerNorm 的缩放与平移 |
qkvw/b | (L, 3C, C) | QKV 投影(Q、K、V 合成一个矩阵) |
attprojw/b | (L, C, C) | 注意力输出投影 |
fcw/b | (L, 4C, C) | MLP 升维 |
fcprojw/b | (L, C, 4C) | MLP 降维 |
每个张量的元素数由 fill_in_parameter_sizes(train_gpt2.c:556-577)算出。注意两个细节:
- 填充词表:logits/嵌入按
padded_vocab_size(50304 = 50257 向上取到 128 的倍数)分配,为了 让 CUDA kernel 对齐(train_gpt2.c:529的注释;CPU 版沿用了同一约定)。 NUM_PARAMETER_TENSORS = 16:这个数在后面测试、checkpoint、ZeRO 分片里反复出现。
2.2 一次 malloc,指针依次排进去
分配方式是全仓库最有「C 味」的设计之一(malloc_and_point_parameters,train_gpt2.c:580-598):
// 示意,非源码(真实代码见 train_gpt2.c:580-598)
float* params_memory = mallocCheck(num_parameters * sizeof(float)); // 一整块
float* it = params_memory;
for (int i = 0; i < NUM_PARAMETER_TENSORS; i++) {
*(ptrs[i]) = it; // 把 struct 里第 i 个指针指到当前位置
it += param_sizes[i]; // 游标前进一个张量
}
要点:没有逐张量 malloc,没有智能指针。grads(梯度)用同一个函数再切一块同样布局的内存;
gpt2_free(train_gpt2.c:1035-1044)只需 free 掉几个大指针。23 个激活张量
(ActivationTensors,train_gpt2.c:601-625)照此办理,大小由
fill_in_activation_sizes(train_gpt2.c:628-655)按 B、T 算出。
2.3 激活是「为反向缓存的中间量」
23 个激活张量值得多看一眼,因为它精确回答了「反向传播需要记住什么」:
- 每个 LayerNorm 存
mean和rstd(反向要用,见 §4.3)。 - 注意力存
preatt(softmax 前的分数)和att(softmax 后的概率),形状 (L, B, NH, T, T)。 fch(GELU 前)和fch_gelu(GELU 后)都存——GELU 反向需要输入值。- 残差流的每一站(
encoded、residual2、residual3)都在,因为反向要按原路把梯度加回去。
3. 前向:gpt2_forward 一次走完
3.1 层内九连
gpt2_forward(train_gpt2.c:765-890)主体是一个 for 循环,每层依次调 9 个函数
(train_gpt2.c:863-872):
residual ─► layernorm ─► matmul(QKV) ─► attention ─► matmul(proj) ─► (+residual)
─► layernorm ─► matmul(FC 4C) ─► gelu ─► matmul(proj C) ─► (+residual)
对应代码就是把这串调用原样写出,例如前三行:
layernorm_forward(l_ln1, l_ln1_mean, l_ln1_rstd, residual, l_ln1w, l_ln1b, B, T, C);
matmul_forward(l_qkv, l_ln1, l_qkvw, l_qkvb, B, T, C, 3*C);
attention_forward(l_atty, l_preatt, l_att, l_qkv, B, T, C, NH);
(train_gpt2.c:863-865)每层开始前先算一串 l_ 前缀的层内指针(params.qkvw + l * 3*C * C 这
种),这正是 §2 内存布局的用法:层 l 的张量 = 大数组 + l × 单层大小。
3.2 每个层的数学(各 20~70 行)
| 层 | 函数(行号) | 一句话 |
|---|---|---|
| 嵌入 | encoder_forward(train_gpt2.c:35) | out[b,t] = wte[token] + wpe[t],两次查表相加 |
| LayerNorm | layernorm_forward(train_gpt2.c:78) | 每个 (b,t) 对 C 维求均值/方差,归一化后缩放平移;缓存 mean/rstd 给反向(train_gpt2.c:114-115) |
| 矩阵乘 | matmul_forward(train_gpt2.c:184) | 朴素三重循环的 tiling 版:8 个 (b,t) 一组,权重行复用 8 次(train_gpt2.c:196-225) |
| 注意力 | attention_forward(train_gpt2.c:271) | 4 趟循环:QK 点积 → 减 max 求 exp → 归一化(含因果掩码)→ 加权求和 V |
| GELU | gelu_forward(train_gpt2.c:408) | tanh 近似的逐元素非线性 |