起因#
上一篇讨论模型显存时,我直接用了一张表:
| dtype | bytes / param |
|---|---|
| FP32 | 4 |
| FP16 / BF16 | 2 |
| 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.01Relative 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.isclose、numpy.isclose、torch.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.23 和 1.24 可能还能区分,1.234 和 1.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:
| FP16 | BF16 | |
|---|---|---|
| fraction bits | 10 | 7 |
| significand precision | 11 bits | 8 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.float16 或 torch.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"]
- 数据在 memory 中仍然是 FP32(4 bytes)
- Tensor Core 执行矩阵乘法时,把 FP32 输入的 fraction 从 23 bits 截断到 10 bits
- 用截断后的精度做乘法
- 累加到 FP32 的 accumulator
- 结果存回 FP32
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 官方文档。
总表#
四种格式并排:
| Format | Sign | Exp | Frac | Total | Storage | Range | Precision |
|---|---|---|---|---|---|---|---|
| FP32 | 1 | 8 | 23 | 32 | 4 bytes | ~10⁻³⁸ ~ 10³⁸ | ~7 位十进制 |
| FP16 | 1 | 5 | 10 | 16 | 2 bytes | ~6×10⁻⁵ ~ 65504 | ~3 位十进制 |
| BF16 | 1 | 8 | 7 | 16 | 2 bytes | ~10⁻³⁸ ~ 10³⁸ | ~2 位十进制 |
| TF32 | 1 | 8 | 10 | 19 | ※ | ~10⁻³⁸ ~ 10³⁸ | ~3 位十进制 |
※ TF32 不是独立的 storage format,数据仍以 FP32(4 bytes)存储。
如果把 storage dtype 和 arithmetic precision 拆开:
| Format | Storage dtype | Multiply precision | Accumulator |
|---|---|---|---|
| FP32(无 TF32) | FP32(4B) | FP32 | FP32 |
| FP32 + TF32 | FP32(4B) | TF32(10-bit frac) | FP32 |
| FP16 | FP16(2B) | FP16 | 通常 FP32 |
| BF16 | BF16(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 参数:
| dtype | weight 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,硬件支持怎么样。
结论#
Floating point 是一种有限 bit budget 下的近似实数表示。 不精确不是 bug,是 trade-off。
Exponent bits 主要购买 dynamic range,fraction / significand bits 主要购买 precision。
FP16 和 BF16 都是 16 bit,但做了完全不同的选择:FP16 保留更多 precision(10 fraction bits),BF16 保留更多 range(8 exponent bits)。
BF16 不是 FP16 的全面升级,而是一个更适合很多 ML workload 的 range / precision trade-off。
TF32 和 BF16 / FP16 最大的区别之一:它主要描述 Tensor Core arithmetic precision,而不是一种用来给模型权重减半的 storage dtype。
在 ML Infra 中讨论"精度"时,要把 storage dtype、multiply precision、accumulator precision 和 output dtype 分开。
降低精度的收益不仅是显存,还包括 bandwidth、cache、communication 和 Tensor Core throughput。
低精度优化本质上是在用 neural network 能容忍的 numerical error,交换系统效率。
回到开头的问题:如果线上 inference 的瓶颈是模型权重太大,从 FP32 切换到 FP16 / BF16 确实可以把 parameter storage 大致减半。但选哪种 dtype,需要同时看模型的数值稳定性、目标硬件的支持、以及实际 workload 特征。
这些 dtype 不需要当成"记住名字就行"的配置项。理解了 bit budget 上的 trade-off 以后,面对 FP8、INT8、INT4 甚至更新的格式,都可以用同一套 mental model 去分析。
参考资料#
- 从参数量到 Peak Memory:模型显存到底应该怎么算?(姊妹篇)
- 从 FLOPs 到 Latency:GPU 推理性能到底由什么决定?(姊妹篇)
- IEEE 754-2019. IEEE Standard for Floating-Point Arithmetic.
- NVIDIA. Accelerating AI Training with NVIDIA TF32 Tensor Cores.
- NVIDIA. Training Neural Networks with Tensor Cores.(ECCV 2020 Mixed Precision Tutorial)
- PyTorch. TensorFloat-32 (TF32) on Ampere (and later) devices.
- Google Cloud. BFloat16: The secret to high performance on Cloud TPUs.
- Kalamkar et al. A Study of BFLOAT16 for Deep Learning Training. 2019.