第 8 章 工程实践要点 把散落各章的工程经验集中讲,方便查阅。本章不讲新代码,讲「为什么」和「怎么做」。 8.1 可复现性清单 「同样的代码、同样的数据,为什么结果不一样?」——这是新手最常遇到的坑。 复现四件套 措施 | 作用 固定 random / numpy / torch 种子 | 控制 Python/NumPy/torch 随机源 用 dataclass 集中配置 | 避免魔法数字散落 checkpoint 里存配置(含种子) | 续训/推理时恢复 同硬件 + 同种子 + 同代码 | 近似可复现 固定 4 个随机源,覆盖项目所有随机性来源。 残留非确定性来源 即便如此,结果仍可能不完全一致:cuDNN 的某些卷积/attention 算子默认非确定(为了性能选了非确定实现);
把散落各章的工程经验集中讲,方便查阅。本章不讲新代码,讲「为什么」和「怎么做」。
「同样的代码、同样的数据,为什么结果不一样?」——这是新手最常遇到的坑。
| 措施 | 作用 |
|---|---|
| 固定 random / numpy / torch 种子 | 控制 Python/NumPy/torch 随机源 |
| 用 dataclass 集中配置 | 避免魔法数字散落 |
| checkpoint 里存配置(含种子) | 续训/推理时恢复 |
| 同硬件 + 同种子 + 同代码 | 近似可复现 |
固定 4 个随机源,覆盖项目所有随机性来源。
即便如此,结果仍可能不完全一致:cuDNN 的某些卷积/attention 算子默认非确定(为了性能选了非确定实现);多卡 all-reduce 浮点累加顺序不固定;dropout 的 GPU 实现某些情况下不可复现。
torch.use_deterministic_algorithms(True) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False
这三行加上理论上完全复现,但训练速度可能慢 20-50%。只在调试或学术复现时开启。工程建议:日常训练只 set_seed 接受小偏差;论文复现/对比实验开 deterministic;生产推理不需要复现。
Loss 突然变 NaN?训练后期震荡?多半是这三个问题之一没处理好。
warmup_iters=100,前 100 步 lr 从 0 线性升到 3e-4。为什么需要:模型刚初始化,权重乱七八糟,一开始就用大 lr 会把权重打飞。慢慢加 lr 让模型「热身」,先找到大致正确的方向。经验值:总步数的 1-5%。
clip_grad_norm_(model.parameters(), max_norm=1.0)。为什么需要:Transformer 训练偶尔会出现「梯度爆炸」——某一步梯度范数突然变成正常值的 100 倍。不做裁剪,一次更新就把权重打飞,loss 变 NaN 无法恢复。裁剪相当于「保险丝」:正常不触发,爆炸时强制缩到 1.0。经验值:max_norm=1.0 是 GPT 系列标准值,过小(如 0.01)会过度压缩梯度导致训练停滞。
min_lr_ratio=0.1。为什么需要:训练后期如果 lr 保持峰值,会在最优解附近震荡,无法精细收敛。退火让 lr 平滑下降,末期小步微调,找到更精确的最优。
loss 突然变 NaN │ ▼ 1. 检查学习率:是否过大?预热是否太短? │ 正常 ▼ 2. 检查数据:是否有异常(超长样本、特殊字符、inf/nan)? │ 正常 ▼ 3. 检查模型:是否数值溢出?加 torch.autograd.set_detect_anomaly(True) 定位 │ ▼ 4. 检查 AMP:是否混合精度下溢?配合 GradScaler │ ▼ 5. 降低学习率 + 增大 warmup,重启训练
⚠️
torch.autograd.set_detect_anomaly(True)开启后训练变慢 2-10 倍,但能精确定位 NaN 来源,仅调试时用。
| 字段 | 用途 | 是否必须 |
|---|---|---|
model_state_dict |
恢复模型 / 推理 | ✅ |
optimizer_state_dict |
续训 | 续训必须,推理不用 |
scheduler_state_dict |
续训 | 续训必须 |
step |
知道训到哪了 | ✅ |
gpt_config / train_config |
推理时自动恢复架构 | ✅ |
loss |
观测历史 | 可选 |
save_iter=500 是「保命频率」——崩溃最多丢 500 步。
| 场景 | 推荐 save_iter |
|---|---|
| 本项目(小实验) | 500 |
| 大模型训练(成本高) | 100-200 |
| 调试(频繁崩溃) | 50 |
| 长期稳定训练 | 1000-2000 |
本项目每个 checkpoint 都保留,长期训练会攒一堆快照。生产中常见做法:只保留最近 N 个 + loss 最好的 M 个;或用「滚动覆盖」思路,固定 N 个槽位,新 checkpoint 覆盖最旧的。
# 只保留最近 N 个 import os, glob ckpts = sorted(glob.glob("checkpoints/gpt_step*.pt"), key=os.path.getmtime, reverse=True) for old in ckpts[N:]: os.remove(old)
训练时显存被这些东西吃掉:
模型权重 N 参数 × 4 字节(fp32) ← 25M → 100MB 优化器状态 N 参数 × 8 字节(Adam 两份) ← 25M → 200MB 梯度 N 参数 × 4 字节 ← 25M → 100MB 激活值 batch × seq × n_embd × 层数 ← 训练时最大头
25M 模型训练显存约 1-2GB(视 batch/seq),任何现代 GPU 都轻松。
| 项目 | 25M(本项目) | 1.5B(GPT-2 XL) |
|---|---|---|
| 模型权重 | 100MB | 6GB |
| 优化器状态 | 200MB | 12GB |
| 梯度 | 100MB | 6GB |
| 激活值(batch=32, seq=128) | ~500MB | ~5GB |
| 总显存 | ~1GB | ~30GB |
放不下单卡时需要这些技术:
non_blocking + pin_memory 配合# 数据层 pin_memory=torch.cuda.is_available() # 训练循环 x = x.to(device, non_blocking=True)
这对组合让数据传输与计算重叠,免费的加速。工作机制:
时间轴 ──────────────────────────────────► 普通模式: [计算 step N] [等数据拷贝] [计算 step N+1] [等数据拷贝] ... ↑ GPU 空闲 non_blocking + pin_memory: [计算 step N] [计算 step N+1] [数据拷贝 step N+1] ← 异步,与计算重叠
注意:要确保「下一步计算不立即依赖这次传输」,否则会被强制同步,效果归零。
任何 spawn 模式下要跑的代码都必须放进 if __name__ == "__main__":。这是 Python 多进程在 Windows 的硬性要求。
原理再强调一遍:Windows 没有 fork(),多进程用 spawn 启动子进程:子进程会重新导入主模块。如果训练调用不在保护下,会发生递归启动(主进程 → 子进程 → 子子进程……),最终卡死或 OOM。
正确做法:if __name__ == "__main__": 保护下,子进程 import 时 __name__ 不是 "__main__",不会重复执行。本项目默认 num_workers=0(不开子进程),无论哪个平台都安全。详见《第 1 章 环境准备与首次运行》。
本项目用 print + tqdm。生产级建议升级:
| 需求 | 工具 | 用法 |
|---|---|---|
| 结构化日志 | Python logging 模块 |
替代 print,支持级别/格式/文件 |
| loss / lr 曲线可视化 | TensorBoard / Weights & Biases | 实时画曲线 |
| 系统监控 | nvidia-smi / nvitop |
看 GPU 利用率、显存 |
| 实验对比 | MLflow / W&B | 多次实验的曲线叠加 |
from torch.utils.tensorboard import SummaryWriter writer = SummaryWriter("runs/exp1") # 在训练循环的 log 点 if step % log_iter == 0: writer.add_scalar("train/loss", avg_loss, step) writer.add_scalar("train/lr", current_lr, step)
然后 tensorboard --logdir runs/,浏览器打开本地端口 6006,能看到 loss 下降曲线、lr 调度曲线,调参时超有用。
| 指标 | 健康表现 | 不健康表现 |
|---|---|---|
| loss | 平滑下降 | 突然 NaN / 震荡剧烈 |
| lr | 按调度曲线 | 偏离预期 |
| 梯度范数 | 稳定在 0.1-10 | 突然飙到 1000+(爆炸)或 0(消失) |
| GPU 利用率 | > 80% | < 50%(数据瓶颈) |
| 训练速度(step/s) | 稳定 | 越来越慢(内存泄漏?) |
「Garbage In, Garbage Out.」——模型再好,数据烂也白搭。
训练前问自己:数据量够吗(25M 模型至少要百万 token)?数据质量好吗(去掉乱码、HTML、重复)?数据分布对吗(训练莎士比亚不能生成代码)?
💡 NLP 训练通常不用数据增强——因为语言模型的目标本来就是建模自然分布,人为扭曲分布反而有害。
太大(1e-2)→ loss 飞天 / NaN 合适(3e-4)→ 平滑下降 太小(1e-6)→ 下降太慢或不下降
找学习率的方法:从 3e-4 开始(Transformer 经验值),观察前几百步 loss:飞上来减 10 倍;平稳下降合适;不动增 10 倍。
经验:从 32 开始,显存够就翻倍。太大(1024)会掉进局部最优;太小(2)梯度太噪训练抖动。
与数据量匹配:
| 数据量 | 推荐规模 |
|---|---|
| < 1MB | n_layer=2-6, n_embd=64-384 |
| 1-100MB | n_layer=6-12, n_embd=384-768 |
| 100MB-1GB | n_layer=12-24, n_embd=768-1024 |
| > 1GB | n_layer=24+, n_embd=1024+ |
模型越大越能拟合数据,但数据不够会过拟合(loss 训得下去但生成质量差)。
learning_rate 改成 1e-2,观察第几步 NaN。use_deterministic_algorithms(True) 对比。nvidia-smi -l 1(每秒刷新),观察显存和利用率。if __name__ == "__main__":。工程基础打牢后,去《第 9 章 进阶拓展方向》看往哪些方向深入。