chapter01.md — 总览与可复现环境
1. 开篇段落
欢迎来到《从零到可复现:LLM 训练实战》的第一章。本章是整个课程的基石,我们不直接深入复杂的算法,而是聚焦于保障大规模语言模型训练这一昂贵的“科学实验”能够可靠、可复现、可观测的 foundational practices。成功的 LLM 训练,其挑战往往并非源于算法的晦涩,而是源于在数百万亿次浮点运算中对混沌的控制。一次 1T token 的训练,其成本堪比一次小型火箭发射,任何由于环境不一致、随机性失控导致的“意外”结果,都是不可接受的资源浪费。在本章结束时,你将掌握一套工业级的实验管理框架:如何系统性地控制训练中的随机来源,建立一套精确无歧义的符号与单位体系用于沟通和计算,并设计出能够从容应对硬件或系统故障的日志与检查点策略。这不仅是工程上的最佳实践,更是保障算法研究结论有效性的科学前提。
2. 文字论述
2.1 实验可复现性:科学训练的圣杯
在LLM领域,可复现性(Reproducibility)绝非学术上的吹毛求疵,而是决定项目成败的生命线。它确保了算法改进的有效性可以被验证,训练过程中的 bug 可以被定位,以及团队成员之间的协作有共同的基准。我们追求的可复现性有两个层次:
- 比特级复现 (Bit-wise Reproducibility): 在完全相同的软硬件环境下,两次独立运行的结果(包括每一轮的 loss、模型权重等)完全一致。这是算法调试和验证阶段的黄金标准。
- 统计级复现 (Statistical Reproducibility): 在不同时间或相似(但不完全相同)的硬件环境下,多次运行宏观结果(如最终的 PPL、loss 曲线的整体趋势和收敛点)在统计意义上无显著差异。这是大规模生产训练的务实目标。
以下是实现这两个层次可复现性的关键控制点:
-
全局与局部随机种子 (Global & Local Random Seeds)
- 核心: 训练过程中的随机性主要源于:模型参数的初始化、dropout、数据 shuffling 和数据增强(若有)。控制这一切的起点是设定一个全局随机种子。
- 实践: 一个健壮的训练框架会实现一个
seed_everything函数,在程序入口处调用。它至少应覆盖:random.seed(seed)np.random.seed(seed)torch.manual_seed(seed)torch.cuda.manual_seed(seed)(如果使用单GPU)torch.cuda.manual_seed_all(seed)(如果使用多GPU)
- 分布式环境: 在 PyTorch Lightning 这类框架中,需要确保种子在所有分布式进程(rank)上被正确设置,并且通常会为数据加载、模型初始化等不同阶段派生出不同的种子,以避免它们之间不必要的关联。例如,数据加载的 worker 种子可以是
global_seed + worker_id。
-
CUDA 确定性行为 (CUDA Deterministic Behavior)
- 背景: 为了最大化并行计算效率,NVIDIA 的 cuDNN 库中许多算法(特别是卷积和某些 reduction 操作)本质上是非确定性的。例如,浮点数的原子加法
atomicAdd在并行执行时,其累加顺序是不固定的,而浮点数加法不满足结合律(a+b)+c ≠ a+(b+c),导致每次结果可能存在微小差异。 - 控制开关:
torch.backends.cudnn.deterministic = True: 强制 cuDNN 使用确定性算法。这可能会禁用一些最高效的算法。torch.backends.cudnn.benchmark = False:benchmark=True时,cuDNN 会在每次遇到新的输入尺寸时,自动测试多种算法并选择最快的。这个选择过程本身可能引入不确性,且会增加初次迭代的耗时。在输入尺寸固定的 LLM 训练中,其收益有限,但关闭可增强确定性。
- Rule-of-thumb (性能与确定性的权衡):
- 算法验证/调试阶段: 必须开启确定性模式 (
deterministic=True,benchmark=False)。此时性能损失(可能高达 20-30%)是值得的,因为它能帮助你隔离 bug,确保算法逻辑的正确性。 - 大规模生产训练: 建议关闭确定性模式以换取极致的吞吐量。此时,我们依赖于统计级复现。只要多次运行的 loss 曲线在噪声范围内重合,我们就认为训练是健康的。
- 算法验证/调试阶段: 必须开启确定性模式 (
- 背景: 为了最大化并行计算效率,NVIDIA 的 cuDNN 库中许多算法(特别是卷积和某些 reduction 操作)本质上是非确定性的。例如,浮点数的原子加法
-
数据加载顺序 (Data Loading Order)
- 陷阱:
torch.utils.data.DataLoader的num_workers > 0是最常见的非确定性来源。多个并行的 worker 进程会以不可预测的顺序完成数据块的预处理和加载。 - 解决方案:
- Worker 内部 seeding: 通过
worker_init_fn为每个 worker 设置独立的种子。这能保证每个 worker 内部的数据处理(如某些随机采样)是确定的,但不能保证 worker 之间返回数据的顺序。 - 全局预 shuffling (黄金标准): 在训练开始前,对整个数据集进行一次彻底的、有确定性种子的 shuffle,生成一个索引文件(或一个记录了文件顺序的 manifest 文件)。训练时,
DataLoader不再进行随机 shuffle,而是严格按照这个预先计算好的索引顺序来读取数据。所有 worker 都从这个共享的顺序队列中取任务。这是实现数据加载比特级复现的最稳健方法。
- Worker 内部 seeding: 通过
- 陷阱:
-
分布式通信与浮点累积误差 (Distributed Communication & Floating-Point Error)
- 根源: 在数据并行训练中,每个 step 的梯度同步依赖于
AllReduce操作。这个操作在多个 GPU 之间对梯度张量进行求和。如前所述,大规模并行求和的顺序是不确定的。 - 影响: 单步的差异可能只有
1e-8级别,但在数万甚至数十万步的训练中,这些微小的差异会通过梯度更新、优化器状态(动量)等非线性系统不断累积和放大,最终导致 loss 曲线可观测到的偏离。 - 应对: 这是我们在大规模训练中几乎无法追求比特级复现的根本原因。我们不应尝试用环境变量(如
NCCL_ALGO)去“修复”它,而应接受它,并建立监控机制来确保这种偏离在可控的统计范围内。
- 根源: 在数据并行训练中,每个 step 的梯度同步依赖于
2.2 统一语言:符号、记号与单位
精确的术语是科学交流的基石。在LLM训练中,模糊的表述(如“batch size”)会导致代价高昂的误解。以下是我们贯穿整个教程的标准化符号体系:
| 符号 | 含义与深度解释 |
| 符号 | 含义与深度解释 |
|---|---|
N_params |
模型非嵌入参数量 (B)。通常指 Transformer blocks 中的参数。我们将其与 embedding table 分开因为 6·N·T 的 FLOPs 估算主要适用于前者。Embedding 的计算量相对较小。 |
T_tokens |
训练总 tokens (T)。这是衡量训练规模的核心指标,代表了模型“阅读”过的语料总量。本教程默认 1T (1万亿) tokens。 |
L_ctx |
上下文长度。单个训练样本的序列长度。例如 4096 或 8192。 |
GB_tok |
Global Batch in Tokens。这是最重要的批量单位,是整个训练优化的“原子操作”单元。它指代单次 optimizer step所见过的 token 总数。Scaling Law 和 LR scaling 法则都是基于这个值。 |
μ_tok |
Micro-batch in Tokens。单张 GPU 在一次独立的 forward -> backward 传递中处理的 token 数量。μ_tok = sequences_per_gpu × L_ctx。这个值直接受限于单张 GPU 的显存。 |
A |
梯度累积步数。为了在显存有限的情况下实现大的 GB_tok,我们执行 A 次 forward -> backward,累梯度,然后执行一次 optimizer.step()。 |
D |
数据并行度 (Data Parallel Degree)。参与数据并行的 GPU 数量。 |
FLOPs |
训练浮点运算量。对于 Decoder-only 模型,一个被广泛验证的近似公式是: $FLOPs \approx 6 \cdot N_{\text{params}} \cdot T_{\text{tokens}}$。这个 6 来自于:forward pass 的 2NT (主要在两个 MLP 的 MatMul 和 Attention 的 MatMul) 和 backward pass 的 4NT (理论上是 forward 的两倍)。这个公式为我们估算成本和时间提供了理论基础。 |
PUE |
电能使用效率 (Power Usage Effectiveness)。数据中心总能耗 / IT 设备能耗。一个现代化的数据中心 PUE 约在 1.1-1.2 之间。 |
¥ / kWh |
成本与能耗单位。所有金额统一为人民币(¥),电量为千瓦时(kWh)。 |
ASCII 图解:批大小的层级关系
========================= ONE GLOBAL BATCH (Optimizer Step) =========================
Total tokens processed for one weight update: GB_tok = D * A * μ_tok
-------------------------------------------------------------------------------------
| |
| [Accumulation Step 1] [Accumulation Step 2] ... [Accumulation Step A] |
| [Accumulation Step 1] [Accumulation Step 2] ... [Accumulation Step A] |
| | | | |
| (grads accumulated) (grads accumulated) (grads updated to weights) |
| | | | |
| --- Micro-Batch Execution on D GPUs --- ... (repeated A times) ... |
| | GPU0: μ_tok | | GPU1: μ_tok | ... | GPUD: μ_tok | |
| ------------------------------------- |
| |
=====================================================================================
2.3 观测与恢复:日志、指标与检查点
大规模训练是脆弱的马拉松,而不是短跑。节点故障、网络抖动、甚至电源波动都可能中断训练。强大的观测与恢复机制是我们的安全网。
-
训练日志与指标:训练的“仪表盘”
- 核心算法指标:
loss(training loss): 监控收敛性,通常会进行平滑处理(如滑动平均)以观察趋势。val_ppl(validation perplexity): 在留出验证集上的困惑度,是评估模型泛化能力的关键。learning_rate: 验证 LR schedule 是否按预期工作。grad_norm(gradient norm): 在梯度裁剪前的全局梯度范数,用于监控梯度爆炸或消失。
- 性能与效率指标:
tokens_per_second_per_gpu: 单 GPU 的有效吞吐量。total_tokens_per_second: 整个集群的吞吐量。MFU (Model FLOPs Utilization):实际 TFLOPs / 理论峰值 TFLOPs,衡量硬件利用效率。H100 上 MFU 达到 50-60% 是一个很好的目标。
- 工具与实践: PyTorch Lightning 的
Logger接口与 TensorBoard、W&B 等工具无缝集成。- Rule-of-thumb: 在训练启动时,将完整的配置文件(YAML/JSON)、git commit hash 和
pip freeze的结果作为文本日志记录下来。这保证了任何一次实验的所有环境和配置细节都可以被精确追溯。
- Rule-of-thumb: 在训练启动时,将完整的配置文件(YAML/JSON)、git commit hash 和
- 核心算法指标:
-
检查点策略:从灾难中恢复
- 目标: 检查点不仅是模型的权重,它是整个训练状态机在某个时间点的完整快照。一个完备的检查点必须能够让训练在中断后,从完全相同的状态继续,就好像从未发生过中断一样。
- 完备检查点的内容:
- 模型
state_dict: 模型的权重。 - 优化器
state_dict: 至关重要。包含 AdamW 的一阶矩(动量)和二阶矩(方差)等状态。丢失它会导致训练恢复后性剧烈震荡。 - 学习率调度器
state_dict: 记录当前在 schedule 的哪个位置。 - 训练进度: 当前的
global_step/epoch。 - 数据加载器状态: 当前读取到数据集的哪个位置,确保不重复或遗漏数据。
- 随机数生成器状态:
torch.get_rng_state()等,保证 dropout 模式等随机过程可以被恢复。
- 模型
- 存储与 I/O 优化:
- 频率: 保存检查点会暂停训练,是一个 I/O 密集型操作。13B 模型的 bf16 检查点(含优化器状态)大小约为
13*2 (weights) + 13*8 (optimizer states) = 130 GB。频繁保存会严重影响吞吐。策略通常是“按时”(如每4小时)和“按最佳PPL”保存。 - 分片保存 (Sharded Checkpoint): DeepSpeed 等框架会将模型和优化器状态分片保存在各个 rank 上。例如,在 64 卡上,每个 rank 只需处理约 2GB 的数据。这极大地利用了并行 I/O,将保存/载时间从数十分钟缩短到几十秒。
- 异步保存: 在后台线程或进程中执行保存操作,让主训练循环的等待时间最小化。
- 原子性: 始终遵循“先写入临时目录/文件,成功后再原子性地重命名”的原则。这可以防止在写入过程中因节点故障而产生损坏的、不可用的检查点文件。
- 频率: 保存检查点会暂停训练,是一个 I/O 密集型操作。13B 模型的 bf16 检查点(含优化器状态)大小约为
3. 本章小结
本章我们为整个 LLM 训练旅程搭建了坚固的“地基”。其核心思想是将 LLM 训练视为一门严谨的、可度量的科学实验。
- 拥抱分层可复现性: 在调试阶段,不惜性能代价追求比特级复现,以确保算法的正确性。在规模化训练阶段,接受分布式系统固有的非确定性,转向追求统计级复现,并建立监控体系。
- 坚持使用精确的语言: 以
GB_tok(全局 token 批次) 作为思考和配置的核心,用6·N·TFLOPs 公式作为预算和性能评估的标尺。这能消除团队内外的通壁垒。 - 防患于未然: 训练的脆弱性是常态。通过详尽的日志(包含代码和环境快照)和原子性的、完备的、分片的检查点策略,我们能将意外中断的损失降到最低,保障项目的顺利推进。
掌握了这些基础实践,我们才能在后续章节中,安心地探索 scaling laws、优化器选择、并行策略等更深层次的算法与技术。
4. 常见陷阱与错误 (Gotchas)
-
恢复训练后 Loss 曲线“跳崖”:
- 陷阱: 从检查点恢复训练,loss 突然升高,然后才慢慢下降,仿佛“从头再来”。
- 根源: 只加载了模型权重,没有加载优化器状态。AdamW 等优化器内部维护的动量和方差信息(相当于梯度的“历史记忆”)丢失了。优化器在恢复后突然面对一个“陌生”的梯度,导致更新步长和方向出现剧烈偏差。
- 诊断: 对比恢复前后的
optimizer.state_dict(),会发现恢复后是的或初始化的。务必确保检查点和加载逻辑都包含了优化器。
-
不同机器/集群间的“玄学”差异:
- 陷阱: 完全相同的代码、配置和数据,在一个集群上跑得很好,在另一个集群上却出现 loss 发散或性能差异。
- 根源: 环境漂移 (Environment Drift)。这可能源于细微但关键的差异:NCCL 版本、CUDA 驱动、cuDNN 库版本、CPU 微架构(影响某些浮点运算),甚至是 IB 网络的配置。
- 解决: 使用容器技术(如 Docker、Singularity/Apptainer)将整个软件栈(从操作系统库到 Python 包)打包成一个不可变的镜像。这是根治环境依赖问题的最有效方法。
-
“静默”的数据损坏:
- 陷阱: 训练运行数天后,loss 曲线突然开始出现无法解释的尖峰或缓慢劣化。检查所有代码和配置都无问题。
- 根源: 存储在 CPFS 上的某个数据分片文件(如
.tar或 Parquet 文件)可能发生了“位翻转”或损坏。数据加载器读取了错误的数据,导致模型看到了“毒丸”样本。 - 预防: 在数据预处理阶段,为每个数据分片计算并存储其哈希值(如 SHA256)。在训练时,数据加载器可以在加载每个分片后(或以一定概率抽样)校验其哈希值,一旦不匹配就立即报错并跳过该损坏文件。
-
检查点与代码版本不匹配:
- 陷阱: 你修改了模型结构(比如增加或删除了一个层),然后试图从旧代码生成的检查点中恢复训练。PyTorch 的
load_state_dict默认strict=True会直接报错。如果改为strict=False,程序能跑起来,但可能会出现灾难性的后果。 - 后果: 未被加载的层(新层)将使用随机初始化的权重,而已被删除的层的优化器状态则被丢弃。这会导致模型内部状态极度不协调,训练很可能立即崩溃或走向一个糟糕的局部最点。
- 最佳实践: 检查点应与生成它的 git commit hash 强绑定。在加载检查点时,程序应首先验证当前代码的 git hash 是否与检查点元信息中的 hash 一致。如果不一致,应强制用户明确处理(如编写专门的权重迁移脚本),而不是“静默”地忽略差异。
- 陷阱: 你修改了模型结构(比如增加或删除了一个层),然后试图从旧代码生成的检查点中恢复训练。PyTorch 的