如果你自己从零训过超过十亿参数的大模型,大概率经历过这些场景:代码写好了,数据也检查过了,结果训练卡刚跑起来,显存不足的报错迎面砸过来;把模型换成低精度之后显存安静了,loss 却开始抽风,要么突然变成 nan,要么几十个 step 过去纹丝不动。这两个问题,根子往往都在同一个地方——你用什么精度的浮点数做训练。
这篇文章想把 FP32、FP16 与混合精度训练这三件事彻底讲透。我会先拆解浮点数在内存里的真实结构,说明为什么 FP16 能让训练变快、变省,却又不能无脑替换 FP32;然后讲清混合精度训练最常见的三种设计机制:FP32 权重主副本、损失缩放、动态调整;最后落到大模型训练的真实场景里,算一算显存账,并整理一份踩坑排查表。适合的人群是:训练过模型但没细究过数值精度的工程师,准备开始训练大模型的学生,以及做推理加速但想理解模型数值表现的人。
1. 为什么大模型必须正视浮点精度:FP32 与 FP16 的底层差异
1.1 一个浮点数在内存里的真实结构
很多人把“FP16”简单理解成“比 FP32 省一半显存”,但这个理解不够。要搞懂混合精度,得先知道一个浮点数在内存里是怎么组织的。
现代计算机的浮点格式基本都遵循同一套国际标准(IEEE 754),核心是用“符号位 + 指数位 + 尾数位”三部分表示一个数。你可以把它类比成科学计数法:一个数 = 符号 × 尾数 × 2 的指数次方。符号位决定正负;指数位决定数值的动态范围,也就是能表示多大和多大的数;尾数位决定精度,也就是能精确到几位有效数字。
两种最常见精度的结构差异如下:
| 类型 | 符号位 | 指数位 | 尾数位 | 最大有限值 | 最小正常数 |
|---|---|---|---|---|---|
| FP32 | 1 bit | 8 bit | 23 bit | 约 3.4 × 10^38 | 约 1.2 × 10^-38 |
| FP16 | 1 bit | 5 bit | 10 bit | 65504 | 约 6.1 × 10^-5 |
指数位 8 位 vs 5 位,决定了 FP32 能表示从 10^-38 到 10^38 的夸张范围,而 FP16 最大只能到 65504。这个数字在深度学习中是个隐患:loss 或梯度一旦超过 65504,FP16 里就会直接变成无穷大。
尾数位 23 位 vs 10 位,决定了有效数字的多少。23 位二进制大约对应 6.9 位十进制有效数字,10 位二进制大约对应 3 位十进制有效数字。也就是说,FP16 的精度只有 3 位左右,一个数存进去,后面那些小数位会被直接抹掉。
举个具体例子:假设某个权重当前数值在 1024 附近,FP16 在这段区间的分辨能力只有 1 左右。等于说,你要在这个权重上加一个 0.01 量级的梯度更新,FP16 根本“看不见”这个变化,更新直接无效。这种“大数吃小数”的舍入问题,是 FP16 无法独立承担训练任务的第一个原因。
1.2 FP16 的收益那么大,代价藏在哪
既然 FP16 精度这么低,为什么大模型训练还非要用它?因为收益实在太大了。
第一是显存减半。一个 1000 亿参数的模型,权重用 FP32 存储需要 400GB,换成 FP16 只需要 200GB。大模型训练时显存是稀缺资源,能省一半权重空间,意味着可以塞下更大的 batch,或者塞下更长的序列。
第二是带宽减半。训练过程要反复读取权重和激活值,数据搬运的时间在整个训练里占比很高。同样的数据量,FP16 只需要搬一半字节,通信开销也随之下降。
第三是算力翻倍。当前主流加速卡都内置了针对半精度矩阵乘法的专用计算单元,FP16 的矩阵运算吞吐通常是 FP32 的两倍甚至更多。大模型训练绝大部分时间花在矩阵乘法上,这让 FP16 成为了实际上的“主力计算格式”。
那你可能想问:精度低这么多,模型不会训崩吗?这里有个关键观察:深度学习训练本身对数值噪声有一定容忍度。梯度下降本来就是在噪声中逼近最优解,小规模的舍入误差会被 batch 之间的随机波动部分抵消。真正让 FP16 无法直接用的,不是“有点误差”,而是“误差大到权重更新时被吃掉”,以及“数值溢出到不可收拾”。
1.3 上溢、下溢与舍入:FP16 不能全信任的三个理由
FP16 在实际训练中有三个典型问题,必须单独拿出来讲。
第一个是上溢(Overflow)。FP16 最大只能到 65504,而反向传播里偶尔会出现很大的梯度值,尤其在训练早期和 batch 特别“难”的时候。一旦超过 65504,数值就变成 inf 或 nan,之后整个训练都乱了。
第二个是下溢(Underflow)。这个比上溢更隐蔽,也更常见。FP16 能表示的带完整精度的最小正常数是 6.1 × 10^-5,虽然下面还有更小的次正规数,但精度已经非常差。而深度学习中大量梯度的数量级在 10^-6 到 10^-8 甚至更小,这些梯度在 FP16 里直接变成 0。结果就是模型“看起来还在训练”,但实际上参数已经不再更新了。
第三个是舍入误差的累积。权重更新本质上是成千上万次小步的累积。如果每一步都在 FP16 里操作,舍入误差会反复叠加;更糟糕的是,当权重数值较大而梯度更新量较小时,更新会被直接吞掉。这就是前文说的“大数吃小数”。
这三个问题,单独靠“把模型切成 FP16”解决不了。所以业界才发展出了一套更精细的做法:不是所有东西都用 FP16,而是让 FP16 和 FP32 各司其职。这就是混合精度训练。
2. 混合精度训练的三大设计支柱
2.1 第一根支柱:FP32 权重主副本,让更新不“近视”
混合精度训练最核心的思想,用一句话概括:能用低精度算的,用低精度算;不能用低精度更新的,坚决保留高精度。
所谓“权重主副本”(Master Weights),就是在内存里始终保留一份 FP32 权重的“真值”。前向传播和反向传播时,把这份 FP32 权重临时转换成 FP16 参与运算;但优化器更新时,则用 FP16 计算出的梯度去更新 FP32 权重,而不是直接更新 FP16 权重。
为什么非要多此一举?因为权重更新是一个长期累积过程。假设一个权重当前是 2048,梯度是 0.1,学习率是 0.001,那么这步更新量是 0.0001。如果权重和梯度都在 FP16 里,2048 这个量级下 FP16 根本分辨不了 0.0001 的变化,更新等于白做。但如果更新发生在 FP32 权重上,FP32 在 2048 附近的精度远高于 FP16,这 0.0001 的更新能被准确记录下来。
你可以把 FP32 权重主副本理解成一本总账:FP16 是草稿纸,用来快速做计算;FP32 是正式账本,每一次最终修订都记在这本账上。如果直接在草稿纸上改账,几轮下来账目就乱了。
这也是为什么很多框架的混合精度模块会额外占一块显存:它不仅存了 FP16 计算副本,还保留着 FP32 主副本。这块开销不能省,它是训练稳定性的地基。
2.2 第二根支柱:损失缩放,对抗小梯度下溢
有了 FP32 主副本,下溢问题依然存在:反向传播算出来的梯度本身是 FP16,如果梯度过小,直接在源头就变成 0 了,后面拿 FP32 账本也没用。
解决办法是损失缩放(Loss Scaling)。思路非常朴素:既然梯度太小会变 0,那就先把梯度放大,等它过了 FP16 的安全范围,再在更新前缩回来。
具体怎么做?前向传播计算出 loss 后,不直接用它做反向传播,而是先乘上一个较大的缩放因子,比如 1024 或 65536。由于反向传播的梯度是通过链式法则从 loss 逐层传回来的,loss 被放大多少倍,每一层的梯度也近似被放大多少倍。这样那些原本在 FP16 下会变 0 的小梯度,被放大后变得可表示了。
等反向传播结束、梯度准备用于更新之前,再把这些梯度统一除以缩放因子,恢复到原来的数量级,然后用它去更新 FP32 主副本。
这里有一个关键顺序问题:损失缩放只应该在反向传播阶段起作用,更新阶段必须还原。如果忘了还原,相当于学习率被无意识放大了 scale 倍,训练曲线会被破坏。
2.3 第三根支柱:动态调整与整套数据流
固定一个缩放因子行不行?不太行。训练不同阶段梯度的数量级差异很大:训练初期梯度往往较大,固定的大 scale 容易放大成 inf;训练后期梯度变得很小,固定的小 scale 又压不住下溢。
所以现代混合精度实现普遍采用动态损失缩放。它的运行机制很像自动调节水位的阀门:训练开始时给一个相对大的 scale;如果在反向传播后检测到梯度中出现 inf 或 nan,说明 scale 太大,就跳过这一轮的参数更新,并把 scale 减半;如果连续若干步都没有异常,就尝试把 scale 翻倍,让水位慢慢回升。
整套混合精度训练的完整数据流可以概括成六个步骤:
- 初始化一份 FP32 权重主副本。
- 前向传播前,把 FP32 权重转换成 FP16,用 FP16 做矩阵乘等计算;对数值敏感的算子仍保持 FP32。
- 计算 loss 后,乘上当前缩放因子,得到放大后的 loss。
- 用放大后的 loss 做反向传播,得到 FP16 的梯度。
- 检查梯度是否出现 inf 或 nan:如果有,跳过更新并减小 scale;如果没有,把梯度除以缩放因子还原。
- 用还原后的梯度更新 FP32 权重主副本,然后根据最近一段时间的异常频率,决定是否调大 scale。
这套设计就是混合精度训练的灵魂。很多初学者以为混合精度“就是把模型改成半精度”,实际上它是个精密配合的多层系统。理解了这三根支柱,后面看任何框架的 AMP 代码都只是细节问题。
3. 大模型场景下的典型配置与工程权衡
3.1 先算一笔显存账
聊完原理,我们来算一笔具体的账,看看混合精度在大模型场景下到底省了什么、没省什么。
假设一个 100B(千亿)参数的模型,用 Adam 优化器训练:
- 模型权重:FP32 需要 400GB,FP16 需要 200GB。
- Adam 优化器状态:每个参数通常维护一阶动量 m 和二阶动量 v 各一份。如果都用 FP32,需要 800GB。
- 梯度:FP32 需要 400GB,FP16 需要 200GB。
如果全用 FP32,权重、优化器状态、梯度三项合计约 1600GB,这还没有算激活值。如果简单粗暴地全部转 FP16,三项合计约 800GB,但训练稳定性会很差。
混合精度的典型配置是:权重保留 FP32 主副本(400GB),优化器状态保留 FP32(800GB),梯度在反向传播时用 FP16(200GB),合计约 1400GB。看起来比全 FP32 只省了 200GB,但这里没有包含前向传播时需要保存的激活值——在训练大模型时,激活值往往能占到显存的大头,把它们从 FP32 降到 FP16,省下的空间非常可观。
更关键的是,权重主副本和优化器状态这两大块,混合精度并没有帮你砍掉。这就是为什么大模型训练还需要优化器状态分片、激活值重计算等技术。混合精度真正的价值,更多体现在“计算吞吐翻倍、通信量减半、临时缓冲显存减半”上,而不是把所有存储都变成半精度。
3.2 框架视角下的混合精度流水线
理解了原理,再看实际代码会清晰很多。现在主流框架基本都内置了自动混合精度模块,核心 API 通常包含两个角色:自动类型转换器和梯度缩放器。
自动类型转换器负责在前向传播时自动判断哪些算子可以用低精度,哪些必须保留高精度。它不会简单粗暴地把整个模型都转成 FP16,而是让矩阵乘法、卷积这类对精度不敏感的算子走 FP16,让 LayerNorm、Softmax 这类对动态范围敏感的算子留在 FP32。
梯度缩放器则承担了前面提到的损失缩放和动态调整功能。它有一个内部状态,记录当前的 scale 值,并在每次优化器更新后根据是否有 nan/inf 来调整。
一份典型的训练循环伪代码如下:
scaler = GradScaler(init_scale=65536, growth_interval=2000) optimizer = Adam(model.parameters(), lr=0.001) for batch in dataloader: optimizer.zero_grad() with autocast(dtype=float16): loss = model(batch) scaler.scale(loss).backward() scaler.unscale_(optimizer) clip_grad_norm_(model.parameters(), 1.0) scaler.step(optimizer) scaler.update()有几个点值得逐个解释:
scaler.scale(loss).backward()这一步,做的就是把 loss 放大后再反向传播,保证反向出来的梯度是放大过的。scaler.unscale_(optimizer)在更新前把梯度还原;为什么要有这一步?因为你可能需要在更新前做梯度裁剪,而裁剪阈值是基于真实梯度量级设定的,必须在 unscale 之后裁剪,否则阈值会被 scale 污染。scaler.step(optimizer)内部会先检查梯度有没有 inf/nan,如果有就跳过这轮更新;scaler.update()则根据最近一段时间的表现决定 scale 增大还是减小。
3.3 大模型训练里容易忽略的两个精度决策
在大模型训练中,有两个精度相关的决策容易出问题,值得单独提醒。
第一个是梯度累积与精度。为了模拟更大的 batch,很多训练流程会累积多个微批次的梯度再统一更新。梯度的累积缓冲器最好保留为 FP32。如果直接在 FP16 上累积,每次累加都在引入舍入误差,累积几十个 batch 后,误差可能超过梯度本身的信号。框架默认行为不一定合理,建议检查你用的并行框架对梯度缓冲区的精度处理。
第二个是分布式通信时的精度。梯度在反向传播后还处于放大状态,如果直接在通信模块做梯度规约,不同卡之间的梯度先被送到一起再平均,这中间的舍入风险会被放大。常见做法是在通信前把梯度还原成 FP32,或者在通信时保持 FP32 缓冲区。除非你明确知道通信模块对 FP16 做了特殊处理,否则不要盲目用半精度梯度做 AllReduce。
这两个点都属于“事情能跑,但结果悄悄变坏”的类型,问题是训练过程不会立刻报错,而是让模型收敛速度悄悄下降。排查时很费劲,所以最好一开始就按稳妥的方式配置。
4. 现场排障:混合精度训练的常见坑与排查表
4.1 现象一:loss 突然变成 NaN
这件事几乎每个做混合精度训练的人都遇到过。常见原因有几个:输入数据里本身就带有 NaN;损失缩放因子太大,导致中间梯度上溢成 inf;某个自定义算子或底层 kernel 在 FP16 下数值不稳定。
排查可以先看数据:把每个 batch 里的标签和特征扫一遍,排除坏样本。然后看梯度:在反向传播后打印梯度范数,如果出现 inf,基本可以确认是缩放因子过大的问题。再就是看计算图:对比同一个模型在 FP32 下能否稳定训练,如果 FP32 正常、FP16 崩溃,问题大概率出在某个算子的低精度实现上。
一个很实用的临时缓解方案:调低初始 scale 值,同时调大梯度裁剪的阈值。如果问题消失了,说明是损失缩放过大;如果问题还在,就要往算子和数据方向查。
4.2 现象二:loss 不降反稳,模型几乎不学习
这种情况比 NaN 更折磨人,因为报错都没有,看起来一切正常,但曲线就是不动。
最常见的原因是梯度下溢:大量梯度的绝对值小于 FP16 的最小正常数,在反向传播阶段就被截成 0。尤其到了训练中后期,梯度整体变小,这个问题会越来越突出。你可以做一次简单的检查:在 unscale 之后打印梯度张量的分布,看看有多少比例的梯度精确等于 0。如果过半梯度都是 0,那基本可以断定下溢是元凶。
另一个原因是权重更新量被舍入吃掉。虽然我们保留了 FP32 主副本,但如果你在代码里手动把主副本也转了 half,或者优化器状态没有保持 FP32,更新阶段就会重新陷入“大数吃小数”的局面。检查一下模型参数的实际 dtype,确认主副本始终是 FP32。
实际处理时,我会先改成 BF16 试试。BF16 的指数范围和 FP32 一致,天然没有下溢问题,虽然尾数精度更低,但在很多大模型训练场景下表现反而比 FP16 稳定。如果你的加速卡支持 BF16,这是一个性价比很高的尝试。
4.3 现象三:显存没省多少,速度反而变慢
有些读者会反馈:开了混合精度,显存好像没降多少,速度还变慢了。这不奇怪。
显存没降多少,可能是因为权重主副本和优化器状态本来就是 FP32,混合精度省的主要是激活值。如果你的序列长度和 batch 不算大,激活值省下的空间不明显,反而多了一份额外的 FP32 主副本,净收益自然微薄。要真正压缩显存,还得配合优化器状态分片、梯度分片这类手段,单纯指望混合精度不现实。
速度变慢,通常出在“低精度算子没生效”上。混合精度模块只会把支持的算子转成低精度;如果某个自定义算子没有对应的 FP16 kernel,它会静默退回 FP32,再加上自动类型转换器带来的额外判断开销,整体速度反而下降。排查方式是做一次逐算子的性能剖析,观察关键算子的执行 dtype 和耗时占比。还有一个常见问题是自动转换的范围过大,导致频繁在 FP32 和 FP16 之间拷贝数据。对已经调好的工程,我会缩小自动转换范围,只对计算量最大的矩阵乘法路径开启半精度,保留其余部分为 FP32。
4.4 踩坑速查表
| 现象 | 常见原因 | 快速验证方式 | 处理建议 |
|---|---|---|---|
| loss 变 NaN | 数据含 NaN / scale 过大 / 算子不稳定 | 检查数据样本;打印梯度范数 | 清洗数据;降低初始 scale;尝试强制 FP32 算子 |
| loss 纹丝不动 | 梯度下溢为 0 / 主副本精度错误 | 打印梯度分布,统计 0 值占比 | 开启损失缩放;提高 scale;换 BF16 |
| 显存没明显下降 | 省的是激活值,参数和优化器仍占大量 | 统计激活值内存占比 | 结合分片、激活重计算等手段 |
| 速度不升反降 | 算子静默退回 FP32 / 频繁 cast | 剖析算子 dtype 和执行时间 | 缩小混合精度范围;补齐低精度 kernel |
| 梯度裁剪纸失效 | clip 放在 unscale 之前 | 检查代码顺序 | 先 unscale 再做 clip |
| 多卡训练结果不一致 | 通信时梯度精度处理不当 | 对比单卡与多卡 loss 曲线 | 通信前恢复 FP32 梯度,或使用 FP32 规约缓冲 |
4.5 一个验证混合精度是否生效的小技巧
最后分享一个我常用的验证技巧。训练跑起来之后,不用等几十个 step,先做两件事:第一,打印模型权重的 dtype,确认主副本是 FP32;第二,观察梯度缩放器内部的 scale 值。
在训练初期,scale 值应该会明显变化:如果输入数据正常,它会在一个范围内上下浮动,而不是一条直线。如果 scale 从头到尾完全不变,说明你的梯度异常检测逻辑可能根本没生效,要么损失缩放没有真正参与反向传播,要么梯度一直都正常到不需要调整。
另外一个我经常做的对照实验:同一份数据和同一个随机种子,分别用纯 FP32 和混合精度各训练几步,对比每一步的梯度范数。如果两条梯度范数曲线的形状大体一致,说明混合精度配置是健康的;如果差异超过一个量级,就该回查具体算子了。这个对照实验的成本很低,却能帮你提前很久发现数值隐患。
混合精度训练入了门之后,你会发现它不是一个简单的开关,而是一整套需要和模型结构、数据规模、并行方式协同设计的系统工程。但正因如此,搞清楚它才特别值——它能让你在相同的硬件条件下,把训练规模往前推进一大步。