☰
bfloat16 vs float16:从位布局到混合精度训练,选型不再纠结
2026/10/8 15:55:03 网站建设 项目流程

这两年帮不少朋友排查过训练崩溃,日志里刷屏的经常是同一句话:FP16 overflow, loss scaling factor reduced。群里每次都会有人问一句“为什么不换 bfloat16?”。这个问题看似简单,但 bfloat16 和 float16 的区别真要讲清楚,牵扯到 bit 布局、数值范围、硬件指令、混合精度策略一堆东西。今天就把这块掰开揉碎说一遍。如果你在用 PyTorch 做预训练或微调,或者在 A100 和消费级显卡之间来回切换,又或者只是想把模型转成 16bit 省显存,这篇文章值得看完。

先说一个反直觉的结论:bfloat16 并不是 float16 的高精度升级版。它把 float16 本就不算优秀的尾数精度又砍了一截,换来的是和 float32 几乎一致的动态范围。训练大模型时这一换太值了,但换到数值敏感的算子或老显卡上,可能就是个坑。

1. 最容易被搞反的结论:bfloat16 并不是“精度更高的 float16”

1.1 两个都叫16bit,为什么总有人混为一谈

第一次接触这两种格式的人,看到“都是16bit、都是2字节、都能让显存占用减半”,很容易觉得它们只是名字不同。再加上不少教程把 float16 叫“半精度”,把 bfloat16 叫“BF16”,连称呼都透着一股“BF16 是 16bit 里的升级版”的错觉。

实际上 bfloat16 的来历就和精度优化没关系。它最早出自 Google Brain,是为 TPU 上的深度学习训练设计的,全称是 Brain Floating Point。设计目标很明确:宁可牺牲尾数精度,也要保住指数范围。因为在神经网络训练里,梯度和激活值的数量级经常横跨 1e-3 到 1e3,偶尔还会冒出个 1e5 的异常 logits。如果格式的动态范围太小,一个inf就能让整个 loss 变成 NaN,这比精度损失致命得多。

float16 则是 IEEE 754 标准里的半精度格式,标准推出时主要面向图形、嵌入式场景,后来被 CUDA 和 Tensor Core 带进了深度学习。这两种格式走的是完全相反的设计路线:float16 把位数尽量留给尾数,bfloat16 把位数尽量留给指数。搞清楚这个前提,后面所有差异都顺理成章。

1.2 一句话记住核心差异

我把最核心的区别压成一句话:

float16 精度更高,但最大只能表示到 65504;bfloat16 精度更低,但动态范围和 float32 基本一致。

这句话听起来简单,实际操作中很多问题都是从这里引出来的。举个例子,训练一个分类模型,logits 跑到 70000,float16 直接变inf,反向传播一碰到inf,整条梯度链就塌了;换成 bfloat16,70000 还能正常表示,虽然表示得比较“粗糙”,但至少不会崩。

两种格式的核心参数对比如下:

属性float16 (FP16)bfloat16 (BF16)float32 (FP32)
总位数161632
符号位111
指数位588
尾数位10723
指数偏置15127127
最大有限值65504约 3.39e38约 3.40e38
最小正规数约 6.10e-5约 1.18e-38约 1.18e-38
有效十进制位数约 3 到 4 位约 2 到 3 位约 7 位

注意最后一行的有效十进制位数。很多人以为 BF16 和 FP16 同样都是 16bit,精度应该差不多,实际上 BF16 的有效位数比 FP16 还要少一截。FP16 的尾数有 10 位,BF16 只有 7 位,相对误差大约差了 8 倍。换句话说,BF16 是“范围向”的 16bit,FP16 是“精度向”的 16bit,谁也别想覆盖谁的全部优点。

2. 从二进制位拆开看,两种16位格式到底怎么装数

2.1 符号位、指数位、尾数位:三段式布局

所有 IEEE 风格浮点数,核心公式都是:

V = (-1)^S * 2^(E - bias) * (1 + fraction)

正规数的情况下,符号位 S 决定正负,指数位 E 决定数量级,尾数 fraction 决定在这个数量级内的精确位置。两种 16bit 格式的差别就在三段分配上:

FP16 : 1位符号 | 5位指数 | 10位尾数 BF16 : 1位符号 | 8位指数 | 7位尾数 FP32 : 1位符号 | 8位指数 | 23位尾数

FP16 的指数只有 5 位,偏置是 15,所以它能表示的正规数范围大概在 2^-14 到 2^15 这个量级,也就是约 6e-5 到 65504。一旦数值超过 65504,指数位就撑不住了,结果变成inf。

BF16 的指数有 8 位,偏置和 FP32 一样是 127,所以它的指数范围和 FP32 完全相同,最大能到 2^127,也就是约 3.39e38。从二进制布局就能一眼看出,BF16 本质上就是把 FP32 的 23 位尾数砍到 7 位,指数位原封不动地保留下来。

尾数少意味着什么?FP16 有 10 位尾数,相对误差约 2^-11,BF16 只有 7 位尾数,相对误差约 2^-8。直观点说,同一数量级里 FP16 能细分出 1024 个台阶,BF16 只能细分出 128 个台阶。台阶越粗,数值表示的“颗粒感”越明显。

2.2 从FP32转成BF16:不是“四舍五入到更高精度”,而是“砍掉23位尾数”

把 FP32 转成 FP16,要重新计算指数偏置和尾数长度,遇到大数还会溢出。把 FP32 转成 BF16 则更像一次“截断”:指数位和偏置完全一样,只需要把 23 位尾数压成 7 位。很多硬件实现里,这个过程会做 round-to-nearest-even,而不是简单地从中间砍一刀,但结果都是尾数信息大幅丢失。

我用一个小例子说明这个丢失有多明显。以 0.1 为例:

  • FP32 表示是 0.100000001490116...
  • 转成 FP16,得到 0.0999755859375,误差大概是 2.4e-5 这个量级
  • 转成 BF16,得到约 0.10009765625,误差大概是 9.8e-5 这个量级

BF16 的误差比 FP16 大了大约 4 倍。这个差异在单次运算里微乎其微,但在矩阵乘法、归一化、指数运算里反复累积后,就会体现为模型训练曲线上的细微差别。

所以下次看到有人把 BF16 吹成“FP32 级别的精度”,可以直接拿出二进制布局反驳:BF16 只是继承了 FP32 的指数范围,尾数精度连 FP16 都不如,更不可能和 FP32 平起平坐。

2.3 用PyTorch跑一组数值,眼见为实

光看理论容易晕,直接写几行 PyTorch 验证最快:

import torch vals = [0.1, 3.14159, 1234.5678, 65504.0, 70000.0, 1e10] for v in vals: f16 = torch.tensor(v, dtype=torch.float16).item() bf16 = torch.tensor(v, dtype=torch.bfloat16).item() print(f"{v:>12}: fp16={f16!r:>22} bf16={bf16!r:>22}")

我这里列一个参考输出,不同机器上末位可能有细微差别,但结论一致:

原始值FP16 表示BF16 表示
0.10.09997558593750.10009765625
3.141593.1406253.140625
1234.5678约 1234.5约 1232 到 1240 之间的某档
65504.065504.065504.0
70000.0inf约 70144
1e10inf约 9.999e9

看到没,FP16 遇到 70000 就直接炸了,而 BF16 还在正常表示,只是表示粒度非常粗。1e10 这种在深度学习中很少出现的极端值,BF16 也能兜住,代价是有效位数只剩 2 到 3 位。这就是两者最本质的分野:范围 vs 精度,不是谁的精度更高。

3. 精度和范围,哪个才是训练里的真正命门

3.1 FP16的65504天花板:loss scaling为什么是必需品

深度学习中,浮点数会出现在三个关键位置:前向传播的激活值、反向传播的梯度、优化器里的参数状态。前两项尤其危险,因为它们经常在做加法、乘法、softmax、logsumexp 这些运算,数值跨度极大。

FP16 的上限是 65504,听起来不算小,但一次矩阵乘法就可能把两个中等数值乘成一个大数。举个例子,如果 logits 在 256 左右,softmax 之前又要做指数运算,中间结果很容易超过 65504。一旦变成inf,反向传播梯度里就会出现 NaN,整个训练进程基本宣告报废。

这就是为什么 FP16 混合精度训练离不开 loss scaling。思路很简单:在计算 loss 之后、反向传播之前,给 loss 乘一个大因子,比如 1024 或 4096,让梯度整体放大。这样梯度在 FP16 里的表示就不会因为太小而失真,也不会因为溢出而变成inf。等优化器 step 的时候,再把梯度缩小回去。

听上去是个很优雅的补丁,但代价是引入了一个超参数。PyTorch 的GradScaler虽然能动态调整缩放因子,但动态过程本身就是一次试探:它会观察梯度有没有溢出,有就降因子,没有就慢慢涨。这个“试探”过程在训练初期很容易造成额外的不稳定,也会让问题排查变复杂——loss 崩了,到底是模型问题、学习率问题,还是 loss scaling 没调好?

3.2 7位尾数的大模型:为什么反而更稳

既然 BF16 尾数更少,为什么如今大模型预训练普遍用 BF16?关键在于,神经网络对“相对精度”的容忍度远比对“绝对值范围”的容忍度高。

训练过程中的权重和梯度,经常是大量小数值分布在 1e-5 到 1e-1 之间,又掺杂少量 1e2 到 1e4 的大梯度。这种分布对指数范围极其敏感,但对尾数丢了两位并不敏感。原因是优化器本身就不是精确计算器:SGD、AdamW 每一步都在做随机采样式的梯度更新,权重的最终精度取决于大量步数的统计效果,而不是单步的精确舍入。

另外,混合精度训练通常会把“主权重”保留在 FP32 里,BF16 只承载前向和反向的计算过程。也就是说,BF16 的粗尾数会影响每一次前向传播的“表达”,但权重本身仍然是 FP32 精度的,更新后误差会不断被后续 step 修正。这也是为什么很多框架在 BF16 模式下仍然能保持接近 FP32 的训练曲线。

更关键的是,BF16 的动态范围让很多算子不需要被提升回 FP32。在使用 FP16 的 autocast 时,PyTorch 为了保证数值稳定,会把一部分容易溢出的算子自动提升到 FP32 计算;而 BF16 因为指数范围和 FP32 一样,这类保护性提升会少很多。结果就是,某些模型在 BF16 下不仅训练更稳定,跑起来还更快。

3.3 一个真实崩溃案例

说一个我实际遇到过的案例。某个多任务分类模型,训练到第 800 步左右 loss 突然变成 NaN。日志里没有任何报错,只有 PyTorch 警告说GradScaler发现梯度溢出,反复降低缩放因子,最后还是没用。

一开始怀疑学习率太大,调小了没效果;怀疑数据里有脏标签,清洗了也没效果。后来把关键 tensor 打出来,发现问题出在一个自定义的 pairwise loss 上:两个大 logits 相减之后又做了指数归一化,中间值轻松超过 65504。FP16 一次溢出就把整条链路带崩了。

换成 BF16 之后,同样逻辑、同样数据、同样的学习率,训练曲线稳稳走完了。这不是 BF16 比 FP16 更聪明,只是它没有在那个数值点上爆表。如果你的训练频繁死于梯度或激活值溢出,优先想到的不是更复杂的 scaler 调参,而是换一个指数范围更大的格式。

4. 混合精度训练的两种打开方式:BF16能省掉loss scaling吗

4.1 PyTorch里FP16和BF16的标准写法

现在 PyTorch 里标准的混合精度训练写法已经很简洁。FP16 路线通常是这样的:

scaler = torch.cuda.amp.GradScaler() with torch.autocast(device_type="cuda", dtype=torch.float16): loss = model(input) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()

BF16 路线则简单得多:

with torch.autocast(device_type="cuda", dtype=torch.bfloat16): loss = model(input) loss.backward() optimizer.step()

区别很明显:BF16 模式下不需要GradScaler。因为指数范围够大,梯度直接落进去也不会溢出,省掉了动态缩放那一整套机制。对新手来说,这几乎是最直接的收益——少一个组件,就少一类问题。

不过要注意,PyTorch 的 autocast 并不会把所有算子都塞进 16bit。它有一套自己的规则:矩阵乘法、卷积这类运算用 16bit,softmax、layernorm、loss 这类容易失稳的算子很多会被提升回 FP32。这个规则在 FP16 和 BF16 下并不完全一致,所以同一条训练代码,切换 dtype 后算子的实际执行精度会变,训练曲线也不可能一模一样。

4.2 loss scaling解决的只是溢出,不是精度

很多人对 loss scaling 有个误解,以为它能把 FP16 的精度提高到接近 FP32。实际上,loss scaling 解决的只是“溢出”和“下溢”问题,它不会凭空增加尾数。

FP16 的尾数是 10 位,在不溢出的前提下,它能表示的相对精度是固定的。放大 loss 也好,缩小梯度也好,尾数宽度不会变。梯度被放大后,只是从“小到无法表示”变成“可以表示”,但相邻两个浮点数之间的间距依然受尾数位数限制。

BF16 天然不需要这个补丁,它的指数范围已经覆盖了 FP32 能出现的数量级。训练中常见的梯度值在 1e-6 到 1e3 之间,对 BF16 来说都处于“安全区”。当然,BF16 的尾数只有 7 位,这会让每个数值的相对误差更大,但这个误差通常不会像溢出那样直接摧毁训练,也就是前面说的“能凑合,但不算精确”。

把这两句话合起来就是:FP16 需要 scaling 是因为它可能溢出,BF16 不需要 scaling 是因为它很少溢出,但 BF16 的精度并不会因此比 FP16 高。选型时这两件事必须分开看,不然很容易得出“BF16 是 FP16 的完全替代品”这种错误结论。

4.3 FP16在哪些场景反而更值得选

BF16 优点这么多,不代表 FP16 就该被淘汰。以下几个场景里,FP16 依然是更合理的选择:

  • 硬件不支持 BF16 的 GPU。V100、T4、RTX 20 系是 FP16 Tensor Core 的主场,BF16 在这些卡上要么软件模拟,要么根本不加速,硬要跑 BF16 只会更慢。
  • 推理部署生态更成熟。TensorRT、ONNX Runtime 里 FP16 的算子覆盖度明显优于 BF16。同一个模型,FP16 可能一套转换就能跑,BF16 却要检查算子支持表,遇到不支持的算子还会回退到 FP32,速度优势全没了。
  • 对数值敏感的小模型或自定义算子。如果模型里有很多连续乘加、方差计算、指数运算,FP16 的 10 位尾数比 BF16 的 7 位尾数更抗误差累积。模型规模不大时,也不会触及 65504 的天花板。

所以我很反对“无脑上 BF16”的说法。格式选型永远要结合硬件、算子、模型特点一起看,而不是只看论文里说大模型用了 BF16。

5. 硬件支持与算子优化:不是所有卡都“16bit同速”

5.1 主流硬件的BF16/FP16支持盘点

BF16 看起来只是一个小改动,但它依赖硬件指令集的配合。不同厂商的支持情况差别很大:

硬件平台FP16 支持BF16 支持
Google TPU v2/v3/v4支持较少,通常用于推理原生支持,训练主力
NVIDIA Volta/Turing(V100、T4、RTX 20 等)原生 Tensor Core基本不支持,需软件模拟
NVIDIA Ampere+(A100、RTX 30、H100、RTX 40 等)原生 Tensor Core原生 Tensor Core
AMD CDNA2+(MI200/MI300 等)原生原生
Intel Sapphire Rapids+(AMX/AVX512_BF16)支持支持,且针对 BF16 有专门优化

这张表只能当参考,具体到某个 GPU 型号,还需要看 CUDA Compute Capability 和框架文档。有一个简单经验:如果你手里的卡 Compute Capability 在 8.0 以下,大概率没有原生 BF16 加速;8.0 及以上才有得商量。

这也是为什么很多老项目至今仍用 FP16。项目跑在 V100 上时,BF16 就是不可选项,不是不想用,是硬件不给力。

5.2 显存都减半,速度却可能差一倍

FP16 和 BF16 都是 16bit,从内存角度讲,两者节省的显存和带宽完全一样,都是把 FP32 的占用和传输量砍半。所以“BF16 省显存更多”是不存在的,省出来的是 16bit 相对 32bit 的优势,不是 BF16 相对 FP16 的优势。

速度就不一定了。同一块 Ampere 或 Hopper 显卡上,FP16 和 BF16 的矩阵乘算力可能相同,但实际跑起来,算子是否被优化决定了最终速度。卷积、Transformer 里的标准模块,FP16 的优化历史更长,内核更成熟;BF16 虽然这两年追赶很快,但遇到冷门算子时,可能被打回 FP32,速度直接打对折。

另一个容易忽略的点是 CPU。Intel 从 Sapphire Rapids 开始有 AMX 指令集,专门加速 BF16;FP16 在部分 CPU 上也有 AVX512-FP16 指令,但普及度和性能都因平台而异。如果用户最终要部署到 CPU,BF16 和 FP16 的性能差距就不是“差不多”了,必须实测。

我的建议是:**不要凭感觉猜速度,写一段小的 profiling 脚本,把前向、反向、推理三个阶段的耗时分别打出来,用数据决定选型。**很多项目里最终选择 FP16 不是因为理论指标更好,而是因为实测速度更快、部署链路更顺。

6. 选型速查与翻车细节

6.1 场景选型速查表

综合前面的原理和硬件差异,我整理了一张常用选型表:

使用场景推荐格式理由
大模型预训练/微调(Ampere+ GPU)BF16动态范围大,无需 loss scaling,LLM 事实标准
在 V100/T4/RTX 20 系列上训练FP16硬件原生加速 BF16 不可用或慢
小规模 CNN/分类模型FP16 或 FP32数值范围可控,FP16 精度更高
数值范围极端的自定义 loss 或回归任务BF16(硬件允许时)防止 inf 摧毁训练,稳定性优先
推理部署(TensorRT/ONNX)FP16算子覆盖度和生态成熟度更高
显存不足但必须用 16bitFP16 或 BF16两者内存收益完全一样,按硬件支持选

注意最后一行:很多人以为 BF16 会比 FP16 更省显存,这是错的。两者都是 2 字节存储,显存收益完全一致。真要压缩显存,得去看 8bit 量化、分片、卸载这些手段。

6.2 踩坑一:model.half()和to(torch.bfloat16)不是一回事

PyTorch 里model.half()只会把模型转成 float16,不会转成 bfloat16。想用 BF16 得写model.to(torch.bfloat16)。这个坑看着低级,但实操中很常见:代码里两个地方,一个用了.half(),一个用了.to(torch.bfloat16),结果参数一半是 FP16、一半是 BF16,自动类型转换一参与,数值就乱套了。

排查方法是把模型第一个参数打出来看 dtype:

for name, param in model.named_parameters(): print(name, param.dtype) break

训练前顺手做这个检查,能省掉大半天定位时间。

6.3 踩坑二:从BF16转FP16可能直接得到inf

有些场景需要把训练好的 BF16 模型转成 FP16 部署。多数权重没问题,但个别层的 scale 参数、layernorm 的 gamma、某些统计量可能超过 65504。一旦超过,转换结果直接是inf,模型前向传播当场报废。

转之前先扫一遍:

max_abs = max(p.abs().max().item() for p in model.parameters()) print(max_abs)

如果最大值大于 65504,就别硬转 FP16,要么调整存储方式,要么检查那层参数为什么这么大。很多线上推理模型出现诡异inf,最后查出来都是这个原因。

6.4 踩坑三:切到BF16之后,旧的GradScaler该关就关

从 FP16 训练脚本迁移到 BF16 时,最省事的做法是把GradScaler关掉,或者直接删掉。有些人图省事,把autocast(dtype=torch.bfloat16)和GradScaler同时留着。程序能跑,但你会在排查问题时被误导:明明 BF16 没有溢出问题,scaler 却还在反复调整缩放因子,日志里那些 warning 会让人误以为模型训练不稳。

建议这样改:

scaler = torch.cuda.amp.GradScaler(enabled=False)

或者干脆在切换 BF16 的分支里不初始化 scaler。少一个组件,日志干净,排查链路也短。

最后再分享一个我自己的习惯:新项目开始前,用同一个随机种子、同一个 batch,跑 50 步,分别用 FP32、FP16、BF16 各跑一遍,对比 loss 曲线的走势。如果三者差异很小,说明模型对尾数不敏感,可以放心用 16bit;如果 FP16 和 BF16 的曲线明显偏离 FP32,那就得提前想清楚,你的模型是否真的能被低精度格式容忍。这个“50步 sanity test”花不了十分钟,却能省下后面无数次炸 loss 的排查时间。

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

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

立即咨询