第 8 章 工程实践要点


文档摘要

第 8 章 工程实践要点 把散落各章的工程经验集中讲,方便查阅。本章不讲新代码,讲「为什么」和「怎么做」。 8.1 可复现性清单 「同样的代码、同样的数据,为什么结果不一样?」——这是新手最常遇到的坑。 复现四件套 措施 | 代码位置 | 作用 固定 random / numpy / torch 种子 | | 控制 Python/NumPy/torch 随机源 用 dataclass 集中配置 | | 避免魔法数字散落 checkpoint 里存配置(含种子) | | 续训/推理时恢复 同硬件 + 同种子 + 同代码 | — | 近似可复现 setseed 干了什么 固定 4 个随机源,覆盖项目所有随机性来源。

第 8 章 工程实践要点

把散落各章的工程经验集中讲,方便查阅。本章不讲新代码,讲「为什么」和「怎么做」。

8.1 可复现性清单

「同样的代码、同样的数据,为什么结果不一样?」——这是新手最常遇到的坑。

复现四件套

措施 代码位置 作用
固定 random / numpy / torch 种子 train.py:set_seed 控制 Python/NumPy/torch 随机源
用 dataclass 集中配置 config.py 避免魔法数字散落
checkpoint 里存配置(含种子) train.py:save_checkpoint 续训/推理时恢复
同硬件 + 同种子 + 同代码 近似可复现

set_seed 干了什么

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 个随机源,覆盖项目所有随机性来源。

残留非确定性来源

即便如此,结果仍可能不完全一致,因为:

  1. cuDNN 非确定算法:某些卷积/attention 算子默认非确定(为了性能选了非确定实现)。
  2. 多卡 all-reduce:浮点累加顺序不固定。
  3. dropout 的 GPU 实现:某些情况下随机数生成不可复现。

完全复现(牺牲性能)

torch.use_deterministic_algorithms(True) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False

这三行加上,理论上完全复现,但训练速度可能慢 20-50%。只在调试或学术复现时开启

工程建议

  • 日常训练:只 set_seed,接受小偏差。
  • 论文复现/对比实验:开 deterministic。
  • 生产推理:不需要复现(每次生成本来就该有随机性)。

8.2 训练稳定性三板斧

Loss 突然变 NaN?训练后期震荡?多半是这三个问题之一没处理好。

板斧 1:学习率预热

warmup_iters=100 # 前 100 步 lr 从 0 线性升到 3e-4

为什么需要:模型刚初始化,权重乱七八糟。如果一开始就用大 lr(3e-4),梯度方向噪声大,一步就可能把权重打飞。慢慢加 lr 让模型「热身」,先找到大致正确的方向。

经验值:总步数的 1-5%。5000 步训练用 100 步预热(2%)合适。

板斧 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)会过度压缩梯度,训练停滞。

板斧 3:余弦退火

get_cosine_schedule_with_warmup(..., min_lr_ratio=0.1)

为什么需要:训练后期如果 lr 保持峰值,会在最优解附近震荡,无法精细收敛。退火让 lr 平滑下降,末期小步微调,找到更精确的最优。

Loss NaN 排查流程

loss 突然变 NaN │ ▼ 1. 检查学习率:是否过大?预热是否太短? │ ▼ 正常 2. 检查数据:是否有异常(超长样本、特殊字符、inf/nan)? │ ▼ 正常 3. 检查模型:是否数值溢出? 加 torch.autograd.set_detect_anomaly(True) 定位 │ ▼ 4. 检查 AMP:是否混合精度下溢? 配合 GradScaler │ ▼ 5. 降低学习率 + 增大 warmup,重启训练

anomaly detection 调试

torch.autograd.set_detect_anomaly(True) # 然后正常训练,NaN 时会打印是哪个算子产生的

开启后训练变慢 2-10 倍,但能精确定位 NaN 来源。仅调试时用。

8.3 Checkpoint 策略

存什么

字段 用途 是否必须
model_state_dict 恢复模型 / 推理
optimizer_state_dict 续训 续训必须,推理不用
scheduler_state_dict 续训 续训必须
step 知道训到哪了
gpt_config / train_config 推理时自动恢复架构
loss 观测历史 可选

中途 checkpoint vs final

中途 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 覆盖最旧的。

8.4 设备与内存

显存占用大头

训练时显存被这些东西吃掉:

模型权重 N 参数 × 4 字节(fp32) ← 25M → 100MB 优化器状态 N 参数 × 8 字节(Adam 两份) ← 25M → 200MB 梯度 N 参数 × 4 字节 ← 25M → 100MB 激活值 batch × seq × n_embd × 层数 ← 训练时最大头

25M 模型训练显存约 1-2GB(视 batch/seq),任何现代 GPU 都轻松。

放大到 1.5B 会怎样

项目 25M(本项目) 1.5B(GPT-2 XL)
模型权重 100MB 6GB
优化器状态 200MB 12GB
梯度 100MB 6GB
激活值(batch=32, seq=128) ~500MB ~5GB
总显存 ~1GB ~30GB

放不下单卡时需要这些技术:

  • 梯度检查点(gradient checkpointing):丢弃中间激活,反向时重算。省 50-70% 激活显存,慢 20-30%。
  • 混合精度(AMP):fp16/bf16 存权重和激活,省一半显存。
  • ZeRO 分片(DeepSpeed):把权重/优化器/梯度切到多卡,每卡只存一部分。

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] ← 异步,与计算重叠

注意:要确保「下一步计算不立即依赖这次传输」,否则会被强制同步,效果归零。

8.5 Windows 多进程陷阱

# 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 的硬性要求。

8.6 日志与观测

本项目用 print + tqdm。生产级建议升级:

需求分层

需求 工具 用法
结构化日志 Python logging 模块 替代 print,支持级别/格式/文件
loss / lr 曲线可视化 TensorBoard / Weights & Biases 实时画曲线
系统监控 nvidia-smi / nvitop 看 GPU 利用率、显存
实验对比 MLflow / W&B 多次实验的曲线叠加

最小改动接入 TensorBoard

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) 稳定 越来越慢(内存泄漏?)

加这些打印不费时,调试时救命。

8.7 数据工程要点

数据决定上限

「Garbage In, Garbage Out.」——模型再好,数据烂也白搭。

训练前问自己:

  • 数据量够吗?(25M 模型至少要百万 token)
  • 数据质量好吗?(去掉乱码、HTML、重复)
  • 数据分布对吗?(训练莎士比亚不能生成代码)

数据预处理 checklist

  • 去重(n-gram 去重或 minhash)
  • 去噪(HTML 标签、特殊字符)
  • 长度过滤(过短文档扔掉)
  • 编码统一(UTF-8)
  • 分词后长度统计(是否有异常长样本)

数据增强(语言模型不太用)

CV 训练常用翻转、裁剪、色彩扰动。NLP 训练通常不用数据增强——因为语言模型的目标本来就是建模自然分布,人为扭曲分布反而有害。

8.8 调参经验

学习率(最重要)

太大(1e-2)→ loss 飞天 / NaN 合适(3e-4)→ 平滑下降 太小(1e-6)→ 下降太慢或不下降

找学习率的方法:从 3e-4 开始(Transformer 经验值),观察前几百步 loss:

  • 飞上来 → 减小 10 倍(3e-5)
  • 平稳下降 → 合适
  • 不动 → 增大 10 倍(3e-3)

Batch size

太大(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 训得下去但生成质量差)。

8.9 动手实验

  1. 故意制造 NaN:把 learning_rate 改成 1e-2,观察第几步 NaN。
  2. 对比 deterministic:固定种子训两次,对比 loss 曲线是否完全一致;再开 use_deterministic_algorithms(True) 对比。
  3. 接 TensorBoard:按 8.6 的代码改造 train.py,看曲线。
  4. 监控 GPU:训练时另开终端跑 nvidia-smi -l 1(每秒刷新),观察显存和利用率。
  5. 测试断点续训的优化器状态:训 200 步存 ckpt,只加载模型不加载优化器,对比续训前几十步 loss 抖动。

8.10 小结

  • 可复现性 = set_seed + 配置存档 + 同硬件;完全复现需开 deterministic(牺牲性能)。
  • 稳定性三板斧 = 预热 + 梯度裁剪 + 余弦退火。
  • Checkpoint 要存全(模型+优化器+调度器+配置),final 可省优化器。
  • 显存四件套:权重 + 优化器 + 梯度 + 激活;放大模型时考虑 AMP / 梯度检查点 / ZeRO。
  • Windows 多进程:入口必须 if __name__ == "__main__":
  • 日志升级:TensorBoard + nvidia-smi 是最低配。

8.11 下一章

工程基础打牢后,去 第 9 章 进阶拓展方向 看往哪些方向深入。


发布者: 作者: 转发
评论区 (0)
U