Skip to main content
  1. Posts/

从 IEEE 754 到 BF16:理解 ML Infra 中的浮点精度选择

Table of Contents
Note: This article is available in Chinese only. 本文暂无英文版本。 View original

起因
#

上一篇讨论模型显存时,我直接用了一张表:

dtypebytes / param
FP324
FP16 / BF162
INT8~1

当时关注的是 peak memory model,所以这张表只是作为 parameter memory 的输入,没有展开。

但回头看,这张表背后有一个更基本的问题:

FP32 是 4 bytes,BF16 是 2 bytes。少掉的 2 bytes 到底从哪砍的?FP16 也是 2 bytes,和 BF16 有什么不同?TF32 名字里有 32,它到底是什么?

如果只是记住"BF16 = 2 bytes / param",那把模型从 FP32 转成 BF16 就只是在做一件看起来很简单的事:每个参数的存储从 4 bytes 变成 2 bytes。

但这样理解没法回答:为什么有时候 FP16 会出 overflow 而 BF16 不会?为什么 TF32 名字里有 32 却不能像 BF16 一样减半显存?

这篇文章从 IEEE 754 FP32 的 bit layout 开始,搞清楚 FP16、BF16、TF32 分别做了怎样的 bit 分配。全文围绕一个核心 idea:

浮点格式本质是在固定 bit budget 下,在 dynamic range、precision、storage 和 compute throughput 之间做 trade-off。

IEEE 754 FP32:32 个 bit 在表示什么
#

平时说的 float / FP32,全称是 IEEE 754 binary32。

32 个 bit 分三部分:

1┌──────┬──────────┬───────────────────────┐
2│ sign │ exponent │       fraction        │
3│  1   │    8     │         23            │
4└──────┴──────────┴───────────────────────┘

对于 normal number(绝大多数情况),一个 FP32 值可以这样理解:

$$(-1)^s \times 1.\text{fraction} \times 2^{E - 127}$$

逐项拆一下。

sign(1 bit)
#

决定正负。0 正 1 负。

exponent(8 bits)
#

存储一个无符号整数 \(E\)(范围 1~254;0 和 255 有特殊用途,分别对应 subnormal / zero 和 infinity / NaN)。减去 bias 127 之后,实际指数范围大约是 \(-126\) 到 \(+127\)。

exponent 主要决定数的数量级——是 \(10^{-38}\) 级别还是 \(10^{38}\) 级别。

fraction(23 bits)
#

存储小数点后面的二进制位。加上一个隐含的前导 1(hidden bit),得到完整的 significand:

$$\text{significand} = 1.\underbrace{\text{xxxxxxx...x}}_{\text{23 bits}}$$

比如 fraction bits 是 10100000...0,significand 就是 \(1.101_2 = 1.625_{10}\)。

fraction 主要决定精度:相邻两个可表示数的"间距"有多大。

Hidden Bit
#

这个"隐含的前导 1"是怎么回事?

任何非零的二进制浮点数,标准化以后总是:

$$1.xxxxx \times 2^n$$

最高位永远是 1,没必要真的存。所以 FP32 虽然只存了 23 个 fraction bits,实际有 24 bits significand precision。

免费多出来的 1 bit。

术语
#

bit field 本身通常叫 fraction(IEEE 754 用语)。加上 hidden bit 之后叫 significand。工程里经常听到 mantissa,指的是同一个东西。后文用 fraction 指 bit field,significand 指含 hidden bit 的完整有效数字。

为什么叫 Floating Point
#

因为小数点的位置是"浮动"的。

十进制科学计数法:

$$1.2345 \times 10^8 = 123{,}450{,}000$$

改变 exponent,就是在移动小数点。二进制同理:

$$1.10101_2 \times 2^n$$

\(n\) 变化时,radix point 跟着移动。这就是 “floating point”。

作为对比,fixed-point 格式里小数点在 bit layout 中的位置是固定的。

对后文更重要的一个直觉:

浮点数不是把整个数轴均匀地切成很多格。它用 significand 描述有效数字,用 exponent 描述数量级。越靠近零,相邻可表示数的绝对距离越小;越远离零,绝对距离越大。

Exponent 和 Fraction 到底控制什么
#

这一节是理解所有低精度 dtype 的桥梁。

Exponent bits → Dynamic Range
#

exponent bits 越多,能表示的数值范围越大。

FP32 有 8 个 exponent bits,范围大约覆盖 \(10^{-38}\) 到 \(10^{38}\)。

如果只有 5 个 exponent bits(后面会看到这就是 FP16),range 就小很多——最大 finite 值只有约 65504。

Fraction bits → Precision
#

fraction bits 越多,相邻可表示数的"间距"越小,精度越高。

更多 fraction bits 能区分:

11.000001 和 1.000002

更少 fraction bits 只能区分:

11.00 和 1.01

Relative Precision
#

一个容易忽略但很重要的性质:

floating point 提供的是近似固定的 relative precision(相对精度),而不是固定的 absolute precision(绝对精度)。

数值越大,相邻两个 representable number 之间的绝对距离也越大。但相对于数值本身的比例是大致固定的。

Machine epsilon 可以量化这个精度。FP32 的 machine epsilon 大约是 \(2^{-23} \approx 1.19 \times 10^{-7}\):任意实数被舍入到最近的 FP32 后,relative error 不超过大约 \(10^{-7}\) 量级。

BF16 只有 7 个 fraction bits,machine epsilon 约 \(2^{-7} \approx 0.0078\)。同一个实数,舍入到 BF16 的 relative error 可能达到约 1%。

这个差异后面讨论 BF16 在 ML 中够不够用时会很关键。

0.1 + 0.2 ≠ 0.3:一个插曲
#

很多人第一次注意到浮点数"不精确",是因为:

1>>> 0.1 + 0.2
20.30000000000000004

原因:十进制的 0.1 转成二进制是无限循环小数。

$$0.1_{10} = 0.0\overline{0011}_2$$

一个有限的二进制小数只能表示 \(\frac{k}{2^n}\) 形式的数。\(0.1 = \frac{1}{10}\),分母不是 2 的幂,无法用有限 binary fraction bits 精确表示。

但这不是 IEEE 754 的 bug。任何固定 bit 数的数据类型都不可能精确表示所有实数。想要精确表示所有有理数,可以用 arbitrary precision library 或 rational representation,但 storage 和 compute cost 完全不在一个量级。

Binary floating point 的设计目标是:

在固定 bit width 下提供高效、标准化、可预测的近似计算,而不是精确表示所有实数。

CPU 和 GPU 对 binary floating point 有极其成熟的硬件支持。对科学计算和 ML 来说,关心的通常是 error 是否足够小,而不是每次 arithmetic 是否和数学实数完全一致。

实践中判断两个浮点数"是否相等":math.isclosenumpy.isclosetorch.testing.assert_close。这些 API 的存在本身就说明了工程哲学:接受近似,但控制误差。

从 FP32 到 16 bit:bit budget 不够了怎么办
#

到这里回到文章最核心的问题。

FP32 有 32 个 bit:

1FP32 = 1(sign)+ 8(exponent)+ 23(fraction)

现在只给你 16 个 bit。去掉 1 个 sign bit 以后,剩下 15 个 bit 要在 exponent 和 fraction 之间分配。给 exponent 多一些,dynamic range 更大但 precision 更低;给 fraction 多一些,precision 更高但 range 更小。

flowchart TD
    FP32["FP32
1 + 8 + 23"] FP32 -->|"砍 exponent + fraction"| FP16["FP16
1 + 5 + 10"] FP32 -->|"只砍 fraction"| BF16["BF16
1 + 8 + 7"] FP32 -->|"Tensor Core 截断 fraction"| TF32["TF32
1 + 8 + 10"]

低精度 floating-point format 的设计,本质上是在 dynamic range 和 precision 之间重新分配有限的 bit budget。

这就是 FP16 和 BF16 的核心分歧。

FP16(IEEE Half Precision)
#

FP16 的 bit 分配:

1FP32: 1 | 8  | 23
2FP16: 1 | 5  | 10

相比 FP32,FP16 同时减少了 exponent 和 fraction:

  • exponent:8 → 5
  • fraction:23 → 10

Range 大幅缩小
#

5 个 exponent bits,bias = 15,最大 finite 值大约 65504。

65504 看起来不小,但 ML 里 activation 或 loss 达到几万甚至更大并不罕见。一旦超过 65504 就变成 inf(overflow)。

FP16 能表示的最小 positive normal 大约 \(6.1 \times 10^{-5}\)。如果 gradient 小于这个值,会 underflow 到零。

这就是 FP16 在 training 中经常需要 loss scaling 的原因:gradient 的 dynamic range 超出了 FP16 的表示范围,需要人为缩放到 FP16 能表示的区间。

本文主要讨论 inference,不展开 loss scaling 的细节。但 FP16 的 range 问题对于理解它和 BF16 的区别很关键。

Precision 下降,但仍高于 BF16
#

FP16 有 10 个 fraction bits(11 bits significand),machine epsilon 约 \(2^{-10} \approx 10^{-3}\)。

比 FP32 差了约 \(10^4\) 倍,但后面会看到,这仍然比 BF16 的 7 个 fraction bits 精度高不少。

BF16(Brain Floating Point)
#

BF16 做了一个和 FP16 完全不同的 trade-off:

1FP32: 1 | 8  | 23
2BF16: 1 | 8  | 7
3FP16: 1 | 5  | 10

和 FP32 相同的 Exponent
#

BF16 保留了和 FP32 完全相同的 8 个 exponent bits。BF16 的 dynamic range 接近 FP32——大约 \(10^{-38}\) 到 \(10^{38}\)。

直接后果:BF16 不容易遇到 FP16 那种 overflow / underflow。不需要 loss scaling,不用担心 activation 超过 65504 变成 inf

实际上 BF16 可以看成 FP32 的"截断版"——把 FP32 的低 16 位直接砍掉就得到 BF16。这使得 FP32 和 BF16 之间的转换非常高效。

代价:Precision 明显下降
#

fraction 只剩 7 bits(8 bits significand)。machine epsilon 约 \(2^{-7} \approx 0.0078\)。

粗略地说,BF16 只能保证大约两位十进制有效数字。1.231.24 可能还能区分,1.2341.235 就很可能被舍入到同一个值。

为什么 BF16 适合 ML
#

Neural network 对 dynamic range 往往比对 precision 更敏感:

11.234567 被近似成 1.234 → 通常问题不大
21e-30 被近似成 0(underflow)→ 可能很危险
31e5 被截断成 inf(overflow)→ 几乎一定出问题

BF16 的设计理念:宁可让数字粗一些,也尽量不把 dynamic range 砍得太厉害。

这是 Google Brain 在设计 Cloud TPU 时做出的选择(BF16 = Brain Floating Point),最初用于 TPU v2。后来 NVIDIA GPU(Ampere 及以后)、Intel、AMD 和几乎所有主流 ML 硬件都支持了 BF16。

BF16 不是 FP16 的"升级版"
#

一个常见误解是 BF16 全面优于 FP16:

FP16BF16
fraction bits107
significand precision11 bits8 bits
machine epsilon~10⁻³~10⁻²
max finite~65504~3.4×10³⁸

在 FP16 能表示的范围内,FP16 的 precision 反而比 BF16 更高。

BF16 并不是 FP16 的全面升级,而是做了一个不同的 range / precision trade-off:用更多 range 换更少 precision。 对很多 ML workload,这个 trade-off 更好。但在对 precision 要求更高、且确认数值在 FP16 range 内的场景,FP16 可能更合适。

TF32(TensorFloat-32)
#

TF32 是最容易产生误解的一个。

从 bit layout 看,TF32 概念上是:

1FP32: 1 | 8  | 23
2TF32: 1 | 8  | 10
3BF16: 1 | 8  | 7
4FP16: 1 | 5  | 10

取了 FP32 的 exponent(8 bits)和 FP16 的 fraction(10 bits),共 19 bits。

但接下来的区别非常关键。

TF32 不是一种普通的 Storage Dtype
#

不能简单把 TF32 理解成类似 torch.float16torch.bfloat16 的东西。

TF32 是 NVIDIA Tensor Core 的一种 arithmetic mode,不是 storage format。

具体语义:

flowchart LR
    A["FP32 Input
4 bytes / element"] --> B["TF32 Precision
Multiply"] B --> C["FP32
Accumulation"] C --> D["FP32 Output
4 bytes / element"]
  1. 数据在 memory 中仍然是 FP32(4 bytes)
  2. Tensor Core 执行矩阵乘法时,把 FP32 输入的 fraction 从 23 bits 截断到 10 bits
  3. 用截断后的精度做乘法
  4. 累加到 FP32 的 accumulator
  5. 结果存回 FP32
如果目的是减少 FP32 模型的参数显存,TF32 通常帮不上忙。 模型 tensor 的 storage 仍然是 FP32(4 bytes / parameter)。TF32 主要改变的是 Tensor Core 执行 FP32 矩阵乘法时的 arithmetic precision,而不是数据的 storage format。

TF32 主要解决的问题是:如何让 FP32 GEMM 更充分利用 Tensor Core,提高矩阵计算吞吐。

它只作用于 matmul 和 convolution——pointwise operation、reduction 等仍然是完整的 FP32。

PyTorch 中的 TF32
#

PyTorch 中通过两个 flag 控制:

1torch.backends.cuda.matmul.allow_tf32   # 控制 matmul,1.12+ 默认 False
2torch.backends.cudnn.allow_tf32          # 控制 cuDNN conv,默认 True

启用后,FP32 matmul 在 Ampere+ GPU 上的速度可以显著提升(NVIDIA 在 A100 上的数据约 7x),但会引入约 \(10^{-3}\) 量级的 absolute error。是否值得开启取决于模型对 numerical accuracy 的容忍度。

注意:allow_tf32 这组 API 正在被新的 precision control API 取代。不同 GPU generation 和 PyTorch version 的行为可能有差异,具体参考 PyTorch 官方文档。

总表
#

四种格式并排:

FormatSignExpFracTotalStorageRangePrecision
FP321823324 bytes~10⁻³⁸ ~ 10³⁸~7 位十进制
FP161510162 bytes~6×10⁻⁵ ~ 65504~3 位十进制
BF16187162 bytes~10⁻³⁸ ~ 10³⁸~2 位十进制
TF32181019~10⁻³⁸ ~ 10³⁸~3 位十进制

※ TF32 不是独立的 storage format,数据仍以 FP32(4 bytes)存储。

如果把 storage dtype 和 arithmetic precision 拆开:

FormatStorage dtypeMultiply precisionAccumulator
FP32(无 TF32)FP32(4B)FP32FP32
FP32 + TF32FP32(4B)TF32(10-bit frac)FP32
FP16FP16(2B)FP16通常 FP32
BF16BF16(2B)BF16通常 FP32

这第二张表比第一张更重要。它明确回答了一个容易混淆的问题:TF32 减 storage 吗?不减——它只改 multiply precision。

Storage 和 Compute 不是一回事
#

上面的拆分引出一个更一般的 mental model。

面对一个 GPU kernel,不应该只问"这是 BF16 计算吗?",而应该分别问:

11. Input storage dtype?
22. Multiply precision?
33. Accumulator dtype?
44. Output dtype?

一个典型的 BF16 mixed-precision pipeline:

flowchart LR
    A["BF16 Input
2 bytes"] --> B["BF16 Multiply"] B --> C["FP32 Accumulator"] C --> D["BF16 Output
2 bytes"]

TF32 的 pipeline:

flowchart LR
    A["FP32 Input
4 bytes"] --> B["TF32 Multiply"] B --> C["FP32 Accumulator"] C --> D["FP32 Output
4 bytes"]

注意 accumulation 为什么经常保留更高精度。

矩阵乘法的核心:

$$C_{ij} = \sum_k A_{ik} B_{kj}$$

单次 \(A_{ik} \times B_{kj}\) 的 rounding error 可控。但 \(K\) 次累加过程中,大量小误差不断积累,最终结果的误差可能远大于单次乘法的误差。

因此 modern accelerator 普遍采用:

low-precision multiply + higher-precision accumulate

这是理解 Tensor Core、BF16、TF32 乃至 FP8 的关键模式。Multiply 精度可以低一些(单次误差可控),accumulate 保持较高精度来控制误差积累。

回到线上推理:低精度究竟省了什么
#

回到开头的问题。假设模型有 1B 参数:

dtypeweight storage
FP32~4 GB
BF16~2 GB
FP16~2 GB

从 FP32 切换到 BF16,parameter storage 大约减半。7B 参数的模型就是 ~28 GB → ~14 GB。

上一篇 已经讨论过:

parameter memory 只是模型的 static footprint,不等于整个 inference 的 peak memory。

实际 GPU memory 还包括 runtime activations、workspace、CUDA context、allocator reserved memory、KV cache(Transformer / LLM 场景)等。

所以更准确的表述:把 FP32 weights 换成 FP16 / BF16,可以非常直接地减少 parameter memory。但总显存降低多少,取决于 parameter memory 占总显存的比例。

不只是显存
#

低精度的收益远不止 storage capacity。

Memory bandwidth. 同样数量的 weights,BF16 只需要从 HBM 读取 FP32 一半的数据量。对于 memory-bound workload,这可能显著改善 latency。

Cache footprint. 同样大小的 cache 能容纳更多 BF16 数据。

Communication. 在 distributed inference 或 training 中,更小的 dtype 意味着更少的通信量。

Compute throughput. 现代 GPU 的 Tensor Core 对 FP16、BF16、TF32、FP8 通常有专门的执行路径,throughput 往往高于 FP32 CUDA Core。

flowchart TD
    LP["低精度 dtype"]
    LP --> S["Storage ↓"]
    LP --> BW["Memory Bandwidth ↓"]
    LP --> CF["Cache Footprint ↓"]
    LP --> CM["Communication ↓"]
    LP --> TP["Tensor Core Throughput ↑"]

但一定注意:理论 FLOPS 更高,并不意味着端到端 latency 一定同比下降。 姊妹篇已经讨论过,最终 latency 取决于 compute-bound vs memory-bound、kernel implementation、Tensor Core utilization、shape 等因素。

为什么神经网络敢用这么低的 Precision
#

传统 scientific computing 可能非常关注 numerical accuracy。但 neural network 本身有大量近似性:

  • Stochastic optimization(SGD、Adam 本身就是随机的)
  • Mini-batch noise
  • Model approximation(模型本身是对真实函数的近似)
  • Noisy data
  • Redundant parameters

很多情况下不需要每个 weight 都保持 FP32 全部有效位。只需要保证 model quality 和 numerical stability 仍然满足要求。

ML 中的低精度不是"结果错了也无所谓",而是我们发现模型对某些 numerical error 有足够高的 tolerance,于是把这部分 numerical margin 换成了系统效率。

不要把"低精度"简单等价成"数字更不准"
#

比较两个 dtype 时,至少应该看这些维度:

flowchart TD
    FMT["Floating-point Format"]
    FMT --> EXP["Exponent bits"]
    FMT --> FRAC["Fraction bits"]
    EXP --> DR["Dynamic Range
overflow / underflow"] FRAC --> PREC["Precision
rounding error"] FMT --> SYS["System Cost"] SYS --> STOR["Storage"] SYS --> BW2["Bandwidth"] SYS --> COMP["Compute Throughput"] FMT --> ARITH["Arithmetic Details"] ARITH --> MULT["Multiply Precision"] ARITH --> ACC["Accumulator Precision"]

而不是只看 16 bit < 32 bit → “精度更低”。

FP8 及更低精度:相同的 Trade-off
#

现代 LLM 已经进一步走到 FP8:

1FP8 E4M3: 1 + 4 + 3
2FP8 E5M2: 1 + 5 + 2

名字本身就编码了设计选择:E = exponent bits,M = mantissa bits。E4M3 偏向 precision(更多 mantissa),E5M2 偏向 range(更多 exponent)。

它们仍然在做完全相同的 trade-off:在更小的 bit budget 下分配 range 和 precision。

甚至再往下还有 INT8、INT4、FP4。理解了 FP32 / FP16 / BF16 的框架以后,看这些格式不需要死记硬背——问三个问题就行:给了多少 bit 给 range,多少给 precision,硬件支持怎么样。

结论
#

  1. Floating point 是一种有限 bit budget 下的近似实数表示。 不精确不是 bug,是 trade-off。

  2. Exponent bits 主要购买 dynamic range,fraction / significand bits 主要购买 precision。

  3. FP16 和 BF16 都是 16 bit,但做了完全不同的选择:FP16 保留更多 precision(10 fraction bits),BF16 保留更多 range(8 exponent bits)。

  4. BF16 不是 FP16 的全面升级,而是一个更适合很多 ML workload 的 range / precision trade-off。

  5. TF32 和 BF16 / FP16 最大的区别之一:它主要描述 Tensor Core arithmetic precision,而不是一种用来给模型权重减半的 storage dtype。

  6. 在 ML Infra 中讨论"精度"时,要把 storage dtype、multiply precision、accumulator precision 和 output dtype 分开。

  7. 降低精度的收益不仅是显存,还包括 bandwidth、cache、communication 和 Tensor Core throughput。

  8. 低精度优化本质上是在用 neural network 能容忍的 numerical error,交换系统效率。

回到开头的问题:如果线上 inference 的瓶颈是模型权重太大,从 FP32 切换到 FP16 / BF16 确实可以把 parameter storage 大致减半。但选哪种 dtype,需要同时看模型的数值稳定性、目标硬件的支持、以及实际 workload 特征。

这些 dtype 不需要当成"记住名字就行"的配置项。理解了 bit budget 上的 trade-off 以后,面对 FP8、INT8、INT4 甚至更新的格式,都可以用同一套 mental model 去分析。

参考资料
#

Related