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%。

下面这张图可以帮你直观理解两者的区别:

对称量化 vs 非对称量化 对称量化 FP32: [-a, a] INT8: [-128, 127] zero_point = 0,映射简单 非对称量化 FP32: [min, max] INT8: [0, 255] 或 [-128, 127] zero_point ≠ 0,充分利用编码空间 关键区别 • 对称量化:zero_point=0,适合权重(分布对称),计算快 • 非对称量化:zero_point≠0,适合激活值(分布偏斜),精度高 • CMSIS-NN 实践中:权重用对称,激活用非对称,这是黄金组合

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%以内