浮点数、bfloat,以及比特如何决定模型的内存
在所有抽象之下,神经网络就是一大堆被称为参数的数字——每个神经元在每一层携带的权重和偏置,我们在讲网络如何学习的那篇文章里详细走过一遍。参数量告诉你模型存了多少个这样的数字;数值格式告诉你每一个有多大(多少字节)。两者相乘就得到模型的内存占用——在你挑硬件、挑精度,或者判断这东西究竟能不能装进一块 GPU 之前,你首先需要的就是这个数字。
拿我们在 MNIST 那篇文章里训练的小网络为例——一个由两个 Keras Dense 层构成的稠密分类器:784 个展平的像素输入进入一个 128 神经元的隐藏层,再进入一个 10 神经元的输出层(每个数字一个)。
这种形状的简写是 784 → 128 → 10,其中 784 是输入大小,不是一层。参数量加起来是:
Keras 默认把每个参数存为 fp32——32 位浮点,每个 4 字节。所以这个训练好的模型加载后在内存中占 。但这是个为学习而造的玩具模型——小到可以用邮件发送。真实世界的模型要大好几个数量级,而参数量正是从那里开始变得举足轻重。
发布在 Hugging Face 这类平台上的每个模型都附带一张模型卡——放在权重文件旁边的公开 README,概述模型是什么、如何训练的,以及我们这里关心的两个数字:它有多少参数,每个参数以何种数值格式存储。
我们拿一个当下最先进的开源 OCR 模型举例——在 Hugging Face 上打开 PaddleOCR-VL,侧边栏就会告诉你我们关心的两件事:参数数量,以及每个参数的存储格式。

Model size 是参数总数——模型存了多少个数字。Tensor type 是 dtype——data type(数据类型)的缩写。
其中的 d 是为了把它和普通的 type 区分开:在 Python 里,type(x) 告诉你容器是什么(list、numpy.ndarray、torch.Tensor),而 dtype 告诉你里面的元素是什么(float32、int8、bfloat16)——决定每个权重占多少字节的正是后者。
这个术语始于 NumPy,后来每个 ML 框架都沿用了它。
在代码里,你通过任何张量或数组的 .dtype 属性读出它——每个框架都以同样的方式报告,即该格式的规范名称:
>>> import torch
>>> torch.tensor([0.5], dtype=torch.bfloat16).dtype
torch.bfloat16
>>> import numpy as np
>>> np.array([0.5], dtype=np.float16).dtype
dtype('float16')Hugging Face 直接从 safetensors 文件读取同样的信息——safetensors 是 Hugging Face 用于保存和分发训练好权重的文件格式,每个张量的 dtype 都存在其元数据里,侧边栏显示的「Tensor type」就是它。
侧边栏中的 BF16 意味着模型中的每个权重都是 bfloat16——「Brain Float 16」——一种由 Google 为 ML 设计的 16 位浮点格式。它是一种类浮点格式:与 IEEE-754 浮点数相同的符号/指数/尾数结构,只是被塞进 16 位而非 64 位,并且指数与尾数之间的划分自成一格。
我们会在文章后面仔细看它们的差异——包括为什么 bf16 与 IEEE-754 的 float16 尽管同为 16 位,却不是一回事。
所以计算模型内存占用的步骤与我们对 MNIST 所做的完全相同,只是数字更大。1.0B 这个数是模型所有层参数之和——正是我们为 MNIST 手算的那种逐层算术,只不过 Hugging Face 已经替我们走遍 safetensors 文件里的每个张量并把元素数加了起来。
于是模型卡说有 10 亿参数(「B」是 billion,即 的简写)。乘以每个 bf16 权重 2 字节,我们得到模型的权重占用:
这是推理时占主导的内存开销:那里激活是瞬时的(前向传播中一次只有一层的量驻留在内存里),而 KV 缓存若存在,在典型负载下通常也小于权重。训练则是另一回事——同一个模型需要权重加梯度、优化器状态,以及为反向传播缓存的激活——我们会在下面专门的一节里量化它。
现在把同样的算术放大,赌注很快就变了。一个 70 亿参数的模型在 fp32 下占 28 GB——在 24 GB 的 RTX 4090 上根本装不下,在 32 GB 的 RTX 5090 上也只剩勉强 4 GB 余量(不足以放激活和 KV 缓存)。在 fp16 下 14 GB,两张卡都能从容装下;int8 的 7 GB 还能富余。一个 70B 模型在 fp16 下是 140 GB——没有任何一张 GPU 装得下,而格式的选择开始决定你需要一张 GPU、两张,还是八张。
尽管训练框架里的默认格式是 fp32,模型往往以更窄的格式发布:团队会例行地用训练时的一部分精度,去换取更小的下载体积和推理时更低的显存开销。 这种交换有两种大的形式——降到 16 位浮点格式(fp16、bf16),或者更进一步走向量化,把权重映射到一小组离散级别上,通常是 8 位或 4 位。 如果作者把 PaddleOCR-VL 以 fp32 发布,同一个模型会占 4 GB。 他们选了 bf16,因为相对 fp32 它把内存减半而实际上几乎没有质量损失——这个选择为什么成立,我们会在文章余下部分讲清楚。
网络里并非每个数字都是权重
模型权重并不是运行中的网络里唯一的数字。完整的图景包含几个不同的类别——每一类都消耗内存,每一类都可以自由选择自己的格式。关于每一类从何而来——权重与偏置、前向传播中的层激活、反向传播中的梯度,以及优化器的更新步——你可以在讲网络如何学习的那篇文章里了解更多。
其中两类是通用的:无论你是在推理还是训练,权重和激活都存在。权重就是训练好的参数本身——历史上是 fp32,如今是 bf16 或 fp16,或者为大规模推理量化到 8 位、4 位。激活是前向传播中每层的中间输出——推理时是瞬时的(一次只有一层的输出活在内存里,因为每层的输入就是上一层的输出),训练时则要为反向传播缓存(每层的输出都必须活着,好让反向传播复用),并且通常与权重存成同一种格式。
推理时,基于 Transformer 的 LLM 还会带一份 KV 缓存——自回归生成过程中缓存下来的历史 token 的键和值,这样注意力就不必为每个新 token 重算它们。为了在长上下文中省内存,常保持在 fp16 或 fp8。
训练期间,还有两个类别进入画面:
- 梯度——反向传播中为更新权重而计算的导数。跨越许多个数量级(尤其是那些很小的量),所以这里范围至关重要,也正因如此 bf16(以及后来的 fp8 E5M2)在这上面获胜。
- 优化器状态——Adam 为每个参数保留两个动量,也就是参数量的 2 倍。即便权重存在 bf16 里,它也几乎总是 fp32,因为微小的漂移会在成千上万个训练步中累积。
在代码里,dtype 出现在几个特定的地方。 下面是我们训练 MNIST 那篇文章里的相关片段:
# Input data — explicitly cast to float32
X_train = train_images.reshape(-1, 784).astype("float32") / 255.0
# Model — Dense layers default to fp32 for weights and biases (no dtype= passed)
model = keras.Sequential([
keras.layers.Dense(128, activation="relu", input_shape=(784,)),
keras.layers.Dense(10, activation="softmax"),
])
# Optimizer — owns its own state, allocated separately
model.compile(
optimizer=keras.optimizers.SGD(learning_rate=0.1),
loss="sparse_categorical_crossentropy",
metrics=["accuracy"],
)三行不同的代码,三个不同的类别。.astype("float32") 设定输入数据的 dtype,随后随着数值向前流过各层传播进激活。Dense(...) 构造函数并不要求 dtype= 参数——Keras 对权重和偏置默认取 fp32。要覆盖这个默认值,你得显式传入 dtype=:
# Same architecture, but weights and biases stored in bf16
model = keras.Sequential([
keras.layers.Dense(128, activation="relu", input_shape=(784,), dtype="bfloat16"),
keras.layers.Dense(10, activation="softmax", dtype="bfloat16"),
])# PyTorch equivalent — nn.Linear takes the dtype directly
model = nn.Sequential(
nn.Linear(784, 128, dtype=torch.bfloat16),
nn.ReLU(),
nn.Linear(128, 10, dtype=torch.bfloat16),
)两种写法都只覆盖权重和偏置的 dtype——它们对激活、梯度或优化器状态毫无影响。优化器是独立构造的,并且拥有其优化器状态的 dtype:普通 SGD 没有状态,而 Adam 会为每个参数分配两个 fp32 动量。像 torch.amp.autocast 和 GradScaler 这样的混合精度封装把这些类别进一步解耦——例如在 bf16 中跑前向传播,同时把参数保持在 fp32。所以给某一层选 dtype= 只回答了一个问题:权重以什么格式存储。其余的都是独立决定的。
这就是人们说的混合精度训练与推理:同一个模型有意为不同角色使用不同格式,每种格式都按其数值需求挑选。 在文章余下部分,当我们说「模型是 bf16」时,通常意思是权重在 bf16 里。其他角色可能高于或低于它。
要估算训练时实际的内存账单,你需要把每个参数的成本叠起来。标准的混合精度配方——bf16 权重加 fp32 的梯度下降优化器(最常见的是 Adam)——在不算激活之前,折算下来是每个参数 16 字节:
- bf16 权重:2 字节
- bf16 梯度:2 字节(与权重同形状)
- fp32 Adam 动量:8 字节(两个动量 × 各 4 字节)
- fp32 主权重副本(由优化器保留以保证更新稳定):4 字节
注意这个倍数。推理时每个权重花 2 字节;训练把每参数成本膨胀到 16 字节——多了 8 倍——这还没算激活。光是优化器状态(fp32 动量)本身就是权重占用的 4 倍。再叠上激活,其大小取决于批大小、序列长度和架构,很容易让总量再翻一番。
所以一个在 bf16 推理下只需约 14 GB 的 70 亿参数模型,按这个配方训练需要约 112 GB 外加激活——大约是服务同一模型所需内存的 8–10 倍。 正是这个差距,使得一张 A100(80 GB)可以从容服务一个 7B 模型,却无法在不把优化器状态分片到多张 GPU 的情况下训练它(诸如 ZeRO 和 FSDP 这类技术)。
默认的浮点数:你所知的每种语言里都是 64 位
在看 ML 实际使用的数值格式之前,先从我们每天在 Python 和 JavaScript 里用的熟悉数字类型说起会有帮助:
| 语言 | 种类 | 类型 | 说明 |
|---|---|---|---|
| Python | 整数(无小数,精确) | int | 任意精度。CPython 按需扩展底层存储,所以 2**1000 就能直接工作,且结果精确。 |
| 浮点(64 位 IEEE-754 双精度) | float | 正是我们要在文章余下部分拆解的那种格式。Python 脚本里的字面量 0.5 是 8 字节。 | |
| JavaScript | 整数(无小数,精确) | BigInt | 任意精度整数,作为独立类型后来才加入。写法带 n 后缀(例如 5n)。最接近 Python int 的等价物。 |
| 浮点(64 位 IEEE-754 双精度) | Number | 与 Python 的 float 是同一种格式。JavaScript 用它同时表示整数和小数;默认没有单独的整数类型。 |
这里重要的是浮点那两行。Python 的 float 和 JavaScript 的 Number 是完全相同的 64 位 IEEE-754 双精度——同样的格式、同样的硬件 FPU 做运算,所以它们在比特层面表现一致:0.1 的二进制相同,0.1 + 0.2 = 0.30000000000000004 相同,NaN 行为相同,溢出到无穷也相同。
这 64 位如何划分为符号、指数和尾数,为什么指数以偏移而非补码存储,以及 0.1 如何舍入——这些机制我在本系列前面的文章里详细讲过。那里的一切都直接适用于这两种语言,因为底层是同一种 64 位格式。
对本文余下部分而言,重要的一点是:这两种语言都不允许你在语言层面要一个 32 位浮点数。值 0.5 占 64 位,而不是 32 位。对日常算术这没问题,但 ML 需要一份宽得多、也专门得多的数值类型菜单——这正是 NumPy、PyTorch、TensorFlow、JAX 这类框架各自暴露 dtype 体系的原因,其中有 bf16、fp16、int8、int4 这些格式。
转换如何丢比特:从语言浮点到 ML 的 dtype
每当一个值从一种数值格式转到另一种——Python 的 float 到 int,float 到 int8,fp64 到 fp16——都有可能装不下这些比特。总得有东西让步,而丢失的比特去了哪里,取决于源格式和目标格式。
最简单的情况是 float → int:把小数点之后的一切都丢掉。
>>> int(3.7)
3
>>> int(-3.7)
-3 # truncates toward zero — drops the fractional bits
>>> import math
>>> math.floor(-3.7)
-4 # floor rounds toward -infinity instead机制上,硬件读取浮点数的指数,把尾数移位使小数点落在整数位与小数位之间,然后只读整数那一侧。小数位在物理上被丢弃。
举个例子,取 5.75。二进制是 101.11——小数点前三位(101 = 4 + 1 = 5),小数点后两位(.11 = 0.5 + 0.25 = 0.75)。不过 IEEE-754 并不这样存储;它把数字规格化,让小数点前恰好只有一个 1,并把移位量记在指数里:
101.11 → 1.0111 × 2^2
^^^ ^^ ^ ^^^^ ^
│ │ │ │ └── exponent: how far to shift the radix back
│ │ │ └───────── mantissa (fractional part of the 1.xxx form)
│ │ └─────────── the implicit leading 1 (not stored)
│ └──────────────────── original fractional bits
└──────────────────────── original integer bits所以内存里的浮点数保存的是尾数 0111 和指数 2——而不是字面上的数字 101.11。要把它变回整数,硬件必须撤销规格化:取出尾数,在前面补上隐含的 1,再把小数点按指数右移,恢复原来的布局:
mantissa: 1.0111
shift by 2: 101.11
^^^ ^^
│ └── fractional bits → discarded
└────── integer bits → 101 = 5小数点右边的一切——那个 .11,十进制里是 0.75——被扔掉,于是 int(5.75) 返回 5。注意没有发生舍入:小数位并不会被检查以决定是否把整数进位,它们只是被丢掉。这就是上面代码里 int(-3.7) 给出 -3 而不是 -4 的原因——截断朝零方向切,不管被丢掉的比特实际是什么。
当你从语言浮点转到更窄的 ML dtype 时,同样的机制在起作用。PyTorch、NumPy、TensorFlow、JAX 都在 tensor(...) / array(...) 内部完成转换:
>>> torch.tensor([3.7, -3.7], dtype=torch.int8) # float → int, truncates toward zero
tensor([3, -3], dtype=torch.int8) # → fractional bits dropped on each element
>>> torch.tensor([1000, 1001], dtype=torch.int8) # int → int8, overflows (int8 max is 127)
tensor([-24, -23], dtype=torch.int8) # → low 8 bits kept, neighbours wrap together
>>> torch.tensor([1e5, -1e10], dtype=torch.float16) # fp64 → fp16, overflows (fp16 max is ~65504)
tensor([inf, -inf], dtype=torch.float16) # → saturate to ±inf; magnitudes are lost
>>> torch.tensor([2**24 + 1, 2**24 + 2], dtype=torch.float32) # int → fp32, exceeds mantissa precision
tensor([16777216., 16777218.], dtype=torch.float32) # → +1 rounds away (24-bit mantissa); +2 is exact这四行涵盖了任何数值类型转换所归属的几种典型模式。每次的底层规则都一样——源格式持有目标格式留不住的比特——但硬件有四种不同反应,取决于哪个字段溢出:
| 模式 | 从 | 到 | 例子 | 结果 |
|---|---|---|---|---|
| 截断 | float | int | 3.7 → int8 | 3 |
| 回绕 | int | 更窄的 int | 1000 → int8 | -24 |
| 饱和到无穷 | float | 更窄的 float | 1e5 → fp16 | inf |
| 精度损失 | int | float | 2^24 + 1 → fp32 | 2^24 |
每一行都值得细看——它们的机制有着有趣的差异。
截断。 3.7 → int8 保留 3。无论小数部分多大,都朝零方向丢弃,所以 3.01、3.5、3.999 全都落到 3。负值同样朝零切:-3.999 变成 -3,而不是 -4。这与我们上面走过的 int(5.75) 是同一个机制——硬件撤销规格化,然后连看都不看地丢掉小数点右边的一切。
回绕。 1000 → int8 得到 -24。int8 用 8 位补码覆盖 ,所以当一个值装不下时,只有低 8 位存活并按该有符号编码重新解释——这让 1000 落到 -24。邻近的值如 1001 回绕到 -23,而 -129 回绕到 +127——数轴每 256 步就弯回自身,所以 int8 把这个范围当作一个环,而不是一条线。
饱和到无穷。 1e5 → fp16 变成 inf,-1e10 变成 -inf。fp16 的指数字段只有 5 位宽,它能编码的最大有限值是 65,504(1.1111111111 × 2^{15})。值 100,000 需要 2^{16} 的指数,而 fp16 没有对应的比特模式——于是 IEEE-754 做了它唯一能做的事:返回 +inf(符号位决定是 +inf 还是 -inf,其余比特模式相同)。与整数回绕不同,浮点溢出不循环;它饱和。1e5、1e10、1e38 全都塌缩到同一个 +inf 比特模式,而 -1e5、-1e10、-1e38 塌缩到同一个 -inf——转换之后没有任何东西能区分它们。
精度损失。 2^24 + 1 → fp32 变成 2^24。fp32 的尾数是 24 位(23 位显式 + 1 位隐含),所以从 0 到 2^24(16,777,216)的每个整数都能精确表示,但超过之后,可表示值之间的间距每经过一个 2 的幂就翻倍。整数 2^24 + 1 落在两个可表示的 fp32 值之间,被舍入到较近的那个(2^24,因为 +1 恰好在正中间,而平局向偶数舍入)。这与 fp64 从 2^53 ≈ 9 × 10^15 开始丢失整数精度是同一个故事——JavaScript 的 Number.MAX_SAFE_INTEGER 正好是 2^53 − 1。
这就是本文余下部分每一次转换的通则:装不进目标格式的比特总得有个去处——被截断、被舍入、被回绕,或者被冲进无穷——而这四者中发生哪一个,由源格式与目标格式决定,而不是由数值决定。
走出语言的默认设定
ML 生态提供的菜单比 Python 或 JavaScript 原生所提供的宽得多。每个主流数值框架——NumPy、PyTorch、JAX 和 TensorFlow——都允许你显式选择 dtype。下面是同一操作在四个框架里的写法:
import torch
x = torch.tensor([0.5]) # fp32 by default — 4 bytes
y = torch.tensor([0.5], dtype=torch.float16) # fp16 — 2 bytes
z = torch.tensor([0.5], dtype=torch.bfloat16) # bf16 — 2 bytes
w = torch.tensor([0.5], dtype=torch.float64) # fp64 — 8 bytes, same as Python floatimport tensorflow as tf
x = tf.constant([0.5]) # fp32 by default — 4 bytes
y = tf.constant([0.5], dtype=tf.float16) # fp16 — 2 bytes
z = tf.constant([0.5], dtype=tf.bfloat16) # bf16 — 2 bytes
w = tf.constant([0.5], dtype=tf.float64) # fp64 — 8 bytesimport numpy as np
x = np.array([0.5], dtype=np.float32) # 4 bytes per element
y = np.array([0.5], dtype=np.float16) # 2 bytes
# NumPy core has no bfloat16 — you need the `ml_dtypes` package, or JAX/TF/Torch arraysimport jax.numpy as jnp
x = jnp.array([0.5], dtype=jnp.bfloat16) # bf16 — 2 bytes这些 API 在外观上不同(torch.tensor(...) 对 tf.constant(...) 对 np.array(...)),但契约是一样的:你传入一个数值和一个 dtype,框架就把每个元素按恰好那么多字节打包进一段连续缓冲区。NumPy 在 ML 时代的 dtype 上稍显落后——它的核心类型里没有 bfloat16,因为 bfloat16 是 Google 为 TPU 引入的,并先通过其他框架标准化。PyTorch、JAX 和 TensorFlow 都原生支持它。
就 Python 而言,字面量 0.5 仍然是 64 位双精度——转换发生在构造张量的时候。在内部,每个元素依 dtype 占 8、4、2 或 2 字节。到 fp8 就是 1 字节;到 int4 则是半个字节。
要看清这些字节究竟编码了什么——以及为什么把它们减半并不会简单地把可表示范围也减半——我们得看看底层的格式。
ML 格式家族,以及 bf16 为何胜出
现代 ML 使用少数几种浮点格式,它们全都共享继承自 IEEE-754 的同一种三字段布局:
- 符号(1 位)——正或负。
- 指数——小数点要移动多远,以偏移二进制存储。
- 尾数(有效数字)——数字在规格化科学计数形式下的前导数位,隐含的前导
1被省略。
它们还整体继承了 IEEE-754 的各种约定——隐含前导 1、带偏移的指数、±0 / ±inf / NaN / 次正规数的特殊比特模式、默认的「就近舍入、平局取偶」规则——以及各字段扮演的角色:指数位买来的是范围(能表示多大或多小的数),尾数位买来的是精度(能多细地区分量级相近的数)。
无论宽度如何,这一映射在每种 IEEE-754 浮点上都是恒定的。 下面四种格式之间真正变化的,是比特在这两个字段之间如何分配——而这个分配就是全部的设计抉择。 四者中的三个——fp64、fp32 和 fp16——完全符合 IEEE-754 标准。 bf16 是个异类:由 Google 为 TPU 设计,从未经 IEEE 标准化,但通篇建立在同样的约定之上:
| 格式 | 总位数 | 符号 | 指数 | 尾数 | 字节/参数 |
|---|---|---|---|---|---|
| fp64 (IEEE-754 double) | 64 | 1 | 11 | 52 | 8 |
| fp32 (IEEE-754 single) | 32 | 1 | 8 | 23 | 4 |
| bf16 (Brain Float 16) | 16 | 1 | 8 | 7 | 2 |
| fp16 (IEEE-754 binary16) | 16 | 1 | 5 | 10 | 2 |
那两行 16 位的才是有意思的——同样的总比特预算,相反的划分。拖动下面的滑块来感受这种取舍:每往指数里挪一位,可达范围就翻倍,精度就减半,反之亦然。fp16 和 bf16 预设会吸附到真实的格式选择上。
[1, 4) (128 per octave; gap doubles at 2)把比特挪进指数,可表示的最大值增长得非常快——但每个八度内的间隔也同步增长(在 里是 ,在 里是 ,依此类推,每个八度翻一倍),于是网格处处变粗。
把比特挪进尾数,间隔收缩而最大值崩塌。fp16 和 bf16 预设正落在 16 位下这一取舍的两端:bf16 触及 ~10^38,在 1 附近的间隔约为 ~0.008;fp16 止步于 ~65,504,间隔约为 ~10^-3。
组件底部的条带让间隔可见:选中 bf16 时,你能看到分散在 [1, 2] 上的一根根刻度——那些间隔就是该格式在单位尺度上的精度边界。切到 fp16,刻度融成一条实线,因为间距已降到亚像素。网格依然是离散的;只是密到你再也看不出离散性——而这正是这里「更高精度」的含义。
条带还延伸到下一个八度 [2, 4)(越过 2 处的虚线标记),以便说明这种间隔翻倍并非 [1, 2) 区间独有——同样数量的刻度(每个尾数状态一根)被摊到宽度两倍的区间上,所以右半边的视觉间距是左半边的两倍。把滑块拖到低尾数设置(例如 M=4),翻倍就变得一目了然。
为了具体起见,下面是每个预设在 [1, 2] 中最初几个可表示的值:
| bf16(128 个值,间隔 = 1/128) | fp16(1,024 个值,间隔 = 1/1024) |
|---|---|
1.0 | 1.0 |
1.0078125 | 1.0009765625 |
1.015625 | 1.001953125 |
1.0234375 | 1.0029296875 |
1.03125 | 1.00390625 |
| … | … |
1.9921875 | 1.9990234375 |
(2.0) | (2.0) |
bf16 的每个后继值都恰好落在 fp16 的每第 8 个值上——在这个八度里,bf16 的网格是 fp16 网格的严格子集,只是每八根刻度去掉了七根。这个比值恰好是 (fp16 有 10 位尾数,bf16 有 7 位)——每多一位尾数,每个八度内可表示值的数量就翻倍。
这种均匀间隔只在 [1, 2) 这样的单个八度内成立——那是任何正值下网格最密的部分,也正是组件中「[1, 2) 内间隔」这项统计所报告的。
一旦跨进 [2, 4),指数加 1,尾数步长乘以 2,间隔翻倍。
下面是 bf16 在几个八度上的情况:
| bf16 八度 | 间隔 |
|---|---|
[1, 2) | 1/128 ≈ 0.0078 |
[2, 4) | 2/128 = 1/64 ≈ 0.0156 |
[4, 8) | 4/128 = 1/32 ≈ 0.0313 |
| … | … |
[1024, 2048) | 1024/128 = 8 |
所以 bf16 用精度换范围——它能在两个方向上触及极端量级(小的一侧到 这样的微小分数,大的一侧到 ),但会把相邻的值粗糙地舍到一起。fp16 则是镜像——它能分辨相近值之间的细微差别,却在两个方向的极端上溢出。正是这个取舍让两种 16 位格式在现代 ML 中共存:bf16 用于训练(梯度跨越许多数量级,范围要紧),fp16 用于某些推理场景(当数值有界时,精度更要紧)。
bf16 的比特为什么这样划分
组件展示了 bf16 和 fp16 是同一个 16 位预算的相反划分。但 bf16 的设计并不是对 fp16 的回应——它是对 fp32 的回应。 Google 为 TPU 设计 bf16 时围绕的是一个问题——如何在不丢失 fp32 范围的前提下把它的内存开销减半——并给出了一个简单答案。 把 8 位指数原封不动保留(同样的偏移 127,与 fp32 逐位一致),把尾数从 23 位砍到 7 位。 这个格式很快扩散到 TPU 之外:NVIDIA Ampere(A100,2020)、AMD CDNA、Intel CPU(AVX-512 BF16)和 ARM Armv8.6-A 都加入了原生 bf16 支持——它是现代硬件上默认的 ML 16 位格式。
所以 bf16 本质上就是被挖掉尾数的 fp32——与 fp32 同样的范围,大致从 到 ,只是网格更粗。每一位尾数都会把相邻可表示值之间的间距减半,因此去掉 16 位就把间距翻倍了 16 次——在任意给定指数下粗了 倍。于是像 1.005 这样的权重在每种格式里存得都不一样:
| 格式 | 存储的值 | 误差 |
|---|---|---|
| fp32 | 1.005(实际上精确) | ~10⁻⁸ |
| fp16 | 1.0048828125 | ~1.2 × 10⁻⁴ |
| bf16 | 1.0078125 | ~2.8 × 10⁻³ |
误差更低意味着存储的值离原值更近——所以 fp16 在这里精度胜出(误差约比 bf16 低 24 倍),但在范围上落败。
一个接近 1e-7 的梯度在 fp16 里会下溢,而在 bf16 里能活下来。
ML 对数值格式有两种失败模式,而它们的糟糕程度并不相等。范围失败(上溢/下溢)是灾难性的——消失的梯度会让该参数的训练彻底停滞;溢出的激活产生 NaN 并杀掉整次运行。精度失败则可以忍受——每个权重上的微小舍入误差会在网络的数百万权重上被抹平(原因见下)。bf16 失败在可忍受的那条轴上;fp16 失败在灾难性的那条上——这正是尽管 bf16 在单权重精度上落败,ML 仍偏爱它的原因。
要在范围受限的情况下仍用 fp16 训练,你需要一个叫损失缩放的变通办法。在反向传播之前把损失乘以一个常数 ——梯度对损失是线性的,所以它们都会以同一个 被放大回来,落入 fp16 的范围——然后在应用更新之前再除以 。没有它,一个 1e-7 的梯度会下溢为零,那个参数就完全得不到更新。PyTorch 的 torch.cuda.amp.GradScaler 会自动处理这套记账。bf16 则跳过了这一切:它的范围与 fp32 相同,所以梯度天然装得下。
每一种位宽上的同一个取舍
我们在前面几节看到的——16 位下 bf16 对 fp16——只是一个更广泛模式的一个实例:格式越窄,取舍越尖锐,同一位宽存在多个变体的可能性也越大。fp8(8 位)紧到没有任何一种划分能全面胜出,于是它以两个变体面世——E4M3(更高精度,用于权重)和 E5M2(更大范围,用于梯度)——真实流水线会把它们并排使用。
bf16 的指数与 fp32 对齐还带来一个实际红利:fp32 ↔ bf16 的转换基本免费。 同样的指数字段、同样的偏移 127;你把尾数低 16 位丢掉就完事了。硬件用一次移位就能做到。相比之下,fp16 的转换可能真的会上溢或下溢,因为它的指数范围不同。混合精度流水线在 fp32 与 bf16 之间流动得很顺畅;fp32 ↔ fp16 则需要小心地做缩放。
fp16 仍然活跃于缺乏 bf16 支持的老 GPU 上的推理,以及某些多出来的尾数位值得忍受范围麻烦的部署场景。但对训练而言,bf16 实际上已经取代了它。
每种格式都有自己的硅
数值格式不过是一种比特布局——速度优势来自原生地对这种布局做乘加的专用硅。没有原生实现该格式的硅,软件仍然能存储它,但每次算术运算都会退回到更宽的类型,而当初驱使人们采用更窄格式的吞吐优势也就消失了。
如今每一种被广泛使用的 ML 格式,都可以追溯到某代芯片上的某个具体执行单元:
| 格式 | 在哪里原生运行 |
|---|---|
| fp64 / fp32 | 每个 CPU 和 GPU 上的通用 FPU。通用,但对张量运算而言慢。 |
| fp16 | NVIDIA 张量核心,自 Volta(V100,2017)起;其他厂商随后跟进。 |
| int8 | NVIDIA Turing 张量核心(T4,2018);如今基本每个现代加速器都有。 |
| bf16 | Google TPU v2(2017)、NVIDIA Ampere(A100,2020)、Intel AVX-512 BF16、Arm Armv8.6-A BF16。 |
| fp8 (E4M3, E5M2) | NVIDIA Hopper(H100,2022)、AMD CDNA 3(MI300)、Intel Gaudi 2/3。 |
| int4 | Hopper 时代及更新的张量核心。 |
每家厂商对这些模块都有自己的叫法——NVIDIA Tensor Cores、AMD Matrix Cores、Intel Gaudi Matrix Math Engines(MME)、Google Matrix Multiply Units(MXU)——但想法是一样的:一块专用的硅,对低精度数字的小块执行融合乘加(一步完成 a × b + c 并只舍入一次),通常达到通用 FPU 的 2–16 倍吞吐(且能耗更低)。
从中要带走两点。内存节省在任何 GPU 上都会发生——不论硬件如何,bf16 模型都是 fp32 模型的一半大小。加速只有在配套的硅存在时才发生——2–16 倍的吞吐倍数正来自那里。bf16 模型在 A100 上全速运行;在 V100(只有 fp16)上,它要么需要提前转换,要么需要逐算子的软件转换,而后者一开始就抵消了速度收益。fp8 在任何早于 H100 的硬件上也是同样的故事。所以选择更窄的格式总能在内存中缩小模型,并且当芯片拥有该格式的张量核心(或等价物)时,还能缩短运行时间。
这里还有一个时间维度:格式的采用滞后于硬件。新的布局下周就能在纸上提出,但要等到带原生支持的一代芯片出货——通常是两到四年后——才会被大规模使用。这就是为什么现代加速器不断堆叠单元(fp16 → bf16 → fp8 → fp4)而不是替换它们:每一代都为下一种格式加上硅,同时保留之前的格式以向后兼容。
实践中这意味着部署规划要从模型卡上的张量类型开始。在挑 GPU 之前,先看权重是以什么格式发布的(fp32、bf16、fp16、fp8、int8、int4——Hugging Face 在我们前面看到的侧边栏里就展示了这一点),并把它与你拥有或能租到的硅对上号:bf16 需要 Ampere 或更新,fp8 需要 Hopper 或 MI300 或 Gaudi 2/3,int4 需要 Hopper 时代的张量核心。两者对不上,你要么付出软件转换的税、把格式的速度优势扔掉,要么根本无法全速运行这个模型。
展望:fp8
在最前沿,16 位正在让位给 8 位。fp8 已作为原生硬件格式登场——NVIDIA H100、AMD MI300 和 Intel Gaudi 2/3 都随附了专用的张量核心支持——而且像 fp16 与 bf16 之争一样,在这么紧的预算里它无法定于一种划分,因此有两个变体:
| 格式 | 符号 | 指数 | 尾数 | 用于 |
|---|---|---|---|---|
| fp8 E4M3 | 1 | 4 | 3 | 前向传播的权重与激活(偏向精度) |
| fp8 E5M2 | 1 | 5 | 2 | 梯度(偏向范围) |
两者都通过开放计算项目(Open Compute Project)的 FP8 Formats for Deep Learning 规范完成标准化,该规范由 NVIDIA、Intel 和 Arm 共同撰写。
两个变体共享硬件:支持 fp8 的张量核心(NVIDIA H100、AMD MI300、Intel Gaudi 2/3)根据逐操作的模式标志,把同样的 8 位解码为 E4M3 或 E5M2——并不存在各自独立的硅。一个训练步通常对前向传播的权重和激活使用 E4M3(偏向精度,因为这些值受网络设计约束而有界),对反向传播的梯度使用 E5M2(偏向范围,因为梯度量级跨越许多数量级)。为了补偿 fp8 极小的动态范围,每个张量都带一个逐张量缩放因子——通常根据观察到的数值分布动态计算——把它的实际范围映射进 fp8 的可表示窗口。代码如下:
import torch
w_fp32 = torch.randn(1024, 1024, dtype=torch.float32) * 0.1
# Per-tensor scale: |max| / fp8_max maps the tensor's range into fp8's
FP8_E4M3_MAX = 448.0
scale = w_fp32.abs().max() / FP8_E4M3_MAX
# Quantize: divide by scale, then cast to fp8
w_fp8 = (w_fp32 / scale).to(torch.float8_e4m3fn)
# Reconstruct on read: cast back, multiply by scale
w_back = w_fp8.to(torch.float32) * scale更高层的库(NVIDIA 的 Transformer Engine、PyTorch torch.amp 的 fp8 路径)会自动封装这套缩放——te.fp8_autocast(enabled=True) 会逐张量跟踪 amax 并计算缩放因子,无需调用方自己算。
调整滑块或选择一个预设来设定张量的最大值:绿色刻度是 fp8 的可表示值乘以逐张量缩放因子后的位置,蓝色圆点是所选范围内「张量数据」的一份采样。随着张量最大值上下移动,缩放因子随之变化,好让绿色网格始终铺满数据——fp8 原生的 ±448 网格还是同一套硬件,只是缩放因子重新标注了它的坐标轴,使网格点落在数据真正所在之处。主权重保持在 fp32,优化器以更高精度运行,而权重在每次前向传播时被下转为 E4M3。NVIDIA 的 Transformer Engine、PyTorch 的 torch.float8_e4m3fn / torch.float8_e5m2,以及 JAX 都实现了这一配方。推理使用同样的硬件但跳过反向传播——权重和激活都待在 E4M3 中,逐张量的缩放因子在离线阶段标定并在部署时冻结。
今天大多数已部署的模型仍然是 bf16,或为推理量化到 int8,但 fp8 正是下一代训练与推理的走向——而我们在 fp16 与 bf16 之间看到的那个取舍(精度对范围),如今正在更低一档的位宽上重演。
为什么模型能容忍更少的比特
在 bf16 与 fp16 的选择、fp8 的划分,以及量化中即将登场的一切之下,是一个奠基性的论断:单个 ML 权重本身并不重要。浮点格式的设计前提是每个数字都自身要紧——流体力学仿真中的一个值、有限元求解器中的一个系数、几何算法中的一个坐标。ML 权重不是那样的。一个权重只是数百万个相互适配的、带噪声的值之一,它们的效果会在一层内被求和,而每个权重上的微小舍入误差会在这个求和中被冲刷掉。
这就是为什么对一个用 fp32 训练的模型来说,fp16 / bf16 推理实际上是免费的;为什么尽管每个单独权重明显不那么精确,int8 对多数负载几乎也是免费的;以及为什么这个趋势还在继续往下走。经验上,模型越大,容忍度越高:一个小 CNN 在 int4 下可能损失明显的准确率,而一个 70B 的 LLM 在 int4 下通常不会。
经验证据横跨近十年。第一波来自 CNN 压缩:Deep Compression(Han 等,2015)表明 CNN 权重可以量化到 8 位而准确率损失可忽略,再结合剪枝与霍夫曼编码,把 AlexNet 缩小了 35 倍而不损害性能。几年后,Mixed Precision Training(Micikevicius 等,2017)确立了「fp16 权重加 fp32 主副本」的配方,它成为训练时的规范默认——多数以此方式训练的模型相对完整 fp32 显示不出可测量的准确率差异。
LLM 时代把极限又推了一程。LLM.int8()(Dettmers 等,2022)与 GPTQ(Frantar 等,2022)证明了仅权重的 int8 与 int4 量化能为十亿参数级的 LLM 保住质量。QLoRA(Dettmers 等,2023)引入了 NF4——一种针对权重通常服从的正态分布调校过的 4 位非均匀格式——并用它在单张 48 GB GPU 上微调了 650 亿参数的模型。最令人惊讶的是,BitNet b1.58(2024)用三值权重训练 LLM——每个权重被限制在 {-1, 0, +1},约合每个权重 1.58 比特——在相同参数量下仍能匹配 fp16 基线。
贯穿这些工作的模式是一致的——存储格式与被存储之物的统计行为是两个分开的问题,而神经网络恰好在后者上很宽容。这种宽容,正是本文迄今每一节都在悄悄依赖的东西——而下一节会把它推得更狠。
量化是同一个想法,被推得更远
到目前为止的一切,都把每个权重存成一个自足的 IEEE-754 浮点数,带着自己的符号位、指数位和尾数位。量化使用了一种非常不同的存储方案——每个权重变成它自己整数空间里的一个小整数(int8 是 1 字节,int4 是 4 位),而一个单独的浮点缩放因子(每组权重保存一份)承担起过去由逐值指数完成的工作。
在进入机制之前,先看看量化究竟买到了什么:
- 内存——我们一路铺垫的头号收益。从 fp32 起算,int8 小 4 倍,int4 小 8 倍。在一个 70B 参数的模型上,这就是「需要 8 张 GPU」与「一张就装得下」的差别。
- 速度——更小的占用意味着推过内存层级的数据更少,而推理时间大多正是花在那里。在支持的硬件上(并且当部署真的以整数算术执行矩阵乘,而不是先反量化到 fp16 时),int8 的乘加在现代张量核心上也比 fp16 / bf16 的 MAC 更快。内存带宽的收益在两种部署下都适用;整数 MAC 的收益只在真正的整数矩阵乘中适用。
- 可部署性——移动 NPU、微控制器和边缘加速器往往只支持 int8,别无选择。它们的硅是为定点算术设计的,而不是完整的浮点。经过细致编码(把缩放因子本身存成一个 int32 乘数 + 一个右移位数,就像 TFLite 那样),整个矩阵乘可以完全用整数运算跑完,完全不碰 FPU。量化是在这类硬件上部署的唯一途径——没有它,模型在那儿根本跑不起来。
- 能耗——整数运算每次操作耗费的焦耳比浮点更少。这在电池供电设备(手机、IoT、端侧助手)和数据中心规模上都要紧,后者的功率预算才是吞吐的真正天花板。
如今在实践中,大多数生产环境的 ML 推理至少部分是量化的。自托管的 LLM(通过 llama.cpp、Ollama、LM Studio)几乎总是跑 int4 或 int8——受限于内存预算,完整的 bf16 推理在消费级硬件上很罕见。数据中心的 LLM 服务在规模上大量依赖 fp8 和 int8,常常把对离群值敏感的层保留在更高精度。边缘/移动端的神经网络部署(端侧语音、计算机视觉、传感器融合)基本上永远是 int8——硅根本不支持别的。纯 fp32 推理如今主要限于研究流程、科学计算,以及少数对准确率极为关键的生产端点。量化不是你以后也许会做的优化;对多数现代部署目标而言,它就是默认。
真正做量化有两条流程,取决于你在模型生命周期的哪个阶段做。它们共享同一套「吸附到网格」的算术,我们会在下一小节展开。
训练后量化(PTQ) 拿一个已经训练好的 fp32 / bf16 模型,在部署时量化它的权重,不再做任何训练——便宜(不需要 GPU 工时),也是当今 LLM 推理的标准流程。 LLM 上常见的 PTQ 算法是 GPTQ 和 AWQ——两者都建立在「吸附并缩放」的配方之上,再在其上叠加更聪明的机制。 实现这些算法的流行工具是 bitsandbytes(与 PyTorch 集成,被 Hugging Face Transformers 使用)和 llama.cpp(对 CPU 和消费级 GPU 友好,带有自己的 GGUF 量化格式)。
量化感知训练(QAT) 在训练过程中就模拟量化噪声——前向传播使用伪量化的权重,好让优化器学会补偿。更贵(需要训练流水线和带标签的数据集),但在极端位宽(int4 及以下)下能给出更好的准确率。QAT 在生产环境的 CNN 部署(移动、边缘)中是标准做法,在 LLM 上因训练成本而较少见——不过像 LLM-QAT 这样面向 LLM 的变体,用无数据蒸馏扩展了这一技术。
不过任一流程都有天花板——在 int8 上能干净地互相抵消的舍入误差,到了低得多的位宽就不再干净地抵消了。int4 权重量化会让一些重推理的任务退化。同样的压缩压力还延伸到权重之外——长上下文下的 KV 缓存量化本身就是一个活跃的研究方向,那里像 TurboQuant 中的 3 比特方案这样的极端格式之所以可行,是因为它重排了问题(量化前先把向量旋转到一个已知分布),使残余的舍入误差在信息论意义上接近最优。
「吸附并缩放」配方
当一个模型被量化时,原始的 fp32 权重被丢弃,取而代之的是 (int8 索引, 缩放因子 S) 对——每个权重一个索引,每组一个缩放因子。所以权重不再以连续浮点数存储;量化把它们吸附到一个小而固定的离散级别集合上——int8 有 256 个(对称 int8 是 255 个),int4 有 16 个。
而这些级别并不通用——它们是按每组权重、从数据本身构建出来的。
不过这个被存储的整数并不是权重本身——它是由 定义的一张浮点位置网格上的索引。
反量化过程恢复出原始小数值的一个近似:推理时,索引 × S 把它重建出来。在现代硬件上,这个重建是即时的——被融合进矩阵乘、从不在内存中物化——所以网络直接在压缩表示上工作,只在需要的那一刻才恢复出近似的 fp32 值。
那么假设我们挑了一组五个权重一起量化:
这个过程有两步,下面的组件让你可以逐步点击。宏观来看:首先,我们为整组计算一个缩放因子 ——它给出所有权重将要吸附到的网格。然后,对每个权重执行真正的压缩——吸附到最近的网格点(平局用就近取偶)并存下整数索引。第 1 步每组跑一次;第 2 步每个权重跑一次。
要定义这个离散集合,先看这组权重的取值范围,并从数据中算出缩放因子 。下面是对称 比特量化的通用公式:
分母是 ——对称 比特有符号整数中绝对值最大的索引。int8 是 ;int4 是 ;int2 是 。
分子是该组中绝对值最大的权重:,其中 是你一起量化的那 个权重。
所以这个设计就是:用绝对值最大的权重除以绝对值最大的索引。这保证了最极端的权重恰好映射到网格点 ——不截断,网格利用充分。
对上面五个示例权重与 int8(,因此分母 = ):
注意 同时扮演两个角色:它是乘数(索引 × S 恢复出 fp32 值),也是网格间距(相邻索引相差 1,因此相邻网格位置正好相差 )。例如索引 41 解码为 41 × 0.0748 ≈ 3.067,索引 42 解码为 42 × 0.0748 ≈ 3.142——它们之间的间隔正是 0.0748,也就是 。同一个数,两种含义。
有了 ,网格便直接随之而来:每个网格点都是 索引 × S,索引取从 -127 到 +127 的整数:
代入我们的 ——每个网格点是 索引 × 0.0748:
要把每个权重吸附到最近的网格点上,请记得第 1 步只是建好了网格;真正的逐权重压缩发生在这里。对组内每个权重 ,计算它的索引:
这给出最近网格点的整数索引——与二进制舍入那篇文章里的就近舍入机制相同:挑最近的可表示值,平局取偶。唯一的区别是可表示值的集合如今小得多,而且是显式枚举出来的。存下这个 1 字节整数(int4 则是 4 位);共享的 随行同存,每组只存一次,因此它的成本会被摊到组内每个权重上。对我们五个权重、:
| 权重 | 存储(int8 值) | ||
|---|---|---|---|
3.1416 | 42.0 | 42 | 42 |
-1.7 | -22.7 | -23 | -23 |
0.0234 | 0.31 | 0 | 0 |
1.5 | 20.05 | 20 | 20 |
-9.5 | -127.0 | -127 | -127 |
五个 fp32 权重(20 字节)变成五个 int8 值加一个 fp32 缩放因子(共 9 字节)——小了 2.2 倍,而且组越大,节省越多。
下面的组件把这个循环做成可点击的,用的就是同样这五个权重——挑一个,看量化、吸附、反量化端到端跑一遍:
挑一个权重,看看在上述公式所用的同样五个值上,完整的「量化—吸附—反量化」循环。绿色刻度是 int8 网格(对 −127 到 +127 的每个索引取 索引 × S)。蓝色圆点是原始 fp32 权重;红色标记是它吸附到的网格点。流程行展示第 1 步和第 2 步的算术:除以 ,就近舍入到整数(这才是被存下来的东西),读取时再乘回 。于是 3.1416 / 0.0748 ≈ 42.0 舍入为 42,重建为 42 × 0.0748 ≈ 3.142——几乎一致。试试 0.0234,看看一个极小的权重下溢到网格点 0 时会发生什么。
值得一提:这里有两个平行的集合在起作用。索引空间 才是真正存在内存里的东西——纯整数,每个 1 字节。取值空间 才是网络在反量化之后当作权重的东西——由每个索引乘以 得到的 fp32 数。同一个集合,被缩放过。整数是廉价的寻址系统;实值网格才是网络真正拿来计算的东西。
要重建一个权重,硬件读出那个小整数并施加逆运算:
其中 是存储的整数, 是浮点缩放因子。不同的权重组拿到不同的 ——这就是为什么同一个 int8 值 42 在两个不同的通道里可以解码出两个不同的实数。
注意这里没有发生什么:并不存在像 IEEE-754 转换那样「重新组装」符号/指数/尾数字段的算法。重建只是两条标准指令——一次 int 到 fp32 的转换(42 → 42.0)和一次 fp32 乘法(42.0 × 0.0748 ≈ 3.142)。我们拿回来的 fp32 尾数是在乘法时构造出来的,并没有存在任何地方——压缩后的权重不过是缩放因子的一个系数。
重建出的值与原值相似但不相同——这就是公式用 ≈ 而不是 = 的原因。就近舍入让任何权重距离其原位置最多偏 S/2,所以每个重建值都落在起点的半个网格间距之内。对于本身就小于 S/2 的权重,这个误差可以是整个权重(它们被舍到 0)。量化背后的全部赌注在于:这些逐权重的误差足够小、足够不相关,以至于能在数百万权重上被平均掉——正如我们在为什么模型能容忍更少的比特一节所见。
它实际在哪里运行
实践中,真实部署会沿这套算术走两条路径之一:
- 真正的整数矩阵乘:权重和激活都被量化。矩阵乘完全在整数算术中进行,缩放因子只在最后重新施加。这是 CNN 部署和数据中心 int8 服务常用的路径。
- 仅权重量化:只有权重被量化;激活保持 fp16/bf16。权重在矩阵乘内部即时反量化,随后矩阵乘以浮点执行。这是 GPU 上 LLM 推理的主流流程。
两条路径使用同样的重建数学(q × S);它们的差别在于反量化发生在哪里——一次性发生在矩阵乘的输出端(路径 1),还是即时发生在 GEMM 内核内部(路径 2)。无论哪种,存在内存里的都是压缩后的 (int8, S) 表示;fp32 值只在算术需要时才被重建。
选择粒度
我们一直说「每组一个缩放因子」——但究竟什么算一组,是一个选择。这个选择就是粒度这根杠杆——你多久计算一次新的缩放因子:
- 逐张量:整个权重矩阵一个缩放因子。最便宜(每个张量一个),但单个离群值就会把缩放因子撑大,毁掉矩阵中每个权重的精度。
- 逐通道:一层的每个输出通道一个缩放因子。这是生产中权重量化的标准做法:各通道的量级分布常常差别很大,逐通道的缩放因子让每个通道保有自己的分辨率。
- 逐组:每 N 个连续权重一个缩放因子(通常是 32、64 或 128)。这是极低位宽(int4 及以下)的标准做法,那里逐通道的网格仍然太粗,而通道内部的一个离群值会毁掉局部精度。更小的组能更好地贴合局部分布,但每个参数要付出更多的缩放因子开销。
分组究竟怎么定?对权重而言,主要是结构性的,而非实验性的。逐张量很平凡——一组就是整个张量。逐通道则从层的几何结构中免费得到:一个 Linear 层的输出维度已经给了你天然的通道,Conv 层的输出滤波器也是如此。逐组是唯一带超参数的设置——组大小——而常见默认值(128、64、32)来自研究和工具链:GPTQ、AWQ 和 llama.cpp 对 int4 都默认使用 128 左右的组大小。更小的组能提升质量,但每个权重的缩放开销更高,合适的取值通常是在留出的验证集上试几个选项来定。
为什么这行得通
「缩放因子即乘数」在数学上站得住脚,这一点几十年前就已证明——有三个经典结果为它背书。Bennett 的量化噪声定理(1948)表明,对于步长为 的均匀量化器,舍入误差以 为界,均值为零、方差为 ——量化噪声在构造上就是良态的,这与支撑你听过的每个数字音频文件里 PCM 的数学是同一套。「先量化再反量化」的映射在舍入意义下也是线性的,所以权重之间的距离和比值都在同样的 误差内被保持,原始权重空间的几何结构得以延续。而且由于标量乘法对矩阵-向量积满足分配律,缩放因子可以提到矩阵乘之外——这正是上面路径 1 在数学上等价于 fp32 版本的原因。
「缩放因子即乘数」是浮点数早已在做之事的直系后裔。浮点数的指数实际上就是一个逐值的缩放因子(配上一个逐值的尾数作为被量化的载荷)。量化不过是让多个值共享一个缩放因子,用一些精度换取每个元素更少的比特。
浮点那一节的网格图让结构性差异变得可见:浮点数把它的可表示值铺在一张对数间距的网格上,靠近零处密集,随量级增大而拉伸——bf16 中 1.0 附近 ~0.008 的间隔,到 1024 附近变成 ~8,到 10⁶ 附近变成 ~10⁴。量化用一张线性网格取而代之:在一个块内,256 个(int8)或 16 个(int4)可表示值以 s 为间距均匀分布。没有对数拉伸;靠近零处也没有额外分辨率。块的数据范围定下 s,网格在该范围内是规则的,而无论块内的值是小是大,舍入误差都一样。
把窗口在 [0, 4]、[0, 16] 和 [0, 1024] 之间切换,就能看到对比:bf16 的刻度在左侧挤成近乎实心的一堵墙,向右逐渐稀疏(对数网格在拉伸),而 int8 和 int4 那两行在任何窗口尺寸下都保持为间距均匀的梳齿——同样的 256 个(或 16 个)级别,只是被拉伸去覆盖该块所需的任何范围。
于是在组大小为 64 的 int4 下,存储图景是这样的:
- 每个权重 4 位 = 0.5 字节
- 加上每 64 个权重一个 fp16 缩放因子 ≈ 2 字节 / 64 = 0.031 字节/权重
- 合计 ≈ 0.53 字节/权重
这就是为什么实践中把「int4」报作「每个参数 0.5 字节」——开销真实存在,但很小。
量化那个 MNIST 分类器
为了在我们已经见过的模型上把这件事讲具体,我们把同样的配方应用到本系列早前那个 MNIST 分类器的第一个 Dense 层——即带有 (784, 128) fp32 权重矩阵的 Dense(128, input_shape=(784,)) 层。选择逐通道粒度:128 组,每个输出通道一组,每组包含 784 个权重。对每个通道 ,计算 。如果通道 0 绝对值最大的权重是 0.234,那么 ,该通道的网格就是 ——256 个点,间距 0.00184,恰好铺满该通道的范围。通道 1 得到不同的 ,所以同一个 int8 值 42 解码为 ——每个通道对应一个不同的实数:
import keras
import numpy as np
model = keras.models.load_model('mnist_classifier.keras')
W = model.layers[0].kernel.numpy() # (784, 128), fp32
print(W.shape, W.dtype, W.nbytes) # (784, 128) float32 401408
INT8_MAX = 127
# Per-channel int8: one scale per output channel (128 channels)
scales = np.abs(W).max(axis=0) / INT8_MAX # (128,), fp32
# Quantize: divide by scale (broadcasts), round to nearest int, cast to int8
W_q = np.round(W / scales).astype(np.int8) # (784, 128), int8
# Dequantize on read: cast to fp32, multiply by per-channel scale
W_back = W_q.astype(np.float32) * scales
# Storage: 100,352 bytes (int8 weights) + 128 * 4 = 512 bytes (scales)
# vs original 401,408 bytes — ~4× smaller, scale overhead ~0.13%第一层的权重矩阵从约 400 KB(fp32)缩到约 100 KB(int8)外加 512 字节的逐通道缩放因子——干净利落地减少 4 倍。逐权重误差大致是逐通道缩放因子的一半(这一层通常约 ~10⁻⁴),远低于一个经过 60,000 次随机 SGD 更新训练出的网络的噪声底。把层的权重替换为 W_back 再评估模型,测试准确率基本一致。
把同样的配方用到第二层的 (128, 10) 权重上,我们就有了整个模型的图景:
| 层 | 形状 | fp32 权重 | int8 权重 | 逐通道缩放因子 | int8 合计 |
|---|---|---|---|---|---|
| Dense 1 | (784, 128) | 401,408 B | 100,352 B | 128 × 4 = 512 B | 100,864 B |
| Dense 2 | (128, 10) | 5,120 B | 1,280 B | 10 × 4 = 40 B | 1,320 B |
| 合计 | 406,528 B(约 397 KB) | 102,184 B(约 100 KB) |
偏置(共 138 个,通常保持 fp32 ≈ 552 字节)在这个量级上可以忽略。最终结果:整个 MNIST 模型的权重从 约 397 KB 降到约 100 KB,干净的 约 4 倍缩减,而逐通道缩放因子只在 int8 权重存储之上增加了 ~0.55% 的开销。int8 PTQ 就是这样,逐层重复而已。