本文依据用户提供的 Andrej Karpathy《Let’s reproduce GPT-2 (124M)》完整英文逐字稿整理,按原视频 31 个章节展开。读者应已掌握线性代数、反向传播与基本 PyTorch;重点是模型计算、训练目标和系统实现如何相互约束。
代码核对来源:karpathy/build-nanogpt,本次核对 revision 为 6104ab1b53920f6e2159749676073ff7d815c1fa。文中短代码按该实现摘录或重组,新增推导与工程边界会明确标为补充。课程性能、云价格、软件兼容性和排行榜数字均是录制时期的观察,不是当前运行保证。
1. 复现的对象与判据(00:00)
GPT-2 的复现先解决结构等价,再解决训练效果。加载 OpenAI checkpoint 的意义,是验证自写 nn.Module 与目标模型具有一致的参数布局和计算路径;能生成连贯文本只是初步检查,更强的检查是在相同输入下比较 logits。训练阶段则保留这套结构、重新初始化参数,并用验证集 loss 与下游评测判断重新训练得到的权重。
GPT-2 是模型系列,包含约 124M、350M、774M、1558M 参数的版本;不能把最小模型与通常称作 GPT-2 的最大版本混为一谈。原论文表格中的部分参数计数与后来代码修正不一致。课程选择最小版本:12 层、12 个 attention heads、768 维 residual stream、1024 token context。GPT-3 同样是模型系列,175B 是最大版本,课程比较的也包括其小模型。
这里的目标不是逐项恢复 2019 年训练配方:GPT-2 公开了模型权重与推理代码,但没有充分公开训练配置和 WebText;GPT-3 论文披露了更多优化细节,因此课程采用 GPT-2 架构、GPT-3 风格优化参数与 FineWeb-Edu 数据。效果相近并不意味着训练数据、训练预算或所有能力相同。视频开场提到约一小时、约 10 美元,是当时云资源和特定配置的量级估计;后续实际展示的 10B token run 约需 1.7–2 小时。
2. checkpoint 是模型结构的可检查规格(03:39)
OpenAI 原版 GPT-2 使用 TensorFlow。课程借助 Hugging Face Transformers 已转换的权重,读取 GPT2LMHeadModel.from_pretrained("gpt2").state_dict();其中 gpt2 指 124M,最大版本名为 gpt2-xl。state_dict 提供参数和部分 buffer 的名称、形状与数值,可用于倒推架构,而不是只能把模型当生成接口。
| 张量 | shape | 作用 |
|---|---|---|
transformer.wte.weight | (50257, 768) | token ID 到 embedding 的查表 |
transformer.wpe.weight | (1024, 768) | 可训练的绝对位置 embedding |
transformer.h.0 至 .11 | 12 个 block | 反复更新 residual stream |
transformer.ln_f | scale、bias 各 (768,) | 输出分类前的 LayerNorm |
lm_head.weight | (50257, 768) | hidden state 到 vocabulary logits |
GPT-2 的 vocabulary 由 256 个 byte tokens、50,000 次 BPE merges 和一个 <|endoftext|> 构成,共 50,257。位置向量从随机值学习,不是原始 Transformer 中固定的 sinusoidal encoding。视频展示其训练后部分通道形成平滑、近似周期的结构;这是可视化观察。作者据噪声讨论了可能欠训练,不能把曲线是否平滑当作充分的训练收敛判据。
用 Hugging Face pipeline 给 Hello, I'm a language model, 生成多条续写,是加载成功的直观检查。固定 seed 仍可能因库版本和采样实现变化得到不同文本,不能仅凭生成字符串不同判定权重错误。
3. GPT-2 block:pre-norm、attention 与逐位置 MLP(13:47)
GPT-2 是 decoder-only Transformer:去掉 encoder 及 encoder–decoder cross-attention,仅保留 causal self-attention 与 MLP。相对原始 Transformer 的 post-norm,LayerNorm 移到子层输入,并在最后增加 ln_f:
x = x + self.attn(self.ln_1(x))
x = x + self.mlp(self.ln_2(x))若子层写作 x_next = x + f(LN(x)),其 Jacobian 包含恒等项 I,给梯度保留一条直接路径;其余梯度仍经过子层,这不意味着所有梯度均原封不动或训练必定稳定。attention 在时间维聚合不同 token 的信息,MLP 则对每个位置独立地应用同一组参数;MLP 本身不交换位置之间的信息。
用 ModuleDict 保存 wte/wpe/h/ln_f、用 ModuleList 保存 12 个 block,主要是使参数名与 checkpoint 对齐,同时让 PyTorch 正确注册参数。最终 LM head 为 nn.Linear(768, 50257, bias=False)。课程简化实现不加入 dropout;eval() 仍应保留为推理入口规范,但这套层在训练和评估模式下没有 dropout/BatchNorm 那样的行为分歧。
MLP 的通道流为 (B,T,768) → (B,T,3072) → (B,T,768):
self.c_fc = nn.Linear(C, 4 * C)
self.gelu = nn.GELU(approximate="tanh")
self.c_proj = nn.Linear(4 * C, C)GELU 精确定义为 x Φ(x),GPT-2 使用的近似为:
课程解释 tanh 近似与早期 TensorFlow 中 erf 的性能有关。复现选择近似版本是为了匹配 GPT-2,并非断言当前硬件上它一定更快。GELU 避免了 ReLU 负半轴整段严格为零的导数,但不能据此断言每个输入处的梯度都非零。SwiGLU 等后续变体属于架构变更,不在本次严格结构复现范围内。
多头 attention 把 head 维作为并行 batch 维,而不是为每个 head 编写独立 Python 调用。设 C=768, H=12, D=C/H=64:
q, k, v = self.c_attn(x).split(C, dim=2) # 各 (B,T,C)
q = q.view(B, T, H, D).transpose(1, 2) # (B,H,T,D)
k = k.view(B, T, H, D).transpose(1, 2)
v = v.view(B, T, H, D).transpose(1, 2)
scores = (q @ k.transpose(-2, -1)) / math.sqrt(D)
scores = scores.masked_fill(causal_mask == 0, float("-inf"))
att = F.softmax(scores, dim=-1) # (B,H,T,T)
y = att @ v # (B,H,T,D)
y = y.transpose(1, 2).contiguous().view(B, T, C)
y = self.c_proj(y)这段是解释计算路径的重组代码,causal_mask 应为覆盖当前 T 的下三角布尔 mask,包含对角线。1/√D 控制点积尺度,mask 阻止位置 t 访问 t+1 及以后,softmax 在 key/time 维归一化。最后的 transpose 恢复时间维,contiguous() 使内存布局满足后续 view,拼接各 head 后再投影回 C 维。显式 attention 中间矩阵为 (B,H,T,T),这是后面 FlashAttention 要消除的主要 HBM 开销。
4. 搬运权重时必须验证布局(28:08)
加载器根据 model type 确定层数、head 数和维度:gpt2=(12,12,768)、gpt2-medium=(24,16,1024)、gpt2-large=(36,20,1280)、gpt2-xl=(48,25,1600);checkpoint 的 context 与 vocabulary 分别固定为 1024、50257。
参数名相同不代表存储布局相同。Hugging Face 沿袭 GPT-2 的 Conv1D 包装,而 PyTorch Linear 权重为 (out_features,in_features);因此以下四类权重需转置:
transposed = [
"attn.c_attn.weight", "attn.c_proj.weight",
"mlp.c_fc.weight", "mlp.c_proj.weight",
]每个 key 都应验证 shape,转置项验证 reversed shape,随后在 torch.no_grad() 下 copy_。attention 的 .bias 或 .masked_bias 在这里可能是 causal mask buffer,不能误认为可训练 bias。加载器排除这些 buffer 后验证 key 数量,再逐项复制,避免“没有报错”掩盖漏载。最终复现的是同一个参数化模型族;checkpoint 载入正确后,再用随机初始化训练,才能把结构错误与优化问题分开。
5. forward 的输出为何是每个位置一组 logits(31:00)
输入 idx 为 (B,T) 的整数 token IDs,要求 T≤1024。wte(idx) 得 (B,T,C),wpe(arange(T)) 得 (T,C),后者沿 batch 维广播;经过所有 block 后 shape 不变,ln_f 后由 LM head 投影成 (B,T,V)。
pos = torch.arange(T, dtype=torch.long, device=idx.device)
x = self.transformer.wte(idx) + self.transformer.wpe(pos)
for block in self.transformer.h:
x = block(x)
logits = self.lm_head(self.transformer.ln_f(x))logits[b,t,:] 预测的是该行当前位置之后的 token。因 causal mask 限制,虽然训练一次算出所有位置,位置 t 并未看见自己的 next-token label。logits 尚非概率,softmax 才把 vocabulary 维变成条件分布。
6. 采样前缀、tokenizer 与推理状态(33:31)
课程用 tiktoken.get_encoding("gpt2") 编码前缀,示例得到 8 个 tokens,将其重复成 (5,8),产生五条独立续写;初始 max_length=30 指包含前缀在内的总长度。tokenizer 与 checkpoint 必须匹配,同一整数在另一套 vocabulary 中可能代表不同文本。
model.to(device) 移动 module 的参数和 buffer。输入张量也要在相同 device;位置索引按 idx.device 创建,避免 CPU/CUDA 混用。eval() 控制模块行为,no_grad() 控制 autograd 记录,两者职责不同,不能互相替代。
7. top-k sampling 的索引与概率(37:02)
每轮对完整前缀 forward,只取最后位置的 (B,V) logits,softmax 后保留前 50 项,再从这些概率权重中抽样。课程选择 k=50 是对齐当时 Hugging Face pipeline 的设置,不能视为所有版本的默认保证。
with torch.no_grad():
while x.size(1) < max_length:
logits, _ = model(x)
probs = F.softmax(logits[:, -1, :], dim=-1)
weights, ids = torch.topk(probs, 50, dim=-1) # (B,50)
local_id = torch.multinomial(weights, 1) # (B,1)
next_token = torch.gather(ids, -1, local_id) # vocabulary ID
x = torch.cat((x, next_token), dim=1)multinomial 接受非归一化的非负权重,内部按相对权重抽样,故无需手工再次除以总和。返回的 local_id 是 top-k 列表内的索引,必须用 gather 映射回真实 token ID。top-k 截断降低极低概率 token 被采到的机会,也改变了原模型分布,不是无损变换。
该实现没有 KV cache,每轮重新计算前缀所有位置,正确但浪费计算。课程生成很短,足以调试;若延长至 context 上限,必须处理最大长度,不能直接无界追加。补充:生产生成通常缓存历史 K/V;padding 后的 vocabulary 还应在采样前屏蔽无 tokenizer 定义的 ID。
8. 采样一致性与设备自动检测(41:47)
课程自写采样结果与 pipeline 不同,但对 Hugging Face model 使用同一手写采样逻辑能够匹配,说明问题更可能在 pipeline 配置,而非模型内部。seed 只是复现条件的一部分,tokenizer、采样参数、dtype、后端与版本也会影响结果。
设备选择按 CUDA、MPS、CPU 的可用性回退。CPU/MPS 能完成小规模结构与训练调试,不能把短采样约六秒的演示推断为完整预训练的可行耗时。随机创建 GPT(GPTConfig()) 后会生成无意义 token,符合尚未训练的状态;之后还需覆盖 PyTorch 默认初始化,以匹配课程采用的 GPT-2 初始化策略。
9. 用移位 token stream 构造训练批次(45:50)
Tiny Shakespeare 是调试数据:约四万行、二十万词、一百万 ASCII bytes;课程得到约 338K GPT-2 tokens,英文示例里字符/token 比约 3:1。这是数据集观察,不能套用到中文或所有文本。先取 1000 字符验证编码,换行在该 tokenizer 中常见 ID 为 198。
从连续 token stream 取 B*T+1 个元素:
buf = tokens[p : p + B*T + 1]
x = buf[:-1].view(B, T)
y = buf[1:].view(B, T)x[b,t] 和 y[b,t] 始终相差一个 token;多读的一个元素提供最后一个输入的 label。若先按行分组再只在行内右移,就容易丢掉末尾位置。课程先用 (B,T)=(4,6) 展示重排,再用 (4,32) 调试,logits 为 (4,32,50257)。
各行是独立 attention 序列;上一行不会成为下一行的上下文,即使它们在原始 stream 中相邻。行末 token 的 label 可以来自 stream 中紧随其后的 token,目标定义仍成立,但下一行会重新开始自己的有限上下文。
10. Cross entropy、flatten 与随机基线(52:53)
训练优化每个位置正确 next token 的负对数概率:
loss = F.cross_entropy(
logits.view(-1, logits.size(-1)), # (B*T,V)
targets.view(-1), # (B*T,)
)这里 flatten 是让 class 维符合接口约定;并不是 cross entropy 完全不能处理高维输入,它也支持把 class 维放在规定位置的高维形式。传入 raw logits,避免先 softmax 再交给 cross_entropy 造成重复计算与稳定性问题。
若初始化近似均匀分布,p≈1/50257,则 L≈ln(50257)≈10.825。视频初始 loss 约 11,量级合理。它只是 sanity check:数值接近不能证明 mask、label 和所有梯度都正确。
11. 单 batch 过拟合用于验证优化链路(56:42)
先固定一小批数据,用 AdamW、初始调试 learning rate 3e-4 训练约 50 步,确认 loss 能显著下降:
optimizer.zero_grad()
_, loss = model(x, y)
loss.backward()
optimizer.step()backward() 向 .grad 累加,因此每个正常 update 前必须清梯度;梯度累积则有意延迟清零。AdamW 维护一阶矩 m 与二阶矩 v,相比 SGD 增加了优化器状态。课程暂将优化器视作已知工具,不在这里推导其全部性质。
设备迁移有一个容易忽略的差别:module.to(device) 修改 module,而 tensor 的 .to() 应接收返回值,如 x = x.to(device)。只调用 x.to(device) 不会让原变量自动指向 GPU tensor。loss.item() 把标量带回 CPU,CUDA 上可能引入同步;频繁日志也会影响测量。
单 batch loss 接近零只能说明模型有能力记住这些 targets、链路基本工作;它不能证明泛化,下一步必须换新 batch。
12. DataLoaderLite:按序遍历的最小状态机(62:00)
loader 在 CPU 内存保留 token stream,用当前位置 p 取 BT+1,每次令 p += BT,不足下一批时重置到零。多读的一个 label token 不计入步进,否则会跳过训练位置。数据留 CPU、仅把当前 batch 搬到 GPU,保留显存给模型与中间激活。
课程 (B,T)=(4,32) 时,每 epoch 约 2600 batches,50 次更新远不足一个 epoch;loss 从约 11 降至 6.6,符合先学会高频 token 与排除大量未出现 token 的预期。原文以“调整输出 bias”解释这个过程,但本实现 LM head 没有 bias,应理解为通过参数改变输出 logits,而不是存在独立输出 bias 参数。
按序读取相当于在一轮内不重复抽取已消费位置,直到回到开头。它便于调试且足够启动训练,但没有随机 shuffle、文档感知切分或严格覆盖全部尾部 token;这些边界会在多卡和多 epoch 时产生影响。
13. Weight tying:同一矩阵承担输入与输出(66:14)
wte.weight 与 lm_head.weight 都为 (V,C)。GPT-2 不仅让二者初值相等,而是共享同一个 Parameter:
self.transformer.wte.weight = self.lm_head.weight输入端取矩阵第 i 行作为 token i 的表示;输出端用 hidden state 与每行做内积生成 logits。共享把“表示 token 的方向”与“识别 token 的方向”绑定,是一种归纳偏置。反向传播会把输入 embedding 路径和输出分类路径的梯度相加到同一参数上。用 copy_ 复制数值并不能建立这个约束,两个独立参数后续仍会分离。
50257*768=38,597,376 个参数因此只存一份。视频称其约 40M、约为 124M 的三成;精确含义是相对于不共享时额外增加的一张矩阵,省掉 38.6M,而不是在已经含 tying 的 124M 上再减去这些参数。可检查 .data_ptr() 是否相同,而不仅是元素相等。共享还减少对应梯度和优化器状态的存储;checkpoint 加载与参数分组也必须尊重此别名关系。
14. 初始化为何要缩放 residual 输出(73:47)
课程使用线性层与 embedding 的 N(0,0.02²),bias 为零;LayerNorm 保留默认 scale=1、bias=0。它指出 OpenAI 原推理代码中位置 embedding 的 std 为 0.01,但教学实现统一采用 0.02,因此不能称为每项完全相同的历史初始化。0.02 与这些模型宽度下 1/√fan_in 的量级相近,不过固定值不等于一般 Xavier 公式。
residual stream 不断累加子层输出。简化为 n 个独立、零均值、单位方差贡献,其和的方差为 n,std 为 √n;将每项乘 1/√n 可以保持总体方差量级。GPT-2 每层有 attention 与 MLP 两次 residual addition,因此为两类 c_proj 使用:
std = 0.02
if hasattr(module, "NANOGPT_SCALE_INIT"):
std *= (2 * self.config.n_layer) ** -0.5
nn.init.normal_(module.weight, mean=0.0, std=std)标记附在 attention 和 MLP 的输出 projection 上,而非全部层。独立随机贡献只是解释尺度的近似,真实网络子层相关且非线性,不能当作精确方差证明。apply 遍历子模块时,共享的 wte/LM head 会被初始化两次,最终保留后一组抽样;两次分布相同但会消费额外 RNG 状态。课程设置 seed 1337 保持实验可比较,seed 本身不能覆盖所有非确定性来源。
15. 先建立可信的性能基线(82:18)
课程云机器有 8× NVIDIA A100 SXM 80GB;此时先只使用 GPU 0。以 B=16,T=1024、FP32 测得约 1000ms/step,显存约 35GB。每步 16,384 tokens,所以吞吐应约 16384/1s≈16.4K tokens/s;逐字稿出现的 163K 与时间及 batch 不一致,应按公式纠正。
| 优化阶段 | 视频单 GPU 约耗时 | 条件与解释 |
|---|---|---|
| FP32 baseline | 1000ms | B=16,T=1024 |
| TF32 | 333ms | 参数/张量仍为 FP32 |
| BF16 autocast | 300ms | 部分计算与激活改用 BF16 |
torch.compile | 130ms | 排除初次编译时间 |
| FlashAttention/SDPA | 96ms | causal attention 融合实现 |
| vocabulary 50304 | 93ms | 对齐矩阵维度 |
| fused AdamW | 90ms | 同时引入相应优化配置 |
这些是同一课程渐进改代码的近似测量,不能简单当作各项独立、可相乘的普适加速比。OOM 时降低 B;有显存余量时可试更大且对齐良好的 B。指标用 tokens/s 而不只是 steps/s,因为 B 与 accumulation 改变后每步工作量会变化。
16. Tensor Cores、TF32 与异步计时(88:14)
A100 规格中,视频引用 dense FP32 约 19.5 TFLOPS、TF32 Tensor Core 约 156 TFLOPS、FP16/BF16 约 312 TFLOPS;带 sparsity 的更高数字不适用于这里的 dense workload。峰值是算术上限,不是训练吞吐承诺。Transformer 的大部分 FLOPs 来自矩阵乘法;124M 这样的小模型里 (BT,768)×(768,50257) 的输出投影尤其大。
Tensor Cores 执行小块矩阵乘累加,大矩阵由分块组合完成。视频用 4×4 示意基本操作,具体 tile 与指令格式随架构/精度而变,不能把所有 Tensor Core 实现固定为 4×4。
FP32 的 sign/exponent/fraction 为 1/8/23 bits;TF32 计算使用 1/8/10 的有效输入精度,保留 exponent 范围,乘累加的 accumulator 使用 FP32。外部 tensor 仍然 FP32,HBM 存储和传输不会因此减半。
torch.set_float32_matmul_precision("high")在课程 A100 环境,此设置允许适用 matmul 使用更低内部精度,测得约三倍而非峰值八倍加速。原因包括仍需搬运 FP32 数据、非 matmul 开销与算力利用率。该 API 的具体算法选择依赖后端版本和硬件,不能概括成任何环境都强制采用 TF32。
CPU 向 CUDA 提交 kernel 后通常继续执行;计时终点必须等 GPU 完成:
torch.cuda.synchronize()
t1 = time.time()
tokens_per_second = B * T / (t1 - t0)补充:做独立 benchmark 时应先 warmup、同步测量起点并排除编译/初始化,把是否含数据搬运、optimizer、评测说明清楚。原代码外层计时在评测步还会包含验证、采样等工作,这类 step 不应与纯训练 step 直接比较。
17. BF16 mixed precision 降低哪些开销(99:38)
FP16 使用 1/5/10 bits,BF16 使用 1/8/7。BF16 和 FP32 有相同 exponent 位数,范围相近但精度较低;FP16 范围较窄,训练时更容易遇到梯度 underflow/overflow,因此常搭配 loss scaling。课程选择 A100 支持的 BF16,未使用 GradScaler;这不是所有算法在任何状态下都不会数值不稳定的保证。
with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
logits, loss = model(x, y)
loss.backward()
optimizer.step()autocast 包围 forward 与 loss,backward 和 optimizer step 放外面,不把整个 model 直接 .bfloat16()。视频观察参数保持 FP32、logits 为 BF16;matmul 类操作适合降精度,LayerNorm、softmax、loss 等可能保留或提升到 FP32。具体 dtype 由 autocast 的 op policy 与输入决定,不能认为所有 activations 都为 BF16。
相较 TF32,BF16 还可减少部分激活的存储与传输,所以同时影响计算与 bandwidth;视频只从约 333ms 到 300ms,因为其他瓶颈仍在。FP64 在科学计算中有用途,INT8 在课程语境主要用于推理;这些是选择本次训练精度的背景,不应扩大成“整数量化训练永远不可行”的结论。
18. torch.compile:减少解释器与 HBM 往返(108:15)
model = torch.compile(model)课程从约 300ms 降至 129–130ms,约 2.3 倍,吞吐约 125K tokens/s。编译能在可捕获的计算区域减少 Python dispatch,并对操作做融合。它不保证所有模型只有一个 kernel,也不保证没有 graph break、重编译或 Python 执行。
以 tanh GELU 的展开式为例,eager 模式中逐个 power、multiply、add、tanh 可能产生多次 kernel launch 与中间 tensor 写回;融合后一个数据块读入片上存储,连续执行多个 elementwise 运算,再写回一次。省下的是 launch overhead 与 global memory traffic,不只是少执行 Python 行数。
GPU memory hierarchy 中,HBM 容量通常为几十 GB,带宽高但访问仍比片上昂贵;SM 内的 registers、shared memory/L1 和片上 L2 更快,但容量小得多。视频以约 2TB/s 的 A100 HBM 带宽、几十 MB 片上缓存量级说明差距,并展示 SM、Tensor Core、FP32/FP64 等计算单元。HBM 是 High Bandwidth Memory;CPU DRAM 与 GPU HBM 都不能与磁盘混为一谈。kernel fusion 的价值是让有限的片上空间服务更多计算,而非把完整模型永久放入缓存。
编译有启动成本,短调试、动态 shape 和难捕获控制流未必获益。课程后续遇到 generation/HellaSwag 与 compile 的兼容性问题并关掉它;应记录版本和错误再判断,而不能把这段课程测量解释成“总应无条件开启”。
19. FlashAttention 改写算法以避开 T² 中间存储(120:18)
标准 attention 先生成 (B,H,T,T) scores,mask、softmax 后再乘 V。T=1024 时每个 batch/head 就有约一百万个 score,跨 HBM 反复读写代价大。FlashAttention 用 tiling 与 online softmax 组织计算,在片上处理 Q/K/V 子块,避免将完整 T×T attention matrix 写到 HBM;它不把 attention 近似成另一个目标函数。
online softmax 的核心是逐块维护 row max m 与归一化量 l。补充推导:旧块状态为 (m,l),新块 scores 为 s,令 m'=max(m,max(s)),则 l'=exp(m-m')l+Σ exp(s-m');加权 V 累加器也按同一尺度重标定。这样无需先读全行 scores 才能求稳定的归一化结果。算法还可在 backward 重计算部分中间量,以 FLOPs 换 HBM traffic。
课程提及 2018 年 NVIDIA online softmax 工作、2022 年 FlashAttention 与后来的 FlashAttention-2,强调 FLOPs 更少不一定更快,memory access pattern 同样关键。论文中潜在 7.6 倍等数字对应其 benchmark,不能套给整个 GPT-2 step。
y = F.scaled_dot_product_attention(q, k, v, is_causal=True)该 compound op 表达完整 attention,适用环境可 dispatch 到 FlashAttention;具体后端依赖 device、dtype、shape、版本,不能保证每次调用都使用 flash kernel。课程约从 130ms 到 96ms,单步耗时降低约 26%,对应吞吐提高约 35%(约 1.35 倍);第 49 步 loss 从约 6.05661 变为 6.058,体现相同数学计算仍有浮点顺序差异。切换后旧 causal mask buffer 已不必要,官方 errata 后来删除了它。
20. vocabulary padding 为何增加计算却更快(126:54)
50304 是 128 的倍数,比 50257 大 47。将训练 config 的 vocabulary 扩到 50304,可使大输出投影更贴合 kernel tile,减少边界处理;视频 nightly 版本约 96.5ms→93ms,约 4%,作者说早期 PyTorch 2.3.1 或更旧版本曾观察到约 30%。这是版本敏感的实验,不是所有“2 的幂越大越好”。768、1024 等维度已适合分块;GPT-2 XL 的 25 heads 则曾给自定义 kernel 带来额外处理。
padding 后 tokenizer 不变:输入不会索引新增行,但 weight tying 让它们仍参与输出分类。新增 logits 进入 softmax denominator,即使没有正标签也会获得输出端梯度,训练会压低其概率。它并非数学上完全无影响:随机均匀基线从 ln(50257) 变为 ln(50304),只差约 0.000935。
补充:新 ID 50257–50303 无法由原 tokenizer 正常解码,尤其训练初期不应依赖“自然不被选中”。生成时可在 softmax 前令 logits[...,50257:] = -inf。加载原 checkpoint 必须仍使用 50257,或显式处理扩容;不能把 shape 不同的原权重直接 copy 到扩大的矩阵。
21. AdamW 配置与 global gradient clipping(134:55)
课程沿用 GPT-3 的 betas=(0.9,0.95)、eps=1e-8,而非常见 Adam 默认 beta2=0.999。更小 beta2 改变二阶矩的时间尺度,不能孤立认为它总更好;learning rate、batch size、decay 和训练预算共同决定优化行为。
backward 后将所有参数梯度视作一个长向量 g:
norm = torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)返回值是裁剪前 norm,值得记录以观察尖峰与不稳定。视频开始 norm 约 30,随后经 6、2、10 等波动后降至较低量级;初始化早期分布调整剧烈并不罕见。clipping 限制当前梯度幅度,并不等价于直接限制 AdamW 的参数更新距离,也不能替代查找坏数据、溢出或学习率过高等根因。
22. Warmup 与 cosine decay 的时间单位(141:06)
GPT-3 论文描述先在 375M tokens 上线性 warmup,再在前 260B tokens 内 decay 到峰值的 10%,其后继续以该值训练到 300B。课程小模型采用峰值 6e-4、最低 6e-5,而不是调试期的 3e-4;并简化为在整个 run 内完成 cosine decay。
令 s 为从零开始的 optimizer step,W 为 warmup steps,S 为 decay horizon:
s+1 避免首步学习率恰为零。每个 step 设置所有 optimizer.param_groups 的 lr;自写几行 scheduler 使边界可检查。最终 W=715,S=19073,需要结合每步 token 数理解:改变 global batch 却保持 steps 不变,就改变了 token 维度上的日程。range(S) 最后实际步为 S−1,离数学终点还差一个离散步。
23. Batch ramp、weight decay 与 fused AdamW(146:21)
课程没有复现 GPT-3 的逐渐增大 batch size。作者的解释是早期样本梯度相关,较小 batch 已提供相似方向,batch ramp 可能节约系统计算;但采用它会使每步 token 数变化,增加日程换算复杂度。这里保留固定 global batch,这是明确偏离论文的实现选择。
weight decay 设 0.1,只对二维及以上参数应用;bias 和 LayerNorm scale 等一维参数不 decay:
decay = [p for p in model.parameters() if p.requires_grad and p.dim() >= 2]
no_decay = [p for p in model.parameters() if p.requires_grad and p.dim() < 2]
groups = [
{"params": decay, "weight_decay": 0.1},
{"params": no_decay, "weight_decay": 0.0},
]AdamW 将 decay 与自适应梯度更新解耦。补充直观形式为 θ ← (1−ηλ)θ−η·AdamDirection,而不是把 L2 梯度先混进 Adam 的 moment 后再缩放。课程约有 50 个 decay tensors、98 个 no-decay tensors,后者约十万参数,占比很小;具体统计应以模型实现为准,共享参数要去重。
通过 inspect.signature(torch.optim.AdamW) 检查 fused 选项,且只在适用 CUDA 环境启用,减少逐 tensor 更新的 kernel overhead。课程约从 93ms 到 90ms;“fused”不保证整个 optimizer 在任何实现里严格只有一个 kernel。优化器选项兼容性与默认值随版本变化,应记录实际环境。
24. Gradient accumulation 必须保持 loss 的平均尺度(154:09)
GPT-3 small 的 batch 约 0.5M tokens,不能误当作 0.5M 行序列。课程选择 N=2^19=524288 tokens/update;单 GPU B=16,T=1024 时,每个 microbatch 16384 tokens,需要 A=N/(BT)=32 次 forward/backward 后才更新。
若每个 microbatch loss 已经是 BT 个 token 的均值,则全 batch 目标为:
直接连续 loss.backward() 会把梯度放大 A 倍;课程用四个回归样本的 MSE 演示,一批四样本与四次单样本必须补上 1/4 才等价。cross entropy 的默认 reduction 也为 mean,因此写法是:
optimizer.zero_grad()
loss_accum = 0.0
for _ in range(grad_accum_steps):
x, y = loader.next_batch()
with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
_, loss = model(x.to(device), y.to(device))
loss = loss / grad_accum_steps
loss_accum += loss.detach()
loss.backward()
norm = torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()记录 detached loss 避免保留整条图;不能只打印最后一个 microbatch 代替 global loss。clipping 和 step 都在累积完成之后。吞吐分子改为 B*T*A。课程约每 update 三秒,与 32×约 0.1 秒相符。
固定 global batch 后,B 主要成为显存与吞吐调节量,A 相应变化;须满足整除关系。等价前提包括相同样本、相同每样本目标与正确权重,不含依赖 batch 统计的层,且允许浮点误差。若 microbatch 的有效 token 数不同,应按有效 token 数加权,简单除 A 不再严格等价。
25. DDP:数据不同、梯度相同、参数同步更新(166:52)
DDP 为每张 GPU 启动一个进程,每进程保留完整模型、梯度和 optimizer state。它增加数据并行度,并不把一个大模型切到多张卡来降低单卡模型存储需求。课程使用:
torchrun --standalone --nproc_per_node=8 train_gpt2.pyRANK 是全局进程编号,LOCAL_RANK 指当前节点的 GPU 编号,WORLD_SIZE=P 为进程总数;单节点时 rank/local rank 恰好一致,多节点不能混用。设置 cuda:LOCAL_RANK,初始化 NCCL process group,rank 0 负责日志/checkpoint。课程脚本要求 CUDA/NCCL,并不意味着 DDP 理论上只能运行在 GPU。
global batch 变为 N=B*T*A*P。B=16,T=1024,P=8 时,一轮 microstep 总计 131072 tokens,A 从 32 降为 4。要让八卡看到不同数据,loader rank r 的起点为 rBT,之后步进 PBT;读取仍为 BT+1,重置时仍保留 rank offset。
model = DDP(model, device_ids=[ddp_local_rank])
raw_model = model.moduleraw_model 用于自定义 configure_optimizers、config 和 state dict。所有 rank 使用相同初始化种子,DDP 还会同步初始状态;同一 update 后各 rank 应有相同参数。
DDP 在 backward 中对梯度做 all-reduce,使每卡得到 rank 间平均梯度;实际实现可按 bucket 与 backward 重叠通信。梯度累积的前 A−1 次无需同步,仅最后一次同步即可。课程直接切换 require_backward_grad_sync;官方 errata 特别修正:它在 forward 中也被使用,因此必须在 forward 前设置,而不是只在 backward 前设置。
if ddp:
model.require_backward_grad_sync = (micro_step == A - 1)
with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
_, loss = model(x, y)
(loss / A).backward()补充:更稳健的公开接口是前几次用 model.no_sync() 包住 forward 和 backward,最后一次正常同步;可配合 nullcontext() 避免复制整段训练代码。直接访问内部属性要承担版本耦合。
DDP 已按 rank 平均,不要再把 loss 除 P,否则梯度缩小 P 倍。每 rank 除 A 后 all-reduce average,正好得到所有 P*A*BT 个 token 的均值梯度。loss 日志不在 DDP 包装的梯度同步范围内,需另外 all_reduce(AVG);计时 throughput 为 B*T*A*P/dt。所有 rank 都要参加 collective,不能只让 rank 0 调用。退出调用 destroy_process_group()。
课程八卡约 1.5M tokens/s。单卡与多卡 loss 若不完全相同,先检查 loader 尾部丢弃/重置边界;其简单分块方式在 P 改变时可能消费不同样本。作者缩小 batch/上下文并避开边界后做对比,以区分数据差异和 DDP 计算错误。
26. 从 WebText 到 FineWeb-Edu:数据也是复现变量(190:21)
GPT-2 的 WebText 未公开,论文描述从 Reddit 至少三个 karma 的帖子所指外链开始采集,约 45M links、最终约 40GB 文本;OpenWebText 是后续复现尝试。GPT-3 使用经过处理的 Common Crawl、WebText2、books、Wikipedia 等 mixture,原始 mixture 也未完整公开。原始 Common Crawl 含广告、模板、表格、垃圾内容,过滤与去重直接影响每 token 的训练价值。
课程讨论 RedPajama、清洗去重后的 SlimPajama,并提到其中 Common Crawl/C4、GitHub、books、arXiv、Wikipedia、StackExchange 等来源;最终使用 FineWeb-Edu 的 sample-10BT。视频介绍 FineWeb 约 15T tokens,转录中 educational 1.3T 与 high-educational 5.4T 的描述有阈值混淆。补充核对:官方数据卡 说明,score≥3 的 FineWeb-Edu 保留约 1.3T tokens,较宽松的 score≥2 版本保留约 5.4T。Llama3-70B-Instruct 为约 500K 样本生成教育质量评分,再用这些标注训练较小 classifier 扩展筛选,并非让 70B 模型逐篇直接处理全部语料。这里使用的是下方代码中的 sample-10BT,数据卡标明约 10B GPT-2 tokens;当前 dataset 已有后续版本,复现历史 run 还需固定 revision。
fw = load_dataset("HuggingFaceFW/fineweb-edu",
name="sample-10BT", split="train")
tokens = [enc._special_tokens["<|endoftext|>"]]
tokens.extend(enc.encode_ordinary(doc["text"]))
tokens = np.asarray(tokens, dtype=np.uint16)每篇文档前放 <|endoftext|>,ID 50256;它虽叫 end-of-text,在拼接 stream 中也标识下一篇开始。encode_ordinary 对文档文本普通编码,不把正文里恰好出现的 special-token 字面形式当作边界指令。uint16 上限 65535,足以存 GPT-2 IDs,写入前检查所有值在 [0,2^16);这是磁盘格式,不是模型输入 dtype。
预处理使用 multiprocessing,按 CPU 数的一半创建 worker、imap(...,chunksize=16) 分词,累积每 shard 100M tokens,保存 .npy。100 shards 约 10B tokens;第一个 shard 为 val,其余为 train。跨 shard 的文档可被切开,尾 shard 可不足 100M。100M uint16 元素约 200MB,10B 量级约 20GB,尚不含原文缓存和其他开销。课程预处理约三十分钟,受 CPU、网络和磁盘影响。
loader 增加 split、排序后的 shard 列表、current_shard 与 current_position,读尽后切下一 shard,最终循环。输入需转成 torch.long;官方后补 uint16→np.int32→torch.long 规避早期 PyTorch 的 uint16 转换限制。
每 update 524288 tokens,约 10B 的预算换成 max_steps=19073;375M token warmup 换成 warmup_steps=715。B=64,T=1024,P=8 刚好得到 524288,A=1,课程约 330ms/update,估算 19073*0.33≈6294s≈1.75h。
这些是预算近似:第一个 shard 留作验证、每 shard 尾部被 loader 跳过、整数步数与循环策略都会影响真实训练覆盖。不能把 19073 步解释为对全部十亿级样本严格一次无遗漏遍历。
27. 验证、采样与训练随机状态的隔离(203:10)
val loader 每次 reset(),对固定区域累计 20 个 batch 的平均 loss;用 eval() 与 no_grad(),DDP 下再平均 rank 的结果。课程最初每 100 步验证/采样,最终代码改成每 250 步或最后一步。验证结束恢复 train() 后再执行训练 update。
训练与验证 loss 的差距帮助发现 overfitting,尤其多 epoch run;相近不等于没有数据污染。OpenAI GPT-2 在同一 FineWeb-Edu 验证区域上的 loss 是一个可比较的数据点,但该分布对新训练模型更匹配,不能据此泛化为“所有任务优于 GPT-2”。20 batches 也不等于整个 100M-token val shard。
采样应有独立 RNG:
sample_rng = torch.Generator(device=device)
sample_rng.manual_seed(42 + ddp_rank)
ix = torch.multinomial(topk_probs, 1, generator=sample_rng)这让日志采样不消耗训练的 global RNG state,避免仅调整 sample frequency 就改变后续训练随机过程。不同 rank 用不同 seed 得到多组文本。最终代码生成四条、总长 32,与开头五条、总长 30 的调试演示要区分。
课程遇到开启 compile 后 sampling 报错,曾禁用采样保留编译,后为采样及评测又默认关闭 compile。核对 revision 的 use_compile=False 仍保留该说明;本文不假定这个问题在当前版本已修复,也不把它当作所有 PyTorch 版本的固有缺陷。
28. HellaSwag:用 continuation likelihood 评测基础模型(208:23)
HellaSwag 是四选一的常识续写任务,题材来自 ActivityNet/WikiHow,错误选项通过对抗方式构建。随机基线 25%;课程引用其早期人类约 95%、当时模型约 48%,以及录制时期更强模型约 96%,只用于解释 benchmark 历史。它对小模型提供平滑的 early signal,适合观察从随机基线逐渐提升。
基础 124M model 没有经过指令训练,不应强求它读懂 A/B/C/D 输出格式。课程对每个选项分别构造 context + ending,四行 padding 成 (4,Tmax);context 和 padding 的 mask 为 0,ending 为 1。每个候选只见自己的续写,不看其他候选。ending 前补空格以匹配 GPT-2 tokenization。官方脚本 validation 实际为 10,042 条,逐字稿约称一万条。
评分不是“token 概率的算术平均”,而是候选 token 的平均负对数概率:
这相当于最大化 token probability 的几何平均。总 log-likelihood 与按长度归一化的结果不同,分别对应脚本的 acc 与 acc_norm。
shift_logits = logits[:, :-1, :].contiguous()
shift_tokens = tokens[:, 1:].contiguous()
losses = F.cross_entropy(
shift_logits.view(-1, shift_logits.size(-1)),
shift_tokens.view(-1), reduction="none",
).view(tokens.size(0), -1)
shift_mask = mask[:, 1:]
score = (losses * shift_mask).sum(1) / shift_mask.sum(1)
prediction = score.argmin()去掉 logits 的最后位置,同时去掉 tokens 和 mask 的首位置,使位置 t 的 logits 对齐位置 t+1 的目标及其计分标记;这样 context 最后一个位置预测的第一个 ending token 计入,而 context 自身不计分。padding token 仍可能经过 forward,但不进入评分;空 ending 会令分母为零,应在通用实现中验证。
同一脚本记录 GPT-2 124M acc=28.59%、acc_norm=29.55%,GPT-2 XL acc=38.42%、acc_norm=48.93%。课程要超越的红线是 29.55%。源码注释另列 Eleuther harness 的 GPT-2 28.92%/31.14%、XL 40.04%/50.89%;作者未确认差异根因,不能把“可能是 multiple-choice 格式”写成定论。比较时必须同时固定 rendering、tokenization、长度归一化与 split。
DDP 评测按 i % P == rank 分题,各 rank 累积 correct/total,最后分别 all-reduce SUM,再相除;不能在题数不等时直接平均各 rank accuracy。训练日志按 step train/val/hella value 追加到 log/log.txt,新 run 初始会清空该文件。最终每 250 步评测并生成,编译冲突时跳过相应部分。
29. 10B 与 40B token run 能说明什么(223:05)
10B 预算运行 19073 steps,视频曲线显示 FineWeb-Edu val loss 低于原 GPT-2 124M,同协议 HellaSwag 也超过 29.55%。逐字稿没有给出该 run 末尾精确 accuracy,不应凭图像记忆补造。train loss 出现周期性形状,作者怀疑 sample 未充分 shuffle 与顺序 loader 的组合,但当时没有确证。
| 运行/参照 | 课程所述训练 token 量 | 可据原文陈述的结果 |
|---|---|---|
| OpenAI GPT-2 124M | 约 100B(课程引用) | 本课程 HellaSwag acc_norm 29.55% |
| 本次单轮预算 | 约 10B | 超过上述红线,val loss 也更低 |
| 本次 overnight | 约 40B,四轮预算 | HellaSwag 33.24%,接近但未达到课程 GPT-3 小模型参照 |
| GPT-3 小模型参照 | 300B(论文训练预算) | 协议与来源须一起核对,不能只比较模型名 |
40B run 约八小时、约 76.3K steps,在同一 10B sample 上重复,而非新增 40B unique tokens。更多训练改善样本文本连贯性,但“像是在谈论自己”是文本表象,不证明模型具有自我意识。作者推测第五轮可能追上参照,不能写成已发生的实验结果。
较少 tokens 达到某个 benchmark 分数,可能来自更高质量数据、更窄的英文教育分布、更少多语言/代码/数学竞争、优化配置差异,或 benchmark contamination。这些是待验证假设。视频希望数据构建方已去除 HellaSwag,但没有证明无污染,因此不能从这个实验推出十倍普适学习效率。
多 epoch 顺序读取会反复让相同文档相邻,产生无关的跨文档关联。可在每 epoch shuffle shards 与 documents,打破固定顺序;只 shuffle shards 不能消除 shard 内顺序。实现时应保留所有 rank 对同一全局排列的协调,避免独立乱序引入重复/遗漏。周期性 loss 需要先审计数据顺序、质量分布和边界,不能断言 shuffle 是已验证的唯一修复。
GPT-3 小模型设置并非这里的严格最优:作者提到有人可把 peak LR 提到近三倍,但这不构成可无条件复制的稳定配方。若把 context 从 1024 改为 GPT-3 的 2048,并保持八卡每步 524288 tokens,可将 B 从 64 降至 32;还必须扩大模型 block_size 与位置 embedding,不能只修改 loader 的 T。GPT-3 的其他架构细节与训练数据也需检查,不能因这两个数相同就声称完全等价。
保存 checkpoint 发生在验证后、rank 0,每 5000 步或最后一步,包含 model state、config、step、val loss。核对代码中,验证与保存发生在该 step 的 optimizer.step() 之前:例如 model_19072.pt 是已完成 19072 次更新的状态,随后循环还会执行一次更新但不再次保存。同 step 的 val 与 train 日志因此也不对应完全相同的参数时刻。仅这些可恢复模型推理,但不足以精确恢复训练;还需 optimizer 的 m/v、scheduler/step、RNG states、数据 shard/position 与必要的分布式状态。更全面能力比较应另用统一 evaluation harness,覆盖代码、数学、多语言等任务。
训练截至 pretraining:next-token 目标学的是文档续写,不能直接期待 ChatGPT 式对话。课程将 SFT 概念化为替换成 user/assistant 对话数据后继续训练;工程上还需明确格式、special tokens、loss mask 与推理 prompt。本课程没有完成 chat fine-tuning。
30. llm.c:以参考实现验证专用 CUDA 路径(236:21)
karpathy/llm.c 提供 GPT-2/3 风格训练的 C/CUDA 实现,PyTorch train_gpt2.py 作为 reference,train_gpt2.cu 为专用路径,涉及 CUDA、NCCL、MPI 等系统组件。验证重点是对齐相同步骤的 loss 与 gradient norm,不只是两边都能下降。
课程把 PyTorch 与 llm.c 分别放到单卡 GPU 0/1 同时运行;C/CUDA 版本更快启动,PyTorch 当时仍在 compile。稳定后示例吞吐约 llm.c 223K tokens/s、PyTorch 185K tokens/s,约 1.21 倍,loss/norm 打印值对应一致。作者明确没有确认 PyTorch 已充分调优;这里也没有给出足够条件把这组数据与前面 B=16 的逐项优化表直接合并。
专用实现可以更直接安排内存、kernel 与通信,但增加开发和验证成本;PyTorch 支持更广泛的模型与自动微分。性能比较应固定模型、batch、精度、数据与同步方式,才能把收益归因给实现。课中的一致日志是有力检查,仍不等于逐位一致或对所有配置都验证过。
31. 把教学代码转为可靠实验(239:39)
课程形成一条完整路径:checkpoint 检查→自写结构→权重移植→采样→next-token 数据和 loss→单 batch 调试→随机初始化训练→mixed precision/compile/FlashAttention→AdamW 与日程→accumulation/DDP→FineWeb-Edu→validation/HellaSwag→checkpoint 与多轮结果。
原文结尾保留三个未解决点:loss 周期性原因、compile 与采样/评测冲突、epoch 边界的数据 shuffle。官方仓库 commit history 适合逐步比对每次机制变化;README errata 已补充删除旧 mask buffer、uint16 转换、device/device_type 区分、DDP sync 标记必须早于 forward 等修正。
device="cuda:3" 不能原样作为 autocast 的 device_type,后者应为 "cuda";所核对教学代码把非 CUDA 路径映射为 CPU,也特别说明其 MPS 路径仍需核实。复现时应把这类教学简化与支持范围写清楚,而非未经验证扩展到所有设备。
这份笔记中的模型数学与 shape 可独立核查;耗时、价格、分数和未修问题则应连同课程条件阅读。重跑实验时至少保存代码 revision、依赖版本、GPU 型号、tokenizer/data revision、实际 tokens、global/micro batch、precision、compile 开关与完整评测协议,才能判断偏差来自实现、数据还是运行环境。
来源与勘误说明
- 主要素材:用户提供的完整逐字稿
Pasted text(20261021-053558).txt,227126 bytes,1396 行;已按原文 31 章节阅读并映射。原视频:Let’s reproduce GPT-2 (124M)。 - 代码与 errata:build-nanogpt 固定 revision,重点核对
train_gpt2.py、fineweb.py、hellaswag.py和 README。短代码可能为解释机制重组,未冒充独立可运行的完整训练脚本。 - 论文出处由官方课程仓库链接:GPT-2 paper、GPT-3 paper。本文没有声称对论文全部附录重新复核。
- 已修正常见转录错字:
1024→124、50257的多位混写、tanh→10h、AdamW、tiktoken、NCCL、HellaSwag、FineWeb 等。吞吐 1000ms 对应的 token 数按B*T/dt纠正;HellaSwag 的“平均概率”按源码纠正为平均负 log probability。 - 作者对数据污染、loss 周期、pipeline 差异等提出但未证实的解释保留为假设。新增 online-softmax 推导、padding 采样屏蔽、长度加权 accumulation 和 resume 状态等为帮助理解的工程补充,未称为课程已完成的修改。