第 8 章 工程实践要点 把散落各章的工程经验集中讲,方便查阅。本章不讲新代码,讲「为什么」和「怎么做」。 8.1 可复现性清单 「同样的代码、同样的数据,为什么结果不一样?」——这是新手最常遇到的坑。 复现四件套 措施 | 代码位置 | 作用 固定 random / numpy / torch 种子 | | 控制 Python/NumPy/torch 随机源 用 dataclass 集中配置 | | 避免魔法数字散落 checkpoint 里存配置(含种子) | | 续训/推理时恢复 同硬件 + 同种子 + 同代码 | — | 近似可复现 setseed 干了什么 固定 4 个随机源,覆盖项目所有随机性来源。
把散落各章的工程经验集中讲,方便查阅。本章不讲新代码,讲「为什么」和「怎么做」。
「同样的代码、同样的数据,为什么结果不一样?」——这是新手最常遇到的坑。
| 措施 | 代码位置 | 作用 |
|---|---|---|
| 固定 random / numpy / torch 种子 | train.py:set_seed |
控制 Python/NumPy/torch 随机源 |
| 用 dataclass 集中配置 | config.py |
避免魔法数字散落 |
| checkpoint 里存配置(含种子) | train.py:save_checkpoint |
续训/推理时恢复 |
| 同硬件 + 同种子 + 同代码 | — | 近似可复现 |
def set_seed(seed: int) -> None: random.seed(seed) # Python random np.random.seed(seed) # NumPy torch.manual_seed(seed) # CPU torch torch.cuda.manual_seed_all(seed) # 所有 GPU torch
固定 4 个随机源,覆盖项目所有随机性来源。
即便如此,结果仍可能不完全一致,因为:
torch.use_deterministic_algorithms(True) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False
这三行加上,理论上完全复现,但训练速度可能慢 20-50%。只在调试或学术复现时开启。
Loss 突然变 NaN?训练后期震荡?多半是这三个问题之一没处理好。
warmup_iters=100 # 前 100 步 lr 从 0 线性升到 3e-4
为什么需要:模型刚初始化,权重乱七八糟。如果一开始就用大 lr(3e-4),梯度方向噪声大,一步就可能把权重打飞。慢慢加 lr 让模型「热身」,先找到大致正确的方向。
经验值:总步数的 1-5%。5000 步训练用 100 步预热(2%)合适。
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
为什么需要:Transformer 训练偶尔会出现「梯度爆炸」——某一步梯度范数突然变成正常值的 100 倍。不做裁剪,一次更新就把权重打飞,loss 变 NaN,无法恢复。
裁剪相当于「保险丝」:正常情况不触发(梯度范数小于 1.0),爆炸时强制缩到 1.0。
经验值:max_norm=1.0 是 GPT 系列标准值。过小(如 0.01)会过度压缩梯度,训练停滞。
get_cosine_schedule_with_warmup(..., 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) # 然后正常训练,NaN 时会打印是哪个算子产生的
开启后训练变慢 2-10 倍,但能精确定位 NaN 来源。仅调试时用。
| 字段 | 用途 | 是否必须 |
|---|---|---|
model_state_dict |
恢复模型 / 推理 | ✅ |
optimizer_state_dict |
续训 | 续训必须,推理不用 |
scheduler_state_dict |
续训 | 续训必须 |
step |
知道训到哪了 | ✅ |
gpt_config / train_config |
推理时自动恢复架构 | ✅ |
loss |
观测历史 | 可选 |
中途 gpt_stepXXX.pt |
gpt_final.pt |
|
|---|---|---|
| 用途 | 续训 | 推理 |
| 优化器状态 | ✅ 存 | ❌ 不存 |
| 调度器状态 | ✅ 存 | ❌ 不存 |
| 文件大小 | ~2× 模型 | ~1× 模型 |
final 不存优化器是因为只用来推理,不训练,存了浪费空间。
save_iter=500 是「保命频率」——崩溃最多丢 500 步。
| 场景 | 推荐 save_iter |
|---|---|
| 本项目(小实验) | 500 |
| 大模型训练(成本高) | 100-200 |
| 调试(频繁崩溃) | 50 |
| 长期稳定训练 | 1000-2000 |
本项目每个 checkpoint 都保留,长期训练会攒一堆 gpt_stepXXX.pt。生产中常见做法:
# 只保留最近 N 个 + loss 最好的 M 个 import os, glob ckpts = sorted(glob.glob("checkpoints/gpt_step*.pt"), key=os.path.getmtime, reverse=True) # 删除第 N 个之后的 for old in ckpts[N:]: os.remove(old)
或用「滚动覆盖」思路:固定 N 个槽位,新 checkpoint 覆盖最旧的。
训练时显存被这些东西吃掉:
模型权重 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 配合# dataset.py pin_memory=torch.cuda.is_available() # train.py 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] ← 异步,与计算重叠
注意:要确保「下一步计算不立即依赖这次传输」,否则会被强制同步,效果归零。
# train.py 末尾 if __name__ == "__main__": train()
Windows 没有 fork(),多进程用 spawn 启动子进程:子进程会重新导入主模块。
如果训练调用 train() 不在 if __name__ == "__main__": 下,会发生:
主进程 import train.py ──► 执行 train() ──► 起 DataLoader 子进程 │ ▼ 子进程重新 import train.py 执行 train() ──► 起 DataLoader 子进程 │ ▼ 递归...
最终要么卡死、要么 OOM、要么端口冲突。
if __name__ == "__main__": 保护下,子进程 import 时 __name__ 不是 "__main__",不会重复执行 train()。
TrainConfig.num_workers = 0(不开子进程),无论哪个平台都安全。Windows 用户没特殊需求就用默认值。
任何 spawn 模式下要跑的代码都必须放进 if __name__ == "__main__":。这是 Python 多进程在 Windows 的硬性要求。
本项目用 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.」——模型再好,数据烂也白搭。
训练前问自己:
CV 训练常用翻转、裁剪、色彩扰动。NLP 训练通常不用数据增强——因为语言模型的目标本来就是建模自然分布,人为扭曲分布反而有害。
太大(1e-2)→ loss 飞天 / NaN 合适(3e-4)→ 平滑下降 太小(1e-6)→ 下降太慢或不下降
找学习率的方法:从 3e-4 开始(Transformer 经验值),观察前几百步 loss:
太大(1024)→ 显存爆 + 掉进局部最优 合适(32) → 平衡显存和梯度噪声 太小(2) → 梯度太噪,训练抖动
经验:从 32 开始,显存够就翻倍。
与数据量匹配:
| 数据量 | 推荐规模 |
|---|---|
| < 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) 对比。train.py,看曲线。nvidia-smi -l 1(每秒刷新),观察显存和利用率。if __name__ == "__main__":。工程基础打牢后,去 第 9 章 进阶拓展方向 看往哪些方向深入。