这是一篇前置讲,不对应原课的某一次课。原课第 19 讲(量化)的第一部分就是”计算机里的低精度数字”,第 20 讲开头回顾了 BF16、INT8、absmax 和零点量化;第 10–11 讲的 LightSeq 用混合精度训练器;第 21 讲的 FlashAttention-3 用 FP8。这篇把这些内容需要的数字基础从比特讲起。 依据说明:本讲讲的是通用的计算机知识(补码、IEEE 754 浮点标准)和几篇被广泛引用的论文里的做法:Micikevicius et al. (2018) Mixed Precision Training(FP32 主权重、损失缩放、FP32 累加),Kalamkar et al. (2019) A Study of BFLOAT16 for Deep Learning Training,Micikevicius et al. (2022) FP8 Formats for Deep Learning(E4M3、E5M2)。硬件数字取自 NVIDIA H100 的公开规格。所有实验都是本讲义自己设计的。 配套代码 Code:
code/prereq2/(4 个脚本 + 1 个模块;需要 numpy 和 PyTorch CPU 版;每个脚本约 1 秒) 预计阅读时间:3 小时(含运行代码) 建议阅读时机:第 4 讲之前;第 19 讲(量化)之前必读
0. 本讲导航 · How to read this lecture
0.1 为什么要懂这些
LLM 系统里到处是”换一种数字格式”的决定:
- 为什么训练几乎都用 BF16,而不是精度更高的 FP16?为什么还要额外存一份 FP32 的权重?
- 为什么 FP16 训练要做”损失缩放”,BF16 就不用?
- 为什么 GPU 上同一个程序跑两次,结果在最后几位上不一样?
- 为什么 H100 的 FP8 算力是 BF16 的两倍?FP8 一共能表示几个数?
- 把一个 70B 模型压成 INT4,“损失精度”到底损失在哪里?
- LayerNorm 的内核为什么不能用”方差 = E[x²] − E[x]²”这个最直接的公式?
这些问题的答案都不深奥,只需要知道:数字在计算机里怎样存,以及这种存法带来了什么误差。
0.2 学习目标 Learning goals
- 会写出整数的二进制补码;知道 INT4 / INT8 / INT16 / INT32 的范围;理解溢出时的”回绕”,并能举出 LLM 系统里整数溢出的例子(大张量的下标、低精度累加)。
- 能把一个浮点数拆成符号、指数、尾数三段,写出解码公式;说出 FP32、TF32、FP16、BF16、FP8 E4M3、FP8 E5M2、FP4 的范围和精度,并解释为什么 BF16 成了训练的主流。
- 理解舍入与间距(相对精度固定、绝对间距随大小增长);知道溢出、下溢、次正规数、inf、NaN 是什么。
- 理解浮点运算的几个陷阱:加法不满足结合律、低精度累加停滞、顺序不同导致结果不可复现、灾难性抵消;知道为什么硬件总是”低精度相乘、高精度累加”。
- 说出混合精度训练的要点:FP32 主权重、损失缩放(FP16)、FP32 累加;能用实验说明每一条在解决什么问题。
- 理解量化的第一步:absmax(对称)与零点(非对称)量化、缩放因子的粒度、离群值的破坏力。
0.3 本讲地图 Map
§1 比特、字节与整数:2^n 个值;补码;溢出回绕;LLM 里的整数 ← 代码 01
§2 浮点数:科学计数法;三段比特;七种格式;间距;特殊值;FP8 / FP4 ← 代码 02
§3 浮点运算的陷阱:结合律;累加停滞;不可复现;高精度累加;灾难性抵消 ← 代码 03
§4 混合精度训练:为什么用低精度;FP32 主权重;损失缩放;它到底省了什么 ← 代码 04 第 1–3 部分
§5 量化预告:absmax 与零点;粒度;离群值 ← 代码 04 第 4 部分
§6 小结 §7 常见误区 §8 练习 §9 延伸阅读 §10 下一篇预告
0.4 先修
前置讲 P1 的 §2(类型与字节)。会用 NumPy。知道”梯度下降每步把参数减去学习率 × 梯度”即可(不熟悉可以先读 前置讲 P3)。
1. 比特、字节与整数 · Bits, bytes and integers
1.1 n 个比特 → 2^n 个值
计算机里的一切都是比特(0 或 1),8 个比特是一个字节。n 个比特有 种组合,所以最多表示 个不同的值(01 号脚本第 1 部分):
| 类型 | 位数 | 不同的值 | 范围 |
|---|---|---|---|
| INT4 | 4 | 16 | −8 … 7 |
| INT8 | 8 | 256 | −128 … 127 |
| UINT8 | 8 | 256 | 0 … 255 |
| INT16 | 16 | 65,536 | −32,768 … 32,767 |
| INT32 | 32 | 约 43 亿 | 约 ±21.5 亿 |
| INT64 | 64 | 约 1.8 × 10¹⁹ | 约 ±9.2 × 10¹⁸ |
INT4 只有 16 个值。第 19–20 讲把模型权重压到 4 位时,每个权重只能在 16 个值里选一个——这就是量化损失精度的根源:不是”算错了”,而是”能选的值太少”。
1.2 补码:负数怎样存
直觉。 想象一个只有 8 位的里程表:从 0 往回倒一格,就变成 255(全是 9 的那种感觉)。补码就是这样:把 −1 存成 = 11111111,−2 存成 254,−128 存成 128 = 10000000。于是最高位是 1 的就是负数。
| 值 | 8 位补码 | 当作无符号数读 |
|---|---|---|
| 0 | 00000000 | 0 |
| 1 | 00000001 | 1 |
| 127 | 01111111 | 127 |
| −1 | 11111111 | 255 |
| −2 | 11111110 | 254 |
| −128 | 10000000 | 128 |
为什么这样设计? 补码的加法不用区分正负:把两个数当无符号数相加,丢掉溢出的第 9 位,结果就对。例如 5 + (−3):00000101 + 11111101 = 1 00000010,丢掉最高的进位,得到 00000010 = 2。所以 CPU 和 GPU 里只需要一种整数加法器。
1.3 溢出:不报错,只回绕
固定位数的整数算出超出范围的结果时,只保留低位、丢掉进位,叫”回绕”(wrap-around)。01 号脚本第 3 部分(NumPy 的 int8 数组):127 + 1 = −128,−128 + (−1) = 127,100 + 100 = −56;uint8 的 255 + 1 = 0。C、CUDA、NumPy 都是这样,没有任何报错或警告。
1.4 LLM 系统里的整数
大张量的下标会溢出。 一个批次 32,768 个 token、词表 128,256(Llama-3)的 logits 张量有 4,202,692,608 个元素,约 42 亿——超过了 INT32 的最大值 2,147,483,647。如果 CUDA 内核用 int 计算元素位置,最后一个元素的位置会回绕成 −92,274,689,读写到完全错误的内存。这类 bug 在真实的框架里出现过不止一次,所以处理大张量的内核要用 64 位下标(int64_t)。
token id 用几位存? GPT-2 的词表 50,257 小于 65,536,uint16 就够了;Llama-3 的 128,256 必须用 32 位。一份 15T token 的语料存成 uint16 要 30 TB,int32 要 60 TB,int64(PyTorch 嵌入层要求的类型)要 120 TB。类型选择直接决定存储和读取的带宽。
INT8 的点积要用 INT32 累加。 两个 INT8 相乘最大 127 × 127 = 16,129,已经接近 INT16 的上限。长度 4096 的点积,01 号脚本第 5 部分用 int16 累加得到 −1,788,精确值是 63,748(本身就超出了 INT16);用 int32 累加完全正确。所以 GPU 的 INT8 张量核心总是把乘积累加到 INT32 里,最后再乘以缩放因子变回浮点数。**“低精度存储 + 高精度累加”**是这一讲反复出现的原则。
2. 浮点数 · Floating-point numbers
2.1 从科学计数法说起
整数的问题是范围和精度绑死了:32 位整数能表示到 21 亿,但小数一个都表示不了。科学计数法把一个数拆成”有效数字”和”数量级”:、——用几位有效数字就能表示极大和极小的数。浮点数就是二进制的科学计数法:
2.2 三段比特
一个浮点数占三段比特:
- 符号位 (1 位):0 正 1 负。
- 指数字段 (若干位):存的是 ,偏置 = (FP32 是 127,FP16 是 15),这样指数字段就是一个无符号整数。
- 尾数字段 (若干位):小数点后面的二进制位。规格化数的首位总是 1,不用存——白赚一位精度。
解码公式(规格化数,即 不是全 0 也不是全 1):
02 号脚本第 1 部分把几个数拆开(节选):
| 原数 | 格式 | 符号 指数 尾数 | 指数字段 − 偏置 | 实际存下的值 | 相对误差 |
|---|---|---|---|---|---|
| π | FP32 | 0 10000000 10010010000111111011011 | 128 − 127 = 1 | 3.141592741 | 2.8e-08 |
| π | FP16 | 0 10000 1001001000 | 16 − 15 = 1 | 3.140625 | 3.1e-04 |
| π | BF16 | 0 10000000 1001001 | 128 − 127 = 1 | 3.140625 | 3.1e-04 |
| π | FP8 E4M3 | 0 1000 101 | 8 − 7 = 1 | 3.25 | 3.5e-02 |
| 0.1 | FP32 | 0 01111011 10011001100110011001101 | 123 − 127 = −4 | 0.1000000015 | 1.5e-08 |
| 0.1 | BF16 | 0 01111011 1001101 | −4 | 0.1000976562 | 9.8e-04 |
| 1000.3 | FP16 | 0 11000 1111010001 | 24 − 15 = 9 | 1000.5 | 2.0e-04 |
| 1000.3 | FP8 E4M3 | 0 1111 110 | 15 − 7 = 8 | 448 | 0.55 |
验算一个:π 在 FP16 里尾数字段是 584,值 = 。
两点直觉:指数位决定范围(能表示多大、多小的数),尾数位决定精度(有几位有效数字)。0.1 在二进制里是无限循环小数,任何格式都存不精确。
2.3 七种格式
02 号脚本第 2 部分用 num_lab.py 的公式算出了七种格式的关键数字,PyTorch 支持的五种和 torch.finfo 逐项一致;再把 6000 个从 1e-10 到 7e4 的随机数存进去,round_to 的结果和 PyTorch 的真实转换逐个相同——浮点格式没有魔法:
| 格式 | 总位数 | 指数位 | 尾数位 | 最大值 | 最小规格化数 | eps(1 附近的间距) | 十进制有效数字 | 主要用途 |
|---|---|---|---|---|---|---|---|---|
| FP32 | 32 | 8 | 23 | 3.4e38 | 1.2e-38 | 1.2e-7 | 7.2 | 优化器状态、累加器 |
| TF32 | 19(存储仍占 32) | 8 | 10 | 3.4e38 | 1.2e-38 | 9.8e-4 | 3.3 | 张量核心内部:FP32 的范围 + FP16 的精度 |
| FP16 | 16 | 5 | 10 | 65504 | 6.1e-5 | 9.8e-4 | 3.3 | 半精度;训练要配合损失缩放 |
| BF16 | 16 | 8 | 7 | 3.4e38 | 1.2e-38 | 7.8e-3 | 2.4 | 当今训练的主流 |
| FP8 E4M3 | 8 | 4 | 3 | 448 | 0.0156 | 0.125 | 1.2 | FP8 的前向(权重、激活) |
| FP8 E5M2 | 8 | 5 | 2 | 57344 | 6.1e-5 | 0.25 | 0.9 | FP8 的反向(梯度) |
| FP4 E2M1 | 4 | 2 | 1 | 6 | 1 | 0.5 | 0.6 | Blackwell 的 FP4(配合缩放因子) |
为什么是 BF16? BF16(brain floating point,最早由 Google 为 TPU 设计)就是把 FP32 的尾数砍掉 16 位:指数位和 FP32 一样多,所以范围和 FP32 几乎一样,只是精度降到约 2–3 位十进制有效数字。FP16 反过来:精度高一些,最大值却只有 65504。深度学习对两者的容忍度不同:梯度下降本来就有噪声,低精度带来的误差常常被淹没;而一个溢出成 inf 的数会让整个训练变成 NaN。所以宁要范围、不要精度——BF16 成了训练的主流,FP16 则需要”损失缩放”来补救(§4.3)。
TF32 是 NVIDIA 张量核心的内部格式:输入按 FP32 存,乘法只用 10 位尾数,换来数倍的速度。PyTorch 里 torch.backends.cuda.matmul.allow_tf32 控制 FP32 矩阵乘法要不要悄悄用它。
FP8 有两种:E4M3 多一位尾数(更细),E5M2 多一位指数(更宽)。常见搭配是前向的权重和激活用 E4M3,反向的梯度(范围变化大)用 E5M2。
2.4 舍入与间距
一个实数存进浮点格式时,被舍入到最近的可表示数(平局时取尾数为偶数的那个,round half to even)。可表示数之间的间距随数的大小成比例增长(02 号脚本第 3 部分):
| 在这个数附近 | FP32 的间距 | FP16 的间距 | BF16 的间距 |
|---|---|---|---|
| 1 | 1.2e-7 | 0.00098 | 0.0078 |
| 10 | 9.5e-7 | 0.0078 | 0.0625 |
| 256 | 3.1e-5 | 0.25 | 2 |
| 1000 | 6.1e-5 | 0.5 | 4 |
| 65504 | 0.0039 | 32 | 512 |
浮点数的相对精度是固定的(每个二进位区间 里的可表示数一样多),绝对间距越大越稀。两个直接的后果:
- 大整数存不下。 FP32 能精确表示所有整数直到 16,777,216(),FP16 到 2048,BF16 只到 256——257 存进 BF16 会变成 256。如果有人把位置编号(第 257 个 token)放进 BF16 里计算位置编码,就会悄悄出错;位置编码(例如 RoPE 的角度)通常要用 FP32 计算,原因就在这里。
- 小增量会被吞掉。 1 + eps/2 存进去变回 1。“比 1 大的最小的数”是 1 + eps,更小的增量会被舍入掉——§4.2 会看到,这正是低精度权重”学不动”的原因。
2.5 溢出、下溢、次正规数、inf 与 NaN
指数字段全 0 和全 1 是特殊的:
- 全 0:次正规数(subnormal)。公式里的”1 +“变成”0 +“,指数固定为最小指数。它让数可以比最小规格化数更小、逐渐失去精度地趋向 0,而不是突然变成 0。
- 全 1(IEEE 式格式):尾数为 0 是 ±inf,否则是 NaN(Not a Number)。
02 号脚本第 4 部分:
| 存进去的数 | FP32 | FP16 | BF16 | FP8 E4M3 | FP8 E5M2 |
|---|---|---|---|---|---|
| 70000(超过 FP16 最大值) | 70000 | inf | 70144 | 448(饱和) | inf |
| 1e-8 | 1e-8 | 0(下溢) | 1.001e-8 | 0 | 0 |
| 3e-6(FP16 的次正规区间) | 3e-6 | 2.98e-6 | 2.995e-6 | 0 | 0 |
| 500(超过 E4M3 最大值 448) | 500 | 500 | 500 | 448(饱和) | 512 |
- 溢出:FP16 存 70000 就是 inf;一个 inf 进了损失函数,下一步就是 NaN,训练就坏了。BF16 只是精度差一点。
- 下溢:1e-8 在 FP16 里变成 0,在 BF16 里还活着。很小的梯度就是这样”消失”的。
- E4M3 没有 inf:它把”指数全 1”也用来表示普通的数(只有尾数也全 1 时是 NaN),换来更大的范围(最大 448 而不是 240)。超出范围时,本机 PyTorch 把它饱和成 ±448;不同库的约定不同,有的会变成 NaN。
- NaN 不等于任何数,包括它自己:
inf − inf = nan,nan == nan是 False。检查 NaN 要用isnan。
2.6 FP8 和 FP4 一共只有几个数
8 位只有 256 种比特模式。02 号脚本第 5 部分把它们全部枚举了一遍:
| 格式 | 比特模式 | 有限值(含 ±0) | 正数个数 | 最小正数 | 最大正数 | [1, 2) 里有几个 |
|---|---|---|---|---|---|---|
| FP8 E4M3 | 256 | 254 | 126 | 0.00195 | 448 | 8 |
| FP8 E5M2 | 256 | 248 | 123 | 1.5e-5 | 57344 | 4 |
| FP4 E2M1 | 16 | 16 | 7 | 0.5 | 6 | 2 |
FP4 的全部非负值只有 0、0.5、1、1.5、2、3、4、6。每个权重只能从这么少的值里选一个,而且这些值集中在某个固定的数量级附近。所以 FP8 / FP4 一定要配合缩放因子:先把一组数乘上一个系数,挪到格式最擅长的范围,再存;用的时候再除回去。新的”微缩放”(microscaling)格式让每一小块(例如 32 个数)共享一个缩放因子,正是为了让 4–8 位的格式也能用于训练和推理(第 19–21 讲)。
3. 浮点运算的陷阱 · Pitfalls of floating-point arithmetic
3.1 加法不满足结合律
每做一次浮点运算,结果都要舍入一次。于是数学上相等的两种计算顺序,在计算机里可能不相等(03 号脚本第 1 部分):
| 格式 | 三个数 | (a + b) + c | a + (b + c) |
|---|---|---|---|
| FP32 | 1e8, −1e8, 1 | 1.0 | 0.0 |
| FP16 | 1000, 0.3, 0.3 | 1001.0 | 1000.5 |
| FP64 | 0.1, 0.2, 0.3 | 0.6000000000000001 | 0.6 |
FP32 在 1e8 附近的间距是 8,所以 −1e8 + 1 舍入后还是 −1e8,那个 1 就丢了;先把两个大数相消,再加 1 就保住了。著名的 0.1 + 0.2 == 0.3 是 False,也是同一个原因。
后果:编译器、数学库、GPU 内核只要改变了加法的顺序(为了并行、为了向量化),结果就可能在最后几位上不同。P1 §1.3 里 NumPy 的求和和顺序累加结果不同,就是这个原因。
3.2 累加停滞
用低精度格式做累加器,把 10000 个 0.1 一个一个加起来(03 号脚本第 2 部分):
| 格式 | 精确值 | 顺序累加 | 成对(树形)累加 | Kahan 补偿累加 |
|---|---|---|---|---|
| FP32 | 1000.0 | 999.903 | 1000 | 1000 |
| FP16 | 999.76 | 256 | 1000 | 1000 |
| BF16 | 1000.98 | 32 | 1000 | 1004 |
FP16 加到第 2208 个时停在 256,BF16 加到第 277 个时停在 32。原因:部分和到了 256,FP16 的间距是 0.25,一半是 0.125——大于 0.1。于是 舍入后还是 256,之后无论再加多少次都不变。
两种补救办法:
- 成对(树形)求和:先两两相加,再把结果两两相加……每次相加的两个数大小相近,误差的积累从 降到 量级。GPU 上的并行归约天然就是这个形状(第 3 讲)。
- Kahan 补偿求和:多用一个变量 记住每一步被舍入掉的部分,下一步再加回去:;;;。全部运算都在低精度里做,却能得到接近”高一倍精度”的结果。
3.3 顺序不同 → 结果不可复现
03 号脚本第 3 部分把同样 10 万个标准正态分布的 float32 用 20 种随机顺序相加:得到了 19 个不同的结果,彼此相差约 ±0.002(精确值 −90.825078)。
GPU 上很多归约用原子加法(atomicAdd)实现:成千上万个线程谁先算完谁先加,顺序每次运行都不一样,所以同一个程序、同样的输入,两次运行的结果可能在最后几位上不同。大多数时候这无关紧要,但调试时会让人困惑(“我什么都没改,loss 怎么变了?”)。需要逐位可复现时,要用固定顺序的归约,往往更慢——PyTorch 的 torch.use_deterministic_algorithms(True) 就是做这个的。
3.4 低精度相乘,高精度累加
03 号脚本第 4 部分算了两个长度 4096 的 BF16 向量的点积(精确值 29.969941):
| 做法 | 结果 | 相对误差 |
|---|---|---|
| 乘积和累加都用 BF16 | 30.625 | 2.2e-2 |
| 乘积精确、累加用 BF16 | 30.625 | 2.2e-2 |
| 乘积精确、累加用 FP32(张量核心的做法) | 29.969946 | 1.7e-7 |
误差几乎全部来自累加,不是乘法:乘法只做一次,累加要做几千次。所以 GPU 的张量核心都是”低精度输入、高精度累加”:BF16 / FP16 / FP8 相乘,乘积在 FP32 里累加;INT8 相乘,在 INT32 里累加(§1.4)。一个矩阵乘法的输入可以是 BF16,它内部的累加器一定是 FP32。
3.5 灾难性抵消
两个很接近的大数相减,有效数字会大量丢失。最典型的例子是方差。03 号脚本第 5 部分:4096 个数 = 均值 + 标准正态噪声(真实方差约 1),全部用 float32:
| 均值 | 真实方差 | 一遍法 E[x²] − E[x]² | 两遍法 E[(x − 均值)²] |
|---|---|---|---|
| 0 | 1.003654 | 1.003654 | 1.003654 |
| 1000 | 0.987569 | 0.937500 | 0.987569 |
| 10000 | 1.037135 | 0.000000 | 1.037119 |
均值 1e4 时, 和 都约等于 ,FP32 在 附近的间距是 8——两个数各自的舍入误差就比它们的差(约 1)大得多,结果毫无意义(这次是 0,换一组数据也可能是负数)。
和 LLM 系统的关系:LayerNorm 要算每一行的均值和方差(原课 HW4 要你写 LayerNorm 的前向和反向内核,第 10 讲)。为了只读一遍数据,很容易写成一遍法——激活值的均值一旦不小,结果就错了。稳妥的做法是两遍法(先减均值),或者 Welford 在线算法:逐个读入 时更新 和 ,最后方差 ;它只读一遍数据,又不会抵消,还能把几段的结果合并(适合并行)。
4. 混合精度训练 · Mixed-precision training
4.1 为什么要用低精度
三个理由,按重要性排列:
- 算力:H100 的张量核心 BF16 标称约 989 TFLOP/s,FP8 约 1979 TFLOP/s,而不走张量核心的 FP32 只有约 67 TFLOP/s。深度学习的算力几乎全部来自低精度的张量核心。
- 带宽:每个数从 4 字节变成 2 字节,从显存读一遍的时间减半。第 1 讲 §4.4 说过,很多算子是访存受限的——字节数减半,它们就快一倍。
- 显存:激活值、KV 缓存、推理时的权重都减半。
代价是 §2–§3 的那些误差。混合精度训练(Micikevicius et al., 2018)的目标是:耗时的运算用低精度,对误差敏感的少数地方保留 FP32。下面三节是它的要点。
4.2 要点一:FP32 主权重
04 号脚本第 1 部分:权重从 1.0 开始,每步加一个固定的小更新量,加 1000 步:
| 每步更新量 | 精确结果 | FP32 权重 | FP16 权重 | BF16 权重 | FP32 主权重 → 转成 BF16 |
|---|---|---|---|---|---|
| 0.01 | 11 | 11.0002 | 10.74 | 4 | 11 |
| 0.001 | 2 | 2.00005 | 1.977 | 1(一步都没动) | 2 |
| 0.0001 | 1.1 | 1.10002 | 1 | 1 | 1.10156 |
BF16 在 1 附近的间距是 0.0078:1 + 0.001 离 1 比离 1.0078 近,舍入回 1——更新被吞掉了。学习率 × 梯度在真实训练里常常就是 1e-3 到 1e-5 的量级。
办法是FP32 主权重(master weights):优化器在一份 FP32 的副本上累积这些小更新;每一步把 FP32 副本转成 BF16,用于前向和反向。这样权重最终能走到 2.0,而用于计算的 BF16 副本只在变化足够大时才”跳一格”。
04 号脚本第 2 部分在一个线性回归(2048 个样本、32 维、噪声标准差 0.01,所以最优的均方误差约 1e-4)上比较了三种方案:
| 步数 | FP32 | 纯 BF16 | BF16 计算 + FP32 主权重 |
|---|---|---|---|
| 0 | 31.67 | 31.67 | 31.67 |
| 500 | 1.009e-4 | 1.794e-2 | 1.991e-4 |
| 3000 | 1.009e-4 | 1.794e-2 | 1.815e-4 |
纯 BF16 在 500 步时就停在 1.79e-2,之后再也不动——比 FP32 差约 178 倍。混合精度用 BF16 做了全部的矩阵运算,只在更新时用 FP32,误差比纯 BF16 小约 99 倍;它没有完全追平 FP32(约是 FP32 的 1.8 倍,并在记录点之间来回波动),因为前向和反向本身仍是 BF16,梯度里带着舍入噪声。在真实的深度网络里,这点噪声通常远小于小批量采样本身的噪声。
FP32 主权重的代价是显存:每个参数多存 4 字节。这正是第 1 讲 §5.5 那张”每个参数 16 字节”的账里,“FP32 主权重 4 字节”那一项的来历(第 18 讲的 ZeRO 会把它切分到多张卡上)。
4.3 要点二:损失缩放(FP16 才需要)
真实的梯度大多很小,跨越很多个数量级。04 号脚本第 3 部分构造了 20000 个绝对值在 1e-11 到 1e-1 之间(对数均匀分布)的”梯度”,先乘一个缩放因子,再存进 FP16:
| 格式 | 缩放因子 | 下溢成 0 | 上溢成 inf |
|---|---|---|---|
| BF16 | 1 | 0.0% | 0.0% |
| FP16 | 1 | 33.9% | 0.0% |
| FP16 | 2⁸ | 10.7% | 0.0% |
| FP16 | 2¹⁶ | 0.0% | 0.0% |
| FP16 | 2²⁴ | 0.0% | 13.8% |
FP16 的范围太小,三分之一的梯度直接变成了 0。损失缩放(loss scaling)的做法:把损失乘上一个大数 S 再反向传播——反向传播是线性的,所有梯度都跟着乘 S,被整体挪进 FP16 的范围;更新参数之前再除以 S(在 FP32 里做)。S 太小会下溢,太大会上溢。
合适的 S 随训练变化,所以实践中用动态损失缩放:从一个很大的值开始;一旦梯度里出现 inf / NaN,就跳过这一步并把 S 减半;连续若干步(例如 1000 步,脚本的模拟里用 100 步)没有溢出,就试着把 S 加倍。脚本模拟了 400 步:从 2²⁴ 开始,前几步连续溢出,很快降到 2²⁰ 左右;遇到两次梯度尖峰时又自动降到 2¹⁷;400 步里共跳过 9 步。
BF16 的范围和 FP32 一样,不需要这一套——这是 BF16 取代 FP16 成为训练主流的另一个原因。
4.4 要点三:高精度累加与稳定的 softmax
- 矩阵乘法的累加用 FP32(§3.4),张量核心在硬件上就是这样做的。
- 归约用 FP32:softmax 的分母、LayerNorm 的均值和方差、损失的求和,都用 FP32 累加(§3.2、§3.5)。
- softmax 先减最大值:,数学上不变,却能避免 溢出(FP16 里 就已经超过 65504)。前置讲 P3 §2 会细讲。
4.5 混合精度到底省了什么
混合精度没有省下参数相关的显存:训练时每个参数仍要 2(BF16 权重)+ 2(BF16 梯度)+ 4(FP32 主权重)+ 4 + 4(FP32 的 Adam 状态)= 16 字节,比纯 FP32 训练(4 + 4 + 4 + 4 = 16 字节)一点都不少。它省的是:
- 激活值的显存减半(激活是 BF16)——对大模型来说,激活常常比参数还大(第 1 讲 §5.5);
- 矩阵乘法的时间:用上了张量核心,快一个数量级;
- 访存受限算子的时间:读写的字节减半。
5. 量化预告 · A preview of quantization
5.1 把浮点数映射成小整数
量化(quantization)把浮点数映射成 INT8 / INT4 这样的小整数,用的时候再映射回来。最简单的两种(原课第 19 讲):
- absmax(对称)量化:缩放因子 ,,反量化 。0 精确地映射到 0。
- 零点(非对称)量化:把 线性映射到 :,零点 ,,反量化 。
5.2 粒度与离群值
04 号脚本第 4 部分:一个 64×256 的权重矩阵,元素 ~ N(0, 0.02²),absmax 量化后的相对误差 ‖W − Ŵ‖ / ‖W‖:
| 权重 | 位数 | 整个矩阵共用一个缩放因子 | 每行一个缩放因子 |
|---|---|---|---|
| 正常的权重 | INT8 | 0.92% | 0.69% |
| 正常的权重 | INT4 | 16.69% | 12.74% |
| 同一个矩阵 + 1 个离群值(1.0,约 50 个标准差) | INT8 | 10.68% | 1.48% |
| 同一个矩阵 + 1 个离群值 | INT4 | 93.03% | 15.69% |
- 位数减半,误差大约涨了 18 倍:INT4 每个数只能在 16 个值里选。
- 一个离群值就能毁掉整个矩阵:它把共用的缩放因子撑大了约 12 倍,INT4 时绝大多数权重被量化成了 0(误差 93%)。每行用自己的缩放因子,离群值只祸害它所在的那一行。缩放因子的粒度(逐张量、逐通道、逐组)是第 19–20 讲的核心话题之一;LLM 的激活里真实存在少数幅度极大的”离群特征”,这是 LLM.int8()(第 19 讲)的出发点。
非负的数据(例如 ReLU 之后的激活)用对称量化会浪费一半的整数:INT8 只用到了 100 个不同的值;零点量化用满了整个范围,INT4 的误差从 15.77% 降到 7.30%。
5.3 量化能省多少
一个 7B 模型的权重:FP32 28 GB,BF16 14 GB,INT8 7 GB,INT4 3.5 GB(缩放因子另计,通常不到 1%)。推理时解码是访存受限的(第 1 讲 §4.7),权重的字节数减半,解码速度就接近翻倍——这是量化在服务系统里如此重要的原因。代价是精度,以及”反量化”的额外计算;怎样在 4 位下保住精度,是第 20 讲 GPTQ 的主题。
6. 小结 · Summary
- 整数:n 位 → 个值;负数用补码(−1 是全 1,加法不用区分正负);溢出时只保留低位、不报错。大张量的下标会超过 INT32,INT8 点积要用 INT32 累加。
- 浮点数 = 符号 + 指数 + 尾数:指数位管范围,尾数位管精度。FP16 精度高范围小,BF16 范围大精度低;深度学习更怕溢出,所以训练用 BF16。FP8 分 E4M3(细)和 E5M2(宽),FP4 只有 8 个非负值,都要配合缩放因子。
- 间距随大小增长:BF16 在 256 附近间距 2,257 存不下;1 + eps/2 会变回 1。溢出得 inf、下溢得 0(中间有次正规数),NaN 不等于自己。
- 浮点加法不满足结合律:低精度顺序累加会停滞(FP16 停在 256、BF16 停在 32);换个顺序结果就变(20 种顺序 19 个结果),GPU 的原子加法因此不可复现;误差主要来自累加,所以低精度相乘、高精度累加;两个大数相减会灾难性抵消(一遍法方差算出 0)。
- 混合精度训练:FP32 主权重(小更新不被吞掉)、损失缩放(FP16 的小梯度不下溢,动态调整)、FP32 累加与稳定的 softmax。它省的是激活显存和计算时间,不省参数相关的 16 字节。
- 量化:absmax 与零点量化;离群值会撑大缩放因子,细粒度的缩放因子能把损害限制在局部;权重字节数减半,解码就接近快一倍。
7. 常见误区 · Common pitfalls
- “BF16 比 FP16 精确。” 相反:BF16 只有 7 位尾数,FP16 有 10 位。BF16 赢在范围。
- “浮点数能精确表示所有不太大的整数。” BF16 只能精确表示到 256,FP16 到 2048。
- “(a + b) + c 一定等于 a + (b + c)。” 浮点加法不满足结合律;换一种求和顺序,结果就可能不同。
- “同样的代码、同样的输入,GPU 的输出一定逐位相同。” 原子加法等非确定的归约会让结果每次略有不同;需要可复现要显式开启确定性算法。
- “混合精度训练能把训练显存减半。” 参数相关的部分(16 字节/参数)一点没少;省的是激活和计算时间。
- “FP8 / INT4 直接把数存进去就行。” 不配缩放因子,FP8 E4M3 存 1000.3 会变成 448,INT4 量化一个有离群值的矩阵误差高达 93%。
- “loss 变成 NaN 一定是学习率太大。” 也可能是 FP16 溢出(需要损失缩放)、softmax 没减最大值、或者某处出现了 inf − inf。
- “方差就用 E[x²] − E[x]² 算。” 均值大时会灾难性抵消;要用两遍法或 Welford 算法。
- “整数溢出会报错。” C、CUDA、NumPy 的整数溢出都只会悄悄回绕。
8. 练习 · Exercises
练习 1(补码)。 写出 −5 和 −100 的 8 位补码。用补码计算 −5 + 3,验证结果。
参考答案:−5 → 256 − 5 = 251 =
11111011;−100 → 156 =10011100。−5 + 3:11111011 + 00000011 = 11111110= 254,按补码读是 −2。✓
练习 2(溢出)。 一个 CUDA 内核用 int 计算一个 [B, T, V] 张量的元素位置。B = 8、T = 4096 时,词表 V 最大多少还不会溢出?
参考答案:要求 B × T × V − 1 ≤ 2³¹ − 1,即 V ≤ 2³¹ / 32768 = 65,536。Llama-3 的 128,256 就会溢出。
练习 3(解码浮点数)。 FP16 的比特 0 10010 0100000000 表示什么数?1 01110 1000000000 呢?
参考答案:第一个:指数字段 18,18 − 15 = 3;尾数 256/1024 = 0.25;值 = 1.25 × 2³ = 10。第二个:符号负,指数 14 − 15 = −1,尾数 0.5;值 = −1.5 × 0.5 = −0.75。
练习 4(格式的范围)。 用公式推出 FP16 的最大值 65504 和 BF16 的 eps 0.0078125。
参考答案:FP16:指数全 1 被 inf / NaN 占用,最大指数字段是 30,30 − 15 = 15;尾数全 1 时有效数字是 ;最大值 。BF16 有 7 位尾数,1 和下一个数的距离是 。
练习 5(间距)。 BF16 里,1000 附近相邻两个可表示数的间距是多少?把 1001 存进 BF16 会得到什么?
参考答案:1000 在 区间,间距 。1001 离 1000 比离 1004 近,存成 1000。(02 号脚本的表里 1000 附近 BF16 的间距正是 4。)
练习 6(累加停滞)。 用 BF16 做累加器,不停地加 0.01,部分和会停在哪里?
参考答案:当间距的一半超过 0.01,即间距 > 0.02 时停住。BF16 在 的间距是 ; 要求 ,即 ,区间 [4, 8)。所以部分和停在 4(与 04 号脚本第 1 部分里”每步加 0.01 的 BF16 权重停在 4”一致:那里间距 0.03125,一半 0.0156 > 0.01)。
练习 7(结合律)。 在 FP32 里,(1e8 + 1) + 1 + 1 + 1(从左到右)和 1e8 + (1 + 1 + 1 + 1) 各等于多少?
参考答案:FP32 在 1e8 附近的间距是 8,1e8 + 1 舍入回 1e8,所以从左到右每次加 1 都被吞掉,结果 1e8;先把四个 1 加成 4,1e8 + 4 恰好在 1e8 和 1e8 + 8 正中间,平局取偶——1e8 的尾数是偶数,所以结果仍是 1e8。如果加的是 5 个 1,1e8 + 5 会舍入到 1e8 + 8。(这道题说明:小数先加起来再加到大数上,通常更准,但仍受间距限制。)
练习 8(损失缩放)。 一个梯度的值是 3e-8。(a) 直接存进 FP16 会怎样?(b) 损失缩放因子取 2¹⁰ 呢?(c) 如果同一批梯度里最大的是 50,缩放因子最大能取多少而不上溢?
参考答案:(a) FP16 最小次正规数约 5.96e-8,3e-8 只比它的一半(2.98e-8)大一点点,被舍入成 5.96e-8——值几乎翻倍,相对误差约 99%,这个梯度的信息基本丢光了。(用
num_lab.round_to(3e-8, FP16)可以验证。)(b) 3e-8 × 1024 ≈ 3.07e-5,仍落在 FP16 的次正规区间(最小规格化数 6.1e-5)里,但已经有约 500 个量化台阶可用,相对误差约 0.08%;取 2¹⁶ 时 ≈ 1.97e-3,进入规格化区间,相对误差约 0.02%。(c) 50 × S ≤ 65504,S ≤ 1310,取 2 的幂就是 2¹⁰ = 1024。可见同一批梯度”既要不下溢、又要不上溢”很难两全——这是 FP16 训练的根本困难。
练习 9(混合精度的账)。 一个 7B 模型,用 BF16 混合精度 + Adam 训练。参数、梯度、优化器状态共占多少显存?如果”纯 BF16”训练(权重、梯度、Adam 状态全用 BF16),是多少?为什么大家不这样做?
参考答案:混合精度 16 字节 × 7e9 = 112 GB。纯 BF16:2 + 2 + 2 + 2 = 8 字节/参数,56 GB。不这样做是因为纯 BF16 的权重更新会被吞掉(§4.2:小更新量下权重一步都不动),Adam 的二阶矩在 BF16 里也很难准确;省下的显存换来的是训练不收敛。(有一些研究在尝试用随机舍入等技巧让纯低精度训练可行。)
练习 10(方差)。 用 Welford 算法手算 {10001, 10002, 10003} 的方差(除以 n),每一步写出 和 。
参考答案::,。:,。:,。方差 。整个过程只出现了”减去均值之后”的小数,不会抵消。
练习 11(量化)。 对向量 [0.1, −0.4, 0.25, 0.05] 做 INT4 absmax 量化(),写出缩放因子、整数和反量化的结果。
参考答案:。 = round([1.75, −7, 4.375, 0.875]) = [2, −7, 4, 1]。反量化 [0.114, −0.4, 0.229, 0.057]。最大的数恰好精确,其余各有误差。
练习 12(思考题)。 为什么 FP8 训练里,前向用 E4M3、反向的梯度用 E5M2?如果反过来会怎样?
参考答案:权重和激活的分布比较稳定、集中,更需要精度(E4M3 多一位尾数);梯度的大小在训练过程中、在不同层之间变化很大,更需要范围(E5M2 多一位指数,最大 57344、最小次正规数约 1.5e-5)。反过来的话,梯度更容易溢出或下溢(E4M3 最大只有 448),激活又损失了精度。即使这样搭配,FP8 训练仍然需要按张量(或按块)维护缩放因子。
9. 延伸阅读 · Further reading
- IEEE 754 与浮点误差:David Goldberg (1991), What Every Computer Scientist Should Know About Floating-Point Arithmetic(经典长文)。
- 混合精度训练:Micikevicius et al. (2018), Mixed Precision Training(ICLR);Kalamkar et al. (2019), A Study of BFLOAT16 for Deep Learning Training。
- FP8:Micikevicius et al. (2022), FP8 Formats for Deep Learning;OCP (2023), Microscaling (MX) Formats Specification。
- 求和:Kahan (1965) 补偿求和;Welford (1962) 方差的在线算法;Higham, Accuracy and Stability of Numerical Algorithms 第 4 章(求和)。
- 可复现性:PyTorch 文档 “Reproducibility”(
torch.use_deterministic_algorithms)。 - 量化:原课第 19–20 讲的 slides;Dettmers et al. (2022) LLM.int8()。
10. 下一篇预告 · Next
本讲多次提到”梯度""学习率""softmax""LayerNorm”。前置讲 P3(神经网络与训练速成) 为没学过机器学习的读者补齐这些概念:线性层与激活函数、softmax 与交叉熵、梯度下降与 Adam、过拟合与正则化,并用 NumPy 从零实现原课第 2 讲开头的那个文本分类网络(嵌入查表 → 平均池化 → 线性层 → softmax),手写它的反向传播——第 5 讲的自动微分框架要自动完成的,正是这件事。