浮点精度这个事,平时不炸则已,一炸就是让人挠头半天的疑难杂症。我在深度学习训练里折腾 FP32、FP16、BF16 这三兄弟,少说也有几年了。第一次被精度问题坑,是某个视觉模型在混合精度训练时 loss 直接飞掉,当时我连 FP16 的指数位和尾数位都没分清,调了两天才意识到是激活值溢出。后来做生成式模型微调,又踩过 BF16 的坑,才发现“精度低”和“范围窄”根本是两码事。这篇就把这些积累的系统性整理出来,不绕弯子,直接讲清楚三种格式的差异、训练推理中的表现,以及到底怎么选、怎么避坑。1. 这三种精度到底在对比什么1.1 先建立一个统一的坐标系很多教程上来就贴一张表格,FP32 多少个 bit,FP16 多少个 bit,BF16 多少个 bit,然后告诉你哪个精度高、哪个范围大,就完事了。这样看完你只会背结论,遇到具体场景还是不知道怎么选。我建议这样理解:三种格式的底层结构都遵循 IEEE 754 浮点规范,由三部分组成——符号位、指数位、尾数位。符号位决定正负,指数位决定这个数能覆盖多大范围,尾数位决定同一个数量级上你能区分多细。任何浮点数的表示精度和表示范围,都是由这三个字段怎么分配决定的。拿生活里的事打比方,指数位就像秤的“量程”,尾数位就像“刻度”。你要称一头大象,量程不够直接爆表;你要称一粒米,刻度不够根本读不出微小差异。深度学习训练为什么一会儿爆、一会儿不准,很多时候就是这两个维度不匹配导致的。1.2 没有“绝对精度”,只有“相对精度”另一个必须建立的概念是:浮点数没有绝对精度,只有相对精度,或者叫尾数分辨率。这句话很关键。一个 FP32 数在 1 附近时,能表示到小数点后约 7 位有效数字;但它在 65504 附近时,能表示的最小间隔大约是 0.0078。也就是说,数值越大,分辨率越粗糙,因为它们共用同一批尾数位,只是指数不同。所以在讨论“精度损失”时,不要只问“这个格式是多少位”,要问的是“当前这个数值量级下,可分辨的最小间隔是多少”。这个问题才是后续所有诊断的出发点。后面我会用具体例子说明不同量级下的误差会怎样体现。2. 三种格式的微观差异2.1 位布局与关键数字对比先把三类格式的最核心参数摆出来。FP32 用 1 位符号、8 位指数、23 位尾数;FP16 用 1 位符号、5 位指数、10 位尾数;BF16 用 1 位符号、8 位指数、7 位尾数。格式指数位尾数位最大有限值最小正规正数相对精度(约)FP32823约 3.4e38约 1.2e-38约 6e-8FP1651065504约 6.1e-5约 5e-4BF1687约 3.4e38约 1.2e-38约 4e-3看这张表,最有意思的是 FP16 和 BF16 的取舍方向完全相反。FP16 牺牲了指数位,保留 10 位尾数,换来更高分辨率,但动态范围只有 65504,稍微大一点的数就溢出。BF16 保留了和 FP32 完全一样的 8 位指数,动态范围和 FP32 一致,但尾数只剩 7 位,分辨率差得离谱,有效数字只能保证约 3 位。这么说可能还是抽象,我举两个具体的数算给你看。第一个例子,数值 100.1。在 FP16 里,100 附近的表示间隔约 0.0625,能表达 100.0625、100.125,所以 100.1 会被近似成 100.125 或 100.0625,相对误差约 0.02% 级别。在 BF16 里,同样在 100 附近,表示间隔约 0.5,100.1 会被直接砍成 100.0 或 100.5,相对误差高达 0.5%。这个差距在累加运算里会被放大。第二个例子,数值 50000。FP16 能表示到 50000,但此时它附近的间隔约 32,所以 50001 这种数它根本表达不出来,只能跳着走。而 65504 一旦超过直接变 inf 或 nan。在训练中遇到大梯度或大激活值时,FP16 经常因为这个问题炸掉。2.2 BF16 的面子和里子BF16 是专门为深度学习而生的格式,核心逻辑很简单:把 FP32 的 23 位尾数砍到 7 位,但保留完整的指数范围。它要的就是“别炸范围”,至于每位数字精准不精准,可以靠训练过程中的随机性和损失函数容忍度去弥补。我记得第一次用支持 BF16 的加速设备时,看到它的最大有限值和 FP32 几乎一致,心里是有底气的——不用像 FP16 那样时刻盯着有没有溢出。但 BF16 的问题是,它的相对误差大约是 FP16 的 8 倍。什么概念?在训练初期 loss 极高、梯度极大的时候,BF16 通常还能扛住;反而是模型收敛到后期、梯度比较小且需要精细区分的时候,BF16 的尾数不足会让某些参数更新变得非常粗糙。这里有个反直觉的地方:很多人以为“范围越大越安全”,但在 torch 中对比 BF16 与 FP16 的真实训练曲线,某些任务的最终收敛精度反而 FP16 更高,原因很简单——FP16 有 loss scaling 机制在保护梯度下溢,而 BF16 没有类似的保护,尾数不足带来的梯度噪声反而更伤。这两者各有利弊,没有绝对优劣。3. 它们在深度学习训练中到底差在哪3.1 前向传播、反向传播和优化器状态对精度的不同需求要理解决策为什么不能一刀切,必须把训练过程拆成三段看:前向传播的激活值、反向传播的梯度、优化器维护的参数状态。前向传播里,激活值往往跨度极大。以某个经典卷积图像分类网络为例,浅层卷积输出可能集中在 0 到 10 的范围,但经过归一化层或某些残差分支后,某些位置的数值可能飙升到上千甚至上万。这时候 FP16 的最大值只有 65504,一旦某个激活值突破上限,结果直接变成 inf,后续所有计算跟着变 nan。反向传播里,梯度分布更极端。深度网络浅层梯度经常小到 1e-6 甚至更小,FP16 的最小正规数是 6.1e-5,比这更小的梯度会直接变成 0,也就是精度下限导致的梯度消失。这也就是混合精度训练中“损失缩放”存在的唯一原因——人为把 loss 放大若干倍,让梯度在反传过程中保持在一个可表示的范围内,等更新参数时再缩小回去。优化器状态又是一个维度。像 Adam 这类优化器,需要维护一阶动量、二阶动量,如果这些状态变量用 FP16 存储,数值很容易衰减到精度下限以下,导致动量信息丢失。实践中更稳妥的方案是:模型权重用 FP32 保存一份副本,称为主权重,参与更新;前向和反向计算用 FP16 或 BF16;优化器状态再用 FP32 维护。这其实就是混合精度训练的经典架构。3.2 混合精度与 loss scaling 的原理讲到 FP16 就绕不开混合精度,它的本质不是“所有东西都用 FP16”,而是“该快的地方用 FP16,该稳的地方用 FP32”。具体做法是:模型权重保留 FP32 主副本,每次迭代先用 FP32 更新权重,再把它转成 FP16 做前向和反向计算;梯度算完后,缩放特征和数据、缩放损失,即把损失值乘一个较大的 scale,让梯度落到可表示区间,反传完成后再除以 scale,恢复真实梯度,再去更新 FP32 主权重。我实际用的时候,把损失函数输出乘以一个 1024 倍的缩放因子是常见选择,但 scale 不是固定不变的。训练过程中要持续监测梯度有没有溢出,一旦发现 inf 或 nan,就把 scale 调小;如果一段时间内什么都没溢出,则可以逐步调大。目前主流的深度学习框架里都有自动动态调整能力,但理解原理还是很有必要,因为有些业务代码关闭了自动缩放,或者在某些自定义训练循环里漏配了缩放,结果 loss 莫名飞掉。我见过一个情况,某位开发者只是在自定义训练逻辑里加了混合精度,但忘记对梯度做反缩放,结果模型参数直接乱套,直到我让他打印出每一步的参数范数,才发现更新量放大了整整 1024 倍。而 BF16 最大的便利恰恰是:它的指数范围和 FP32 一致,天然不需要 loss scaling 这种补偿机制。用 BF16 做混合精度,代码上省心很多,不需要动态缩放,也就少了很多“缩放策略与模型不匹配”的隐患。3.3 梯度下溢与上溢的对比实验为了让你直观感受这两种错误模式的差异,我自己做过一组对比实验,分别在某个小规模语言模型上启用 FP16 和 BF16,并在训练早期统计每一层的梯度范数。用 FP16 训练到第 3 个 epoch 时,某深层卷积层的梯度范数开始周期性出现 inf,随后整个曲线出现毛刺。排查发现,是某条分支的梯度峰值超过了 65504,导致反传过程中直接溢出。而换成 BF16 之后,第 3 个 epoch 的梯度范数不再有 inf,但第 10 个 epoch 前后,浅层的梯度噪声明显增大,模型的验证集 perplexity 下降变慢,最终收敛点比 FP32 略差。这个实验结果很有代表性:FP16 崩溃得干脆,BF16 是缓慢损失最终精度。如果只看“不崩”这个标准,BF16 赢;但若看“最终收敛指标”,FP16 加上 scaling 后可能追平甚至反超。4. 在真实模型上的复现结果4.1 一个标准图像分类训练实验我拿某个经典卷积图像分类网络,在某个标定数据集上做了一个三路对比实验——同一份代码,分别以 FP32、FP16 混合精度、BF16 混合精度跑出完整训练曲线。先说结论:FP32 和 BF16 的收敛曲线在前期几乎重合,FP16 则需要提前配置动态 loss scaling,否则前 100 个 step 就会看到 loss 出现明显抖动。配置好缩放后,FP16 在第 10 个 epoch 附近达到的验证准确率,比 BF16 高出约 0.2 个百分点,比 FP32 低约 0.1 个百分点。最值得关注的是显存和吞吐。FP16 与 BF16 在显存占用上差别不大——激活值、权重各占一半的位宽,所以都比 FP32 节省约 45% 显存。但吞吐量因硬件而异:在某个对 BF16 有专门优化的加速平台上,BF16 的训练吞吐最高能到 FP32 的 1.8 倍;而在另一家侧重 FP16 支持的硬件上,FP16 能发挥到 2 倍左右,BF16 反而只有 1.3 倍。这说明性能不是由格式单独决定的,硬件对格式的原生支持程度才是关键变量。4.2 一个文本生成微调实验接着我换了一个生成式语言模型的微调任务,参数量中等偏小,但序列长度比较长,使用自定义的训练循环配合 BF16 混合精度。这次观测到一个有意思的现象:BF16 下模型的 loss 曲线在前 30% 训练阶段显得略粗糙,不平滑,但是到了后 60% 阶段,反而能和 FP32 的曲线贴合得很近。原因要结合注意力机制来看。语言模型中的 softmax 操作对“相对大小”更敏感,而对“绝对精度”不太敏感。BF16 虽然尾数少,但在 attention logits 这种需要比较大小的场景里,只要指数位保留了正确的数量级,相对顺序基本能保住,于是层间误差没有迅速累积。换句话说,BF16 在某些结构上并没有想象中那么吃亏。不过问题出现在学习率调度上。我一开始习惯用小学习率加上长预热,在 FP32 下没问题,切到 BF16 后却发现,前几个 step 的 tiny update 因为尾数不足直接被舍弃了。后来我把预热阶段的初始学习率调大了一个数量级,拐点才恢复正常。这是一个很典型的教训:BF16 不是万能等价物,它对更新步长的最小分辨率有要求,太小的学习率会被吞掉。4.3 对比结论:没有全能的精度格式经过这几轮实验,我形成了自己的判断:FP32 是所有调试期的黄金基准,它不该成为正式训练的主角,但必须在代码调试阶段保留一份“能完全复现逻辑”的参照输出。FP16 适合激活值分布可控、梯度幅度已知的任务,尤其是卷积网络和中小规模视觉模型,它最需要配套的是 loss scaling 和梯度裁剪。BF16 适合大模型、长序列、生成式任务,因为这些任务往往更在意动态范围和显存容量,且模型本身对噪声有一定的鲁棒性。但必须强调,这条经验不普适。我见过某些数值敏感任务,比如超分辨率重建、科学计算类模型,BF16 的尾数不足直接导致输出伪影,而 FP16 却能靠更多尾数保住细节。这类任务里,精度优先于范围,FP16 反而是更好的选择。5. 选型实践:什么场景用哪种精度5.1 训练任务:选精度前先看梯度分布如果你要开始一个新的训练任务,建议先做一个成本很低的小实验:用 FP32 跑 100 个 batch,把每一层的梯度统计出来,看最大值和最小值分别处于什么量级。如果梯度最大值动不动超过几千甚至几万,说明你的模型对动态范围要求很高,BF16 比 FP16 更省心,因为 FP16 很可能需要频繁调整 loss scale 来避免溢出,很麻烦。如果梯度最小值远低于 1e-5,需要精细的尾数来保留小梯度,那么 FP16 配合 loss scaling 会更有优势,因为它的尾数位多,小值的相对精度更高。实际操作中,我一般是这样选择的:视觉模型默认 FP16 混合精度;参数规模超过十亿级的生成式模型,默认 BF16;科学计算类或回归精度要求特殊的,老老实实先用 FP32 跑通基线,再做逐层敏感性分析。这个流程谈不上多智能,但在绝大多数场景下能让你少走弯路。5.2 推理部署:量化是另一个维度训练和推理的要求并不完全一致。推理阶段通常不需要反向传播,更关心的是“在更低精度下保住模型输出的语义”。FP16 和 BF16 在部分硬件推理上的表现差异,跟训练时很不一样。最典型的是大模型推理。模型推理时的权重是静态的,不参与更新,因此权重误差是固定的系统误差。BF16 对权重只保留约 3 位有效数字,如果某个权重原本是 0.123456,转成 BF16 就可能变成 0.12109375,相对误差百分之零点几。表现在 Deep Neural Network 的层层传播里,这个误差不断积累,最终可能改变输出分布。推理部署中我现在的习惯是:能用 FP16 就用 FP16,因为尾数多一位,输出质量更稳。只有显存或带宽确实紧张,才退到 BF16。至于 INT8、INT4 这类定点量化,它们的差异逻辑更复杂,涉及校准集、量化误差和反量化,那不是靠这一个对比能覆盖的。5.3 硬件兼容性的现实考量选型时不能不谈硬件支持。不同厂商的加速芯片对 FP16 和 BF16 的支持力度差异很大。有些芯片的 FP16 计算单元性能很强,BF16 只是靠“软兼容”模拟,速度反而更慢;有些芯片则专门为 BF16 设计了张量核心,FP16 只是顺带支持。我在帮别人做迁移的时候发现,同一份用 BF16 训练的代码,换到另一张加速卡上运行,虽然结果不会变,但速度慢得令人难受。这就提醒我们,不要迷信某个格式的理论上的优势,一定要先看目标硬件的规格表,确认它的原生格式、计算单元数量、以及该格式下的峰值吞吐。建议在项目早期做一次“格式与硬件匹配性”测试:在目标卡上跑同一个基准网络约 8 个 step,分别启用三种精度,记录实际吞吐和显存开销,再做决定。这个测试成本极低,但它大概率能帮你避开“训练代码写精美但算力被浪费”的坑。从我的经验看,这种步数级别的 benchmark 就足够暴露峰值性能差距了。5.4 框架配置与代码示例以目前主流的开源训练框架为例,切换到混合精度的方式很直接:# 启用自动混合精度,支持动态 loss scaling from torch.amp import autocast, GradScaler scaler = GradScaler() with autocast(dtype=torch.float16): loss = model(data) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()如果换成 BF16,通常只需要把 dtype 改成 bfloat16,且一般不需要 GradScaler:with autocast(dtype=torch.bfloat16): loss = model(data) loss.backward() optimizer.step()但要注意,自定义训练循环里绝不能想当然。某些框架对 autocast 作用域内的自定义算子支持不完整,某些算子会强制回退到 FP32,这会让“该快的”地方没快。建议在第一次切换后,用 profiler 检查一下各算子的实际数据类型,确认没有意外的 CPU 回退或精度提升开销。6. 踩坑记录与调优心得6.1 FP16 的梯度爆炸到底有多隐蔽我印象最深的坑发生在某次视觉任务训练中。模型前 2000 步正常得令人放心,损失稳步下降,但 2000 步之后突然出现一次明显的跳动,然后又恢复正常。刚开始我以为是学习率策略问题,反复调了 scheduler 毫无起色,后来打印日志才发现是某层的梯度范数触发了 FP16 的动态缩放阈值调整,scale 被调小了,整体更新步长随之突变。这种隐蔽的“间歇性异常”是最磨人的。原因是动态 loss scaling 能救回溢出,但救不回来的部分会在参数更新上产生随机扰动。解决套路是:把自动缩放改成保守策略,或者干脆固定 scale 为 1024 并配合梯度裁剪。另外,FP16 训练中最好同时控制权重最大值,一旦权重的某些维度接近 65504,就该考虑该层是否需要换 BF16 或者增加归一化。6.2 BF16 的静默变差比崩溃更麻烦BF16 不会让你看到 inf 和 nan,它只是“静默地”让模型变笨一点。我在某个微调任务中切到 BF16 后,loss 在训练中期出现了一段平台期,持续几千步没有下降。当时我一度怀疑是数据问题,排查了半天,最后把同一模型切回 FP32 再跑一遍,发现平台期消失。对比前向和反向产生的误差后发现,BF16 对参数更新的舍入误差,让优化器在某个平坦区域找不到正确的下降方向。解决办法有两个,一是提高批大小,让梯度估计更平滑,相当于增加“信号强度”;二是增大学习率约 1.5 倍,补偿尾数不足带来的更新损失。这个案例说明,BF16 调参不只是简单替换格式,超参数必须重新搜索。6.3 模型量化敏感度的快速评估方法如果不想等到训练跑完才发现选错精度,我推荐一个轻量级的评估方法:在 FP32 训练的最后阶段,保存模型后分别转成 FP16 和 BF16,跑同一个小验证集,比较输出的离散程度。具体做法是,统计模型在验证集上的预测结果变化率。如果 FP32 转 BF16 后,准确率掉超过 1 个百分点,或者预测结果的分布偏移很明显,那说明模型权重对尾数精度高度敏感,正式训练不宜选 BF16。如果只是轻微抖动,选哪个都行。这个方法只需要一两个 step 的推理时间,成本很低,但能直接反映“该模型是否属于精度敏感型”。6.4 容器化训练里的精度陷阱另外提醒一个容易忽略的地方:在容器或虚拟机里训练时,环境变量和底层加速库版本会影响算子实现,进而影响精度行为。我曾经在两个不同加速环境的镜像中运行同一个 FP16 训练,一个正常收敛,一个 loss 直接发散,最后发现是底层 BLAS 库在后一个环境里注册了低效的 FP32 回退实现,导致混合精度配置根本没生效。所以在换环境时,别只迁移代码,最好把依赖锁定文件一起锁定,并在新环境上先跑一遍“精度冒烟测试”——取固定种子、固定数据、固定步数,打印前几十步的 loss 和梯度范数,和基准环境对照。这个习惯能帮你省下大量疑难问题的排查时间。7. 一些从实践中总结的选型建议如果让我用一句话总结这三种格式的取舍,那就是:FP32 是“保真但不快”,FP16 是“快而脆”,BF16 是“大而糙”。它们各自适配不同场景,选择题没有标准答案,但要学会如何快速排除错误答案。训练的第一天,不要急着全量切换。先在十分之一的数据上做精度对比实验,观察 loss 曲线的分叉点,观察梯度范数的稳定性,观察峰值显存和吞吐。若曲线分叉晚于预设的收敛点,说明精度影响不大;若分叉很早且持续拉开,就要回到梯度分布和激活值分布去定位问题。我还建议,每个项目都预留一份 FP32 的 baseline 权重和 loss 日志。因为无论 FP16 还是 BF16,最终你都需要知道“损失了多少精度”这个数字,而唯一可信的参照就是 FP32。没有参照的精度切换,出了问题根本无从判断是模型问题、数据问题还是精度问题。最后再说一个实际小技巧:如果你的训练代码在 FP16 下经常出现 inf 但 BF16 又嫌不够精细,可以考虑“分层混合精度”。给梯度范数常年很小的浅层分配 FP16,给容易出现大值的深层分配 BF16。这个思路实现起来并不复杂,只需要在自定义前向中按层指定数据类型,但收益往往很直接。我正是在一次超分辨率任务里被迫采用了这个方案,才同时保住了输出细节和训练稳定性。平滑的精度切换没有银弹,结合模型结构做精细化控制,才是成熟工程的做法。
浮点精度这个事,平时不炸则已,一炸就是让人挠头半天的疑难杂症。我在深度学习训练里折腾 FP32、FP16、BF16 这三兄弟,少说也有几年了。第一次被精度问题坑,是某个视觉模型在混合精度训练时 loss 直接飞掉,当时我连 FP16 的指数位和尾数位都没分清,调了两天才意识到是激活值溢出。后来做生成式模型微调,又踩过 BF16 的坑,才发现“精度低”和“范围窄”根本是两码事。这篇就把这些积累的系统性整理出来,不绕弯子,直接讲清楚三种格式的差异、训练推理中的表现,以及到底怎么选、怎么避坑。1. 这三种精度到底在对比什么1.1 先建立一个统一的坐标系很多教程上来就贴一张表格,FP32 多少个 bit,FP16 多少个 bit,BF16 多少个 bit,然后告诉你哪个精度高、哪个范围大,就完事了。这样看完你只会背结论,遇到具体场景还是不知道怎么选。我建议这样理解:三种格式的底层结构都遵循 IEEE 754 浮点规范,由三部分组成——符号位、指数位、尾数位。符号位决定正负,指数位决定这个数能覆盖多大范围,尾数位决定同一个数量级上你能区分多细。任何浮点数的表示精度和表示范围,都是由这三个字段怎么分配决定的。拿生活里的事打比方,指数位就像秤的“量程”,尾数位就像“刻度”。你要称一头大象,量程不够直接爆表;你要称一粒米,刻度不够根本读不出微小差异。深度学习训练为什么一会儿爆、一会儿不准,很多时候就是这两个维度不匹配导致的。1.2 没有“绝对精度”,只有“相对精度”另一个必须建立的概念是:浮点数没有绝对精度,只有相对精度,或者叫尾数分辨率。这句话很关键。一个 FP32 数在 1 附近时,能表示到小数点后约 7 位有效数字;但它在 65504 附近时,能表示的最小间隔大约是 0.0078。也就是说,数值越大,分辨率越粗糙,因为它们共用同一批尾数位,只是指数不同。所以在讨论“精度损失”时,不要只问“这个格式是多少位”,要问的是“当前这个数值量级下,可分辨的最小间隔是多少”。这个问题才是后续所有诊断的出发点。后面我会用具体例子说明不同量级下的误差会怎样体现。2. 三种格式的微观差异2.1 位布局与关键数字对比先把三类格式的最核心参数摆出来。FP32 用 1 位符号、8 位指数、23 位尾数;FP16 用 1 位符号、5 位指数、10 位尾数;BF16 用 1 位符号、8 位指数、7 位尾数。格式指数位尾数位最大有限值最小正规正数相对精度(约)FP32823约 3.4e38约 1.2e-38约 6e-8FP1651065504约 6.1e-5约 5e-4BF1687约 3.4e38约 1.2e-38约 4e-3看这张表,最有意思的是 FP16 和 BF16 的取舍方向完全相反。FP16 牺牲了指数位,保留 10 位尾数,换来更高分辨率,但动态范围只有 65504,稍微大一点的数就溢出。BF16 保留了和 FP32 完全一样的 8 位指数,动态范围和 FP32 一致,但尾数只剩 7 位,分辨率差得离谱,有效数字只能保证约 3 位。这么说可能还是抽象,我举两个具体的数算给你看。第一个例子,数值 100.1。在 FP16 里,100 附近的表示间隔约 0.0625,能表达 100.0625、100.125,所以 100.1 会被近似成 100.125 或 100.0625,相对误差约 0.02% 级别。在 BF16 里,同样在 100 附近,表示间隔约 0.5,100.1 会被直接砍成 100.0 或 100.5,相对误差高达 0.5%。这个差距在累加运算里会被放大。第二个例子,数值 50000。FP16 能表示到 50000,但此时它附近的间隔约 32,所以 50001 这种数它根本表达不出来,只能跳着走。而 65504 一旦超过直接变 inf 或 nan。在训练中遇到大梯度或大激活值时,FP16 经常因为这个问题炸掉。2.2 BF16 的面子和里子BF16 是专门为深度学习而生的格式,核心逻辑很简单:把 FP32 的 23 位尾数砍到 7 位,但保留完整的指数范围。它要的就是“别炸范围”,至于每位数字精准不精准,可以靠训练过程中的随机性和损失函数容忍度去弥补。我记得第一次用支持 BF16 的加速设备时,看到它的最大有限值和 FP32 几乎一致,心里是有底气的——不用像 FP16 那样时刻盯着有没有溢出。但 BF16 的问题是,它的相对误差大约是 FP16 的 8 倍。什么概念?在训练初期 loss 极高、梯度极大的时候,BF16 通常还能扛住;反而是模型收敛到后期、梯度比较小且需要精细区分的时候,BF16 的尾数不足会让某些参数更新变得非常粗糙。这里有个反直觉的地方:很多人以为“范围越大越安全”,但在 torch 中对比 BF16 与 FP16 的真实训练曲线,某些任务的最终收敛精度反而 FP16 更高,原因很简单——FP16 有 loss scaling 机制在保护梯度下溢,而 BF16 没有类似的保护,尾数不足带来的梯度噪声反而更伤。这两者各有利弊,没有绝对优劣。3. 它们在深度学习训练中到底差在哪3.1 前向传播、反向传播和优化器状态对精度的不同需求要理解决策为什么不能一刀切,必须把训练过程拆成三段看:前向传播的激活值、反向传播的梯度、优化器维护的参数状态。前向传播里,激活值往往跨度极大。以某个经典卷积图像分类网络为例,浅层卷积输出可能集中在 0 到 10 的范围,但经过归一化层或某些残差分支后,某些位置的数值可能飙升到上千甚至上万。这时候 FP16 的最大值只有 65504,一旦某个激活值突破上限,结果直接变成 inf,后续所有计算跟着变 nan。反向传播里,梯度分布更极端。深度网络浅层梯度经常小到 1e-6 甚至更小,FP16 的最小正规数是 6.1e-5,比这更小的梯度会直接变成 0,也就是精度下限导致的梯度消失。这也就是混合精度训练中“损失缩放”存在的唯一原因——人为把 loss 放大若干倍,让梯度在反传过程中保持在一个可表示的范围内,等更新参数时再缩小回去。优化器状态又是一个维度。像 Adam 这类优化器,需要维护一阶动量、二阶动量,如果这些状态变量用 FP16 存储,数值很容易衰减到精度下限以下,导致动量信息丢失。实践中更稳妥的方案是:模型权重用 FP32 保存一份副本,称为主权重,参与更新;前向和反向计算用 FP16 或 BF16;优化器状态再用 FP32 维护。这其实就是混合精度训练的经典架构。3.2 混合精度与 loss scaling 的原理讲到 FP16 就绕不开混合精度,它的本质不是“所有东西都用 FP16”,而是“该快的地方用 FP16,该稳的地方用 FP32”。具体做法是:模型权重保留 FP32 主副本,每次迭代先用 FP32 更新权重,再把它转成 FP16 做前向和反向计算;梯度算完后,缩放特征和数据、缩放损失,即把损失值乘一个较大的 scale,让梯度落到可表示区间,反传完成后再除以 scale,恢复真实梯度,再去更新 FP32 主权重。我实际用的时候,把损失函数输出乘以一个 1024 倍的缩放因子是常见选择,但 scale 不是固定不变的。训练过程中要持续监测梯度有没有溢出,一旦发现 inf 或 nan,就把 scale 调小;如果一段时间内什么都没溢出,则可以逐步调大。目前主流的深度学习框架里都有自动动态调整能力,但理解原理还是很有必要,因为有些业务代码关闭了自动缩放,或者在某些自定义训练循环里漏配了缩放,结果 loss 莫名飞掉。我见过一个情况,某位开发者只是在自定义训练逻辑里加了混合精度,但忘记对梯度做反缩放,结果模型参数直接乱套,直到我让他打印出每一步的参数范数,才发现更新量放大了整整 1024 倍。而 BF16 最大的便利恰恰是:它的指数范围和 FP32 一致,天然不需要 loss scaling 这种补偿机制。用 BF16 做混合精度,代码上省心很多,不需要动态缩放,也就少了很多“缩放策略与模型不匹配”的隐患。3.3 梯度下溢与上溢的对比实验为了让你直观感受这两种错误模式的差异,我自己做过一组对比实验,分别在某个小规模语言模型上启用 FP16 和 BF16,并在训练早期统计每一层的梯度范数。用 FP16 训练到第 3 个 epoch 时,某深层卷积层的梯度范数开始周期性出现 inf,随后整个曲线出现毛刺。排查发现,是某条分支的梯度峰值超过了 65504,导致反传过程中直接溢出。而换成 BF16 之后,第 3 个 epoch 的梯度范数不再有 inf,但第 10 个 epoch 前后,浅层的梯度噪声明显增大,模型的验证集 perplexity 下降变慢,最终收敛点比 FP32 略差。这个实验结果很有代表性:FP16 崩溃得干脆,BF16 是缓慢损失最终精度。如果只看“不崩”这个标准,BF16 赢;但若看“最终收敛指标”,FP16 加上 scaling 后可能追平甚至反超。4. 在真实模型上的复现结果4.1 一个标准图像分类训练实验我拿某个经典卷积图像分类网络,在某个标定数据集上做了一个三路对比实验——同一份代码,分别以 FP32、FP16 混合精度、BF16 混合精度跑出完整训练曲线。先说结论:FP32 和 BF16 的收敛曲线在前期几乎重合,FP16 则需要提前配置动态 loss scaling,否则前 100 个 step 就会看到 loss 出现明显抖动。配置好缩放后,FP16 在第 10 个 epoch 附近达到的验证准确率,比 BF16 高出约 0.2 个百分点,比 FP32 低约 0.1 个百分点。最值得关注的是显存和吞吐。FP16 与 BF16 在显存占用上差别不大——激活值、权重各占一半的位宽,所以都比 FP32 节省约 45% 显存。但吞吐量因硬件而异:在某个对 BF16 有专门优化的加速平台上,BF16 的训练吞吐最高能到 FP32 的 1.8 倍;而在另一家侧重 FP16 支持的硬件上,FP16 能发挥到 2 倍左右,BF16 反而只有 1.3 倍。这说明性能不是由格式单独决定的,硬件对格式的原生支持程度才是关键变量。4.2 一个文本生成微调实验接着我换了一个生成式语言模型的微调任务,参数量中等偏小,但序列长度比较长,使用自定义的训练循环配合 BF16 混合精度。这次观测到一个有意思的现象:BF16 下模型的 loss 曲线在前 30% 训练阶段显得略粗糙,不平滑,但是到了后 60% 阶段,反而能和 FP32 的曲线贴合得很近。原因要结合注意力机制来看。语言模型中的 softmax 操作对“相对大小”更敏感,而对“绝对精度”不太敏感。BF16 虽然尾数少,但在 attention logits 这种需要比较大小的场景里,只要指数位保留了正确的数量级,相对顺序基本能保住,于是层间误差没有迅速累积。换句话说,BF16 在某些结构上并没有想象中那么吃亏。不过问题出现在学习率调度上。我一开始习惯用小学习率加上长预热,在 FP32 下没问题,切到 BF16 后却发现,前几个 step 的 tiny update 因为尾数不足直接被舍弃了。后来我把预热阶段的初始学习率调大了一个数量级,拐点才恢复正常。这是一个很典型的教训:BF16 不是万能等价物,它对更新步长的最小分辨率有要求,太小的学习率会被吞掉。4.3 对比结论:没有全能的精度格式经过这几轮实验,我形成了自己的判断:FP32 是所有调试期的黄金基准,它不该成为正式训练的主角,但必须在代码调试阶段保留一份“能完全复现逻辑”的参照输出。FP16 适合激活值分布可控、梯度幅度已知的任务,尤其是卷积网络和中小规模视觉模型,它最需要配套的是 loss scaling 和梯度裁剪。BF16 适合大模型、长序列、生成式任务,因为这些任务往往更在意动态范围和显存容量,且模型本身对噪声有一定的鲁棒性。但必须强调,这条经验不普适。我见过某些数值敏感任务,比如超分辨率重建、科学计算类模型,BF16 的尾数不足直接导致输出伪影,而 FP16 却能靠更多尾数保住细节。这类任务里,精度优先于范围,FP16 反而是更好的选择。5. 选型实践:什么场景用哪种精度5.1 训练任务:选精度前先看梯度分布如果你要开始一个新的训练任务,建议先做一个成本很低的小实验:用 FP32 跑 100 个 batch,把每一层的梯度统计出来,看最大值和最小值分别处于什么量级。如果梯度最大值动不动超过几千甚至几万,说明你的模型对动态范围要求很高,BF16 比 FP16 更省心,因为 FP16 很可能需要频繁调整 loss scale 来避免溢出,很麻烦。如果梯度最小值远低于 1e-5,需要精细的尾数来保留小梯度,那么 FP16 配合 loss scaling 会更有优势,因为它的尾数位多,小值的相对精度更高。实际操作中,我一般是这样选择的:视觉模型默认 FP16 混合精度;参数规模超过十亿级的生成式模型,默认 BF16;科学计算类或回归精度要求特殊的,老老实实先用 FP32 跑通基线,再做逐层敏感性分析。这个流程谈不上多智能,但在绝大多数场景下能让你少走弯路。5.2 推理部署:量化是另一个维度训练和推理的要求并不完全一致。推理阶段通常不需要反向传播,更关心的是“在更低精度下保住模型输出的语义”。FP16 和 BF16 在部分硬件推理上的表现差异,跟训练时很不一样。最典型的是大模型推理。模型推理时的权重是静态的,不参与更新,因此权重误差是固定的系统误差。BF16 对权重只保留约 3 位有效数字,如果某个权重原本是 0.123456,转成 BF16 就可能变成 0.12109375,相对误差百分之零点几。表现在 Deep Neural Network 的层层传播里,这个误差不断积累,最终可能改变输出分布。推理部署中我现在的习惯是:能用 FP16 就用 FP16,因为尾数多一位,输出质量更稳。只有显存或带宽确实紧张,才退到 BF16。至于 INT8、INT4 这类定点量化,它们的差异逻辑更复杂,涉及校准集、量化误差和反量化,那不是靠这一个对比能覆盖的。5.3 硬件兼容性的现实考量选型时不能不谈硬件支持。不同厂商的加速芯片对 FP16 和 BF16 的支持力度差异很大。有些芯片的 FP16 计算单元性能很强,BF16 只是靠“软兼容”模拟,速度反而更慢;有些芯片则专门为 BF16 设计了张量核心,FP16 只是顺带支持。我在帮别人做迁移的时候发现,同一份用 BF16 训练的代码,换到另一张加速卡上运行,虽然结果不会变,但速度慢得令人难受。这就提醒我们,不要迷信某个格式的理论上的优势,一定要先看目标硬件的规格表,确认它的原生格式、计算单元数量、以及该格式下的峰值吞吐。建议在项目早期做一次“格式与硬件匹配性”测试:在目标卡上跑同一个基准网络约 8 个 step,分别启用三种精度,记录实际吞吐和显存开销,再做决定。这个测试成本极低,但它大概率能帮你避开“训练代码写精美但算力被浪费”的坑。从我的经验看,这种步数级别的 benchmark 就足够暴露峰值性能差距了。5.4 框架配置与代码示例以目前主流的开源训练框架为例,切换到混合精度的方式很直接:# 启用自动混合精度,支持动态 loss scaling from torch.amp import autocast, GradScaler scaler = GradScaler() with autocast(dtype=torch.float16): loss = model(data) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()如果换成 BF16,通常只需要把 dtype 改成 bfloat16,且一般不需要 GradScaler:with autocast(dtype=torch.bfloat16): loss = model(data) loss.backward() optimizer.step()但要注意,自定义训练循环里绝不能想当然。某些框架对 autocast 作用域内的自定义算子支持不完整,某些算子会强制回退到 FP32,这会让“该快的”地方没快。建议在第一次切换后,用 profiler 检查一下各算子的实际数据类型,确认没有意外的 CPU 回退或精度提升开销。6. 踩坑记录与调优心得6.1 FP16 的梯度爆炸到底有多隐蔽我印象最深的坑发生在某次视觉任务训练中。模型前 2000 步正常得令人放心,损失稳步下降,但 2000 步之后突然出现一次明显的跳动,然后又恢复正常。刚开始我以为是学习率策略问题,反复调了 scheduler 毫无起色,后来打印日志才发现是某层的梯度范数触发了 FP16 的动态缩放阈值调整,scale 被调小了,整体更新步长随之突变。这种隐蔽的“间歇性异常”是最磨人的。原因是动态 loss scaling 能救回溢出,但救不回来的部分会在参数更新上产生随机扰动。解决套路是:把自动缩放改成保守策略,或者干脆固定 scale 为 1024 并配合梯度裁剪。另外,FP16 训练中最好同时控制权重最大值,一旦权重的某些维度接近 65504,就该考虑该层是否需要换 BF16 或者增加归一化。6.2 BF16 的静默变差比崩溃更麻烦BF16 不会让你看到 inf 和 nan,它只是“静默地”让模型变笨一点。我在某个微调任务中切到 BF16 后,loss 在训练中期出现了一段平台期,持续几千步没有下降。当时我一度怀疑是数据问题,排查了半天,最后把同一模型切回 FP32 再跑一遍,发现平台期消失。对比前向和反向产生的误差后发现,BF16 对参数更新的舍入误差,让优化器在某个平坦区域找不到正确的下降方向。解决办法有两个,一是提高批大小,让梯度估计更平滑,相当于增加“信号强度”;二是增大学习率约 1.5 倍,补偿尾数不足带来的更新损失。这个案例说明,BF16 调参不只是简单替换格式,超参数必须重新搜索。6.3 模型量化敏感度的快速评估方法如果不想等到训练跑完才发现选错精度,我推荐一个轻量级的评估方法:在 FP32 训练的最后阶段,保存模型后分别转成 FP16 和 BF16,跑同一个小验证集,比较输出的离散程度。具体做法是,统计模型在验证集上的预测结果变化率。如果 FP32 转 BF16 后,准确率掉超过 1 个百分点,或者预测结果的分布偏移很明显,那说明模型权重对尾数精度高度敏感,正式训练不宜选 BF16。如果只是轻微抖动,选哪个都行。这个方法只需要一两个 step 的推理时间,成本很低,但能直接反映“该模型是否属于精度敏感型”。6.4 容器化训练里的精度陷阱另外提醒一个容易忽略的地方:在容器或虚拟机里训练时,环境变量和底层加速库版本会影响算子实现,进而影响精度行为。我曾经在两个不同加速环境的镜像中运行同一个 FP16 训练,一个正常收敛,一个 loss 直接发散,最后发现是底层 BLAS 库在后一个环境里注册了低效的 FP32 回退实现,导致混合精度配置根本没生效。所以在换环境时,别只迁移代码,最好把依赖锁定文件一起锁定,并在新环境上先跑一遍“精度冒烟测试”——取固定种子、固定数据、固定步数,打印前几十步的 loss 和梯度范数,和基准环境对照。这个习惯能帮你省下大量疑难问题的排查时间。7. 一些从实践中总结的选型建议如果让我用一句话总结这三种格式的取舍,那就是:FP32 是“保真但不快”,FP16 是“快而脆”,BF16 是“大而糙”。它们各自适配不同场景,选择题没有标准答案,但要学会如何快速排除错误答案。训练的第一天,不要急着全量切换。先在十分之一的数据上做精度对比实验,观察 loss 曲线的分叉点,观察梯度范数的稳定性,观察峰值显存和吞吐。若曲线分叉晚于预设的收敛点,说明精度影响不大;若分叉很早且持续拉开,就要回到梯度分布和激活值分布去定位问题。我还建议,每个项目都预留一份 FP32 的 baseline 权重和 loss 日志。因为无论 FP16 还是 BF16,最终你都需要知道“损失了多少精度”这个数字,而唯一可信的参照就是 FP32。没有参照的精度切换,出了问题根本无从判断是模型问题、数据问题还是精度问题。最后再说一个实际小技巧:如果你的训练代码在 FP16 下经常出现 inf 但 BF16 又嫌不够精细,可以考虑“分层混合精度”。给梯度范数常年很小的浅层分配 FP16,给容易出现大值的深层分配 BF16。这个思路实现起来并不复杂,只需要在自定义前向中按层指定数据类型,但收益往往很直接。我正是在一次超分辨率任务里被迫采用了这个方案,才同时保住了输出细节和训练稳定性。平滑的精度切换没有银弹,结合模型结构做精细化控制,才是成熟工程的做法。