本来只是想复盘一次训练事故,结果越查越觉得,LLM 训练里的浮点数问题才是很多“莫名其妙”的崩溃背后真正的元凶。某开发者跑一个 1.5B 参数的语言模型,前 3000 步一切正常,之后 loss 每隔几百步就冲上 NaN,训练吞吐也周期性掉一截。代码没改、数据没换、梯度裁剪开着,最后一点一点排查下来,问题出在 FP16 的 loss scaling 反复回退,梯度在低精度格式下被“抹掉”又“撑爆”,整个训练过程一直在数值悬崖边缘试探。
这是 LLM 训练里非常典型、又很容易被忽略的一类问题:FP32、FP16 与混合精度训练,表面上只是几个配置参数,实际上决定了显存占用、训练速度和数值稳定性。本文从浮点数的位级结构讲起,拆解混合精度训练为什么有效、BF16 为什么在 LLM 场景下更省心,以及我自己在工程里踩过的坑和总结的检查清单。适合自己动手训练语言模型的读者,也适合只想把配置文件里 fp16 / bf16 开关弄明白的入门者。
1. 从一次诡异训练事故讲起:浮点数不是冷知识
1.1 “看似正常”的代码,为什么在 3000 步之后突然崩溃
那次事故的日志非常有意思:前 3000 步 loss 下降平滑,没有任何异常,然后突然开始出现周期性的尖峰,尖峰之后 loss 直接跳到 NaN。重启训练、降低学习率、换随机种子,全都不管用。后来我在 optimizer 状态里加了几个探针,发现了一个很反直觉的现象:模型的大部分权重在崩溃前其实已经“冻住”了——每一步的更新量根本没写进权重里。
原因出在纯 FP16 训练模式下,梯度经过多层反向传播后量级缩小,加上权重本身在 FP16 下可表示的最小间隔太粗,学习率和梯度的乘积(也就是每一步对权重的修正量)小于这个最小间隔,更新被舍入成了 0。Loss 看似正常下降,实际上很多参数已经停止更新,等到某些层突然出现一点大梯度,整个数值系统立刻失衡。
1.2 浮点精度在 LLM 训练中的三个角色
这个事故让我重新审视精度选择在 LLM 训练中的三个角色,缺一个都不行。
存储:FP16 和 BF16 每个数值只占 2 字节,FP32 占 4 字节,直接决定同样的显存能装下多大模型、多大 batch、多长的上下文。计算:现代加速卡的低精度矩阵乘法速度远高于 FP32,低精度格式是“大模型能不能跑起来”的关键。数值稳定:这是最容易忽略的——浮点数有确定的范围和精度,梯度穿过几十层 transformer 后,量级可能缩小几个数量级,也可能在 attention logits 处被放大到溢出,精度格式直接决定训练会不会在某一步突然崩掉。
1.3 这篇文章能解决什么问题
如果你只是用现成库跑别人写好的训练脚本,“fp16 / bf16”大概率只是配置文件里的一个布尔开关;一旦训练出问题,或者想进一步压显存、提吞吐,绕不开对浮点格式和混合精度机制的理解。后面内容我会把 FP32、FP16、BF16 拆到“位”这个粒度,再展开混合精度训练里的主权重、损失缩放、梯度归约等机制,最后给出一套我实际在用的监控和排障手段。
2. FP32 与 FP16 内部到底存了什么:符号位、指数位、尾数位
2.1 三字段结构:一个浮点数是怎么看“数字”的
任何二进制浮点数本质上都是科学计数法:value = (-1)^sign × 1.fraction × 2^exponent。三个字段分别对应符号位、指数位和尾数位。
- 符号位(sign):0 代表正数,1 代表负数
- 指数位(exponent):决定数值范围,用偏移量(bias)表示正负指数
- 尾数位(fraction):决定数值精度,也就是能区分多小的相对变化
这个结构很有用,它把“范围”和“精度”交给了两个独立的字段。指数位越多,能表示的最大值和最小值跨度越大;尾数位越多,相邻两个可表示数之间的间隔越小、精度越高。
FP32 的分配是 1 位符号、8 位指数、23 位尾数,bias 127;FP16 是 1 位符号、5 位指数、10 位尾数,bias 15。FP32 的典型范围约 ±3.4×10^38,最小正规数约 1.18×10^-38,相对精度约 1.19×10^-7(对应 7 位有效十进制数字)。FP16 就惨了,最大只有 65504,最小正规数约 6.10×10^-5,相对精度约 9.77×10^-4,也就是大概 3 位有效十进制数字。
2.2 几个必须记在脑子里的“生死线”
FP16 训练时,下面这几个数直接决定了梯度会不会消失。
最大有限值65504。梯度经过 loss scaling 放大后一旦超过这个数,立刻变成 inf,反向传播回传的梯度就成了垃圾值。最小正规数6.10×10^-5。比这个更小的梯度进入非规格化区间,精度急剧下降。非规格化数的最小值约5.96×10^-8,再小就是 0。换句话说,在 FP16 的世界里,一个数量级在 10^-7 以下的梯度,在你看到它之前就已经“死”了。
这就是为什么很多 FP16 训练脚本必须配 loss scaling:把 loss 乘一个大数,反向传播的梯度也跟着被放大,把 10^-7 量级的梯度抬到 FP16 能表示的区间里。
2.3 在代码里看一个浮点数的位模式
光看表格不够直观,我习惯直接在内存里看位模式。Python 里用 struct 包就能做到:
import struct def float_bits(x: float) -> str: # 把 Python float 转成 32 位单精度,再取内存位 single = struct.pack('f', x) bits = struct.unpack('<I', single)[0] return f"{bits:032b}" for val in [1.0, 0.1, 0.00001]: b = float_bits(val) sign = b[0] exp = b[1:9] frac = b[9:] print(f"{val:8.5f} -> sign={sign} exp={exp} frac={frac}")上面这段代码把 1.0 的位模式打出来,你会看到指数部分是 01111111(十进制 127),尾数全是 0。0.1 在 FP32 里并不是精确的 0.1,而是个近似值,在 FP16 里误差更明显,大约变成 0.0998535。浮点误差不是“偶尔发生”,而是每个数字都在发生的常态。
2.4 非规格化数与“逐渐下溢”
很多人不知道,IEEE 浮点格式还有一个非规格化(subnormal)区间,用来在 0 附近提供平滑过渡。FP32 在指数全 0 时,尾数不再是隐藏的 1.x,而是 0.x,能表示的最小非零数小到约 1.4×10^-45;FP16 则能到约 5.96×10^-8。
问题在于,非规格化数的有效位很少,计算时速度也慢,而且数值本身已经失去精度。LLM 训练中梯度如果长期处于这个区间,即使没有变成 0,也基本带不来有效信号。
3. 混合精度训练的隐藏机制:主权重、损失缩放、梯度归约
3.1 为什么不能直接把模型权重改成 FP16 训练
一个简单算术就能说明问题。假设当前权重大约是 1.0,学习率 1×10^-4,某一层的平均梯度约 0.01,那么这次更新量是 lr × grad = 1×10^-6。
FP16 在 1.0 附近能区分的最小单位大约是 9.77×10^-4。1×10^-6 这个更新量,FP16 根本表示不出来,一步更新等于白做。FP32 在 1.0 附近的最小单位约 1.19×10^-7,1×10^-6 虽然也有舍入损失,但至少大部分信息保留下来了。
所以混合精度训练第一个关键机制是主权重副本(master weights):模型参数保留一份 FP32,每一步的权重更新都在 FP32 上做;FP16 或 BF16 只负责前向和反向计算,算完梯度后,更新量写回 FP32 主权重,再转回低精度副本用于下一步前向传播。这样既享受低精度计算的快和省,又保住了权重更新的分辨力。
3.2 损失缩放:把小梯度“抬”进可表示区间
前面说了,FP16 的最小正规格化数是 6.10×10^-5,更多梯度在反向传播途中会变得比这还小。解决办法是给 loss 乘一个大数 scale,比如 2^16 = 65536,反向传播的梯度也同步放大 65536 倍,原本 10^-7 的梯度就变成 6.5×10^-3,FP16 能轻松表示。
但这里有个反方向的风险:梯度放大后可能超过 65504,变成 inf。于是就有了动态损失缩放:初始 scale 设置一个中等值,如果连续若干步没有溢出,就把 scale 加倍;一旦检测到 inf/NaN,就跳过这次优化器更新,并把 scale 减半。实际框架里的参数一般是 init_scale=2^16、growth_factor=2.0、backoff_factor=0.5、growth_interval=2000。
3.3 梯度归约里最容易被忽略的精度陷阱
多卡并行训练时,梯度需要跨设备求和。这个累加过程非常危险:如果先转成 FP16 再 allreduce,大量小梯度的累加误差会被放大。我在实践中遵循一个原则:梯度归约和累加尽量保持在 FP32 中完成,等累加完成后再转回 FP16。目前的分布式训练框架默认会在 allreduce 时使用 FP32 缓冲区,这属于“框架替你做了正确的事”,但如果自己手写了 allreduce,一定要检查缓冲区精度。
梯度裁剪的顺序同样有讲究。如果直接在缩放后的梯度上做 clip,相当于裁剪了放大后的值,和真实梯度量级完全对不上。正确做法是先调用类似scaler.unscale_(optimizer)的操作把梯度还原到原始量级,再做 clip,最后再交给 optimizer 更新。
3.4 混合精度为什么仍然省显存:激活值占比
这里必须澄清一个常见误解:使用 Adam 优化器时,混合精度在“参数相关存储”上几乎不省。Adam 需要 m 和 v 两个 FP32 优化器状态,每参数 8 字节;模型权重 FP32 主权重 4 字节、低精度副本 2 字节、低精度梯度 2 字节,加起来还是每参数 16 字节,和纯 FP32 训练一样。
省下来的是激活值(activations)。前向传播时要存大量中间张量用于反向传播,transformer 每一层的自注意力输出、MLP 中间结果都要占显存,低精度格式直接让这部分减半。序列一长,激活值的显存开销常常远超参数本身。所以混合精度的显存收益更多来自“中间过程”,而不是“参数本体”。
4. 实战视角:BF16 凭什么在 LLM 训练里更省心
4.1 BF16 的位结构:保范围、舍精度
BF16 的出现是为了弥补 FP16 在 LLM 训练里的天然短板。它的分配是 1 位符号、8 位指数、7 位尾数,和 FP32 一样有 8 位指数,所以数值范围和 FP32 几乎一致:最大约 3.4×10^38,最小正规数约 1.18×10^-38。代价是尾数从 FP16 的 10 位砍到 7 位,相对精度只有约 8×10^-3(约 2 到 3 位有效十进制数字)。
用个生活类比:FP16 像一把量程有限的精密尺子,度量精细但很容易量程爆掉;BF16 像一把量程很大的粗刻度尺子,量程够宽,但每一格比较粗。
在 LLM 训练里,梯度跨层传播之后量级可以从 10^-1 掉到 10^-9,这种动态范围对 FP16 几乎不可容忍,对 BF16 却是常规操作。
4.2 为什么大模型对 BF16 的相对精度没那么敏感
你可能会问:7 位尾数的精度够吗?答案在统计数据上看,LLM 训练通常可以容忍这种噪声。
一方面,每一批数据的梯度本身就是有噪声的统计估计量,用大批量训练时梯度噪声平滑,BF16 带来的相对误差相当于给梯度额外加了一点随机扰动。另一方面,transformer 的残差连接和 LayerNorm 对中间表示做了约束,单层数值误差很难无限制放大到毁灭训练的程度。模型参数越多、规模越大,对低精度的容错能力通常越强。
但 BF16 绝不是万金油。小模型微调、极小 batch、某些依赖长期记忆结构的任务上,相对精度低的缺陷会被放大。一个很常见的现象是:BF16 下 loss 曲线噪声明显变大,这时可以把梯度累加步数变大(等效于增大 batch),让梯度平均抵消一部分量化噪声。
4.3 从 FP16 切到 BF16 之后,那个 1.5B 模型稳定了
回到开头那次事故。排查到最后,我们把训练精度从 FP16 + 动态 loss scaling 切换成 BF16,一个很重要原因是 BF16 的指数范围和 FP32 一样,小梯度不再需要担心下溢,大梯度也不容易在缩放后溢出。切换之后,原来周期性的 loss spike 彻底消失,训练吞吐甚至还有小幅提升。
这不是说 FP16 不能用,而是在大模型训练里,FP16 的“量程太窄”问题几乎无解。如果你只能使用 FP16 的硬件,一定要配合动态 loss scaling、FP32 梯度累积、以及更保守的学习率;如果硬件支持 BF16,预训练阶段直接选 BF16 是更省心的路线。
4.4 硬件支持和 TF32/FP8 的边界
不同加速卡对低精度格式的支持差异很大。较老的主流卡对 FP16 的 Tensor Core 支持很普遍,但 BF16 是较新架构才全面铺开。还有些卡支持 TF32,这是一种“用 FP32 级别的指数范围、降低尾数位宽”的矩阵计算加速格式,通常不需要像 FP16 那样做 loss scaling,属于另一条权衡路线。
FP8 是后续的新方向,包含 E4M3 和 E5M2 两种格式,在推理和部分训练实验里已经开始落地。现阶段我的建议是:LLM 预训练优先考虑 BF16,低精度推理和量化优先考虑 FP8 / INT8 系列,不要在 FP16 上死磕大模型。
5. 显存账本与吞吐账本:混合精度到底省了什么、慢了什么
5.1 参数相关内存的精确账本
先给一张我推断时用的表格,以 Adam 优化器为例:
| 存储对象 | FP32 全精度 | 混合精度(FP16/BF16) | 纯低精度训练 |
|---|---|---|---|
| 权重参数 | 4 字节/参数 | 主权重 4 字节 + 低精度副本 2 字节 | 2 字节/参数 |
| 梯度 | 4 字节/参数 | 2 字节/参数 | 2 字节/参数 |
| Adam 动量 m | 4 字节/参数 | 4 字节/参数 | 4 字节/参数 |
| Adam 方差 v | 4 字节/参数 | 4 字节/参数 | 4 字节/参数 |
| 合计 | 16 字节/参数 | 16 字节/参数 | 12 字节/参数 |
这张表说明一个问题:用 Adam 时,混合精度在“参数块”上并没有节省,真正省的是中间激活。很多人以为打开混合精度就是“所有显存减半”,这是不准确的。实际工程里,一个 3B 参数的模型,加上 2048 或 4096 的序列长度,激活值往往几十 GB 起步,低精度存储能把这块压到一半,整体显存需求自然就降下来了。
5.2 激活值的量级估算
transformer 每个 transformer block 的前向输出、注意力矩阵、MLP 中间结果都要保留。粗略估算时,可以把激活显存近似为:层数 × hidden size × 序列长度 × batch size × 若干倍数。12 层、3072 hidden size、4096 序列长度、batch 4 的时候,FP32 激活轻松超过 30 GB,换成 FP16/BF16 直接降到 15 GB 左右。
这也是为什么做长上下文训练的人格外在意激活值缓存,甚至会用重计算(gradient checkpointing)来牺牲一部分计算量换取显存——本质上都是被浮点格式和显存墙逼出来的。
5.3 吞吐:低精度不只是“省”,更是为了用满 Tensor Core
低精度格式大行其道还有一层原因:现代加速卡对低精度矩阵运算做了专门优化,单位时间能完成的浮点运算次数远超 FP32。通常 FP16/BF16 的理论吞吐是 FP32 的 2 倍以上,配合 Tensor Core 才能把计算单元真正喂饱。
但吞吐要分场景看。小模型、短序列、微调时,瓶颈通常在数据传输和 Python 调度上,低精度带来的算力翻倍可能根本体现不出来。只有在大模型预训练、长序列、大批量这种“把矩阵乘跑满”的场景下,低精度的吞吐优势才明显。
5.4 什么时候不需要混合精度
如果你只是微调一个参数很小、层数很浅的模型,或者显存余量非常充足、训练瓶颈不在算力,混合精度的收益可能非常有限。我的经验是:模型 100M 以下、序列长度 512 以下、batch 也不大的场景,直接 FP32 跑反而省心,省掉一堆数值调试成本。超过 1B 参数或长序列任务,再认真考虑精度选型。
6. 我的精度监控指标与排查清单
6.1 三个必看指标:梯度范数、loss scale、有效更新比
实践里我不会等 loss 崩了再事后翻日志,而是固定监控三个数值。
梯度范数(grad_norm):正常情况下在训练中缓慢变化,如果出现剧烈的数量级跳变,说明某些层的计算路径在 FP16/BF16 下出了状况。
loss scale 变化:FP16 + 动态缩放时,如果 scale 频繁回退,说明梯度频繁溢出,模型正在“死循环”式地跳过优化器更新。看到 scale 回退次数过多,立刻要考虑降低初始 scale、换 BF16。
有效更新比:我习惯计算lr × grad_norm / param_norm,如果这个值小于当前格式的相对精度(FP16 约 1×10^-3,BF16 约 8×10^-3),说明大部分参数更新注定被舍入噪声淹没,这时调低学习率没用,反而要考虑提高学习率,或者把相关层移到更高精度。
6.2 一段可以直接套用的混合精度训练模板
下面是我常用的一段 PyTorch AMP 训练循环骨架,重点是 unscale 和 clip 的顺序:
scaler = torch.cuda.amp.GradScaler() for step, batch in enumerate(train_loader): optimizer.zero_grad(set_to_none=True) with torch.autocast(device_type="cuda", dtype=torch.float16): logits = model(batch) loss = criterion(logits, batch["labels"]) scaler.scale(loss).backward() # 关键:先 unscale 再做梯度裁剪 scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) scaler.step(optimizer) scaler.update() if scaler.get_scale() < 1: logging.warning("loss scale 已降至 1,需要警惕梯度溢出")如果是 BF16,PyTorch 的 autocast 通常不需要 GradScaler,直接换成dtype=torch.bfloat16即可。注意不同训练框架的接口细节不同,但 unscale + clip 的顺序逻辑是通用的。
6.3 常见症状对应表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| loss 直接变 NaN | 梯度缩放后溢出(FP16 超过 65504) | 看 loss scale 是否回退,换 BF16 |
| loss 周期性尖峰 | attention logits 过大,softmax 内部溢出 | 检查 logits 范围,切 BF16 或调整注意力缩放 |
| 权重长时间不变 | 更新量小于浮点最小分辨率 | 确认主权重是否在 FP32,优化器状态精度 |
| 训练吞吐骤降 | 频繁溢出导致 optimizer.step 被跳过 | 动态 scale 回退太多,尽快换精度 |
| 激活出现 NaN 但梯度正常 | 中间激活在低精度下被截断 | 对该层单独用高精度计算,或换 BF16 |
6.4 我自己的排查流程
遇到精度相关问题,我一般先做一件事:用相同配置跑一个 FP32 的 500 到 1000 步“基线”,记录每层梯度范数的分布。然后换成 FP16/BF16 再跑同样步数,对比两者的梯度分布差异。差异在 1% 以内,说明低精度格式足够安全;差异明显,说明精度噪声已经侵入训练主路径。
给关键层加 hook 看梯度范数也很有用。我之前就是这么定位到某个 attention 层的输出在低精度下波动异常,后来把该层的计算单独提到 FP32,问题立刻缓解。这种“局部升精度”的做法在长序列任务里比全局换 FP32 划算得多。
最后补一个容易被忽略的点:checkpoint 恢复后要重新初始化 scaler 状态。如果直接恢复 optimizer state,但 scaler 的历史 scale 值没有同步加载,训练可能在恢复后不久就崩掉。这不算浮点原理,但确实是我见过最多的“同一次训练跑两次结果不同”的原因之一。
最后想说的细节
我对精度格式的态度最近两年变了很多:早几年觉得 FP16 的坑多,不如老老实实 FP32;现在反而觉得,只要理解了范围、精度、缩放这三者的关系,混合精度和 BF16 操做起来是很顺手的一套工具。尤其是碰到模型超过 1B 或者序列特别长的任务,直接默认 BF16,大多数情况下比 FP16 加各种保护措施更省心。
个人习惯里还有一个土办法:开训前把 FP32 baseline 跑几百步保存一份梯度分布存档,之后每次切换精度配置都拿来对比。一旦发现低精度跑出来的梯度分布明显偏离基线,宁可多花点显存把问题层保留在 FP32,也不要放任模型在数值悬崖边缘试探。浮点数本身不复杂,复杂的是在真正跑起来之前,你往往不知道自己的训练路径会踩到哪条“生死线”。把 FP16、BF16 和混合精度的机制吃透,很多事就顺了。