乐于分享
好东西不私藏

nanoGPT 源码解析(三):从业务架构看这个"最小可行训练系统"

nanoGPT 源码解析(三):从业务架构看这个"最小可行训练系统"

写在前面

前两篇分别从目录结构和工程实现细节梳理了  nanoGPT:第一篇画出了代码地图——data 层准备语料、model.py 定义计算图、train.py 做训练调度、config  层负责参数注入;第二篇往下钻了几处具体的技术手法,比如 DDP 手动梯度同步、Flash Attention 能力探测降级、checkpoint   前缀修复。这一篇换个视角——不再看"代码怎么写",而看"这套系统作为一个业务闭环,划了哪些子系统、做了哪些取舍、为什么这么划",把前面看到的零散手法收束回它们各自服务的业务目标。

如果把"训练一个能用的 GPT 模型"当成一项业务需求,nanoGPT 给出的答案是一个极度收敛的最小实现。理解它的业务架构,本质上是在理解它把哪些复杂度纳入了系统边界、又主动舍弃了哪些。

五个业务子系统

把 train.py + model.py  拆开看,可以识别出五个职责边界清晰的子系统。它们不是按文件划分的,而是按"解决什么业务问题"划分的——这一点很关键,因为 train.py  一个文件里其实同时住着编排、数据供给和效率度量三个子系统,按文件切根本切不出这个结构。

1. 训练编排子系统

对应的业务问题是:同一份代码要能覆盖从单机调试到多机集群训练的全部场景,且不能让使用者感知到底层复杂度

它靠三件事做到这点:

  • 环境自适应
    :不靠显式参数告诉程序"我要用几张卡",而是检查 RANK 环境变量是否存在——这个变量由 torchrun 自动注入。单卡跑就是普通脚本,torchrun --nproc_per_node=8 一拉起同一份代码自动切成 DDP 模式,业务逻辑零改动。
  • 执行模式统一分发
    scratch / resume / gpt2* 三种初始化路径共享同一套后续训练循环,差异只体现在"模型权重从哪来"这一个决策点上,之后的 forward/backward/optimizer step 逻辑完全一致。
  • 资源约束下的等效缩放
    gradient_accumulation_steps 让"显存不够、想要大 batch 效果"这个业务诉求,通过多次小 batch 累积梯度来达成,DDP 场景下还会按 world_size 自动均分,保证不同卡数下总 token 吞吐量语义一致。

这个子系统的业务价值在于,把"从个人电脑上跑三分钟的字符级模型"和"8×A100 跑四天复现 GPT-2"这两个量级完全不同的场景,收敛成同一套可维护的代码路径。

2. 数据供给子系统

对应的业务问题是:语料规模远大于可用内存,且数据搬运不能成为 GPU 的等待项

整个子系统就是一个 get_batch 函数,十几行,但三个决策都不是随手写的:

  • 每次调用重新 np.memmap 打开数据文件
    。看起来很浪费,实际是 Karpathy 在注释里明确说明的规避手段——长期持有 memmap 对象会导致内存持续增长。用"每次重开"换掉一个难以定位的长跑期泄漏,这是个典型的以微小常量开销换稳定性的取舍。
  • 随机起点采样,不维护 epoch / shuffle 状态
    。预训练语料足够大时,均匀随机采样在统计意义上已经近似等价于遍历,于是整套 Dataset / DataLoader / sampler / worker 进程的抽象被完整省掉了。
  • pin_memory() + non_blocking=True
    :把 host 到 device 的拷贝变成异步,让数据搬运与上一步的计算重叠,避免 GPU 空转等数据。

这个子系统的业务价值是把通常需要一整个数据管道框架才能承载的职责,压缩到了一个函数里。代价也很明确,放在后面的"业务边界"一节讲。

3. 无状态计算内核

对应的业务问题是:计算逻辑要能被训练、推理、性能测试三种完全不同的上层业务复用,不能每种场景各写一份模型代码

model.py 对外只暴露四个能力入口:

接口
业务场景
forward(idx, targets)
训练/验证时用,给 targets 就顺带算 loss
generate(idx, max_new_tokens, ...)
推理采样
from_pretrained(model_type)
对接 GPT-2 预训练权重生态
configure_optimizers(...)
把参数按是否该走 weight decay 分组

这四个入口之所以能被 train.py、sample.py、bench.py  三个业务场景直接复用,前提是 model.py  内部不持有任何"业务状态"——它不知道现在是第几轮迭代、不知道数据从哪来、不知道是不是分布式环境。这种"计算与调度分离"是可复用性的根本来源,也是为什么后来  nanochat 在扩展出分词、SFT、RL 多个新业务阶段时,依然能沿用同一个模型定义文件的原因。

4. 检查点与恢复子系统

对应的业务问题是:长时间训练任务必须能中断后完整恢复,且恢复后的状态在语义上要等价于"从未中断过"

ckpt.pt 里打包的不只是模型权重,还包括:

  • optimizer
     状态(AdamW 的一阶二阶动量)
  • iter_num
    (当前迭代数,续训后学习率调度能接着算)
  • best_val_loss
    (早停/最优模型判断依据)
  • model_args
     / config(重建模型结构和还原训练配置的完整信息)

这是一个"完整可恢复执行现场"的设计思路——只存权重的话,续训时优化器动量归零,相当于重新走了一遍 warmup,训练轨迹会和不中断的情况产生偏差。

这里真正值得抽出来的是一条判据:判断某个状态要不要进快照,就看"缺了它,恢复后的执行轨迹会不会和不中断时产生可观测差异"。这条标准并不限于模型训练,任何需要断点续跑的长任务都适用。

5. 效率度量子系统

对应的业务问题是:要知道这套训练系统有没有把硬件算力用满,但这个诉求不能侵入核心训练路径

estimate_mfu 用 PaLM 论文的 FLOPs 估算公式,把"实际达到的算力"和"硬件理论峰值算力"做比,产出一个百分比指标;bench.py 是从 train.py 主循环里单独抽出来的一份"去掉评估/日志/checkpoint 噪音"的纯性能剖析脚本;两个分析笔记本(scaling_laws.ipynbtransformer_sizing.ipynb)完全脱离主流程独立存在。

这几件事的共同特点是:都是可选的、旁路的、不影响核心训练路径能否运行。就算把这四个组件全部删掉,train.py 依然能完整地把模型训练出来。这种"核心链路极简、观测能力可插拔"的分层,也是为什么这个项目虽然功能不多,但读起来完全不会有"哪块代码到底是不是必须的"这种困惑。

为什么配置层没有单列

第一篇画代码地图时,config 是和 data、model、train 并列的一层,这里却没有把它算作子系统。原因是它在 nanoGPT 里并不承担独立的业务职责——configurator.py 做的事情是把配置文件和命令行参数 exec 进全局命名空间,本质是一层薄壳,没有校验、没有 schema、没有默认值推导。它是一种实现手法,不是一个有边界的业务模块。

记住这一点,后面对比 nanochat 时会用上。

业务边界:刻意舍弃了什么

理解一个架构,看它做了什么不如看它没做什么。nanoGPT 在业务范围上做了几处明确的收缩:

  • 没有可配置的架构变体
    :只支持标准 GPT-2 结构(LayerNorm + 因果自注意力 + MLP),不支持 RMSNorm、RoPE、GQA 这些后续社区常用的变体。想用就得自己改 model.py。
  • 没有数据清洗/增强管线
    prepare.py 只做分词和切片,不做去重、质量过滤、领域配比这些真正决定预训练效果上限的工程。上一节说数据供给子系统被压缩到一个函数,代价就在这里——它假设"喂进来的语料已经是干净的",而这个假设在真实预训练里从来不成立。
  • 只支持数据并行,不支持模型并行
    :DDP 意味着每张卡都要放下完整模型,一旦模型大到单卡装不下(比如上百亿参数),这套架构直接失效,需要张量并行/流水线并行才能继续训练。
  • 没有任何推理侧优化
    sample.py 是一次性脚本而不是常驻服务,没有请求 batching;更彻底的是,generate 连单次生成内部的 KV cache 都没有——每生成一个 token,都把整个上文序列(裁到 block_size)重新前向一遍,是朴素的 O(n²) 实现。教学上这样最清晰,工程上离能用差着一整套推理引擎。

这些舍弃不是能力缺陷,而是精确的业务范围界定——nanoGPT  要解决的问题是"让一个人在有限时间内理解并复现 GPT-2 级别的训练全流程",不是"提供一个能上生产的训练平台"。一旦业务目标从"教学  demo"变成"能用的产品",这些被舍弃的部分几乎全部要补回来——这正是 nanochat  存在的原因:它把分词器定制、多阶段训练(预训练→中间训练→SFT→RL)、对话服务能力都补齐了,业务边界从"复现一个静态模型"扩展成了"训出一个能聊天的产品"。

从架构视角看这次演进给的启示

nanoGPT 到 nanochat 的演进路径,某种意义上是一次典型的"从最小可行系统到产品级系统"的扩张过程,几个观察点值得记下来:

  1. 核心计算子系统(模型定义)几乎没有变化的必要
    ,变化集中发生在编排层——nanochat 真正的复杂度增量是分词、多阶段训练流程、评估体系,而不是 Transformer 本体。
  2. 配置从"实现手法"升级成了"子系统"
    :nanoGPT  里它只是 exec 覆盖全局变量的一层薄壳,连独立职责都算不上;nanochat 收敛成"单一 depth  参数驱动所有超参"。这不是简化,而是随着支持的模型规模范围扩大,需要一个更强的约束来保证"不同规模的模型都是 compute-optimal  的"这个业务承诺。当一层薄壳需要开始承担业务承诺,它就该被当成子系统重新设计了。
  3. 观测能力从可选组件变成核心竞争力
    :nanoGPT 里 MFU 估算是个旁路小工具,nanochat 里"GPT-2 speedrun 排行榜"直接成了项目对外的核心叙事——当业务目标从"能跑通"变成"要足够快、足够便宜",效率度量子系统的地位会从可选项升级为一等公民。

小结

从业务架构视角看,nanoGPT  是一个把"训练编排、数据供给、无状态计算、状态恢复、效率度量"五个子系统压缩到极致,同时主动放弃了架构灵活性、数据工程、模型并行、推理优化这几块能力的最小闭环系统。它的价值不在于功能完整,而在于用最少的业务范围换来了最高的可理解性——这也是为什么即便它早已不再是  Karpathy 主推的项目,依然值得当作一份架构收敛的范本来读。

三篇看下来,从目录结构到工程细节再到业务架构,其实是同一套设计哲学在三个不同粒度上的重复表达:用最小的复杂度覆盖最核心的业务闭环,能省的抽象、能砍的灵活性,全部主动舍弃。如果之后要继续深入,nanoGPT 到 nanochat 的演进本身是个很好的下一个题目——看同一套哲学在业务范围扩张之后,哪些设计被保留、哪些被推倒重来。