前置讲 P2 · 数字的表示:整数、浮点与低精度格式 · Number Formats: Integers, Floating Point & Low Precision

Large Language Model Systems · 预备知识 2。个人学习讲义与配套实验,按主题整理并持续修订。

课程源码 ZIP ↓

个人整理的学习笔记,非官方讲义;课程来源与整理说明见课程介绍。配套示例未逐一运行验证。

本篇目录

这是一篇前置讲,不对应原课的某一次课。原课第 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

  1. 会写出整数的二进制补码;知道 INT4 / INT8 / INT16 / INT32 的范围;理解溢出时的”回绕”,并能举出 LLM 系统里整数溢出的例子(大张量的下标、低精度累加)。
  2. 能把一个浮点数拆成符号、指数、尾数三段,写出解码公式;说出 FP32、TF32、FP16、BF16、FP8 E4M3、FP8 E5M2、FP4 的范围和精度,并解释为什么 BF16 成了训练的主流。
  3. 理解舍入与间距(相对精度固定、绝对间距随大小增长);知道溢出、下溢、次正规数、inf、NaN 是什么。
  4. 理解浮点运算的几个陷阱:加法不满足结合律、低精度累加停滞、顺序不同导致结果不可复现、灾难性抵消;知道为什么硬件总是”低精度相乘、高精度累加”。
  5. 说出混合精度训练的要点:FP32 主权重、损失缩放(FP16)、FP32 累加;能用实验说明每一条在解决什么问题。
  6. 理解量化的第一步: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 个比特有 2n2^n 种组合,所以最多表示 2n2^n 个不同的值(01 号脚本第 1 部分):

类型位数不同的值范围
INT4416−8 … 7
INT88256−128 … 127
UINT882560 … 255
INT161665,536−32,768 … 32,767
INT3232约 43 亿约 ±21.5 亿
INT6464约 1.8 × 10¹⁹约 ±9.2 × 10¹⁸

INT4 只有 16 个值。第 19–20 讲把模型权重压到 4 位时,每个权重只能在 16 个值里选一个——这就是量化损失精度的根源:不是”算错了”,而是”能选的值太少”。

1.2 补码:负数怎样存

直觉。 想象一个只有 8 位的里程表:从 0 往回倒一格,就变成 255(全是 9 的那种感觉)。补码就是这样:把 −1 存成 28−1=2552^8 - 1 = 255 = 11111111,−2 存成 254,−128 存成 128 = 10000000。于是最高位是 1 的就是负数。

值8 位补码当作无符号数读
0000000000
1000000011
12701111111127
−111111111255
−211111110254
−12810000000128

为什么这样设计? 补码的加法不用区分正负:把两个数当无符号数相加,丢掉溢出的第 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 亿,但小数一个都表示不了。科学计数法把一个数拆成”有效数字”和”数量级”:6.02×10236.02 \times 10^{23}、1.6×10−191.6 \times 10^{-19}——用几位有效数字就能表示极大和极小的数。浮点数就是二进制的科学计数法:

x=(−1)s×1.m1m2…mk (二进制)×2ex = (-1)^{s} \times 1.m_1 m_2 \dots m_k \ (\text{二进制}) \times 2^{e}

2.2 三段比特

一个浮点数占三段比特:

  • 符号位 ss(1 位):0 正 1 负。
  • 指数字段 EE(若干位):存的是 e+偏置e + \text{偏置},偏置 = 2指数位数−1−12^{\text{指数位数}-1} - 1(FP32 是 127,FP16 是 15),这样指数字段就是一个无符号整数。
  • 尾数字段 MM(若干位):小数点后面的二进制位。规格化数的首位总是 1,不用存——白赚一位精度。

解码公式(规格化数,即 EE 不是全 0 也不是全 1):

x=(−1)s×(1+M2尾数位数)×2E−偏置x = (-1)^s \times \Big(1 + \frac{M}{2^{\text{尾数位数}}}\Big) \times 2^{E - \text{偏置}}

02 号脚本第 1 部分把几个数拆开(节选):

原数格式符号 指数 尾数指数字段 − 偏置实际存下的值相对误差
πFP320 10000000 10010010000111111011011128 − 127 = 13.1415927412.8e-08
πFP160 10000 100100100016 − 15 = 13.1406253.1e-04
πBF160 10000000 1001001128 − 127 = 13.1406253.1e-04
πFP8 E4M30 1000 1018 − 7 = 13.253.5e-02
0.1FP320 01111011 10011001100110011001101123 − 127 = −40.10000000151.5e-08
0.1BF160 01111011 1001101−40.10009765629.8e-04
1000.3FP160 11000 111101000124 − 15 = 91000.52.0e-04
1000.3FP8 E4M30 1111 11015 − 7 = 84480.55

验算一个:π 在 FP16 里尾数字段是 584,值 = (1+584/1024)×216−15=3.140625(1 + 584/1024) \times 2^{16-15} = 3.140625。

两点直觉:指数位决定范围(能表示多大、多小的数),尾数位决定精度(有几位有效数字)。0.1 在二进制里是无限循环小数,任何格式都存不精确。

2.3 七种格式

02 号脚本第 2 部分用 num_lab.py 的公式算出了七种格式的关键数字,PyTorch 支持的五种和 torch.finfo 逐项一致;再把 6000 个从 1e-10 到 7e4 的随机数存进去,round_to 的结果和 PyTorch 的真实转换逐个相同——浮点格式没有魔法:

格式总位数指数位尾数位最大值最小规格化数eps(1 附近的间距)十进制有效数字主要用途
FP32328233.4e381.2e-381.2e-77.2优化器状态、累加器
TF3219(存储仍占 32)8103.4e381.2e-389.8e-43.3张量核心内部:FP32 的范围 + FP16 的精度
FP1616510655046.1e-59.8e-43.3半精度;训练要配合损失缩放
BF1616873.4e381.2e-387.8e-32.4当今训练的主流
FP8 E4M38434480.01560.1251.2FP8 的前向(权重、激活)
FP8 E5M2852573446.1e-50.250.9FP8 的反向(梯度)
FP4 E2M1421610.50.6Blackwell 的 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 的间距
11.2e-70.000980.0078
109.5e-70.00780.0625
2563.1e-50.252
10006.1e-50.54
655040.003932512

浮点数的相对精度是固定的(每个二进位区间 [2k,2k+1)[2^k, 2^{k+1}) 里的可表示数一样多),绝对间距越大越稀。两个直接的后果:

  1. 大整数存不下。 FP32 能精确表示所有整数直到 16,777,216(2242^{24}),FP16 到 2048,BF16 只到 256——257 存进 BF16 会变成 256。如果有人把位置编号(第 257 个 token)放进 BF16 里计算位置编码,就会悄悄出错;位置编码(例如 RoPE 的角度)通常要用 FP32 计算,原因就在这里。
  2. 小增量会被吞掉。 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 部分:

存进去的数FP32FP16BF16FP8 E4M3FP8 E5M2
70000(超过 FP16 最大值)70000inf70144448(饱和)inf
1e-81e-80(下溢)1.001e-800
3e-6(FP16 的次正规区间)3e-62.98e-62.995e-600
500(超过 E4M3 最大值 448)500500500448(饱和)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 E4M32562541260.001954488
FP8 E5M22562481231.5e-5573444
FP4 E2M1161670.562

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) + ca + (b + c)
FP321e8, −1e8, 11.00.0
FP161000, 0.3, 0.31001.01000.5
FP640.1, 0.2, 0.30.60000000000000010.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 补偿累加
FP321000.0999.90310001000
FP16999.7625610001000
BF161000.983210001004

FP16 加到第 2208 个时停在 256,BF16 加到第 277 个时停在 32。原因:部分和到了 256,FP16 的间距是 0.25,一半是 0.125——大于 0.1。于是 256+0.1256 + 0.1 舍入后还是 256,之后无论再加多少次都不变。

两种补救办法:

  • 成对(树形)求和:先两两相加,再把结果两两相加……每次相加的两个数大小相近,误差的积累从 O(n)O(n) 降到 O(log⁡n)O(\log n) 量级。GPU 上的并行归约天然就是这个形状(第 3 讲)。
  • Kahan 补偿求和:多用一个变量 cc 记住每一步被舍入掉的部分,下一步再加回去:y=x−cy = x - c;t=s+yt = s + y;c=(t−s)−yc = (t - s) - y;s=ts = t。全部运算都在低精度里做,却能得到接近”高一倍精度”的结果。

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):

做法结果相对误差
乘积和累加都用 BF1630.6252.2e-2
乘积精确、累加用 BF1630.6252.2e-2
乘积精确、累加用 FP32(张量核心的做法)29.9699461.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 − 均值)²]
01.0036541.0036541.003654
10000.9875690.9375000.987569
100001.0371350.0000001.037119

均值 1e4 时,E[x2]E[x^2] 和 E[x]2E[x]^2 都约等于 10810^8,FP32 在 10810^8 附近的间距是 8——两个数各自的舍入误差就比它们的差(约 1)大得多,结果毫无意义(这次是 0,换一组数据也可能是负数)。

和 LLM 系统的关系:LayerNorm 要算每一行的均值和方差(原课 HW4 要你写 LayerNorm 的前向和反向内核,第 10 讲)。为了只读一遍数据,很容易写成一遍法——激活值的均值一旦不小,结果就错了。稳妥的做法是两遍法(先减均值),或者 Welford 在线算法:逐个读入 xix_i 时更新 μi=μi−1+(xi−μi−1)/i\mu_i = \mu_{i-1} + (x_i - \mu_{i-1})/i 和 Mi=Mi−1+(xi−μi−1)(xi−μi)M_i = M_{i-1} + (x_i - \mu_{i-1})(x_i - \mu_i),最后方差 =Mn/n= M_n / n;它只读一遍数据,又不会抵消,还能把几段的结果合并(适合并行)。


4. 混合精度训练 · Mixed-precision training

4.1 为什么要用低精度

三个理由,按重要性排列:

  1. 算力:H100 的张量核心 BF16 标称约 989 TFLOP/s,FP8 约 1979 TFLOP/s,而不走张量核心的 FP32 只有约 67 TFLOP/s。深度学习的算力几乎全部来自低精度的张量核心。
  2. 带宽:每个数从 4 字节变成 2 字节,从显存读一遍的时间减半。第 1 讲 §4.4 说过,很多算子是访存受限的——字节数减半,它们就快一倍。
  3. 显存:激活值、KV 缓存、推理时的权重都减半。

代价是 §2–§3 的那些误差。混合精度训练(Micikevicius et al., 2018)的目标是:耗时的运算用低精度,对误差敏感的少数地方保留 FP32。下面三节是它的要点。

4.2 要点一:FP32 主权重

04 号脚本第 1 部分:权重从 1.0 开始,每步加一个固定的小更新量,加 1000 步:

每步更新量精确结果FP32 权重FP16 权重BF16 权重FP32 主权重 → 转成 BF16
0.011111.000210.74411
0.00122.000051.9771(一步都没动)2
0.00011.11.10002111.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纯 BF16BF16 计算 + FP32 主权重
031.6731.6731.67
5001.009e-41.794e-21.991e-4
30001.009e-41.794e-21.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
BF1610.0%0.0%
FP16133.9%0.0%
FP162⁸10.7%0.0%
FP162¹⁶0.0%0.0%
FP162²⁴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 先减最大值:softmax(z)i=ezi−max⁡z/∑jezj−max⁡z\mathrm{softmax}(z)_i = e^{z_i - \max z} / \sum_j e^{z_j - \max z},数学上不变,却能避免 eze^{z} 溢出(FP16 里 e12e^{12} 就已经超过 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(对称)量化:缩放因子 s=max⁡∣x∣/(2b−1−1)s = \max|x| / (2^{b-1} - 1),q=round(x/s)q = \mathrm{round}(x / s),反量化 x^=q⋅s\hat{x} = q \cdot s。0 精确地映射到 0。
  • 零点(非对称)量化:把 [min⁡x,max⁡x][\min x, \max x] 线性映射到 [0,2b−1][0, 2^b - 1]:s=(max⁡−min⁡)/(2b−1)s = (\max - \min) / (2^b - 1),零点 z=round(−min⁡/s)z = \mathrm{round}(-\min / s),q=round(x/s)+zq = \mathrm{round}(x / s) + z,反量化 x^=(q−z)⋅s\hat{x} = (q - z) \cdot s。

5.2 粒度与离群值

04 号脚本第 4 部分:一个 64×256 的权重矩阵,元素 ~ N(0, 0.02²),absmax 量化后的相对误差 ‖W − Ŵ‖ / ‖W‖:

权重位数整个矩阵共用一个缩放因子每行一个缩放因子
正常的权重INT80.92%0.69%
正常的权重INT416.69%12.74%
同一个矩阵 + 1 个离群值(1.0,约 50 个标准差)INT810.68%1.48%
同一个矩阵 + 1 个离群值INT493.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 位 → 2n2^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

  1. “BF16 比 FP16 精确。” 相反:BF16 只有 7 位尾数,FP16 有 10 位。BF16 赢在范围。
  2. “浮点数能精确表示所有不太大的整数。” BF16 只能精确表示到 256,FP16 到 2048。
  3. “(a + b) + c 一定等于 a + (b + c)。” 浮点加法不满足结合律;换一种求和顺序,结果就可能不同。
  4. “同样的代码、同样的输入,GPU 的输出一定逐位相同。” 原子加法等非确定的归约会让结果每次略有不同;需要可复现要显式开启确定性算法。
  5. “混合精度训练能把训练显存减半。” 参数相关的部分(16 字节/参数)一点没少;省的是激活和计算时间。
  6. “FP8 / INT4 直接把数存进去就行。” 不配缩放因子,FP8 E4M3 存 1000.3 会变成 448,INT4 量化一个有离群值的矩阵误差高达 93%。
  7. “loss 变成 NaN 一定是学习率太大。” 也可能是 FP16 溢出(需要损失缩放)、softmax 没减最大值、或者某处出现了 inf − inf。
  8. “方差就用 E[x²] − E[x]² 算。” 均值大时会灾难性抵消;要用两遍法或 Welford 算法。
  9. “整数溢出会报错。” 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 时有效数字是 2−2−102 - 2^{-10};最大值 =(2−2−10)×215=65504= (2 - 2^{-10}) \times 2^{15} = 65504。BF16 有 7 位尾数,1 和下一个数的距离是 2−7=0.00781252^{-7} = 0.0078125。

练习 5(间距)。 BF16 里,1000 附近相邻两个可表示数的间距是多少?把 1001 存进 BF16 会得到什么?

参考答案:1000 在 [512,1024)[512, 1024) 区间,间距 =29−7=4= 2^{9-7} = 4。1001 离 1000 比离 1004 近,存成 1000。(02 号脚本的表里 1000 附近 BF16 的间距正是 4。)

练习 6(累加停滞)。 用 BF16 做累加器,不停地加 0.01,部分和会停在哪里?

参考答案:当间距的一半超过 0.01,即间距 > 0.02 时停住。BF16 在 [2k,2k+1)[2^k, 2^{k+1}) 的间距是 2k−72^{k-7};2k−7>0.022^{k-7} > 0.02 要求 2k−7≥2−5=0.031252^{k-7} \ge 2^{-5} = 0.03125,即 k≥2k \ge 2,区间 [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),每一步写出 μi\mu_i 和 MiM_i。

参考答案:i=1i=1:μ1=10001\mu_1 = 10001,M1=0M_1 = 0。i=2i=2:μ2=10001+(10002−10001)/2=10001.5\mu_2 = 10001 + (10002 - 10001)/2 = 10001.5,M2=0+(10002−10001)(10002−10001.5)=0.5M_2 = 0 + (10002 - 10001)(10002 - 10001.5) = 0.5。i=3i=3:μ3=10001.5+(10003−10001.5)/3=10002\mu_3 = 10001.5 + (10003 - 10001.5)/3 = 10002,M3=0.5+(10003−10001.5)(10003−10002)=2M_3 = 0.5 + (10003 - 10001.5)(10003 - 10002) = 2。方差 =M3/3=2/3= M_3 / 3 = 2/3。整个过程只出现了”减去均值之后”的小数,不会抵消。

练习 11(量化)。 对向量 [0.1, −0.4, 0.25, 0.05] 做 INT4 absmax 量化(q∈[−7,7]q \in [-7, 7]),写出缩放因子、整数和反量化的结果。

参考答案:s=0.4/7≈0.05714s = 0.4 / 7 \approx 0.05714。q=round(x/s)q = \mathrm{round}(x / s) = 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 讲的自动微分框架要自动完成的,正是这件事。

← 返回课程目录