
这两年帮不少朋友排查过训练崩溃日志里刷屏的经常是同一句话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 精度更高但最大只能表示到 65504bfloat16 精度更低但动态范围和 float32 基本一致。这句话听起来简单实际操作中很多问题都是从这里引出来的。举个例子训练一个分类模型logits 跑到 70000float16 直接变inf反向传播一碰到inf整条梯度链就塌了换成 bfloat1670000 还能正常表示虽然表示得比较“粗糙”但至少不会崩。两种格式的核心参数对比如下属性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 是“范围向”的 16bitFP16 是“精度向”的 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^-11BF16 只有 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, dtypetorch.float16).item() bf16 torch.tensor(v, dtypetorch.bfloat16).item() print(f{v:12}: fp16{f16!r:22} bf16{bf16!r:22})我这里列一个参考输出不同机器上末位可能有细微差别但结论一致原始值FP16 表示BF16 表示0.10.09997558593750.100097656253.141593.1406253.1406251234.5678约 1234.5约 1232 到 1240 之间的某档65504.065504.065504.070000.0inf约 701441e10inf约 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_typecuda, dtypetorch.float16): loss model(input) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()BF16 路线则简单得多with torch.autocast(device_typecuda, dtypetorch.bfloat16): loss model(input) loss.backward() optimizer.step()区别很明显BF16 模式下不需要GradScaler。因为指数范围够大梯度直接落进去也不会溢出省掉了动态缩放那一整套机制。对新手来说这几乎是最直接的收益——少一个组件就少一类问题。不过要注意PyTorch 的 autocast 并不会把所有算子都塞进 16bit。它有一套自己的规则矩阵乘法、卷积这类运算用 16bitsoftmax、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/TuringV100、T4、RTX 20 等原生 Tensor Core基本不支持需软件模拟NVIDIA AmpereA100、RTX 30、H100、RTX 40 等原生 Tensor Core原生 Tensor CoreAMD CDNA2MI200/MI300 等原生原生Intel Sapphire RapidsAMX/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 指令集专门加速 BF16FP16 在部分 CPU 上也有 AVX512-FP16 指令但普及度和性能都因平台而异。如果用户最终要部署到 CPUBF16 和 FP16 的性能差距就不是“差不多”了必须实测。我的建议是**不要凭感觉猜速度写一段小的 profiling 脚本把前向、反向、推理三个阶段的耗时分别打出来用数据决定选型。**很多项目里最终选择 FP16 不是因为理论指标更好而是因为实测速度更快、部署链路更顺。6. 选型速查与翻车细节6.1 场景选型速查表综合前面的原理和硬件差异我整理了一张常用选型表使用场景推荐格式理由大模型预训练/微调Ampere GPUBF16动态范围大无需 loss scalingLLM 事实标准在 V100/T4/RTX 20 系列上训练FP16硬件原生加速 BF16 不可用或慢小规模 CNN/分类模型FP16 或 FP32数值范围可控FP16 精度更高数值范围极端的自定义 loss 或回归任务BF16硬件允许时防止 inf 摧毁训练稳定性优先推理部署TensorRT/ONNXFP16算子覆盖度和生态成熟度更高显存不足但必须用 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自动类型转换一参与数值就乱套了。排查方法是把模型第一个参数打出来看 dtypefor 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(dtypetorch.bfloat16)和GradScaler同时留着。程序能跑但你会在排查问题时被误导明明 BF16 没有溢出问题scaler 却还在反复调整缩放因子日志里那些 warning 会让人误以为模型训练不稳。建议这样改scaler torch.cuda.amp.GradScaler(enabledFalse)或者干脆在切换 BF16 的分支里不初始化 scaler。少一个组件日志干净排查链路也短。最后再分享一个我自己的习惯新项目开始前用同一个随机种子、同一个 batch跑 50 步分别用 FP32、FP16、BF16 各跑一遍对比 loss 曲线的走势。如果三者差异很小说明模型对尾数不敏感可以放心用 16bit如果 FP16 和 BF16 的曲线明显偏离 FP32那就得提前想清楚你的模型是否真的能被低精度格式容忍。这个“50步 sanity test”花不了十分钟却能省下后面无数次炸 loss 的排查时间。