混合精度训练详解:FP16/BF16原理、显存优化与踩坑指南

发布时间:2026/10/17 19:28:50
混合精度训练详解:FP16/BF16原理、显存优化与踩坑指南 最近我在调一个大语言模型训练流程卡在了一个很基础的问题上显存不够。网上所有教程都说用FP16能省一半显存、训练速度快一倍我也跟着改了一行代码——把模型的精度转成半精度。结果跑起来没几步loss直接变成了NaN。后来我才明白FP16不是“精度减半”那么简单混合精度训练是一套完整的数值精度管理方案里面全是细节。这篇文章我想把FP32、FP16、BF16和混合精度训练讲透包括它们的二进制原理、为什么需要混合精度、怎么正确地在训练脚本里落地以及我踩过的一系列坑。适合正在训练中大规模模型的开发者、需要微调大模型的研究者也包括只想搞懂“精度”这件事的人。很多人以为从FP32换成FP16只是把数值从32位缩短成16位误差大一点而已。实际不是“大一点”而是从根本改变了数值的表达能力——范围变窄、精度变低还可能产生溢出和下溢。如果你不了解这些细节混合精度训练就是在盲人摸象。下面我用尽量通俗的方式把这层窗户纸捅破。1. 浮点数的底层逻辑从位说起1.1 一个浮点数是怎么用二进制表示的浮点数本质上是科学与计算机之间的妥协。一个32位浮点数由三部分组成最高位是符号位接着8位指数位后面23位尾数位。具体数值可以近似写成V (-1)^sign × 1.fraction × 2^(exponent - bias)对于FP32bias通常取127所以指数能表示的实际范围大约是-126到127。用生活类比如果把FP32当成一个“可以有科学计数法的计算器”符号位决定正负指数决定小数点往左还是往右挪多少位尾数决定在这个范围内能保留几位有效数字。有效数字才是关键。FP32的尾数有23位对应二进制精度。换算成十进制大约是7位有效数字。也就是说1.0000001和1.0000002这种差距在FP32里是可以区分的但如果你和另一个数相差0.00000001FP32可能直接把它们当成同一个数。这里我补充一个常见误解有人觉得“float32够用了float64更准为什么大模型训练大部分还是用FP32”因为精度过高既有成本又没有必要。FP32已经是绝大多数深度学习框架的默认精度它能覆盖0到3.4e38的数值范围也可以表达1e-38级别的极小正数。对于模型的权重更新来说FP32的精度已经足够完成几百上千步的累积训练。真正的问题出在当我们想用FP16或BF16这种“瘦身版”时精度损失会造成一连串连锁反应。1.2 FP32、FP16、BF16三兄弟的具体差异要理解训练中应该用哪种浮点格式先看几种常见格式的位宽分配和关键参数。下面这张表我整理了很多次建议直接收藏。格式总位数符号位指数位尾数位最大有限值最小正正规数值十进制有效数字FP32321823约3.4e38约1.18e-38约7位FP1616151065504约6.10e-5约3位BF1616187约3.4e38约1.18e-38约2到3位注意FP16两个关键数字最大有限值是65504。什么意思如果你的梯度或者某个中间激活值超过65504直接表示为无穷大inf。最小正正规数是6.10e-5如果某个梯度小于这个数它可能被直接压成0。一个范围又窄、精度又低的数据格式你还敢拿它直接替代FP32吗BF16是站在另一个方向上的妥协它保留了FP32完整的8位指数位所以数值范围几乎和FP32完全一样但尾数只有7位十进制有效数字只有2到3位。也就是说BF16能表达很大的数不会溢出但表达同一个数时误差可能比FP16更大。为什么它还是流行关键在训练大模型时梯度的数量级跨度很大溢出比精度损失更致命。尾数少了可以靠随机舍入和后续累积来弥补而一旦变成inf整个训练直接崩掉。因此后来很多大模型训练采用BF16而不是FP16。1.3 为什么不能无脑用FP16我第一次尝试混合精度的时候以为就是把模型所有的参数和forward输入都转成float16跑起来。结果有两个最直观的失败现象。第一个现象是loss剧烈震荡或者不下降。原因很简单权重更新时每一步的学习率通常只在1e-4到1e-5这个量级而FP16的尾数非常有限。假设你的某个权重是1.0FP16能表示的下一个数是1.001左右那么小于0.001的更新量根本写不进去。训练看起来在跑权重实际上纹丝不动。第二个现象是loss突然变成NaN。这种情况通常出现在损失值本身比较大比如几百甚至上千时乘上某些缩放操作后超过65504于是溢出成inf再从inf传导到梯度得到的梯度就是NaN或inf。所以你会发现光把模型转成FP16不仅没提速反而频繁崩。这不是你的问题是精度格式本身决定了你不能这么做。那怎么解决要引入一套设计好的方案混合精度训练。下一章我们把它掰开揉碎讲清楚。2. 混合精度训练不是“调低精度”而是一套系统工程2.1 核心原则哪些环节必须保持FP32前面提到FP16不能当全能选手所以混合精度训练的核心原则是“该省的地方省该精的地方精”。所谓混合精度就是在一个训练循环中同时使用多种浮点格式各司其职。先看最典型的配置。模型的参数会用FP16保存一份“工作副本”用于前向传播和反向传播的计算这样显存占用和计算速度有优势。但同时必须保留一份FP32版本的“主权重”。在每次梯度更新时更新动作只在FP32主权重上做主权重 主权重 - 学习率 × 梯度。更新完成后再把主权重转换到FP16副本用于下一次前向。为什么必须这样我们回过头看1.3提到的更新步长问题。在FP32里权重更新量可以精确表达在FP16里更新量可能小于它在该权重附近的最小表示间距。因此主权重是你模型“真实”的状态FP16副本只是“快照”。如果不保留主权重训练从第一步开始精度就在累积损失。除了主权重还有两个地方通常也用FP32一个是梯度累加过程另一个是优化器的内部状态。比如主流的Adam优化器会保存一阶动量、二阶动量这些如果都用FP16来存动量的小变化会被精度吃掉。所以在混合精度中你至少有一个“FP32的全参数列表”加“FP16的前向副本”如果使用了Adam优化器状态仍需要存储在FP32中。这意味着显存不是简单减半而是省了模型参数和激活值的存储但优化器状态那部分还在。2.2 损失缩放把小梯度从“消失”边缘拉回来全精度FP32可以表示很小很小的数FP16做不到。在深层网络中反向传播时梯度是从loss往输入方向逐层传播的越靠前的层梯度越小。这是因为链式法则要求我们做连乘很多激活函数的导数量级在0.1左右乘上十几层后梯度就掉到1e-6、1e-8甚至更小。FP16的最小正正规数是6.10e-5。如果某个梯度是1e-6它在FP16里会直接变成0。这一层的权重梯度变成0该层学习不到东西。这就是梯度下溢。如何解决人为地放大梯度。在反向传播之前把损失值乘以一个较大的缩放因子比如1024。由于损失被缩放反向传播得到的梯度也同比放大了1024倍这样原本1e-6的梯度变成1e-3左右FP16可以表示。等你拿到梯度之后再统一除以1024恢复到真实的尺度用于更新FP32主权重。这个操作叫“损失缩放”。你可能会想为什么不直接在获得FP16梯度后乘以scale不行因为当梯度在FP16表示时下溢到0已经是0了乘以任何数还是0。必须要在损失上放大让梯度在计算过程中就保持在不小于FP16最小表示的范围里。2.3 动态损失缩放的细节缩放因子到底该取多少太大也不行因为梯度可能因此超过FP16的最大值65504变成inf太小又不能解决下溢。比较好的做法是动态调整。常见的动态策略是这样的先设一个初始scale比如1024。每个训练step结束检查本轮是否存在溢出比如梯度中是否出现了inf或NaN。如果出现溢出说明当前scale太大那就把scale减半同时跳过这轮的参数更新。如果连续一段时间比如300步都没有溢出就尝试把scale加倍让梯度在不溢出的前提下尽可能放大。这听起来像是一种动态平衡算法。有的框架甚至会自动尝试在溢出时回退一步你可以不用过多关心。但理解这个机制能帮你解决很多问题。补充一个细节损失缩放不仅用于梯度有时也用于计算softmax等数值不稳定操作。但混合精度框架通常会为这些特定算子自动选择FP32计算避免NaN问题。3. 实操如何正确在训练脚本里启用混合精度3.1 从零手动实现一个混合精度训练循环先给一套依赖较少、原理清晰的标准流程。假设你在用某个支持自动微分功能的深度学习框架下面这段伪代码足够说明顺序# 伪代码混合精度训练主循环 model create_model() optimizer create_optimizer(model.parameters()) scale 1024 # 初始损失缩放系数 for step, batch in enumerate(data_loader): # 1. 在低精度环境里做前向 with autocast(): loss model(batch) # 2. 对loss做缩放并反向传播 loss_scaled loss * scale optimizer.zero_grad() loss_scaled.backward() # 3. 梯度缩放回原尺度 for param in model.parameters(): if param.grad is not None: param.grad / scale # 4. 溢出检查 has_overflow any(grad.abs().max().isinf() or grad.hasnan() for grad in model.grads()) if has_overflow: scale max(1, scale // 2) # 减小scale并跳过这一步 continue # 5. 梯度裁剪和更新确保是在未缩放的梯度上 clip_grad_norm_(model.parameters(), max_norm) optimizer.step() # 6. 若无溢出持续多步则增大scale if no_overflow_steps % 300 0: scale * 2注意步骤顺序loss缩放必须在backward之前梯度缩回原尺度必须在裁剪和更新之前。否则梯度裁剪的阈值会被scale干扰。假设你设了max_norm1.0如果忘记除以scale那么这个1.0实际对应的是“放大后的梯度”的1.0普通步长会变成真实梯度的1024倍一定会踩爆参数范数。这里我再强调一下主权重副本。在实际框架里你不需要手动维护FP32和FP16两份参数因为混合精度API会帮你复制。但如果你要手动实现需要做到维护一个FP32的“真实参数”列表每次前向之前从FP32参数拷贝一份FP16版本在FP16版本上进行前向和反向用反向得到的梯度更新FP32参数。3.2 用框架现成能力还是手动实现大部分场景下优先用你当前深度学习框架自带的混合精度接口。因为框架的作者已经把很多坑填了自动选择哪些算子在FP16下计算哪些在FP32下计算loss scale是动态调整的主权重也有。你只需要在代码里打开开关其他交给框架。但我依然建议你明白上面的手动流程原因有两个。第一当训练出问题时调试思路全靠底层原理。比如Loss变成NaN很多框架给出的报错信息非常笼统你如果知道loss scaling在哪个阶段出了问题就能快速定位是梯度溢出还是数据问题。第二框架自动混合精度有时并非最优配置。自动API默认会为几乎所有的卷积和矩阵运算启用FP16但某些自定义算子比如特殊激活函数可能会产生不稳定的数值。这时候需要你手动给某些部分关掉半精度强制走FP32。搞懂原理你才知道该怎么改。3.3 学习率、Batch Size 和模型结构的配合调整换用混合精度不是“其他参数不变只改精度”。有几个细节会让效果差别很大。第一点是学习率。正常情况下混合精度不要求你降低学习率因为主权重和更新都还在FP32里学到的更新量没有改变。但如果你从纯FP16切换到混合精度原来模型可能已经因为精度问题训练得很差这时你调整学习率是为了让模型恢复收敛不是因为混合精度本身。在FP16与BF16之间切换也一样如果使用BF16因为尾数只有7位同一个权重值的表示误差比FP16更大你可能会看到损失曲线更毛糙但这不代表要大幅降低学习率。第二点是batch size。混合精度省下的显存可以让你把batch size调大但batch size变大后梯度是更多样本的平均方差变小适合逐步调大学习率。一般线性缩放规则是batch size扩大k倍学习率也乘以k。不过Adam这类自适应优化器对学习率的敏感性较低仍然不能一次性把lr调太猛。第三点是模型结构。有些结构对精度非常敏感比如包含softmax、log、exp、除法等操作的子模块或者有大量归一化层的模型。在混合精度实现中这些算子可能被框架自动指定为FP32计算所以你不会感知到。但如果你自己写了一个自定义层建议先用FP32跑通再逐段开启混合精度观察数值差异。另外如果你的模型里有批量归一化它的统计量最好用FP32存否则滑动均值会出现漂移误差。4. 踩坑实录常见问题与排查思路4.1 Loss变成NaN或Inf的完整排查路径这是混合精度训练里遇到频率最高的问题。通常有四种来源数据、模型、loss scale、优化器。我一般按照下面的顺序排查先切回纯FP32用一个小数据集跑几十步。如果FP32模式下也出现NaN说明是数据或者模型实现本身有问题别让混合精度背锅。如果纯FP32没问题再开启混合精度。出现NaN先看loss scale是否过大。把初始scale调小比如从1024降到128看是否消失。检查梯度范数。如果梯度中出现了inf说明本轮有些值溢出了。可以在反向传播后打印梯度绝对值最大的几个值和位置。找到异常层后你往往能定位到某个数值范围很大的操作上。检查数据中是否存在极端值或NaN。比如输入文本长度很长导致嵌入层输出很大或者标签中含有空值。这些在FP32下可能没问题在FP16下却会被放大。这里我列一个速查表方便你直接对照。现象可能原因快速定位方法loss直接NaN/Infloss scale过大减小初始scale或临时固定scale为1loss收敛但突然变为NaN学习率过大、梯度爆炸查看梯度范数加入梯度裁剪loss不变权重更新量小于FP16最小间距检查主权重副本是否开启改用BF16或FP32权重更新grad中出现大量0梯度下溢检查loss scale是否过小或改用BF16loss曲线有毛刺但整体下降BF16精度不足使用随机舍入或混合使用FP16速度没有提升甚至变慢小模型或CPU跑混合精度检查是否真的在GPU上运行模型规模是否大到能抵消转换开销4.2 梯度下溢导致的“假收敛”我遇到过一种情况loss降到某个值后就开始平坦看起来像收敛了但换用更慢的FP32训练后loss还能继续下降。这就是典型的精度瓶颈导致的假收敛。原因在于模型前几层的梯度已经在FP16下变成了0。这些层不再更新但它们不直接体现在loss上因为最后几层还在继续学习loss表面上还是很小。你观察权重分布可以看到前几层权重随着训练完全不变。这种情况光靠调loss scale不够。建议BF16 FP32混合或者把模型前几层的精度强制设成FP32。另一个思路是使用梯度累积时在FP32里累积多个batch的梯度再转回FP16但这会增加显存和复杂度。如果你只使用FP16混合精度最好在日志里记录“每层梯度平均绝对值”和“权重更新量”如果某一层的更新量一直为0就要警惕。4.3 显存省了速度却变慢怎么回事混合精度省显存是确定的但提速需要满足两个条件。第一你真的在支持低精度快速运算的硬件上运行。大部分现代GPU都有专门的低精度计算单元但这里的关键是你是否用到了这种能力。混合精度训练使用的半精度矩阵乘法必须能放进专用单元如果你的算子很小或者数据没对齐可能依然要走通用计算路径速度并不会变快。所以小模型不适合混合精度因为拷贝和转换的开销大于省下的计算时间。第二你的训练流程里有大量算子没有走半精度。我之前在一个自定义模型上做过测试发现loss scaling、参数转换、梯度除以scale这些额外操作都是FP32的标量操作它们在高性能模式下开销不大。但如果你的模型里有很多小维度逐元素操作它们在半精度下并不快反而因为频繁转换降低了整体吞吐。遇到这种情况可以看看每个算子的耗时找到实际热点。4.4 多卡并行时容易被忽略的精度问题多卡训练中梯度需要在设备间同步。如果你为了省流量把梯度压成半精度再通信那么精度损失同样会积累。默认的安全做法是梯度以FP32形式同步不做压缩。有些框架提供梯度压缩选项并声称精度影响很小但我个人的经验是除非你的模型非常大、通信已经严重成为瓶颈否则不要为省一点带宽牺牲精度。另外多卡模式下每张卡各自运行混合精度主权重在每张卡上是独立的所以同步后每张卡上的FP32主权重应该保持一致。如果出现权重发散多半是因为某个设备上的loss scale更新策略不同或者某张卡数据并行的梯度被截断了。检查一下scale是否在每张卡上同步更新。有的框架做法是使用全局尺度和全局梯度而不是每张卡各自判断溢出。最后分享一个梯度裁剪的小技巧如果你在混合精度训练中用了梯度裁剪请一定记住“先恢复真实梯度再做裁剪”的顺序。我见过很多调参新手直接把框架默认的梯度裁剪打开结果loss scale存在时裁剪阈值被放大了一千倍等于没裁。后来我把这条规则写在代码注释里每次调试都能少一点麻烦。另外如果你卡在“开混合精度后loss曲线和FP32差很多”的问题上不要急着怀疑实现。可以先在同一个随机种子下分别用纯FP32和混合精度跑几十步分别记录loss和梯度范数比较两者差异。如果差异只在0.1%以内说明你的混合精度方案是健康的如果差异超过1%甚至到10%就要去检查loss scale和主权重处理了。我在实际项目中用过这个办法帮团队定位过好几个自定义算子导致的精度损失问题。混合精度不是一劳永逸的开关它是一套需要耐心调试的数值系统但一旦跑稳你会明显感受到显存和吞吐量带来的回报。