量化感知训练QAT(Quantization-aware-training)

1. 前言
随着人工智能的发展,深度模型越来越大,主流模型通常包含大量参数,需要消耗大量算力。
神经网络通常用两个指标来衡量模型规模和计算复杂度:
参数量(Parameters):模型需要存储多少权重,影响模型大小和显存占用。
浮点运算量(FLOPs, Floating Point Operations):一次前向推理需要多少浮点计算,影响推理速度。
两者并不是线性对应关系。例如,一个参数可以被重复使用很多次,因此参数量相近的模型,FLOPs可能相差数倍。

下面列出一些常见神经网络的大致规模(以常见输入尺寸为准):
| 模型                           |   参数量 | FLOPs(单次前向)|
| ----------------------------- |  -----: | -----------: |
| LeNet-5                       |    6 万 | 0.002 GFLOPs |
| AlexNet                       | 6100 万 |  0.72 GFLOPs |
| VGG16                         | 1.38 亿 |  15.5 GFLOPs |
| VGG19                         | 1.44 亿 |  19.6 GFLOPs |
| ResNet18                      | 1170 万 |   1.8 GFLOPs |
| ResNet34                      | 2180 万 |   3.6 GFLOPs |
| ResNet50                      | 2560 万 |   4.1 GFLOPs |
| ResNet101                     | 4450 万 |   7.8 GFLOPs |
| ResNet152                     | 6020 万 |  11.5 GFLOPs |
| MobileNetV2                   |  350 万 |  0.30 GFLOPs |
| MobileNetV3-Large             |  540 万 |  0.22 GFLOPs |
| EfficientNet-B0               |  530 万 |  0.39 GFLOPs |
| EfficientNet-B7               | 6600 万 |    37 GFLOPs |
| Vision Transformer (ViT-B/16) | 8600 万 |  17.6 GFLOPs |
| ViT-L/16                      | 3.07 亿 |    61 GFLOPs |
| Swin-T                        | 2800 万 |   4.5 GFLOPs |
| Swin-B                        | 8800 万 |  15.4 GFLOPs |

实时地运行这些模型需要消耗大量内存和算力,这使得它们难以部署至Android和其他低功耗边缘设备。为了进一步推动深度神经网络模型在移动端/嵌入式设备中的快速部署,深度学习领域提出了一系列的模型压缩与加速方法:

  剪枝(Parameter pruning)
  低秩分解(Low-rank factorization)
  知识蒸馏(Knowledge distillation)
  量化(quantization)

量化(quantization)是模型压缩的一种常用方法,通常情况下可以使用不同的量化策略,将深度学习模型参数与运算的精度从浮点数(FP32)降低至较低的精度,如INT8,一方面可以提升模型在cpu/gpu等硬件的推理计算效率,减少计算成本,另一方面能够减小模型的size,在边缘设备具有存储优势。现在无论是服务器端或者是移动端,支持INT8量化都是一个发展趋势,比如阿里含光, 华为altlas, 华为npu, 高通npu,nvidia的tensorcore等计算设备均支持INT8计算。在Nvidia T4 GPU上(启用 Tensor Core)INT8算力是单精度算力的16倍。FP32 Mult运算能耗是INT8 Mult运算的18.5倍,芯片占用面积是INT8的27.3倍。

为什么T4 INT8算力比FP32高16倍?原因就是:Tensor Core本身就是按照低精度优化设计的。

| 类型           | FP32  | INT8 |
| ------------  | ----- | ---- |
| 每个数字        | 32bit | 8bit |
| 内存           | ×4    | ×1   |
| 带宽           | 大     | 小    |
| Cache利用率    | 低     | 高    |
| TensorCore吞吐 | 较低    | 极高   |

速度提升不仅来自计算,更重要的是:内存访问减少。现代GPU很多时候真正瓶颈不是ALU,而是Memory Bandwidth。INT8一下把数据量缩小4倍,很多模型自然就快了。

量化是一个信息有损压缩的过程,如果训练过程中使用FP32,在模型推理时使用Post-training Quantization(PTQ)直接量化为INT8模型,模型精度会存在一定损失。而量化感知训练(Quantization-aware-training, QAT)在模型训练过程中就引入了伪量化(Fake-quantization)来模拟量化过程中带来的误差,通过这种方式能够进一步减少量化后模型的精度损失。

2.Quantization-aware-training原理
Google在《Quantization and training of neural networks for efficient integer-arithmetic-only inference》论文中提出了QAT,在训练过程加入了模拟量化。相对于PTQ而言,QAT能够在量化模型后减少精度损失。
2.1 线性量化计算方法简介(Linear Quantization)
在介绍QAT之前,首先简单提一下量化的计算方法,这里以FP32到INT8的量化为例。量化的核心思想就是将浮点数区间的参数映射到INT8的离散区间中,论文中的公式为

  r = S(q - Z)
其中:
  r:FP32值
  q:INT8值
  S:Scale
  Z:Zero Point
量化公式为:
  q = round(r/S) + Z

S、Z 分别为缩放因子(Scale-factor)和零点(Zero-Point),我们需要做的就是要确定 S、Z 两个参数。
其中:

  S = (rmax - rmin) / (qmax - qmin)
其中 rmax、rmin 分别为统计得到的FP32权重浮点数的实际最大值与最小值,qmax、qmin 分别为量化格式理论最大值与最小值。
INT8量化中,qmax、qmin为-128、127,原始值将被映射至[-128,127]整数区间。
Z表示FP32浮点数 r=0 对应的量化值,因为实际的权重值不一定关于0对称。
将两套格式小于0的区间对齐:(Z - qmin) * S = -rmin,整理可得:
  Z = -rmin / S + qmin

Zero Point(Z)为什么必须是整数?
这是因为 INT8 的取值只能是整数,Zero Point 表示的是:
表示浮点数中的0.0在量化后对应的整数值
量化公式是:

  q = round(r/S) + Z

这里 round(…) 的结果已经是整数。Z 如果还是浮点数,得到的就不是 INT8 整数,无法存储。所以 Z 必须是整数。
Zero Point 的计算过程中可以是浮点数,但最终保存和推理使用时一定是整数,真正保存时,会进行四舍五入:
Z=round(Z)
随后还要进行裁剪(Clip):
Z=clip(Z,qmin,qmax)

| 参数        | 数据类型               | 是否参与推理 |
| ---------- | --------------------  |  ------  |
| Scale      | 浮点数(FP32 或 FP16)   | 是      |
| Zero Point | 整数(INT8/UINT8/INT32)| 是      |
反量化公式:
  r=S(q−Z)

但要注意一个例外:对于很多深度学习框架(包括 TensorRT、PyTorch 的权重量化),并不会使用上面的完整公式,而是采用对称量化(Symmetric Quantization):

  Z=0
  不使用 rmin
  Scale 改为 S = max(rmin, rmax) / 127
例如:
  rmin = -5
  rmax = 7
则
  S = 7/127 ≈ 0.0551

这种方式实现更简单,推理效率也更高,因此权重量化几乎都采用对称量化;而激活值由于通常不是以 0 为中心,很多框架仍会采用非对称量化(Asymmetric Quantization)

2.2 为什么存在量化误差
模型量化精度损失的主要原因为量化-反量化(quantization-dequantization)过程中取整引起的误差。误差来源不是INT8本身,而是取整(Round)。

训练时所有浮点数都会经历:
  FP32
   ↓
  INT8
   ↓
  FP32
为什么训练仍然需要FP32梯度
  1.因为梯度本身是连续的小数,而INT8是离散整数,INT8分辨率太粗了,无法有效进行梯度下降。
    例如:Scale=0.1, Z=0
    那么:INT8 = 5
    真正对应的是:FP32 = 0.5
    训练时梯度往往只有 0.0001 甚至 0.000001
    现在梯度告诉它:应该减去0.0001
    结果变成0.4999
    重新量化:Round(0.4999 / 0.1)=5,完全没有变化。
  2.Round 不可导,大部分地方梯度都是 0,在整数边界处梯度不存在,如果直接反向传播梯度0,所有参数都不更新,训练直接失败。
那 Round 的梯度怎么办?
这里用了一个非常巧妙的方法:STE(Straight-Through Estimator,直通估计器)。
  训练时前向:Round(x)
  反向:直接假装Round(x)≈x
  于是:梯度变成dRound(x)/dx≈1,而不是0
这就是为什么 Fake Quant 可以训练。

2.3 PTQ(Post Training Quantization)

FP32训练完成
      │
      ▼
统计激活值范围(Calibration)
      │
      ▼
计算 Scale、Zero Point
      │
      ▼
直接变成 INT8
      │
      ▼
部署

特点:

训练过程中模型完全不知道以后要量化。
最后一次性把 FP32 改成 INT8。
因此模型会突然遇到:
Round()
Clip()
INT8
造成精度下降。

2.4 QAT(Quantization Aware Training)

FP32训练完成
        │
        ▼
插入 FakeQuant 伪量化节点
        │
        ▼
继续 Finetune
        │
        ▼
保存 Scale、Zero Point
        │
        ▼
导出 INT8

这里最大的区别就是:
模型在训练阶段,就已经经历了量化误差。
因此最后真正部署成 INT8 时,模型不会”措手不及”。

2.4.2 伪量化节点(Fake-quantize)
伪量化实际上是quantization+dequantization的结合,实际上就是模拟量化round引起的误差。
也就是:

FP32
 ↓
量化
 ↓
INT8
 ↓
立即反量化
 ↓
FP32
注意:
最终输出仍然是 FP32,但是已经包含了量化误差。
所以训练框架根本不需要支持 INT8 梯度。

Fake Quant 数学公式可以拆成三步理解

第一步:限制数据范围(Clamp)
  r′=clamp(r;a,b)
其中:
  a:FP32浮点数值最小值
  b:FP32浮点数值最大值
这一步就是:截断(Clipping),如果超出量化范围[a,b],那么截断到[a,b]
第二步:计算 Scale
  s=(b-a)/(n-1)
其中:
  n:量化等级数
例如 INT8:n=256
所以:
  s=(b−a)/255
和上一节介绍的 Scale 完全一致。
第三步:真正执行 Fake Quant
可以理解为:
Clamp
 ↓
除Scale,减去a
 ↓
Round
 ↓
乘Scale,加上a
 ↓
输出

Fake Quant 不是为了模拟 INT8。真正模拟的是:Round() 带来的误差。

Fake Quant 插在哪里?
论文中的典型做法是在两个地方插入 Fake Quant:

Weight
   │
FakeQuant
   │
Conv
   │
Activation
   │
FakeQuant
   │
下一层
即:
Weight Quantization:卷积权重进入卷积之前。
Activation Quantization:激活输出之后。
原因是最终部署时,通常权重和激活都会量化为 INT8,因此训练时需要同时模拟这两类误差。

2.4.3 QAT finetune过程量化参数处理
伪量化节点会保存finetune过程中的量化参数,伪量化节点的计算公式中 [a,b] 即为FP32浮点数值的范围,这个值将会在finetune过程中进行估计与更新,上面介绍了伪量化节点分别weight quantization以及activation quantizaion:

对于weight quantization的量化节点,每次前向传播直接将 [a,b] 设置为weights的最大值与最小值即可,即 a=min(w), b=max(w)
对于activation quantizaion,处理方式类似于batch norm,使用了指数移动平均,在finetune的每个batch动态地更新 [a,b]

为什么叫指数移动平均(Exponential Moving Average,EMA)

普通移动平均(Moving Average)通常是最近 N 个值的简单平均,例如:
  (x1 + x2 + x3 + x4 + x5) / 5
而 EMA 不需要保存历史所有数据,只保存上一次 EMA 的结果即可。
  vt​ = β⋅vt−1​ + (1−β)⋅xt​
其中:
  vt:当前时刻的移动平均值
  xt:当前时刻的观测值(比如这个 batch 的激活值最大值)
  β:动量系数,通常取 0.9 ~ 0.999,越接近1,历史信息权重越大,平滑效果越强
  vt−1:上一时刻的移动平均值
展开这个递推式:
  vt=(1−β)*xt + (1−β)β*xt−1 +(1−β)β^2*xt−2 +…
可以看到越久远的历史数据,权重按 β^k 指数衰减,所以叫指数移动平均。

weight quantization为什么不使用EMA?

最根本的原因:权重是参数,激活是数据
| Weight       | Activation     |
| --------     | -------------- |
| 网络参数      | 网络输出           |
| 由优化器更新   | 由输入数据决定        |
| 始终保存在模型中 | 每个 Batch 都重新计算 |
| 当前时刻只有一份 | 每个 Batch 都不同   |
也就是说:
  Weight 是"固定资产",Activation 是"流数据"。

为什么 Activation 需要 EMA?
Activation 的统计值主要受输入数据影响。输入不同的图片,激活范围可能相差非常大。
如果每个 Batch 都直接更新 Scale,Fake Quant 每一步都会改变,训练就会非常不稳定。
为什么 Weight 不需要 EMA?
Weight 本身就是通过梯度下降连续变化的。并不会因为输入图片的不同突然发生巨大变化。
也就是说:权重已经天然是连续更新的。
更重要的原因:Weight 可以直接遍历
一句话总结
Activation 的分布主要由输入数据决定,每个 Batch 波动很大,因此需要 EMA 来获得稳定的量化参数;Weight 是模型参数,本身通过梯度下降平滑更新,并且可以随时完整遍历,因此直接根据当前权重计算 Scale 即可,使用 EMA 反而可能因为统计滞后而增大量化误差。

伪量化操作看起来输入输出没变,但是实际上模拟了量化 round 操作,将这种误差当做训练噪声,在 QAT finetune 时模型会主动适应这种噪声,从而减少最终 INT8 部署时的精度损失。