本文从数学角度完整拆解模型量化(Quantization):它如何把 FP32 浮点权重/激活映射成低比特整数,S 和 Z 怎么求,量化误差从何而来、如何严格证明它的界,信噪比怎么推,以及为什么 8-bit 甚至 4-bit 量化之后,模型精度损失通常并不严重。 内容框架与 MIT 6.5940 / TinyML(韩松 Song Han)课程的 Quantization 一讲一致。
收录于微信公众号:AngryCode 愤怒的代码
0. TL;DR
- 量化的本质是一条仿射变换 :
r = S(q − Z)。r 是 float,q 和 Z 都是整数,S 是 float------全公式只有一次浮点乘法。 - S = 数据范围 ÷ 整数范围。数据范围有三条求法:min-max、裁剪+MSE、裁剪+KL 散度。
- 误差来自四舍五入 ,可严格证明
|ε| ≤ S/2、零均值、方差S²/12(均匀分布假设下积分可得)。 - 每加 1 bit,量化信噪比提升 6 dB (SQNR = 6.02n dB);int8 的噪声只有满量程的 ~0.4%。
- 误差在求和/传播时正负抵消(标准差按 √N 增长),且激活函数 Lipschitz 连续(ReLU 的 L=1)使误差不被放大,因此整网几乎不掉点。
- 矩阵乘法可以拆成纯整数 GEMM + 一次尺度相乘,这是量化省算力、省带宽、省功耗的数学来源。
1. 为什么要量化
端侧/边缘设备(手机、MCU、NPU)受四个硬约束:
- 内存:FP32 一个权重占 4 字节,int8 只占 1 字节 → 模型体积缩小 4 倍。
- 带宽:内存搬运是端侧推理的主要能耗来源,位宽降低直接成比例省电。
- 算力:整数 MAC 单元面积、功耗、时延都远低于 FP32 MAC。
- 实时性:整数运算 + 更小的内存占用 → 更低延迟。
一句话:量化不是为了"更好",而是为了"能跑"。 而它之所以可行,是因为量化误差在数学上可控------这就是本文要讲清楚的。
2. 出发点:FP32 到底存了什么
IEEE 754 单精度浮点数 = 1 符号位 + 8 指数位 + 23 尾数位:
scss
x = (−1)^s × 2^(e−127) × (1 + f / 2^23)
- 数值范围:约 ±3.4 × 10³⁸
- 相对精度:约 2⁻²³ ≈ 1.2 × 10⁻⁷(约 7 位有效数字)
关键认知:神经网络权重是训练出来的统计量,不是物理常数。 它不需要 7 位有效数字的精度。这为量化提供了巨大的冗余空间。
3. 量化的数学定义:仿射映射
3.1 核心公式
量化的核心就是把实数 r 映射到整数 q:
ini
r = S · (q − Z) (反量化:整数 → 实数)
ini
q = clamp( round(r / S) + Z , q_min, q_max ) (量化:实数 → 整数)
clamp 是什么 :限幅函数------把值夹进 [min, max] 区间,太小压到 min、太大压到 max、区间内原样返回:
arduino
clamp(x, min, max) = min 若 x < min
= x 若 min ≤ x ≤ max
= max 若 x > max
在量化里它的作用:round(r/S) + Z 可能超出整数合法范围------比如 r 是校准时没见过的 outlier,算出的整数超出 [q_min, q_max](如 int8 算出 300 或 −254)。clamp 保证结果一定落得进 n 比特(int8 就是 [−128, 127]),不会溢出回绕。代价是被夹掉的值产生裁剪误差 (无界,见 5.1 节)------这正是校准(KL/MSE 选阈值)要优化的那个误差的来源。clamp 在不同框架里也叫 clip(NumPy/TensorFlow)、饱和运算 saturate(硬件)。
3.2 四个量各自的类型(一个常见误解)
Z 不是 float,是整数(和 q 同一个整型):
| 量 | 类型 | 说明 |
|---|---|---|
r |
float(FP32) | 真实值 |
S |
float(FP32) | 尺度/步长 |
q |
整数(int8/uint8) | 量化后的值,存 n 比特 |
Z |
整数(和 q 同型) | 零点:实数 0 所对应的整数 |
所以 r = S(q − Z) 的运算过程是:
css
(q − Z) → 整数减法
× S → 乘一个 float,得到 float
整个公式里只有一个 float 乘法,其余都是整数运算------这正是量化能提速的关键。
为什么 Z 一定是整数? 看 Z 的求法:
ini
Z = round( q_min − r_min / S )
q_min 是整数,r_min/S 是 float,相减后 round 成整数 ,所以 Z 天生是整数,且落在 [q_min, q_max] 内,能用和 q 一样的 n 比特存下来。如果 Z 是 float,硬件就得做浮点减法、多存一个 float,量化就失去意义了。
一个具体例子 :数据范围 r ∈ [−0.5, 1.0],uint8 量化(q ∈ [0, 255]):
scss
S = (1.0 − (−0.5)) / 255 = 1.5/255 ≈ 0.005882
Z = round(0 − (−0.5)/0.005882) = round(85) = 85
验证 r = 0:S(q − Z) = 0 ⟹ q = Z = 85。实数 0 对应的整数就是 85------Z 是整数。
3.3 为什么叫仿射变换
因为 r = S·q − S·Z 是一条直线:斜率 S,截距 −S·Z。它允许"缩放 + 平移",所以既能表示对称分布(Z=0),也能表示非对称分布(Z≠0,如 ReLU 输出的非负激活)。本文的一切结论都从这条直线推出。
4. 怎么求 S 和 Z
S 的计算核心一句话:S = 数据范围 ÷ 整数范围。整数范围由位宽定死,真正要决定的是"数据范围"怎么取。
n 比特整数的表示范围:
| 类型 | q_min | q_max |
|---|---|---|
| 有符号 int8 | −128 | 127 |
| 无符号 uint8 | 0 | 255 |
| 有符号 int4 | −8 | 7 |
| 无符号 int4 | 0 | 15 |
4.1 方法一:min-max 非对称(全覆盖,无裁剪)
ini
S = (r_max − r_min) / (q_max − q_min)
Z = round( q_min − r_min / S )
验证端点对齐:r_min = S·(q_min − Z),r_max = S·(q_max − Z)。
例子 :某层 ReLU 激活,范围 [0, 6.0],用 uint8:
ini
S = (6.0 − 0) / (255 − 0) = 6/255 ≈ 0.023529
- 优点:一个值都不裁剪,全盖住。
- 缺点:对 outlier 极其敏感。如果 99.9% 的值落在 0, 0.5、只有一个 6.0,S 就被这个 outlier 撑大,绝大多数值的量化误差被无谓放大。
4.2 方法二:min-max 对称(Z=0)------为什么必须加绝对值
对称量化强制 Z = 0,映射退化为 r = S·q:
scss
α = max(|r_min|, |r_max|)
S = α / q_max (有符号时 q_max = 2^(n−1) − 1)
这里 abs 不是可选项,是定义本身。 原因:对称量化要求映射关于 0 对称------整数能取 −127 和 +127,对应的实数就是 −S·127 和 +S·127,所以实数区间必须是 [−α, α],而 α 必须 ≥ 所有数据里"离 0 最远"的值,即最大绝对值。
反例:不加绝对值会怎样 。设张量 r_min = −2.0,r_max = 1.0:
- ❌ 不加 abs:
max(r_min, r_max) = 1.0,S = 1.0/127。那么 −2.0 对应整数 =−2.0/(1/127) = −254,超出 int8 范围 −127, 127,被硬夹到 −127,所有小于 −1.0 的负值全部失真。 - ✅ 加 abs:
α = max(|−2|, |1|) = 2.0,S = 2.0/127。区间[−2, 2] → [−127, 127],完美覆盖。
css
数据范围(不对称): [----r_min=-2.0 ····· 0 ····· r_max=1.0----]
对称量化强制摊开成: [----−α=-2.0 ······· 0 ······· +α=+2.0----]
↑
右边 [1.0, 2.0] 这段是"浪费"的
对称量化为了保持 Z=0 的简洁,代价就是两边必须对称摊开,数据偏一边时另一半范围就浪费了------这也是它和非对称量化的核心区别。
| 方式 | 公式 | 需要 abs | 适用场景 |
|-----|-----------------------------------|--------|----------------|-------|-----------|------|-------------|
| 对称 | `S = max( | r_min | , | r_max | )/q_max` | ✅ 需要 | 权重(关于 0 对称) |
| 非对称 | S = (r_max−r_min)/(q_max−q_min) | ❌ 不需要 | 激活(如 ReLU 全非负) |
实现细节 :有符号 int8 的 q_max 用 127 而不是 128,为了 ±q_max 对称(−128 被闲置)------这是"强制对称"付出的微小代价,换来的 GEMM 大幅简化(见第 7 节)。
4.3 方法三:裁剪 + 校准(针对 outlier)
min-max 的问题在 outlier。更聪明的做法:故意裁掉少量极端值,换取更小的步长。
设裁剪阈值 T(0 < T ≤ max|r|),只量化 [−T, T] 内的值,超过 T 的直接夹到端点:
ini
S = T / q_max (对称)
T 的选取是核心,两条准则:
① MSE 准则:选 T 使总误差的均方最小:
css
T* = argmin_T E[ (Q(r) − r)² ]
= argmin_T [ 裁剪误差(超出T的部分) + 量化误差(S²/12) ]
T 太小 → 裁剪误差大;T 太大 → S 大、量化误差大。两者此消彼长,最优 T 平衡它们。
② KL 散度准则(NVIDIA TensorRT 的做法):把 FP32 值和量化后的值各做成直方图,选 T 使两个分布的信息损失最小:
r
T* = argmin_T KL( P_fp32 ‖ P_quant(T) )
直观理解:选一个 T,让"量化前的数值分布"和"量化后的数值分布"长得最像。 对高斯/拉普拉斯分布的权重,最优 T 的经验值约在 ±(2.5 ~ 4)σ------不是 min-max 的全范围,而是收敛在几倍标准差处。
4.4 数据从哪来:权重 vs 激活(一个关键区别)
| 权重的 S | 激活的 S | |
|---|---|---|
| 数据来源 | 权重张量本身 | 校准集(少量代表性输入) |
| 何时算 | 离线,一次算完 | 离线,用校准集跑一遍统计 r_min/r_max |
| 为什么不同 | 权重训练完就固定了 | 激活值依赖输入,只能估计 |
所以 PTQ 流程里常看到"用 100~1000 张校准图片跑前向、统计每层激活的分布"------那就是在给激活算 S(和 Z)。
4.5 方法小结
| 方法 | 公式 | 裁剪 | 适用 |
|-------------|---------------------------------|-------|-------------------------|-------|-----------|---|------|
| min-max 非对称 | S=(r_max−r_min)/(q_max−q_min) | 无 | 无 outlier 的激活 |
| min-max 对称 | `S=max( | r_min | , | r_max | )/q_max` | 无 | 对称权重 |
| 裁剪+MSE | T* = argmin MSE | 有 | 有 outlier 的权重/激活 |
| 裁剪+KL | T* = argmin KL | 有 | 有 outlier 的激活(TensorRT) |
5. 量化误差的数学分析
量化后的值记为 Q(r) = S · round(r/S + Z) − S·Z,误差:
css
ε = Q(r) − r
5.1 性质 1:误差有界------|ε| ≤ S/2 的完整证明
这个上界只在"值落在量化范围内、未被裁剪"时成立。证明只需三步,核心是把误差拆成"步长 S × 四舍五入误差"。
第一步:定义中间变量,把 r 用 t 表示。
令 t = r/S + Z,则反解出 r = S·(t − Z)。t 的含义是"r 在整数刻度上的位置"(还没取整)。量化就是对这个位置四舍五入:
scss
q = round(t) = round(r/S + Z)
第二步:把量化误差改写。
scss
ε = Q(r) − r
= S·(round(t) − Z) − S·(t − Z) ← 代回 Q(r) 和 r
= S·( round(t) − t ) ← S·Z 和 −S·Z 抵消
令 δ = round(t) − t(以刻度为单位的四舍五入误差,无量纲),得到:
ε = S · δ
第三步:证明 |δ| ≤ 1/2。
任取实数 t,它一定落在某个整数区间 [n, n+1] 内(n = ⌊t⌋)。四舍五入到最近的整数,结果要么是 n、要么是 n+1,距离分别是 t−n 和 (n+1)−t。而这两段距离之和:
scss
(t − n) + ((n+1) − t) = 1
两段加起来才 1,所以较短的那段一定 ≤ 1/2。四舍五入恰好选的是最近整数,因此:
scss
|δ| = |round(t) − t| = min(t−n, (n+1)−t) ≤ 1/2
代入第二步:
|ε| = S·|δ| ≤ S/2 ∎
直观理解:四舍五入的本质是"往最近的刻度靠",而刻度间距是 1(在 t 的坐标系里),所以最多走半个刻度。换算回实数坐标系,每个刻度代表 S,所以最多错 S/2。
数值例子 :S = 0.1, Z = 0, r = 0.53:
ini
t = 0.53/0.1 = 5.3 → round = 5 → δ = 5 − 5.3 = −0.3
ε = S·δ = 0.1 × (−0.3) = −0.03
|ε| = 0.03 ≤ 0.05 = S/2 ✓
⚠️ 一个必须记住的例外 :这个上界只对"范围内的值"成立。若 r 超出 [r_min, r_max],它会被 clamp 硬夹到端点,此时误差可以任意大------裁剪误差无界:
scss
总误差 = 量化误差(≤ S/2) + 裁剪误差(无界)
这正是 4.3 节"KL 散度选阈值"要解决的问题:范围太窄 → 裁剪误差大;太宽 → S 大 → 量化误差大。
5.2 性质 2:误差零均值、方差 S²/12------完整推导
方差 S²/12 是在一个前提假设 下算出来的:量化误差 ε 在 [−S/2, S/2] 上均匀分布。
前提:为什么 ε 是均匀的。 设 t = r/S + Z,分数部分 frac = t − ⌊t⌋ ∈ [0, 1)。四舍五入后:
python
frac ∈ [0, 0.5) → round(t) = ⌊t⌋ → δ = round(t)−t = −frac ∈ (−0.5, 0]
frac ∈ [0.5, 1) → round(t) = ⌊t⌋+1 → δ = 1 − frac ∈ (0, 0.5]
关键:如果 frac 在 [0,1) 上均匀分布 (输入数据足够"乱"、步长足够小时这是很好的近似),那么 δ 就在 (−0.5, 0.5] 上均匀分布。再乘上常数 S,得到:
css
ε = S·δ 在 [−S/2, S/2] 上均匀分布
这就是"误差均匀模型"(Bennett 均匀量化模型)。
期望(零均值) :均匀分布的 PDF 是 f(ε) = 1/S(区间长度正好是 S,总概率 1):
scss
E[ε] = ∫ ε·f(ε) dε = (1/S) ∫_{−S/2}^{S/2} ε dε = 0
因为 ε 是奇函数,在关于 0 对称的区间上积分必为 0。
方差(逐步积分) :Var(ε) = E[ε²] − (E[ε])²,而 E[ε]=0,所以:
scss
Var(ε) = E[ε²] = (1/S) ∫_{−S/2}^{S/2} ε² dε
= (1/S) · [ ε³/3 ]_{−S/2}^{S/2}
= (1/S) · [ (S/2)³/3 − (−S/2)³/3 ]
= (1/S) · [ S³/24 − (−S³/24) ]
= (1/S) · ( S³/12 )
= S²/12
标准差:
σ_ε = S / √12 ≈ 0.289·S
几何直觉 :均匀分布在 [−S/2, S/2] 上的随机变量,RMS 值是 S/√12 ≈ 0.289S------比最大值 S/2 = 0.5S 小不少,因为大部分误差落在 0 附近,落在 ±S/2 极端处的是少数。
数字例子 :int8、范围 −1,1:S = 1/127 ≈ 0.00787。
arduino
σ_ε = 0.00787/√12 ≈ 0.00227 ← 误差的"典型大小"
max = S/2 ≈ 0.00394 ← 误差的"最坏情况"
典型误差只有满量程的 0.23%,最坏也才 0.39%------这就是 int8 精度够用的直观来源。
⚠️ 假设什么时候会失效:
- 数据近乎常数(某层激活值都差不多)→ 误差不是随机的,是确定性偏置,方差公式失真。
- 步长太大(低比特,如 2-bit)→ 分数部分不再近似均匀。
- 数据被裁剪(clipping)→ 裁剪误差混进来,不再是均匀模型。
此时 S²/12 只能当粗略估计,实际误差要靠校准(KL 散度 / MSE)来度量。
5.3 性质 3:信噪比与 SQNR = 6.02n dB
信噪比(SNR)= 信号功率 ÷ 噪声功率:
ini
SNR = P_signal / P_noise
"功率"是什么? 对一串数字 x,功率就是均方值 P = E[x²]。关键性质:对零均值信号,功率 = 方差 (Var = E[x²] − E[x]²,均值 0 时第二项为 0)。这正是上面 S²/12 能直接当"噪声功率"用的原因。
dB 是什么? dB(分贝)是一个用对数表示"比值"的单位------它不是某个物理量,而是回答"大了多少倍"的压缩写法:
css
功率比: dB = 10 · log₁₀( P / P₀ )
幅度比: dB = 20 · log₁₀( A / A₀ )
- 是比值,不是绝对值:必须有两个数才能算 dB(工程里"−3 dBm"等写法是约定了参考值 P₀ = 1 毫瓦,本质仍是比值)。
- 为什么 10 或 20 :dB 度量功率 比,用
10·log;功率正比于幅度的平方,所以幅度比要用20·log(10·log(A²/A₀²) = 20·log(A/A₀))。
为什么要用对数? 两个理由:
- 数值范围太大,对数压扁:信噪比可从 1 倍到 10 万倍------10× 是 10 dB、100× 是 20 dB、1000× 是 30 dB、63096× 才 48 dB。对数把跨数量级的比值压成好记的小数字。
- 乘法变加法 :信号经 5 级放大、每级 10 倍,总增益 10⁵ 倍;用 dB 只需
10+10+10+10+10 = 50 dB。级联系统增益直接相加------这是 dB 在工程里无处不在的根本原因。
几个值得记住的锚点:
| dB | 倍数 |
|---|---|
| 0 dB | ×1(与参考一样大) |
| +3 dB | 功率 ×2(10·log₂ ≈ 3.01) |
| +6 dB | 幅度 ×2(20·log₂ ≈ 6.02)← 记住它,下面马上用到 |
| +10 dB | 功率 ×10 |
| −3 dB | 功率减半("半功率点") |
量化的信噪比专门叫 SQNR(量化信噪比),噪声就是量化误差 ε。推导分四步:
步骤 1:信号功率 (信号在 [−α, α] 均匀分布,区间长 2α,方差 (b−a)²/12):
ini
P_signal = (2α)² / 12 = α² / 3
步骤 2:噪声功率 (量化误差在 [−S/2, S/2] 均匀):
ini
P_noise = S² / 12
步骤 3:步长与位宽的关系(n 比特有 2ⁿ 个刻度,铺满总长 2α):
ini
S = 2α / 2ⁿ
步骤 4:相除:
scss
SQNR = (α²/3) / (S²/12) = 4α² / S²
= 4α² / (4α²/2^(2n)) = 2^(2n)
SQNR(dB) = 10·log₁₀(2^(2n)) = 20n·log₁₀(2) = 6.02·n dB
"6 dB/bit" 的含义 :6.02·n dB 是总量公式 ,6 dB/bit 是它的斜率 ------同一件事的两种说法,就像"车费 = 6.02×n 元"和"单价 6.02 元/公里"。6.02·n 这个式子本身就是"每 bit 6.02 dB × 共 n 个 bit"。
从推导链验证"为什么每 +1 bit 恰好 +6 dB",看 n → n+1 时发生什么:
scss
① 刻度数翻倍:2ⁿ → 2ⁿ⁺¹
② 步长减半: S = 2α/2ⁿ → S/2
③ 噪声功率降到 1/4:S²/12 → (S/2)²/12
④ 信噪比 ×4,换算成 dB:10·log₁₀(4) = 20·log₁₀(2) = 6.02 dB
每 +1 bit 都重复这条链条、都 +6.02 dB------注意第④步就是上面锚点表里的"+6 dB = 幅度 ×2":加 1 bit 让整数范围恰好翻倍。n 次累加就是 6.02·n。用 n 从小到大看更直白:
| n | SQNR = 6.02·n dB | 相邻差 |
|---|---|---|
| 1 | 6.02 | --- |
| 2 | 12.04 | +6.02 |
| 3 | 18.06 | +6.02 |
| 8 | 48.16 | +6.02 |
(口语里常说"6 dB/bit",6 是 6.02 的约整。)
| 位宽 n | SQNR | 幅度误差(≈满量程百分比) |
|---|---|---|
| 4 bit | 24 dB | ~6.3% |
| 8 bit | 48 dB | ~0.4% |
| 12 bit | 72 dB | ~0.025% |
| 16 bit | 96 dB | ~0.0016% |
48 dB 意味着什么:把 dB 反解回倍数------dB 的定义是"倍数取对数",反过来就是"对数当指数":
css
dB = 10·log₁₀(P/P₀) ← 定义
dB/10 = log₁₀(P/P₀) ← 两边 ÷10
P/P₀ = 10^(dB/10) ← 两边作为 10 的指数,对数消掉
即 10^(dB/10) 就是 10·log₁₀(·) 的逆运算。代入 48:
scss
功率比 = 10^(48/10) ≈ 63096 ← 信号功率是噪声的 6 万多倍
幅度比 = 10^(48/20) ≈ 251 ← 信号幅度是噪声的 251 倍
(幅度版除以 20 是因为幅度平方才是功率;一致性检查:251² ≈ 63000 ✓。口算技巧:每 10 dB 添一个零------48 dB = 40 dB + 8 dB ≈ ×10000 × 6.3。)
即 int8 量化的噪声幅度只有满量程的 1/251 ≈ 0.4%。
一个常见变体 :若你见过 6.02n + 1.76 dB,那是满幅正弦波 的经典 ADC 公式------正弦波功率 = α²/2(峰值 α),比均匀信号 α²/3 大,多出 10·log₁₀(1.5) ≈ 1.76 dB。区别只在信号形状假设,本质是同一套推导;本文假设均匀信号,用 6.02n dB。
5.4 性质 4:误差相加按 √N 增长,而非 N
若 N 个误差彼此独立:
scss
Var(Σ εᵢ) = N · S²/12 ⟹ std(Σ εᵢ) = √N · S / √12
均值恒为 0。正负误差互相抵消,总误差不会线性爆炸------这是"整张网络几乎不掉点"的统计根基。
6. 部署视角:S/Z 的保存与恢复
6.1 S 和 Z 必须单独保存
S、Z 是量化的"字典"------量化后的权重只是一堆整数,没有 S、Z 就翻译不回真实值,所以必须随模型一起保存/下发(S 一般存 FP32 或 FP16,Z 存成和 q 一样的整数类型)。
但保存开销极小 ,具体看粒度。以一层卷积权重 [512, 512, 3, 3] ≈ 236 万参数 为例:
| 粒度 | S/Z 数量 | 额外开销 | 占比 |
|---|---|---|---|
| per-tensor | 1 对 | 8 字节 | 可忽略 |
| per-channel | 512 对 | 4 KB | ~0.17% |
| per-group(128) | ~1.8 万对 | ~145 KB | ~6% |
对比:INT8 本身把权重从 9.4 MB 压到 2.36 MB。S/Z 元数据通常连 1% 都不到。粒度越细字典越大------这是粒度权衡的另一半。
6.2 「恢复」有两种玩法
玩法 A:反量化后算浮点(weights-only 量化)
scss
存的是 int8 权重 → 推理时先 r = S(q−Z) 还原成 float → 做 float 矩阵乘
激活值保持 float。好处:改动小,LLM 推理(如 llama.cpp 的 GGUF)常用这招。缺点:计算本身还是 float,没吃到整数算力的便宜。
玩法 B:整数 GEMM(integer-only,端侧主流)
权重和激活都保持整数 → 直接做整数矩阵乘 Q_w·Q_x → 最后统一乘一次 S_w·S_x
这里不逐个数恢复------还原动作被"推迟"到整个矩阵乘算完之后,只乘一次标量。既省内存,又省算力。下一节推导它的数学。
7. 从标量到矩阵:整数 GEMM
7.1 矩阵形式的仿射映射:J 的作用
W ≈ S_w·(Q_w − Z_w·J) 就是标量公式 r = S(q−Z) 的矩阵版 ------对 W 的每个元素套一遍 S(q−Z)(per-tensor,共用一套 S、Z),用矩阵符号一次写出来。
问题在于"矩阵元素减标量"不规范,于是引入全 1 矩阵 J(和 W 同形状):
ini
Z_w · J = 每个元素都是 Z_w 的矩阵
这样"减标量 Z_w"就规范地写成了"减矩阵 Z_w·J":
ini
Q_w − Z_w · J = Q_w 的每个元素都减去 Z_w
注意:
Z_w · J里的·是标量乘矩阵,不是矩阵乘法。
数值例子 (非对称、Z≠0):激活 x 范围 [−0.5, 1.0],uint8,S_x = 1.5/255 ≈ 0.005882,Z_x = 85:
scss
x = [ −0.5 ] Q_x = [ 0 ]
[ 0.0 ] [ 85 ]
[ 1.0 ] [ 255 ]
Q_x − Z_x·J = [ 0 ] [ 85 ] [ −85 ]
[ 85 ] − [ 85 ] = [ 0 ]
[ 255] [ 85 ] [ 170 ]
S_x·(Q_x − Z_x·J) = 0.005882 · [ −85 ] = [ −0.5 ] ✓
[ 0 ] [ 0.0 ] ✓
[ 170 ] [ 1.0 ] ✓
Q_x 里存的 85 代表实数 0,所以要减掉 85(即 Z_x)"归零",再乘步长。
为什么要写成带 J 的形式? 纯粹是为了下面 GEMM 展开的代数方便------有了 J 才能用分配律把"整数部分"和"零点修正项"干净拆开。J 是代数工具,实际代码里就是"每个元素减 Z",不会真的存一个全 1 矩阵。
7.2 GEMM 展开
权重矩阵 W 与输入 x 量化后:
scss
W ≈ S_w · (Q_w − Z_w · J)
x ≈ S_x · (Q_x − Z_x · J)
矩阵乘法用分配律展开:
css
W·x ≈ S_w · S_x · [ Q_w·Q_x − Z_w·J·Q_x − Z_x·Q_w·J + Z_w·Z_x·J·J ]
Q_w·Q_x是纯整数矩阵乘法------唯一昂贵的一步,而整数 MAC 单元比 FP32 便宜约一个数量级。- 后三项只是"按行/列求和 + 乘标量",可预计算或廉价完成。
- 若两侧均对称(Z_w = Z_x = 0),后三项全部消失:
scss
W·x ≈ S_w · S_x · (Q_w · Q_x)
只剩"一次整数 GEMM + 一次标量乘法"。这正是对称量化在硬件上最受欢迎的原因。
卷积同理:权重核在空间滑动时被共享,零点修正项可以提前算好,不增加每步的额外开销。
7.3 什么是"纯整数 GEMM":一个点积例子
GEMM(GEneral Matrix Multiply,通用矩阵乘法)就是矩阵乘法,神经网络里全连接层和卷积的核心。每个输出元素是一个点积------"相乘再累加"(Multiply-Accumulate,MAC):
css
C[i,j] = Σ_k A[i,k] × B[k,j]
"纯整数"指:操作数(Q_w、Q_x)都是整数,中间每一次乘、每一次加都是整数运算,浮点不混在循环里。
| 类型 | A、B 的数据 | MAC 运算单元 |
|---|---|---|
| FP32 GEMM(默认) | float32 | 浮点乘法器+加法器 |
| 纯整数 GEMM | int8 | 整数乘法器+加法器 |
具体例子 :权重 W = [1.0, 0.5, −0.5],输入 x = [2.0, 1.0, −1.0],真实点积:
ini
W·x = 1.0×2.0 + 0.5×1.0 + (−0.5)×(−1.0) = 3.0
量化成 int8(对称,W 范围 −1,1 → S_w=1/127,x 范围 −2,2 → S_x=2/127):
ini
Q_w = [127, 64, −64]
Q_x = [127, 64, −64]
纯整数 GEMM 算的是:
ini
Q_w·Q_x = 127×127 + 64×64 + (−64)×(−64) = 24321 ← 全是整数乘加
最后乘一次标量还原:
scss
W·x ≈ S_w·S_x × (Q_w·Q_x) = (1/127 × 2/127) × 24321 ≈ 3.016 ≈ 3.0 ✓
贵的部分(3 次乘 + 3 次加)全在整数域,浮点只用了 1 次(最后乘 S_w·S_x)。
一个工程细节:整数点积累加会溢出 int8,所以累加器(accumulator)通常用 int32,最后再缩回。
7.4 为什么这很重要
- 硬件便宜:整数乘法器/加法器在芯片上的面积和功耗,比浮点单元小一个数量级。
- 数据搬得少:int8 比 float32 少 4 倍字节,内存带宽省 4 倍。
8. 为什么量化后精度损失不大
把前面的数学拼起来,答案有四层:
8.1 误差统计层:小、均匀、互相抵消
- 误差有界且零均值(性质 1、2):单个误差 ≤ S/2,int8 时只有满量程千分之几。
- 误差互相抵消(性质 4):求和时标准差按 √N 增长而非 N,网络各层的成千上万个误差不会叠加成大误差。
8.2 网络鲁棒层:Lipschitz 连续
Lipschitz 连续给"函数变化有多猛"设上限。函数 f 是 L-Lipschitz 连续 的,若存在常数 L ≥ 0,对任意 x, y:
scss
|f(x) − f(y)| ≤ L · |x − y|
L 就是 Lipschitz 常数 。一句话直觉:输入变化多少,输出最多变化 L 倍,绝不超过。
对可导函数,Lipschitz 常数就是导数的最大绝对值 L = sup |f'(x)|------即"导数处处有界,函数不会比某条直线更陡"。
| 函数 | 导数 | Lipschitz 常数 | 效果 |
|---|---|---|---|
f(x)=2x |
2 | L=2 | 扰动放大 2 倍 |
f(x)=0.5x |
0.5 | L=0.5 | 扰动缩小一半 |
ReLU(x) |
0 或 1 | L=1 | 扰动不放大 |
sigmoid(x) |
最大 0.25 | L=0.25 | 扰动缩小到 1/4 |
tanh(x) |
最大 1 | L=1 | 扰动不放大 |
x² |
2x(无界) | ❌ 全实数域不 Lipschitz | 离 0 越远越陡 |
以 ReLU 为例感受 L=1 的含义:
scss
输入 x 从 1.0 变成 1.1(扰动 0.1)
ReLU(1.1) − ReLU(1.0) = 0.1 → 变化正好等于扰动(不放大)
输入从 −1.0 变成 −0.9(扰动 0.1)
ReLU(−0.9) − ReLU(−1.0) = 0 − 0 = 0 → 变化为 0(比 L·扰动还小)
量化误差 ε 是加到每层输出上的小扰动。扰动逐层传播会不会爆炸,就看每层是否 Lipschitz:
输出扰动 ≤ L · 输入扰动
- 常用激活函数(ReLU/sigmoid/tanh)都是 Lipschitz 的,误差逐层最多线性累积,不会指数爆炸。
- ReLU(L=1)和 sigmoid(L=0.25)不仅不放大,还常常收缩------误差越传越弱。
- 反例:若激活像
x²那样导数无界,小误差经过几层就可能被疯狂放大,量化就崩了。神经网络常用的激活函数恰好都避开了这一点------这就是量化误差"传不远"的数学保障。
延伸:权重矩阵本身也可以算 Lipschitz 常数(它的最大奇异值)。完整一层
y = φ(Wx+b)的 Lipschitz 常数 ≈‖W‖ · L_φ。这条线再往下是"谱范数/奇异值",是混合精度量化 HAWQ 的预备知识。
其他两层:
- flat minima:训练收敛到的解位于损失曲面的平坦区域,权重小扰动对输出影响小。
- 过参数化冗余:网络有大量冗余自由度,能吸收量化扰动。
8.3 主动补偿层
PTQ 用校准数据选最优 S(最小化 KL 散度 / MSE),QAT 让网络在训练中"学会适应"量化(见第 10 节)。
一句话总结:量化误差是小而均匀、零均值、互相抵消的噪声;而神经网络恰好是对这种噪声鲁棒的系统。两者一叠加,精度自然保得住。
9. 量化粒度:把误差再降一个量级
9.1 先搞清楚:什么是"输出通道"
"通道"最早来自图像:RGB 图有 3 个通道(红、绿、蓝),每个通道是一张 2D 图。神经网络里,"通道"推广成"特征图"(feature map)。
一个卷积层的权重张量形状是:
css
W 的形状 = [C_out, C_in, K_h, K_w]
↑ ↑ ↑ ↑
输出通道 输入通道 卷积核高 卷积核宽
- C_out :这一层有多少个滤波器(卷积核),每个滤波器产生一个输出通道。
- C_in:输入的通道数,每个滤波器要"看"所有这些通道。
一个滤波器(形状 [C_in, K_h, K_w])在输入上滑动,每个位置做加权求和,产生一整张 2D 图------这张图就是一个输出通道:
css
out[i, h, w] = Σ_c Σ_u,v W[i, c, u, v] · x[c, h+u, w+v]
例子 :224×224 RGB 图(C_in=3),第一层 64 个 3×3 滤波器,权重形状 [64, 3, 3, 3],输出 64 个通道(每个 222×222)------64 个滤波器 → 64 个输出通道,一一对应。
全连接层的对应物:权重矩阵 [C_out, C_in] 的每一行就是一个"滤波器"。所以:
css
per-channel(per output channel)= 权重张量里 C_out 维上的每一个切片
= 卷积层:每个滤波器 W[i,:,:,:]
= 全连接层:每一行 W[i,:]
9.2 三种粒度
- per-tensor:整个张量共用一套 S、Z。最简单,但若不同通道值域悬殊,S 会被 outlier 通道"绑架",其余通道误差放大。
- per-channel :每个输出通道一套 S(作用在权重上)。直接解决 outlier 通道问题,是卷积权重的事实标准。
- per-group:每 32/64/128 个值一套 S。误差最小,但需多存 scales、硬件更复杂------是 LLM 4-bit 等低比特量化的主流方向。
9.3 为什么 per-channel 重要:outlier 通道问题
不同输出通道的权重数值范围可能差很多:
css
权重张量 [64, 3, 3, 3],量化成 int8:
通道3 的权重范围 [−0.01, 0.01] ← 都很小
通道7 的权重范围 [−0.5, 0.5] ← 很大(outlier 通道)
- per-tensor(整个张量一套 S) :S 必须覆盖最大的 −0.5, 0.5,即
S = 0.5/127 ≈ 0.004。通道 3 的值只有 ±0.01,除以 0.004 后只剩 约 ±2.5 个整数刻度------信息几乎丢光,相对误差巨大。 - per-channel(每个输出通道一套 S) :通道 3 用
S = 0.01/127 ≈ 0.00008,小值被精细保留;通道 7 用S = 0.5/127 ≈ 0.004,大值照常覆盖。每个通道各用各的尺子,谁都不拖累谁。
粒度越细 → 每套 S 覆盖的数值范围越贴合 → 误差越小 → 但元数据(scales 本身)与硬件开销上升。本质是误差与开销的权衡(元数据开销数字见 6.1 节)。
10. 两种路线:PTQ 与 QAT
10.1 PTQ(Post-Training Quantization,训练后量化)
训练完成后直接量化,用一小批校准数据选最优 S(min-max / MSE / KL 散度,见第 4 节)。几乎零成本,8-bit 通常无损;≤4 bit 时掉点明显。
10.2 QAT(Quantization-Aware Training,量化感知训练)
QAT 要解决一个矛盾:训练时想让网络"感受"到量化,但量化(round 取整)不可导,梯度传不过去。
- 前向传播要模拟量化:把值"摁"到量化格点上,让网络适应量化误差。
- 反向传播要算梯度:但
round()是阶梯函数,导数几乎处处为 0,梯度会断掉。
解决办法是在计算图中插入伪量化节点(fake quant) :前向装"真量化",反向开"假梯度"。
前向(Forward):量化 → 反量化,把值摁到格点上
ini
q = clamp( round(x/S + Z), q_min, q_max ) ← 量化成整数
x_fake = S · (q − Z) ← 马上反量化回 float
注意:x_fake 还是 float,但它的取值只能落在量化格点上(离散的几个值)。这样下游计算"感受"到的就是量化后的值,但数据类型仍是 float,训练框架不用真的上 int8。
反向(Backward):STE(Straight-Through Estimator),梯度当 1 传过去
python
∂x_fake / ∂x ≈ 1 ← 假装 round 的导数是 1
round 的真实导数几乎处处是 0,STE 直接把它替换成 1,让梯度"穿透"这个节点,去更新底下的真实权重。
为什么叫"伪" :因为它不是真量化 ------内存里存的还是 float 不是 int8,只是把值"摁"到量化格点上,模拟量化的效果,让网络在训练过程中"适应"这个格点约束。真正的 int8 量化等训练完、部署时才做。
数值例子 :设 S = 0.1, Z = 0,输入 x = 0.37:
ini
前向:
t = x/S + Z = 3.7
round(3.7) = 4
x_fake = 0.1 × 4 = 0.4 ← 0.37 被"摁"到了 0.4(格点间距 0.1)
反向:
∂x_fake/∂x = 1 ← 梯度当成没量化一样往下传
关键效果:前向网络看到的是 0.4(带量化误差),但梯度是照着 0.37 算的------底层真实权重被梯度持续优化,最终收敛到一个"即使被摁到 0.4 也照样工作"的状态。
节点插在哪里:量化后部署时权重和激活都要量化,所以伪量化节点也插在这两个位置:
css
W ──[fake_quant]──┐
├──→ conv/linear ──[fake_quant]──→ 下一层
x ──[fake_quant]──┘ ↑
激活的伪量化
↑
权重的伪量化
训练完怎么办:
css
训练阶段: W → [fake_quant] → 计算(float,带格点约束)
训练结束: 拆掉 fake_quant 节点
部署阶段: 真实量化:W → round 成 int8 存下来,推理用整数 GEMM
训练时"演"了量化,部署时"真"量化------因为网络已经在带格点的世界里训练过,精度几乎不掉。代价是训练成本显著上升;收益是 4-bit 甚至更低也能保住精度。
11. 非均匀量化与 FP8
均匀量化假设数据均匀分布,但真实权重常呈长尾分布。进阶方案:
- 非均匀量化:刻度间距不等(对数间距、K-means 聚类得到的 codebook)。更能匹配长尾分布、误差更小,但难以用整数 GEMM 高效实现。
- FP8 :浮点格式的 8-bit,两种变体:
E4M3:1 符号 + 4 指数 + 3 尾数(精度更高,适合权重)E5M2:1 符号 + 5 指数 + 2 尾数(动态范围更大,适合梯度)
对比:int8 是均匀刻度,动态范围小但尾数精度高;FP8 是对数刻度,动态范围大、对 outlier 更友好。NVIDIA H100 引入 FP8,成为大模型训练/推理的新选择。
12. 总结
量化全部的数学,可以压成一条直线和三条结论:
ini
r = S(q − Z) ------ 一条仿射直线,缩放 + 平移
- S 怎么求:数据范围 ÷ 整数范围。数据范围三条路------min-max(不裁剪、怕 outlier)、裁剪+MSE(平衡两类误差)、裁剪+KL(保分布信息)。权重离线算,激活靠校准集统计。
- 误差从哪里来、有多大 :四舍五入,有界
|ε| ≤ S/2(四舍五入最多走半个刻度)、零均值、方差S²/12(均匀分布假设下积分)、每 bit 6 dB(SQNR = 6.02n)。 - 为什么没大事:误差互相抵消(√N 增长)+ 激活函数 Lipschitz 连续不放大扰动(ReLU 的 L=1)+ 网络冗余 + PTQ/QAT 主动补偿。
- 收益从哪来 :整数 GEMM(对称时只剩
S_w·S_x·(Q_w·Q_x)),算力、带宽、内存全面下降;S/Z 元数据开销不到 1%。
量化不是"牺牲精度换速度"的无奈之举,而是利用"误差的统计结构 + 神经网络的鲁棒性"做的一次有数学保障的压缩。
参考文献
- MIT 6.5940 / TinyML and Efficient Deep Learning Computing (EfficientML.ai),韩松(Song Han),MIT:
- 课程主页:efficientml.ai
- 本文的量化框架(仿射映射、误差分析、整数 GEMM、PTQ/QAT、粒度与 FP8)与该课程 Quantization 一讲的内容体系一致。