☰
混合精度训练详解:FP16/BF16原理、显存优化与踩坑指南
2026/10/17 19:28:32 网站建设 项目流程

最近我在调一个大语言模型训练流程,卡在了一个很基础的问题上:显存不够。网上所有教程都说用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)

对于FP32,bias通常取127,所以指数能表示的实际范围大约是-126到127。用生活类比:如果把FP32当成一个“可以有科学计数法的计算器”,符号位决定正负,指数决定小数点往左还是往右挪多少位,尾数决定在这个范围内能保留几位有效数字。

有效数字才是关键。FP32的尾数有23位,对应二进制精度。换算成十进制大约是7位有效数字。也就是说,1.0000001和1.0000002这种差距在FP32里是可以区分的,但如果你和另一个数相差0.00000001,FP32可能直接把它们当成同一个数。

这里我补充一个常见误解:有人觉得“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.0,FP16能表示的下一个数是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_norm=1.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、优化器。

我一般按照下面的顺序排查:

  1. 先切回纯FP32,用一个小数据集跑几十步。如果FP32模式下也出现NaN,说明是数据或者模型实现本身有问题,别让混合精度背锅。

  2. 如果纯FP32没问题,再开启混合精度。出现NaN,先看loss scale是否过大。把初始scale调小,比如从1024降到128,看是否消失。

  3. 检查梯度范数。如果梯度中出现了inf,说明本轮有些值溢出了。可以在反向传播后打印梯度绝对值最大的几个值和位置。找到异常层后,你往往能定位到某个数值范围很大的操作上。

  4. 检查数据中是否存在极端值或NaN。比如输入文本长度很长导致嵌入层输出很大,或者标签中含有空值。这些在FP32下可能没问题,在FP16下却会被放大。

这里我列一个速查表,方便你直接对照。

现象可能原因快速定位方法
loss直接NaN/Infloss scale过大减小初始scale,或临时固定scale为1
loss收敛但突然变为NaN学习率过大、梯度爆炸查看梯度范数,加入梯度裁剪
loss不变权重更新量小于FP16最小间距检查主权重副本是否开启,改用BF16或FP32权重更新
grad中出现大量0梯度下溢检查loss scale是否过小,或改用BF16
loss曲线有毛刺但整体下降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和主权重处理了。我在实际项目中用过这个办法,帮团队定位过好几个自定义算子导致的精度损失问题。混合精度不是一劳永逸的开关,它是一套需要耐心调试的数值系统,但一旦跑稳,你会明显感受到显存和吞吐量带来的回报。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询