4. 量化基础:从FP32到INT8的映射
好,咱们进入量化这个核心话题。说实话,很多做嵌入式AI的朋友一听到「量化」就觉得头大,觉得全是数学公式。其实没那么复杂。你想想看,我们做单片机部署,MCU没有FPU或者FPU很弱,跑float32就像让小学生算微积分——能算,但慢得离谱。
量化的本质就一句话:用更少的比特数去近似表达一个浮点数。把FP32的模型变成INT8,体积直接缩到1/4,推理速度能快2-4倍。我在一个STM32F4的项目上试过,量化前跑一帧要800ms,量化后降到220ms,效果还基本不掉点。
4.1 映射的本质:实数到整数的桥
我们先看最核心的问题:一个FP32的数值,怎么变成INT8?
INT8能表示的范围是[-128, 127](有符号)或者[0, 255](无符号)。FP32的范围可就大了去了,±3.4×10³⁸。所以我们需要做一个线性映射,把FP32的某个区间「压缩」到INT8的区间里。
这个映射由两个参数决定:
- scale(缩放因子):决定了每个INT8步长对应多大的FP32值
- zero_point(零点):决定了FP32的0对应哪个INT8值
公式很简单:
q = round(r / scale) + zero_point
反过来,反量化就是:
r = (q - zero_point) × scale
这里r是原始浮点数,q是量化后的整数。嗯,就这么点东西。我刚开始学的时候也觉得不可思议,这么简单的线性变换就能把模型压到1/4?后来亲手做了一遍才明白,关键不在于映射本身,而在于怎么选scale和zero_point。
4.2 对称量化 vs 非对称量化
这两个概念,说白了就是zero_point怎么处理。
对称量化
对称量化强制zero_point = 0。也就是说,FP32的0映射到INT8的0。这样映射公式就简化成:
q = round(r / scale)
它的特点是:
- 实现简单,计算量小
- 适合权重(weights)——因为权重通常关于0对称分布
- 但如果有偏置或者激活值偏向一侧,对称量化会浪费编码空间
举个例子,如果某个激活层的输出范围是[0, 6.0],用对称量化的话,INT8的[-128, 0]这段就全浪费了。你想想看,128个码字全空着,多可惜。
非对称量化
非对称量化允许zero_point不为0。这样就能把FP32的整个[min, max]区间完整映射到INT8的[0, 255](无符号)或[-128, 127](有符号)。
公式就是前面那个:
q = round(r / scale) + zero_point
它的好处是:
- 充分利用INT8的整个编码空间
- 适合激活值(activations)——ReLU后的输出全是非负的
- 精度损失通常更小
代价就是计算稍微复杂一点,每次运算都要带上zero_point的偏移。
我的经验之谈:在CMSIS-NN里,权重几乎都用对称量化,激活值用非对称量化。这是业界惯例,也是经过大量验证的最优方案。我曾经试过把权重也改成非对称量化,结果精度没提升多少,推理速度反而慢了5%。
下面这张图可以帮你直观理解两者的区别:
4.3 量化参数的计算
好,现在咱们来算scale和zero_point。这部分我建议你亲手算一遍,以后遇到问题心里就有底了。
scale的计算
scale的公式很简单:
scale = (r_max - r_min) / (q_max - q_min)
其中:
r_max和r_min是FP32数据的实际最大值和最小值q_max和q_min是量化后的整数范围边界
举个例子,假设某个激活层的输出范围是[0.0, 6.0],我们用无符号INT8 [0, 255]来量化:
scale = (6.0 - 0.0) / (255 - 0) = 6.0 / 255 ≈ 0.02353
这意味着INT8每变化1,对应FP32变化约0.02353。这个值越小,量化精度越高。
zero_point的计算
zero_point的计算稍微绕一点:
zero_point = round(q_min - r_min / scale)
或者更直观地:
zero_point = round(-r_min / scale) + q_min
继续上面的例子:
zero_point = round(0 - 0.0 / 0.02353) + 0 = 0
因为r_min=0,所以zero_point=0。但如果r_min不是0呢?比如某个层的输出范围是[-3.0, 5.0]:
scale = (5.0 - (-3.0)) / (255 - 0) = 8.0 / 255 ≈ 0.03137
zero_point = round(0 - (-3.0) / 0.03137) + 0 = round(95.6) = 96
验证一下:FP32的0映射到INT8的96,合理。
小技巧:计算zero_point时,记得要clamp到[q_min, q_max]范围内。我曾经遇到过zero_point算出来是-2,结果反量化时直接溢出,排查了半天才发现是clamp没做。
4.4 量化误差的来源
量化不是无损的。误差主要来自两个地方:
| 误差类型 | 产生原因 | 影响程度 |
|---|---|---|
| 舍入误差 | round()操作导致的精度损失 | 较小,通常±0.5个量化步长 |
| 截断误差 | 超出[min, max]范围的值被截断 | 较大,尤其是存在异常值时 |
| 分辨率损失 | INT8只有256个码字,无法精确表示所有FP32值 | 固有误差,无法消除 |
我在一个语音识别模型上遇到过这种情况:某个激活层有个别值跑到100多,但99%的值都在[-5, 5]之间。如果按实际范围[-5, 100]来量化,分辨率会被严重稀释。后来我用了饱和量化,把范围截断到[-5, 5],虽然那1%的异常值被截断了,但整体精度反而提升了0.3%。
注意:量化参数的校准(calibration)非常关键。常用的方法有:
- MinMax校准:直接取实际最小最大值,简单但容易受异常值影响
- Percentile校准:取99.99%分位点,忽略极端异常值
- KL散度校准:最小化量化前后分布的KL散度,精度最优但计算量大
我个人习惯先用MinMax快速验证,再用Percentile做正式校准。KL散度虽然好,但在单片机上跑校准太慢了,不实用。
4.5 量化参数在CMSIS-NN中的表示
在CMSIS-NN里,量化参数不是直接用float存的——那太浪费了。它们用定点数表示:
// CMSIS-NN 量化参数结构体
typedef struct {
int32_t multiplier; // 定点乘数,实际 scale = multiplier / 2^shift
int32_t shift; // 移位量
int32_t offset; // zero_point 的偏移量
} cmsis_nn_quant_params;
为什么这么设计?因为单片机没有FPU,做浮点乘法太慢。把scale拆成multiplier和shift,就可以用整数乘法和移位来实现:
// 反量化:r = (q - zero_point) × scale
// 等价于:r = ((q - offset) × multiplier) >> shift
int32_t tmp = (q - params.offset) * params.multiplier;
int32_t result = tmp >> params.shift;
这样整个计算过程全是整数运算,快得很。我在Cortex-M4上测过,用这种定点方式比直接用float快3倍以上。
4.6 实战:手动计算一组量化参数
咱们来走一遍完整流程。假设有一个卷积层的权重,FP32值如下:
权重数据: [0.5, -0.3, 1.2, -0.8, 0.0, 2.1, -1.5, 0.7]
步骤1:确定范围
r_min = -1.5
r_max = 2.1
步骤2:选择量化方式
权重用对称量化,INT8范围[-128, 127]。
scale = max(|r_min|, |r_max|) / 127
= max(1.5, 2.1) / 127
= 2.1 / 127
≈ 0.01654
步骤3:量化每个值
q = round(r / scale)
q(0.5) = round(0.5 / 0.01654) = round(30.23) = 30
q(-0.3) = round(-0.3 / 0.01654) = round(-18.14) = -18
q(1.2) = round(1.2 / 0.01654) = round(72.55) = 73
q(-0.8) = round(-0.8 / 0.01654) = round(-48.37) = -48
q(0.0) = round(0.0 / 0.01654) = 0
q(2.1) = round(2.1 / 0.01654) = round(126.96) = 127
q(-1.5) = round(-1.5 / 0.01654) = round(-90.69) = -91
q(0.7) = round(0.7 / 0.01654) = round(42.32) = 42
步骤4:验证精度
反量化回去看看误差:
r'(0.5) = 30 × 0.01654 = 0.4962 (误差 0.0038)
r'(-0.3) = -18 × 0.01654 = -0.2977 (误差 -0.0023)
r'(2.1) = 127 × 0.01654 = 2.1006 (误差 0.0006)
最大误差约0.004,对于8位量化来说已经很不错了。
核心要点回顾:
- 量化就是FP32到INT8的线性映射,由scale和zero_point决定
- 对称量化(zero_point=0)适合权重,非对称量化适合激活值
- scale决定精度,zero_point决定偏移
- CMSIS-NN用定点数表示scale,避免浮点运算
- 校准方法选得好,精度损失可以控制在1%以内