浮点数、bfloat,以及比特如何决定模型的内存

在所有抽象之下,神经网络就是一大堆被称为参数的数字——每个神经元在每一层携带的权重和偏置,我们在讲网络如何学习的那篇文章里详细走过一遍。参数量告诉你模型存了多少个这样的数字;数值格式告诉你每一个有多大(多少字节)。两者相乘就得到模型的内存占用——在你挑硬件、挑精度,或者判断这东西究竟能不能装进一块 GPU 之前,你首先需要的就是这个数字。

拿我们在 MNIST 那篇文章里训练的小网络为例——一个由两个 Keras Dense 层构成的稠密分类器:784 个展平的像素输入进入一个 128 神经元的隐藏层,再进入一个 10 神经元的输出层(每个数字一个)。 这种形状的简写是 784 → 128 → 10,其中 784 是输入大小,不是一层。参数量加起来是:

784×128+128hidden layer: 100,480  +  128×10+10output layer: 1,290  =  101,770 parameters.\underbrace{784 \times 128 + 128}_{\text{hidden layer: } 100{,}480} \;+\; \underbrace{128 \times 10 + 10}_{\text{output layer: } 1{,}290} \;=\; 101{,}770 \text{ parameters}.

Keras 默认把每个参数存为 fp32——32 位浮点,每个 4 字节。所以这个训练好的模型加载后在内存中占 101,770×4400 KB101{,}770 \times 4 \approx 400\text{ KB}。但这是个为学习而造的玩具模型——小到可以用邮件发送。真实世界的模型要大好几个数量级,而参数量正是从那里开始变得举足轻重。

发布在 Hugging Face 这类平台上的每个模型都附带一张模型卡——放在权重文件旁边的公开 README,概述模型是什么、如何训练的,以及我们这里关心的两个数字:它有多少参数,每个参数以何种数值格式存储。

我们拿一个当下最先进的开源 OCR 模型举例——在 Hugging Face 上打开 PaddleOCR-VL,侧边栏就会告诉你我们关心的两件事:参数数量,以及每个参数的存储格式。

PaddleOCR-VL-1.5 的 Hugging Face 侧边栏,Model size: 1.0B params 用红色高亮,Tensor type: BF16 用绿色高亮

Model size 是参数总数——模型存了多少个数字。Tensor typedtype——data type(数据类型)的缩写。 其中的 d 是为了把它和普通的 type 区分开:在 Python 里,type(x) 告诉你容器是什么(listnumpy.ndarraytorch.Tensor),而 dtype 告诉你里面的元素是什么(float32int8bfloat16)——决定每个权重占多少字节的正是后者。 这个术语始于 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,即 10910^9 的简写)。乘以每个 bf16 权重 2 字节,我们得到模型的权重占用

1,000,000,0001.0B params (sum over all layers)×2bytes per bf16 weight=2,000,000,000 bytes=2 GB.\underbrace{1{,}000{,}000{,}000}_{\text{1.0B params (sum over all layers)}} \times \underbrace{2}_{\text{bytes per bf16 weight}} = 2{,}000{,}000{,}000 \text{ bytes} = 2\text{ GB}.

这是推理时占主导的内存开销:那里激活是瞬时的(前向传播中一次只有一层的量驻留在内存里),而 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.autocastGradScaler 这样的混合精度封装把这些类别进一步解耦——例如在 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 的情况下训练它(诸如 ZeROFSDP 这类技术)。

默认的浮点数:你所知的每种语言里都是 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 的 floatintfloatint8,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

这四行涵盖了任何数值类型转换所归属的几种典型模式。每次的底层规则都一样——源格式持有目标格式留不住的比特——但硬件有四种不同反应,取决于哪个字段溢出:

模式例子结果
截断floatint3.7int83
回绕int更窄的 int1000int8-24
饱和到无穷float更窄的 float1e5fp16inf
精度损失intfloat2^24 + 1fp322^24

每一行都值得细看——它们的机制有着有趣的差异。

截断。 3.7int8 保留 3。无论小数部分多大,都朝零方向丢弃,所以 3.013.53.999 全都落到 3。负值同样朝零切:-3.999 变成 -3,而不是 -4。这与我们上面走过的 int(5.75) 是同一个机制——硬件撤销规格化,然后连看都不看地丢掉小数点右边的一切。

回绕。 1000int8 得到 -24。int8 用 8 位补码覆盖 [128,127][-128, 127],所以当一个值装不下时,只有低 8 位存活并按该有符号编码重新解释——这让 1000 落到 -24。邻近的值如 1001 回绕到 -23,而 -129 回绕到 +127——数轴每 256 步就弯回自身,所以 int8 把这个范围当作一个环,而不是一条线。

饱和到无穷。 1e5fp16 变成 inf-1e10 变成 -inf。fp16 的指数字段只有 5 位宽,它能编码的最大有限值是 65,5041.1111111111 × 2^{15})。值 100,000 需要 2^{16} 的指数,而 fp16 没有对应的比特模式——于是 IEEE-754 做了它唯一能做的事:返回 +inf(符号位决定是 +inf 还是 -inf,其余比特模式相同)。与整数回绕不同,浮点溢出不循环;它饱和。1e51e101e38 全都塌缩到同一个 +inf 比特模式,而 -1e5-1e10-1e38 塌缩到同一个 -inf——转换之后没有任何东西能区分它们。

精度损失。 2^24 + 1fp32 变成 2^24。fp32 的尾数是 24 位(23 位显式 + 1 位隐含),所以从 02^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 float
import 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 bytes
import 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 arrays
import 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)64111528
fp32 (IEEE-754 single)3218234
bf16 (Brain Float 16)161872
fp16 (IEEE-754 binary16)1615102

那两行 16 位的才是有意思的——同样的总比特预算,相反的划分。拖动下面的滑块来感受这种取舍:每往指数里挪一位,可达范围就翻倍,精度就减半,反之亦然。fp16bf16 预设会吸附到真实的格式选择上。

16-bit float — how to spend the bits
S
E
E
E
E
E
E
E
E
M
M
M
M
M
M
M
8 exponent·7 mantissa
Max representable
±3.39e+38
Smallest positive normal
1.18e-38
Gap in [1, 2)
0.00781
Representable values in [1, 4) (128 per octave; gap doubles at 2)
124

把比特挪进指数,可表示的最大值增长得非常快——但每个八度内的间隔也同步增长(在 [1,2)[1, 2) 里是 2M2^{-M},在 [2,4)[2, 4) 里是 2×2M2 \times 2^{-M},依此类推,每个八度翻一倍),于是网格处处变粗。 把比特挪进尾数,间隔收缩而最大值崩塌fp16bf16 预设正落在 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.01.0
1.00781251.0009765625
1.0156251.001953125
1.02343751.0029296875
1.031251.00390625
1.99218751.9990234375
(2.0)(2.0)

bf16 的每个后继值都恰好落在 fp16 的每第 8 个值上——在这个八度里,bf16 的网格是 fp16 网格的严格子集,只是每八根刻度去掉了七根。这个比值恰好是 2107=23=82^{10 - 7} = 2^3 = 8(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 用精度换范围——它能在两个方向上触及极端量级(小的一侧到 103810^{-38} 这样的微小分数,大的一侧到 103810^{38}),但会把相邻的值粗糙地舍到一起。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 同样的范围,大致从 103810^{-38}103810^{38},只是网格更粗。每一位尾数都会把相邻可表示值之间的间距减半,因此去掉 16 位就把间距翻倍了 16 次——在任意给定指数下粗了 216=65,5362^{16} = 65{,}536 倍。于是像 1.005 这样的权重在每种格式里存得都不一样:

格式存储的值误差
fp321.005(实际上精确)~10⁻⁸
fp161.0048828125~1.2 × 10⁻⁴
bf161.0078125~2.8 × 10⁻³

误差更低意味着存储的值离原值更近——所以 fp16 在这里精度胜出(误差约比 bf16 低 24 倍),但在范围上落败。 一个接近 1e-7 的梯度在 fp16 里会下溢,而在 bf16 里能活下来。

ML 对数值格式有两种失败模式,而它们的糟糕程度并不相等。范围失败(上溢/下溢)是灾难性的——消失的梯度会让该参数的训练彻底停滞;溢出的激活产生 NaN 并杀掉整次运行。精度失败则可以忍受——每个权重上的微小舍入误差会在网络的数百万权重上被抹平(原因见下)。bf16 失败在可忍受的那条轴上;fp16 失败在灾难性的那条上——这正是尽管 bf16 在单权重精度上落败,ML 仍偏爱它的原因。

要在范围受限的情况下仍用 fp16 训练,你需要一个叫损失缩放的变通办法。在反向传播之前把损失乘以一个常数 SS——梯度对损失是线性的,所以它们都会以同一个 SS 被放大回来,落入 fp16 的范围——然后在应用更新之前再除以 SS。没有它,一个 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。通用,但对张量运算而言慢。
fp16NVIDIA 张量核心,自 Volta(V100,2017)起;其他厂商随后跟进。
int8NVIDIA Turing 张量核心(T4,2018);如今基本每个现代加速器都有。
bf16Google 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。
int4Hopper 时代及更新的张量核心。

每家厂商对这些模块都有自己的叫法——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 E4M3143前向传播的权重与激活(偏向精度)
fp8 E5M2152梯度(偏向范围)

两者都通过开放计算项目(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 并计算缩放因子,无需调用方自己算。

Per-tensor scaling — matching fp8's grid to the data
Tensor max
±1.00
Scale factor
2.23e-3
fp8 grid covers
±1.00
Tensor data (blue dots) and the scaled fp8 grid (green ticks)
1.150+1.15
fp8's native grid spans ±448. Multiplied by the per-tensor scale factor (2.23e-3), it now covers ±1.00 — matching the data's range. Without scaling, the entire tensor would crowd around fp8's smallest normal value (~0.0156) or fall below it entirely; with scaling, the grid points spread evenly through the values you actually want to store.

调整滑块或选择一个预设来设定张量的最大值:绿色刻度是 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.cppOllama、LM Studio)几乎总是跑 int4 或 int8——受限于内存预算,完整的 bf16 推理在消费级硬件上很罕见。数据中心的 LLM 服务在规模上大量依赖 fp8 和 int8,常常把对离群值敏感的层保留在更高精度。边缘/移动端的神经网络部署(端侧语音、计算机视觉、传感器融合)基本上永远是 int8——硅根本不支持别的。纯 fp32 推理如今主要限于研究流程、科学计算,以及少数对准确率极为关键的生产端点。量化不是你以后也许会做的优化;对多数现代部署目标而言,它就是默认。

真正做量化有两条流程,取决于你在模型生命周期的哪个阶段做。它们共享同一套「吸附到网格」的算术,我们会在下一小节展开。

训练后量化(PTQ) 拿一个已经训练好的 fp32 / bf16 模型,在部署时量化它的权重,不再做任何训练——便宜(不需要 GPU 工时),也是当今 LLM 推理的标准流程。 LLM 上常见的 PTQ 算法是 GPTQAWQ——两者都建立在「吸附并缩放」的配方之上,再在其上叠加更聪明的机制。 实现这些算法的流行工具是 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 个。 而这些级别并不通用——它们是按每组权重、从数据本身构建出来的。

不过这个被存储的整数并不是权重本身——它是由 SS 定义的一张浮点位置网格上的索引。 反量化过程恢复出原始小数值的一个近似:推理时,索引 × S 把它重建出来。在现代硬件上,这个重建是即时的——被融合进矩阵乘、从不在内存中物化——所以网络直接在压缩表示上工作,只在需要的那一刻才恢复出近似的 fp32 值。

那么假设我们挑了一组五个权重一起量化:

3.1416,1.7,0.0234,1.5,9.53.1416,\quad -1.7,\quad 0.0234,\quad 1.5,\quad -9.5

这个过程有两步,下面的组件让你可以逐步点击。宏观来看:首先,我们为整组计算一个缩放因子 SS——它给出所有权重将要吸附到的网格。然后,对每个权重执行真正的压缩——吸附到最近的网格点(平局用就近取偶)并存下整数索引。第 1 步每组跑一次;第 2 步每个权重跑一次。

要定义这个离散集合,先看这组权重的取值范围,并从数据中算出缩放因子 SS。下面是对称 kk 比特量化的通用公式:

S=max(w1, w2, , wn)2k11S = \frac{\max(|w_1|,\ |w_2|,\ \ldots,\ |w_n|)}{2^{k-1} - 1}

分母2k112^{k-1} - 1——对称 kk 比特有符号整数中绝对值最大的索引。int8 是 271=1272^7 - 1 = 127;int4 是 231=72^3 - 1 = 7;int2 是 211=12^1 - 1 = 1

分子是该组中绝对值最大的权重:max(w1,w2,,wn)\max(|w_1|, |w_2|, \ldots, |w_n|),其中 w1,,wnw_1, \ldots, w_n 是你一起量化的那 nn 个权重。

所以这个设计就是:用绝对值最大的权重除以绝对值最大的索引。这保证了最极端的权重恰好映射到网格点 ±(2k11)\pm(2^{k-1} - 1)——不截断,网格利用充分。

对上面五个示例权重与 int8(k=8k = 8,因此分母 = 127127):

S=max(3.1416, 1.7, 0.0234, 1.5, 9.5)127=9.51270.0748S = \frac{\max(|3.1416|,\ |-1.7|,\ |0.0234|,\ |1.5|,\ |-9.5|)}{127} = \frac{9.5}{127} \approx 0.0748

注意 SS 同时扮演两个角色:它是乘数索引 × S 恢复出 fp32 值),也是网格间距(相邻索引相差 1,因此相邻网格位置正好相差 SS)。例如索引 41 解码为 41 × 0.0748 ≈ 3.067,索引 42 解码为 42 × 0.0748 ≈ 3.142——它们之间的间隔正是 0.0748,也就是 SS。同一个数,两种含义。

有了 SS,网格便直接随之而来:每个网格点都是 索引 × S,索引取从 -127+127 的整数:

{127S, 126S, , 0, , 126S, 127S}\{-127S,\ -126S,\ \ldots,\ 0,\ \ldots,\ 126S,\ 127S\}

代入我们的 S=0.0748S = 0.0748——每个网格点是 索引 × 0.0748

{9.5, 9.42, , 0, , 9.42, 9.5}\{-9.5,\ -9.42,\ \ldots,\ 0,\ \ldots,\ 9.42,\ 9.5\}

要把每个权重吸附到最近的网格点上,请记得第 1 步只是建好了网格;真正的逐权重压缩发生在这里。对组内每个权重 ww,计算它的索引:

q=round(w/S)q = \text{round}(w / S)

这给出最近网格点的整数索引——与二进制舍入那篇文章里的就近舍入机制相同:挑最近的可表示值,平局取偶。唯一的区别是可表示值的集合如今小得多,而且是显式枚举出来的。存下这个 1 字节整数(int4 则是 4 位);共享的 SS 随行同存,每组只存一次,因此它的成本会被摊到组内每个权重上。对我们五个权重、S0.0748S \approx 0.0748

权重 www/Sw / Sq=round(w/S)q = \text{round}(w / S)存储(int8 值)
3.141642.04242
-1.7-22.7-23-23
0.02340.3100
1.520.052020
-9.5-127.0-127-127

五个 fp32 权重(20 字节)变成五个 int8 值加一个 fp32 缩放因子(共 9 字节)——小了 2.2 倍,而且组越大,节省越多。

下面的组件把这个循环做成可点击的,用的就是同样这五个权重——挑一个,看量化、吸附、反量化端到端跑一遍:

Scale-as-multiplier: how a float snaps onto the int8 grid
S =0.0748(= max(|values|) / 127)
Pick a weight:
Weight (fp32)
3.1416
÷ S
w / S
41.9982
round →
int8 index
42
× S
Reconstructed
3.1417
|error|
1.32e-4
int8 grid (each tick is index × S; range is ±9.50)
index space
−1270+127
9.500+9.50
value space (= index × S)
Original weight Snapped grid point int8 grid (every index × S)
Zoom: 7 grid points around the snap
2.9173392.9921403.0669413.1417423.2165433.2913443.366145|err| = 1.32e-4
Top number: value (= index × S). Bottom number: int8 index. Red = snapped grid point.

挑一个权重,看看在上述公式所用的同样五个值上,完整的「量化—吸附—反量化」循环。绿色刻度是 int8 网格(对 −127+127 的每个索引取 索引 × S)。蓝色圆点是原始 fp32 权重;红色标记是它吸附到的网格点。流程行展示第 1 步和第 2 步的算术:除以 SS,就近舍入到整数(这才是被存下来的东西),读取时再乘回 SS。于是 3.1416 / 0.0748 ≈ 42.0 舍入为 42,重建为 42 × 0.0748 ≈ 3.142——几乎一致。试试 0.0234,看看一个极小的权重下溢到网格点 0 时会发生什么。

值得一提:这里有两个平行的集合在起作用。索引空间 {127,126,,127}\{-127, -126, \ldots, 127\} 才是真正存在内存里的东西——纯整数,每个 1 字节。取值空间 {127S,126S,,127S}\{-127S, -126S, \ldots, 127S\} 才是网络在反量化之后当作权重的东西——由每个索引乘以 SS 得到的 fp32 数。同一个集合,被缩放过。整数是廉价的寻址系统;实值网格才是网络真正拿来计算的东西。

要重建一个权重,硬件读出那个小整数并施加逆运算:

wSqw \approx S \cdot q

其中 qq 是存储的整数,SS 是浮点缩放因子。不同的权重组拿到不同的 SS——这就是为什么同一个 int8 值 42 在两个不同的通道里可以解码出两个不同的实数。

注意这里没有发生什么:并不存在像 IEEE-754 转换那样「重新组装」符号/指数/尾数字段的算法。重建只是两条标准指令——一次 int 到 fp32 的转换(4242.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 层的输出滤波器也是如此。逐组是唯一带超参数的设置——组大小——而常见默认值(1286432)来自研究和工具链:GPTQ、AWQ 和 llama.cpp 对 int4 都默认使用 128 左右的组大小。更小的组能提升质量,但每个权重的缩放开销更高,合适的取值通常是在留出的验证集上试几个选项来定。

为什么这行得通

「缩放因子即乘数」在数学上站得住脚,这一点几十年前就已证明——有三个经典结果为它背书。Bennett 的量化噪声定理(1948)表明,对于步长为 SS 的均匀量化器,舍入误差以 S/2S/2 为界,均值为零、方差为 S2/12S^2/12——量化噪声在构造上就是良态的,这与支撑你听过的每个数字音频文件里 PCM 的数学是同一套。「先量化再反量化」的映射在舍入意义下也是线性的,所以权重之间的距离和比值都在同样的 S/2S/2 误差内被保持,原始权重空间的几何结构得以延续。而且由于标量乘法对矩阵-向量积满足分配律,缩放因子可以提到矩阵乘之外——这正是上面路径 1 在数学上等价于 fp32 版本的原因。

「缩放因子即乘数」是浮点数早已在做之事的直系后裔。浮点数的指数实际上就是一个逐值的缩放因子(配上一个逐值的尾数作为被量化的载荷)。量化不过是让多个值共享一个缩放因子,用一些精度换取每个元素更少的比特。

浮点那一节的网格图让结构性差异变得可见:浮点数把它的可表示值铺在一张对数间距的网格上,靠近零处密集,随量级增大而拉伸——bf16 中 1.0 附近 ~0.008 的间隔,到 1024 附近变成 ~8,到 10⁶ 附近变成 ~10⁴。量化用一张线性网格取而代之:在一个块内,256 个(int8)或 16 个(int4)可表示值以 s 为间距均匀分布。没有对数拉伸;靠近零处也没有额外分辨率。块的数据范围定下 s,网格在该范围内是规则的,而无论块内的值是小是大,舍入误差都一样。

Representable values: float vs quantization
bf16log-spaced grid (denser near 0; many sub-pixel ticks at this scale aren't drawn)
int8256 levels, uniform spacing s = 0.0627
int416 levels, uniform spacing s = 1.07

把窗口在 [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 个权重。对每个通道 cc,计算 Sc=max(W[:,c])/127S_c = \max(|W[:, c]|) / 127。如果通道 0 绝对值最大的权重是 0.234,那么 S00.00184S_0 \approx 0.00184,该通道的网格就是 {0.234, , 0, , 0.234}\{-0.234,\ \ldots,\ 0,\ \ldots,\ 0.234\}——256 个点,间距 0.00184,恰好铺满该通道的范围。通道 1 得到不同的 S1S_1,所以同一个 int8 值 42 解码为 42Sc42 \cdot S_c——每个通道对应一个不同的实数:

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 B100,352 B128 × 4 = 512 B100,864 B
Dense 2(128, 10)5,120 B1,280 B10 × 4 = 40 B1,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 就是这样,逐层重复而已。