在算盘上,每一位数都代表着一颗实实在在的珠子,你不拨它,它就不是那个值;拨错一档,整笔账就差了千里。计算机里的浮点数也是这样,它本质上是一套“数位有限的算盘”——只是这颗算盘藏得比较深,日常写代码时根本看不见。但一旦做起深度学习训练,你就会发现,那些FP32、BF16、FP8的字符像走马灯一样在你眼前晃来晃去,每一个都代表不同档位的度量衡。这篇内容就围绕数值精度与浮点表示讲清楚:FP32/BF16/FP8到底各自牺牲了什么,在训练里它们又是怎么决定你的loss曲线走向的。想做模型训练、推理优化或者端侧部署的朋友,这篇文章值得看完。
我最早对“浮点精度”产生强烈感知,是在一次模型训练时遇到了诡异的loss不降:一切代码逻辑都没问题,数据预处理也反复核对过,最后排查到根因是激活值在低精度格式下全部下溢成了0。从那以后,我开始系统性研究这些“算盘珠”的排布方式。接下来我把这份经验完整拆开,尽量用大白话讲清楚。
1. 从算盘珠说起:浮点数是被有限位数锁死的度量衡
1.1 不要觉得“小数点”是天然精确的
我们先回到最朴素的问题:为什么计算机需要搞出这么多浮点格式?答案很简单——计算机里存数字,只能用固定长度的二进制位。你用32个bit表示一个数,跟用16个bit、8个bit表示一个数,能表达的信息量天然就有上限。就像算盘上10个档位,每档只有几颗珠子,能表达的“档位组合”有限,算盘珠的数量本身决定了这把算盘的表达能力。
拿十进制里最普通的0.1举例。在二进制浮点体系里,0.1无法被任何有限位的小数精确表示,它是以“无限循环二进制小数”存在的。FP32会把0.1近似成一个很接近的数,约等于0.100000001490116...。这个误差平时不大,但如果你不断累加、不断做乘法,误差就会滚雪球。FP64、FP128可以让误差小到几乎看不见,但代价是内存翻倍、计算变慢。精度从来不是免费的,它占用的是宝贵的bit。
1.2 一个浮点数内部有三组“珠子”
标准浮点表示的核心是把数字分成三段:符号位、指数位、尾数位。
- 符号位决定正负,成本只要1个bit。
- 指数位决定数值的量程,相当于算盘上的“大档位”。
- 尾数位决定数值的刻度精细程度,相当于算盘上的“小珠子”。
一个带符号的浮点数大致是这样组织的:
[符号位] [指数位区段] [尾数位区段]比如FP32里,符号1位,指数8位,尾数23位,一共32位。BF16里,符号1位,指数8位,尾数7位,一共16位。FP8则更加极端,总共只有8个bit。你拨动珠子时,表面上看到的只是“精度变低”,实际上是这三组珠子的数量分配方式变了。
1.3 量程和刻度是一对不可调和的矛盾
我习惯用一个生活化的类比:量程就好比一把尺子的总长度,刻度就好比尺子上的最小刻度值。FP32是一把刻度很细的量程很大的尺子;BF16把刻度做粗了,但保留了和FP32几乎一样大的量程;FP8进一步压缩,量程和刻度双双缩小。
为什么英文资料里总说“动态范围”(dynamic range)?其实就是这把尺子能覆盖的从最小正数到最大正数的跨度。训练神经网络时,梯度非常需要这个量程:深层网络里某些梯度的绝对值可能是1e-6,而另一些可能是1e2,差距可以达到8个数量级以上。如果你的浮点格式覆盖不了这个跨度,小梯度会被直接“压扁”成0,大梯度可能被“顶爆”成无穷大。
所以选浮点格式时,本质上是在处理一个问题:省下bit之后,你到底更保量程,还是更保精度?
2. 三副算盘各有什么刻度:FP32/BF16/FP8逐格式拆解
2.1 FP32:默认算盘,刻度最细但最占资源
FP32是大多数人最早接触的浮点格式。它的符号位1、指数位8、尾数位23,最大正数约为3.4e38,最小正常正数约为1.2e-38。在数值范围不大、计算量也不大的场景下,FP32几乎不会出现数值问题。
但FP32的代价非常现实:一个参数占4字节,一个大语言模型动辄几十亿参数,光权重就吃掉几十GB显存。我在某次训练模拟模型时,只把精度从FP32切到BF16,显存占用立刻下降了一半,这对能塞多大的batch、能不能跑起来,是决定性的。
2.2 BF16:保留大动态范围,牺牲刻度细度
BF16的全称是BFloat16,由符号1位、指数8位、尾数7位组成。它和FP32的指数位数一样,动态范围几乎一比一复制了FP32。这让它不需要像FP16那样频繁处理溢出问题。
但代价很大:尾数只有7位。在数值约为1的区间里,BF16的最小刻度是2的-7次方,大约0.0078。这意味着1.0000和1.0078之间的数字,它在BF16里根本区分不出来。放到模型里想,如果某个权重是1.003,BF16会直接把它存成1.0;如果权重是1.0079,它又会被存成1.015625左右。这种粗粒度在某些任务上是能忍的,但在需要精细控制的场景上有明显痕迹。
2.3 FP8:量程和刻度的极限压缩
FP8是这两年加速卡支持的热门格式,它内部又有两种主要变体:E4M3和E5M2。
E4M3表示4位指数、3位尾数,量程比较小,最大可表示数值约448。它更接近FP16的“精度依旧还能看”的设计,适合作为前向计算和反向计算的主格式。
E5M2表示5位指数、2位尾数,量程大一些,最大可表示数值约57344,但尾数更短。它适合用在梯度这类动态范围大、但对相对精度要求不那么苛刻的场景。
三大格式的直观对比可以看这张表:
| 格式 | 符号位 | 指数位 | 尾数位 | 最大可表示正数 | 最小正常正数 | 典型用途 |
|---|---|---|---|---|---|---|
| FP32 | 1 | 8 | 23 | 约3.4e38 | 约1.2e-38 | 主权重、长期累积 |
| BF16 | 1 | 8 | 7 | 约3.4e38 | 约1.2e-38 | 混合精度训练的前反向 |
| FP8 E4M3 | 1 | 4 | 3 | 约448 | 约0.0156 | 前向计算、低精度推理 |
| FP8 E5M2 | 1 | 5 | 2 | 约57344 | 约6.1e-5 | 梯度、动态范围大的中间量 |
这张表最值得注意的一点是:BF16和FP32动态范围几乎一样大,但尾数位被砍得只剩7位;FP8则是把指数和尾数都砍到了触目惊心的长度。所以你不能指望FP8直接替代FP32,它只适合经过精心设计后、误差容忍度足够高的位置。
2.4 省下的bit和牺牲的质量怎么换算
我在实战里习惯用一个“接近1.0时的最小刻度”来感受精度损失。数字接近1.0时:
- FP32的最小跳变约是2的-23次方,约1.19e-7。
- FP16的最小跳变约是2的-10次方,约0.001。
- BF16的最小跳变约是2的-7次方,约0.0078。
- FP8 E4M3的最小跳变约是2的-3次方,约0.125。
- FP8 E5M2的最小跳变约是2的-2次方,约0.25。
什么意思?在FP8 E5M2下,1.0和1.25在数值上完全没区别。如果有一层权重过渡依赖0.1这种量级的精细差异,FP8很可能把差异直接吞掉。做训练时,如果对数值分布不够了解就贸然切FP8,通常会在几百步之后发现loss出现异常爬升。
3. 训练中精度从哪里开始崩坏:梯度下溢与混合精度的补救逻辑
3.1 梯度消失的另一种形态:下溢成0
很多人以为梯度消失只是网络太深导致的,其实在低精度浮点格式下,梯度还有可能因为数值太小被格式表示不出来而“归零”。比如在BF16里,一个数量级为1e-4的微小梯度,仍然可以勉强表示出来;但到了FP8 E4M3格式里,靠近正常数值范围的最小刻度已经很大,很多小梯度直接被丢到0。
这就是“下溢”。一旦梯度变成0,对应权重就再也不更新了。训练表面看着还在跑,实际上某些参数成了“僵死”状态。我排查过的一次典型事故,就是模型最后一个线性层的bias项长期不更新,loss在某个平台期停住不降。打印梯度的数值分布之后才发现,低精度格式把绝大多数梯度都压到了0附近。
3.2 混合精度训练的三件套:主权重、前反向、损失缩放
混合精度的核心思路,用一句话概括:关键的东西用高精度,量大且不敏感的东西用低精度。
第一件套,主权重始终保留FP32。因为权重的更新需要长期累积,每次更新只加一点点delta,如果用低精度保存,累积误差可能会让最终权重质量大打折扣。第二件套,前向计算和反向计算的矩阵乘用低精度格式完成,获得速度与显存收益。第三件套,在损失函数上乘一个缩放因子,把反向传播中的梯度整体放大,避免小梯度下溢。
这就像一个公司在记账时:总账本用高精度账簿,日常流水用快速计算器,工资发放前再统一调整。分工不明确的话,很容易出现账目对不上的情况。
3.3 Loss Scaling的真实操作逻辑与取值参考
Loss Scaling为什么能缓解下溢?因为神经网络的前向和反向过程,从损失值到梯度的路径上存在大量乘法。梯度在反向传播的每一层都会被乘上权重矩阵的转置以及激活函数的导数,数值越到下层越小。人工在损失上乘一个大常数C,相当于每一层反向传播的梯度数值都乘上一个大常数,这样小梯度就不会掉出浮点格式的表示下限。
在FP16时代,常用的缩放因子是128、256、512。到了BF16时代,因为动态范围足够大,有时不缩放也问题不大;但FP8场景下缩放又变得关键。我实践下来的经验是,FP8训练时把缩放因子从32起步,观察梯度统计的绝对值分布,再逐步调到128或更高。一个重要的自检是:如果训练初期就出现NaN,先缩小缩放因子;如果梯度范数长期在1e-4以下徘徊,则可能缩放因子调小了。
4. FP8实战中更容易翻车的几个细节:缩放、层分配、监控与调参
4.1 哪些层适合切FP8,哪些层必须保持高精度
不是所有计算都适合低精度格式。我的经验是把网络分成三类:可以切FP8的高计算量模块、建议保持高精度的敏感模块、以及介于两者之间的灰色地带。
适合切FP8的是:多层感知机里的线性层、多头注意力的Q/K/V投影和输出投影。它们的计算量占比非常高,而且矩阵乘法对相对误差的容忍度相对高。
必须保持高精度的有:LayerNorm、Softmax、Loss计算。这几个模块数值分布极其敏感,Softmax的指数运算在低精度下很容易因为上溢变成NaN,LayerNorm里的均值、方差计算如果精度不足,后续归一化效果会明显劣化。
灰色地带是Embedding层和最终输出层。Embedding查表如果量化误差太大,会让语义表示丧失细节;最终logits层在语言模型中直接和交叉熵loss挂钩,梯度质量直接影响收敛。我建议在FP8实验中,可以先固定这两层使用BF16,再看整体效果是否可接受。
4.2 损失缩放因子不是越大越好:过大和过小各有问题
这项我单独拎出来讲,是因为团队在FP8实战里吃了大亏。有一次为了追求极致的低精度收益,把缩放因子设成1024,训练前200步损失下降速度飞快,2000步之后开始出现阶段性NaN,训练直接崩溃。而把缩放因子改成64之后,虽然初期下降稍微慢一点,但整体稳定。
过大缩放因子的风险在于:梯度被整体放大后,部分本来安全的中间值可能超过格式上限,变成Inf或NaN。过小缩放因子的风险则是:小梯度下溢成0,模型部分参数失去更新能力。
稳妥起见,建议训练早期同时跑两组实验:一组缩放因子64,一组256,对比梯度范数和损失下降。在理想情况下,两组曲线若几乎重合,选256;若256那组出现波动,就回落64。这个做法比盲目跟网络上的推荐数值可靠得多。
4.3 训练过程中必须盯住的三个监控指标
既然低精度格式的危险点在下溢、上溢、精度损失,监控也应该围绕这三个点展开。
第一个是日志里的NaN和Inf出现次数。别只看loss值,要在每层梯度上做数值统计,监控值为NaN或Inf的层名称与出现频率。
第二个是梯度范数的变化趋势。每N步打印一次全模型梯度的L2范数,如果某个阶段范数出现断崖式下跌,大概率是发生了下溢。
第三个是每组张量的绝对值分位数。特别是激活值和梯度的第1百分位、第99百分位。如果在低精度切换后,第1百分位直接跌到0且占比陡增,说明下溢严重。
我通常的做法是在训练脚本里加一个简单的监测hook,每隔50步dump一次张量统计值,存成日志文件方便回溯。这比事后靠loss曲线猜问题高效得多。
4.4 FP8和分布式训练的交互:先reduce再unscale的顺序问题
分布式训练中多张卡各自算梯度,然后统一做梯度同步,再对参数做更新。当引入低精度和Loss Scaling时,顺序就变得非常关键。
正确的做法通常是:各卡计算出缩放后的梯度,先进行全局梯度同步(all-reduce),再统一除以缩放因子。如果先把梯度还原成原始大小再做all-reduce,在低精度格式下同步过程很容易丢失精度。这里我踩过一次坑:缩放因子不统一,不同卡的梯度先各自除、再all-reduce,结果同一参数在不同卡上出现数值不一致,模型参数同步后训练发散。
所以把“缩放”看作是模型训练逻辑的一部分,而不是数据后处理。不要到处手动unscale,统一交给训练框架的自动混合精度逻辑处理,是最稳的。
5. 一次跨格式训练对比的复盘:选型结论与踩坑记录
5.1 同一个小型语言模型,在FP32/BF16/FP8下的真实对比
下面是我在一个模拟项目X里的实验经历。模型规模不大,几亿参数级别,数据是通用中文文本语料,训练方式用的是自回归语言建模。我做了三个实验配置:纯FP32、BF16混合精度、FP8前反向(主权重FP32)。
- 纯FP32:loss曲线最平滑,稳定收敛,但训练速度最慢,显存占用最大。
- BF16混合精度:loss曲线和FP32几乎重合,速度比FP32快了很多,显存降了约一半。这是最成熟的组合。
- FP8前反向:前500步loss和BF16非常接近,之后开始出现小幅度震荡;后半程某一层出现了梯度稀疏,最终收敛质量比BF16差一点。
这里的差距不是“不可用”,而是需要额外的调参和层分配。如果你只想尽快拿到可靠结果,BF16依然是首选;FP8更像是在时间和显存成本都不够时要尽力压榨硬件的选择。
5.2 我们踩过的具体坑:把FP8用在了最不该用的地方
第一次尝试FP8时,我把嵌入层也切成了FP8。结果模型学到的词汇表示质量明显下滑,生成内容开始出现大量重复和逻辑断裂。排查了很久,最终发现是词向量空间在FP8的粗刻度下,相近语义的向量被压缩成了几乎相同的表示。
换成“Embedding和最终输出层保持BF16,其余全部FP8”之后,整体质量恢复到了BF16水平,显存又进一步降低。现实经验告诉我们:FP8带来的吞吐收益,必须用“保护关键层精度”来换。任何抛弃边界精度、一刀切的全网FP8都会付出代价。
5.3 按任务类型给出选型建议
| 任务类型 | 第一推荐 | 第二推荐 | 备注 |
|---|---|---|---|
| 大模型预训练 | BF16混合精度 | FP8前反向(关键层BF16) | 先求稳定,再求吞吐 |
| 领域微调 | BF16 | FP32 | 数据量小,精度敏感 |
| 大模型推理 | FP8(E4M3) | BF16 | 关注吞吐与显存 |
| 边缘端部署 | FP8 E5M2 | 更低bit量化 | 需额外校准与评估 |
| 数值计算/科研 | FP32及以上 | 无 | 不建议低精度 |
这个表是我根据多次项目实践总结出来的,具体还要看你的算力平台对FP8的支持程度。硬件能力不匹配时,软件层面的收益会被通信和算子开销直接吃掉。
5.4 从“能用”到“好用”的完整落地步骤
如果一定要把FP8用进训练流程,我建议照着下面的顺序走一遍,避免前人踩过的坑。
第一步,先用BF16把训练流程完整跑通,拿到一组loss曲线作为基线。
第二步,选择FP8支持最成熟的框架和版本,升级驱动和算子库,确认目标层可以被FP8算子覆盖。
第三步,先做单步验证:只跑少量迭代,打印每层的激活值和梯度数值分布,检查是否有大面积下溢或溢出。
第四步,设置损失缩放因子,从64起步,同时监控梯度范数。如果出现NaN,逐级调低;如果梯度范数异常小,逐级调高。
第五步,关键敏感层固定为BF16,例如LayerNorm、Softmax、Embedding、输出logits层。先在8卡以下的小规模环境上做验证。
第六步,和基线对比loss曲线与下游指标,确认收益大于损失后再扩大规模。
第七步,训练中途持续监控NaN率和梯度分位数,不要等模型跑完才回头查日志。
结尾:一些实际体会
做完这几轮精度对比实验后,我的一个切身感受是:混合精度训练里,真正危险的不是格式本身,而是人对数值分布缺乏感知。FP32、BF16、FP8更像三把不同刻度的算盘,你总得先知道你要称量的东西大概是多大数量级,才能决定用哪把算盘去拨。对绝大多数预训练和微调任务,BF16是最稳的甜点位;FP8则更适合已有充分的BF16基线、且被显存和吞吐逼到墙角的情况。如果你手头正好也要做类似切换,建议从把敏感性较高的层保护起来做起,一步到位切成全精度虽然爽快,但后面排查数值问题的代价往往会让你后悔莫及。