实验课:四种精度在我们的模型上比一比,再把显存账对一遍
这一课不讲新概念。前五课的每一条结论,都放到我们自己从 0 训练的小模型上核对:四种精度设置,一次只改一项。所有图表直接读取实验导出的真实结果;没有数据的地方显示「项目实验数据待生成」,不用任何替代数字。
这一课先不碰:FP8 与 FP4 训练(我们的 MPS 设备不支持,只能在第 4 课、岔路课的组件里真算量化误差);CUDA 上的吞吐对比(见下面的「注意」)。
实验是怎么设计的
对照实验的原则还是那一条:一次只改一项。
- 只改精度:在项目基线(RoPE、RMSNorm Pre-LN、SwiGLU、4 层、d = 256、8 头、上下文 256、词表 2,048、AdamW、400 步、种子 1337)上,只改
train.precision:fp32、bf16、fp16;fp16 再分「动态 loss scaling 开 / 关」。 - 怎么混合:PyTorch 的 autocast——参数、梯度、优化器状态仍是 FP32,只有矩阵乘等算子在低精度里做。所以四组的模型状态都是 16 字节/参数,变的是矩阵乘和它们存下的激活。这和第 1 课账本里「16 位权重副本 + FP32 主权重」的记法不同,但道理相同:更新始终落在 FP32 上。
- 动态缩放:初值 65,536;梯度里出现 inf / NaN 就跳过这一步、缩放减半;连续 200 步不溢出就翻倍。
- 下溢探针:在基线的初始化状态与训练结束,各用第 1 步的训练 batch 算一次 FP32 梯度,统计存成 FP16 会怎样;再真在 autocast 下反向一次对照。
- 显存:batch 32 × 256,每种精度新建模型 → 前向 → 反向 → 第一步,分阶段读
torch.mps.current_allocated_memory();在没有其他 GPU 负载时单独测。
为什么这里换例子:实验课的对象是我们自己的模型在整份《三國志演義》语料上的真实训练(每批 32 条 × 256 个 token),不再是贯穿例句那 5 个 token;上面按 FP32 训练记账,这也是项目基线;另外三组用 autocast,参数与优化器状态同样是 FP32,只有算子在低精度里算(上一节)。
注意:单种子;同配置同种子重跑,baseline 与 ref_baseline 这一对差 0.014(实验文档);C14 实验课用 5 次重跑定的噪声底线是极差 0.016、3σ ≈ 0.020,比它小的差不下结论。MPS 上的 autocast 只把部分算子放进低精度,吞吐与 CUDA 上的 bf16 / fp16 行为不同,tokens/s 只作量级参考 证据等级 · 实验验证 由对比实验得出的经验结论,只在出处写明的模型、数据和规模下成立。换了条件可能不再成立,引用时要带上实验设置。 本条我们自己的实验;规模小(3.7M 参数、400 步),不能外推到十亿级模型。 出处待补 。
一、先猜,再看曲线
实测读数(实验 2026-09-26,单种子):fp32 4.6405,bf16 4.6169,fp16 + 动态缩放 4.6212,fp16 不缩放 4.6166。最大与最小相差 0.0239;但 fp32 这一组就是参照 run ref_baseline,它在 C14 的 5 次同配置重跑里恰好最高(5 次在 4.6243–4.6405 之间,均值 4.6293)。三组低精度与这个均值只差 0.008–0.013,都小于噪声底线 3σ ≈ 0.020——在这个规模上,四种精度的 loss 分不出高下。差别在别处:下面的「恰为 0 的梯度」和下溢探针。
读图要回答三个问题:① 四组最终的 val loss 差距,是否超过 C14 的噪声底线(5 次重跑极差 0.016,3σ ≈ 0.020)?② fp16 不缩放那一组和 fp32 差在哪——是 loss 更高,还是训练前期就落后?(第 2 课)③ bf16 与 fp32 的差,和第 3 课「精度粗 8 倍,但由 FP32 主权重兜底」的说法对得上吗?
二、恰为 0 的梯度与 loss scale
找两件事:fp16 不缩放时,「恰为 0 的梯度」比例是否一直高于其他三组;fp16 + 动态缩放那一组的 loss scale 在哪些步骤减半(每一次减半就是一次跳步)。
三、下溢探针:把真实梯度放到数轴上
先看训练结束时的一批梯度,再切到初始化时的一批:
三个核对点:① 不缩放时,落在 2⁻²⁴ 以下的比例是多少?拖到 S = 65,536 之后呢?(第 2 课)② 这批梯度的非零值铺了几个 2 的幂——比 FP16 的 40 个宽还是窄?(第 3 课)③ 第二张表是真在 autocast 下反向:它和第一张的纸面统计差多少?差在哪里?(提示:反向途中的激活梯度也在低精度里。)
四、显存:理论账与实测截面
理论:FP32 + AdamW 的模型状态 = 16 × 3,688,704 = 56.3 MiB(参数、梯度、m、v 各 4 字节)。实测的「第一步后」比它多出来的,主要是激活——按第 5 课的公式,我们的模型(s = 256、b = 32、h = 256、8 头、4 层)存 FP32 激活约 1,184 MiB(式 1 × 层数 4 × FP32 的 2 倍),远大于模型状态:
对账时带上这几条口径:公式按标准块(GeLU、LayerNorm、dropout)推导,我们的模型是 SwiGLU、RMSNorm、没有 dropout;公式不含 logits(batch 32 × 256 × 词表 2,048,FP32 就是 64 MiB)和交叉熵的临时张量;MPS 分配器按块分配,读数有取整。所以验收看的是「差额主要由激活解释」这一定性结论和数量级,不是逐 MiB 相等。
五、把结论对回前五课
| 课 | 那一课的结论 | 在我们的模型上看什么 |
|---|---|---|
| 第 1 课 | 模型状态 16 字节/参数,优化器状态占四分之三 | 「第一步后」相对「反向后」新增的优化器状态,应接近 m + v = 8 字节 × 参数数 |
| 第 2 课 | FP16 下溢;loss scaling 把梯度推回可表示范围 | 探针:不缩放时落在 2⁻²⁴ 以下的比例,乘 65,536 之后的比例;fp16 不缩放的「恰为 0 梯度」比例 |
| 第 3 课 | BF16 范围与 FP32 相同,不用缩放 | bf16 一组没有跳步;探针里 bf16 的恰为 0 比例与相对误差 |
| 第 4 课、岔路 | 8 位、4 位要靠块缩放 | 我们的设备跑不了 FP8 训练:回到第 4 课与岔路课的组件,用真算的量化误差代替 |
| 第 5 课 | 激活由 s、b、h、L 决定,可以远大于模型状态 | 「前向后 − 建模型后」的实测激活,与公式估算的数量级 |
六、为项目拍板
你要拍板的三件事(对应 04-实践项目/MILESTONES.md「C9 精度与显存」):
- 用哪种精度训练我们的模型?理由要同时引用 loss 曲线、跳过步数与下溢比例——不是只看最终 val loss。
- fp16 为什么需要 loss scaling,bf16 为什么不需要?用第三部分的直方图回答:落在 2⁻²⁴ 以下的比例、乘 65,536 之后的比例、BF16 窗口里的位置。
- 显存账:手算 16 字节 × 参数数,与实测分项对账,说清差额从哪来。
验收标准(全部可测):对照表包含 tokens/s、显存、val loss、跳过步数;模型状态的理论值 56.3 MiB 与实测「第一步后」对比,差额由激活解释(给出公式估算与实测差多少);fp16 不缩放的平均「恰为 0 梯度」比例高于 fp16 缩放(方向可测,幅度以实测为准)。
🔍 自己复现这个实验
脚本在 04-实践项目/experiments/exp_c9_precision.py:四组训练(fp32 复用 ref_baseline)与下溢探针直接运行;显存部分要在所有训练结束、确认没有其他 GPU 负载(如 pgrep -fl mlx_lm)之后,用 —memory 单独再跑一次,结果写回 知识平台/src/data/project/c09-precision.json 的 memory 字段,本页所有图自动读取。
一句话
同一模型、只改精度:先写下预测,再看曲线、下溢比例和显存截面,连同条件一起报告结论。
下一个缺口
一张卡不够,怎么办? 我们的小模型一张卡绰绰有余;锚点 27.8B 的模型状态 444.5 GB,是 80 GB 单卡的 5.6 倍。→ C10 并行:一张卡不够怎么办