浅析模型量化的数学原理

本文从数学角度完整拆解模型量化(Quantization):它如何把 FP32 浮点权重/激活映射成低比特整数,S 和 Z 怎么求,量化误差从何而来、如何严格证明它的界,信噪比怎么推,以及为什么 8-bit 甚至 4-bit 量化之后,模型精度损失通常并不严重。 内容框架与 MIT 6.5940 / TinyML(韩松 Song Han)课程的 Quantization 一讲一致。
收录于微信公众号:AngryCode 愤怒的代码


0. TL;DR

  1. 量化的本质是一条仿射变换 :r = S(q − Z)。r 是 float,q 和 Z 都是整数,S 是 float------全公式只有一次浮点乘法。
  2. S = 数据范围 ÷ 整数范围。数据范围有三条求法:min-max、裁剪+MSE、裁剪+KL 散度。
  3. 误差来自四舍五入 ,可严格证明 |ε| ≤ S/2、零均值、方差 S²/12(均匀分布假设下积分可得)。
  4. 每加 1 bit,量化信噪比提升 6 dB (SQNR = 6.02n dB);int8 的噪声只有满量程的 ~0.4%。
  5. 误差在求和/传播时正负抵消(标准差按 √N 增长),且激活函数 Lipschitz 连续(ReLU 的 L=1)使误差不被放大,因此整网几乎不掉点。
  6. 矩阵乘法可以拆成纯整数 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. 数值范围太大,对数压扁:信噪比可从 1 倍到 10 万倍------10× 是 10 dB、100× 是 20 dB、1000× 是 30 dB、63096× 才 48 dB。对数把跨数量级的比值压成好记的小数字。
  2. 乘法变加法 :信号经 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 一讲的内容体系一致。
相关推荐
掉进电商坑三年没爬出来的东叔1 小时前
Vibe Coding实战三:把AI做的小工具部署上云,手机电脑随时用
人工智能·智能手机
甲维斯1 小时前
我用AI做了一款「三国吃鸡」,开局孔明灯,落地青龙刀 !
人工智能·游戏·游戏开发
WeiXin_DZbishe1 小时前
基于Springboot+AI的kuall音乐广场的设计与实现-计算机毕设 附源码【课程设计】12715
vue.js·人工智能·spring boot·后端·python·php·课程设计
boluoji1 小时前
TOP 10 大模型实测:发两个字母,被计了 4223 个 token
人工智能
冬奇Lab2 小时前
LLM 驱动的自动化测试系列(10):自愈定位——当启发式打分撞上语义理解的天花板
人工智能·测试
adrninistrat0r2 小时前
浏览器自动化——AI 生成网页操作脚本
人工智能·自动化
冬奇Lab2 小时前
一天一个开源项目(第234篇):CC Switch —— 一个 App 管理十款 AI 编程工具的 Provider、MCP、Skills 和 Prompts
人工智能·开源·claude
AI创界者2 小时前
结构型模式的现代化演进:深入 Python 高级特性的装饰器与代理模式
人工智能·aigc