December 17, 2023
本文主要介绍深度学习神经网络模型的量化原理和工程实践,主要包括如下内容:
- 基本原理:量化映射、量化粒度、量化计算、算子融合等;
- 模型量化方法:权重\激活量化、PTQ、QAT等;
- 工程实践:PyTorch 模型量化介绍&示例;
- 工程实践:高通 QNN 量化工具介绍&示例;
- LLM 量化算法简介;
一、基本原理
量化简介
神经网络的计算通常以浮点计算(FP32)进行,而量化则是将浮点计算替换为更低比特的计算,如 FP16、INT8 等,从而降低模型的存储大小、降低显存占用、提升推理性能,当然,量化的同时需要尽可能保持模型的精度,因此需要设计合适的量化方案。
量化映射
数值的量化可以看作一个近似过程,主要可分为两类:
-
Fixed Point Approximation
定点近似主要是缩小浮点表示中的指数和小数部分的位宽,不需要额外的量化参数,也没有反量化过程,实现相对简单,但是在数值较大时,直接定点近似会带来较大的精度损失。
-
Range Based Approximation
基于范围的近似,则是先统计待量化数据的分布,然后进行整体的缩放和偏移,再映射到量化空间,精度相对更高,但需要额外存储量化参数(如缩放系数、偏移等),并且计算时需要先反量化,比定点近似更复杂。根据映射方式的不同,又可分为线性映射和非线性映射:
线性映射
线性映射将数值从浮点空间线性变换到量化空间,可用如下公式表示:
其中 r、q 分别是量化前、后的数,S (Scale)和 Z (Zero-Point)是量化系数,Z 有时也称为偏移(Offset),可以看作是原数值 0 量化后的值,根据 Z 是否为 0,线性映射又可分为对称映射和非对称映射:
如上图,对称映射的 Z 始终为 0, 即原数值的 0 量化后仍然是 0,量化前后的数值都是以 0 为中点对称分布,但实际上有些数值的分布并不是左右对称的,比如 ReLU 激活后都是大于 0,这样会导致量化后 q 的范围只用到了一半,而非对称映射则解决了这个问题:
非对称映射的 min、max 独立统计,Z 的值根据 r 的分布不同而不同,这样可以使 q 的范围被充分利用。
我们来看一个实际的例子:量化 FP32 [-1.8, -1.0, 0, 0.5] 到 INT8 [0, 255] (非对称):
- rmin = -1.8,rmax = 0.5, bitWidth = 8
- S = (rmax – rmin)/(qmax – qmin) = (0.5 – (-1.8)) / (255 – 0) = 0.009019607843
- Z = qmin – rmin/S = 0 – (-1.8)/S = 199.56521739 ≈ 200
- 量化结果:q = round([-1.8, -1.0, 0, 0.5] / S + Z) = [0, 89, 200, 255]
反量化:
- r’ = S * ([0, 89, 200, 255] – Z) = [-1.80392157, -1.00117647, 0, 0.49607843]
可以看到:反量化后数值对比原始数值存在一定误差。
非线性映射
量化是一个数值映射过程:[r0, r1, …,rk] -> [q0, q1, …, qk] ,对于线性映射,并没有考虑原始数据本身的分布,如下图的正态分布,越靠近中间的 0,数据分布越密,左边是线性映射,量化后数值也同样会集中在中间的 0 附近,如果更极端一点,会导致大量的数值量化后都是 0, 显然这样就降低了量化的精度,而如果按右图,对数据分布密集的区域,给与更多的量化映射,就能增加量化后的差异性,提高精度。实际上,我们希望量化后的数据在量化空间应该均匀分布,而不是被原始数据的分布所影响。
非线性映射有多种实现,这里介绍一种分位量化方法(Quantile Quantization):分位量化的基本想法是寻找一些分位点对原数据进行划分,使得各个区间的数据个数相等,然后将同一个区间的数据映射到同一个值,从而实现了量化。
如上图左边是一个类似于标准正态的分布图,红色竖线是16等分的分隔线,即相邻的分隔线截取的曲线面积相等,这样我们再把区间中的数都用区间中点来映射,即完成了分位量化,那如何求这些分隔线的位置呢?可以用累积分布函数的反函数来计算,如上图右图,累计分布函数(CDF)可以看作是左图积分图,我们把纵坐标进行16等分,那么等分线与CDF曲线的交点对应的横坐标即为分位点,我们用 Qx 表示CDF函数的反函数,qi 表示各个区间的中点,Qmap 是所有 qi 的集合,Qmap = [q0, q1, … q2^k-1],则有:
实际计算过程中,需要先将数据归一化到合适的范围,并且对于确定的分布来说,分位点也是确定的,因此只需存储分位点的索引即可,整体步骤如下:
- 计算归一化常数 N=max(|T|) ,将输入张量T转化到目标量化数据类型的范围内
- Qmap 是 qi 的集合,对于T/N的每个元素,搜索在 Qmap 中最接近的对应值qi
- 将对应 qi 的索引 i 作为量化输出结果
LLM QLoRA 算法提出的 NF4(4-bit NormalFloat Quantization) 是分位量化的一种实现,其采用 4 bit,总共有16个分位数,并且为了保持 0 映射后仍然是 0 进行了特殊处理,把[-1, 0]分成7份,然后生成[-1, …, 0]共8个分位数, 把[0, 1]分成8份,然后生成[0, …, 1]共9个分位数,两个合起来去掉一个0就生成全部的16个分位数:
Q_map = [-1.0, -0.6961928009986877, -0.5250730514526367, -0.39491748809814453,
-0.28444138169288635, -0.18477343022823334, -0.09105003625154495, 0.0,
0.07958029955625534, 0.16093020141124725, 0.24611230194568634, 0.33791524171829224,
0.44070982933044434, 0.5626170039176941, 0.7229568362236023, 1.0]
NF4 量化的16个区间分布如下图,各区间的面积基本相当,整体上也是符合正态分布的:
具体来看一个 NF4 量化的例子:
- 原始输入数据:[-5, 3, 16, 8]
- 计算归一化常数 N = max{abs(xi)} = 16
- 将输入数据归一化到 [-1, 1]:[-5, 3, 16, 8] / N = [-0.3125, 0.1875, 1.0, 0.5]
- 从Qmap找到最接近的分位数qi, 将索引作为量化结果:[4, 9, 15, 12]
可以看到,量化之后需要存储的是归一化常数 N 和 qi 的索引,接下来是反量化:
- 将量化结果作为索引从Qmap中找到对应分位数[4, 9, 15, 12] -> [-0.28444138169288635, 0.16093020141124725, 0.7229568362236023, 0.33791524171829224]
- 将分位数反归一化(乘以N):[-4.551062107, 2.574883223, 16, 7.051357269]
量化粒度
量化粒度指选取多少个待量化参数共享一个量化系数,通常来说粒度越大,精度损失越大,比如下面5个数一起量化到[0, 255],由于存在一个离群值(outlier) 1000,导致前面四个数量化后都是 0, 失去了差异性,从而影响结果的精度。
[-1.8, -1.0, 0, 0.5, 1000] -> [0, 0, 0, 0, 255] -> [0, 0, 0, 0, 1001.8]
再看一个例子,如下四个数 [100, 90, 0.3, 0.1],如果4个数一起量化,最终误差为 0.64,而如果分为两组分别量化,则最终误差只有 0.241,从这里也可以看出,通过将值域范围接近的数分组量化,能降低量化误差。
根据分组的依据不同,量化粒度有多种形式:
- per-tensor/per-layer
- per-channel/per-axis
- per-col/per-row
- per-embeding/per-token
- per-block/group
一般来说,量化粒度越小,需要额外存储的量化系数就越多,比如针对卷积运算常见的 per-tensor/per-channel 量化,如下图所示,per-tensor 只需一组 (S, Z) 量化系数,而 per-channel 需要多组,提升了量化精度,但同时会一定程度增加量化后数据的大小
量化计算
硬件的整型/定点计算通常比浮点计算更快,因此量化到整型/定点后,有利于推理性能的提升,接下来看看常用的算子在量化后有什么区别,首先是最基本的加、乘:
可以看到,线性量化后,q、z 相关的都是整型计算,但仍然会有量化系数 S(FP32)的计算,这部分(图中红圈)也可以优化为定点计算来提升速度:
我们将浮点数用 Q 表示法转换为定点数:
- qfixed = (int) (xfloat * 2Q)
- xfloat = (float) (qfixed * 2-Q)
这样两个浮点数 x1、x2 的乘法可以展开为:
- x1 x2 = (q1 2-Q) (q2 2-Q) = (q1 q2) 2-2Q = ((q1 q2) >> Q) 2-Q
即最终只有整型的乘法(q1 * q2)和移位操作,举个例子:
- 0.234 * 0.84 = 0.19656,取 Q = 30
- 0.234 ≈ 251255586 * 2-30
- 0.84 ≈ 901943132 * 2-30
- 251255586 * 901943132 = 226618250169335352 (注意这里需要使用64位乘法,避免溢出)
- 226618250169335352 >> 30 = 211054692
即计算结果为: 211054692 2-30 ,转换为浮点数:211054692 2-30 = 0.19655999913 ,可以看到精度是比较高的,这里 Q 取值越大,精度越高,但可表示范围也越小,如下表所示:
下图是卷积运算的硬件实现示意图:可以看到 A1、A2、A3、A4 都是 INT32,即用更高的位宽来存储中间结果,避免溢出,然后再重新量化为 INT8,这里的 requantization 可以合并一些后续的激活操作(如 ReLU),进一步提升推理性能。
对于一些较复杂的激活函数,通常是非线性的,量化计算主要有查表法和拟合法两类。查表法实现相对简单:对于低比特量化,x可取值总数有限,可以提前计算好映射表(如INT8的表长为256),推理时直接从表中取值即可:
而拟合法则是通过使用多相似逼近、泰勒展开等方式,用低阶函数来模拟,比如 GELU 激活中的 erf 函数,通过一个二次函数 L(x) 来拟合:选取一些采样点,然后用最小二乘法,计算得到 a = -0.2888, b = -1.769, c = 1,右边是 erf 函数真实点与拟合曲线的示意,可以看到拟合精度还是不错的。
同样的对于 SoftMax 中的 exp,也可以同样的进行二项式拟合:
以上 GELU、SoftMax 的拟合实现都出自论文:I-BERT: Integer-only BERT Quantization
算子融合
算子融合(fuse)是将多个算子合并成一个,从而加速计算过程,也能一定程度减少量化误差,常见的融合组合有:
- [conv, bn]、[conv, bn, relu]、[conv, relu]
- [linear, relu]、[bn, relu]
这里举两个例子说明融合的计算过程:
1、conv + bn:
如上图,将 conv 公式展开到 bn 公式中,ybn 最后也是一个 conv 的计算形式,因此 conv + bn 融合后仍然是一个 conv 算子
2、conv + relu:
relu 本质上是一个截断操作,可以通过clip实现,而量化计算本身就带了一个clip,因此可以并合并到之前的算子计算(linear,conv)
如上图 conv + relu 的融合后是还是一个 Conv,需要在 relu 后统计量化参数 (min-max),就将 relu 的操作融合到了量化过程
其它优化
Ada Round
Ada Round 是高通提出的一种量化优化算法,其主要想法是:对conv中每个weight值进行量化时,不再是四舍五入的round-to-nearest,而是自适应的决定weight量化时将浮点值转到最近右定点值还是左定点值
具体的算法实现比较复杂,详见论文: Up or Down? Adaptive Rounding for Post-Training Quantization
模型量化方法
模型的量化对象主要分权重和激活两类:
- 权重:训练完后固定,数值范围(range)与输入无关,可离线完成量化,通常相对容易量化;
- 激活:激活输出随输入变化而变化,需要统计数据动态范围,通常更难量化。范围统计的时机有两种:
- training:训练时进行统计
- calibration:训练后推理小批量数据进行统计
根据是否重新训练模型,量化方案主要分位两大类:
-
Post Training Quantization (PTQ)
训练后量化,相对简单高效,只需要已训练好的模型 + 少量校准数据,无需重新训练模型,根据是否量化激活又分为:
- Dynamic Quantization:仅量化权重,激活在推理时量化,无需校准数据
- Static Quantization:权重和激活都量化,需要校准数据
-
Quantization Aware Training(QAT)
量化感知训练:在模型中添加伪量化节点模拟量化,重新训练模型(finetune),流程相对复杂
PTQ dynamic
由于仅量化权重,也称为 weight-only quantization,其激活在推理时进行量化,适用于 LSTM、MLP、Transformer 等模型(权重参数量大)
上图是 PTQ dynamic 模型量化前后的结构对比示意,即只有 weight 被提前量化成 INT8 ,下图是 NCNN 推理引擎中的实现,可以看到 conv 的计算是以 INT8 整型进行,为了避免溢出,内部的中间结果还是以 INT32 存储,然后反量化为 FP32 进行后续的计算。
PTQ static
PTQ static 则是权重和激活都提前量化,为了量化激活,需要使用具有代表性的数据进行推理,然后统计各个激活的数据范围,这个步骤也称为“校准”(Calibration)。由于激活也量化了,通常会进行算子融合以提升性能
激活的数值范围统计决定了激活量化的精度,常用的方法有:
-
Min-Max
直接统计最小、最大值:
-
Moving Average Min-Max
增加了滑动平均,即当前的 min、max 与历史 min、max 的加权和
-
Histogram
统计数值分布直方图,然后基于最小化量化误差或 KL 散度等算法选择合适的范围,如下图,选择 [0, 1.5] 区间就能覆盖 99% 以上的值
QAT
QAT 先在模型中添加伪量化节点,模拟量化过程,然后重新训练模型,通常可以获得更高精度
伪量化节点的功能有:
- 统计数据范围(如min-max),更新量化参数(zero-point、scale)
- 对输入输出进行模拟量化(仍然表示为 FP32),从而让模型感知量化对结果的影响
如上图,forward pass 过程中,伪量化节点 fq 的量化是一个阶梯型的函数,而在 backward pass 中,为了反向传播能正常进行,将 fq 的梯度计算设置为直通(STE),这样在 QAT 训练中,loss 就带上了量化影响,并且能正常进行梯度下降权重更新。下图是算子 QAT 过程的流程示意图:
方案对比
对比 PTQ(static) 和 QAT,其主要区别在于是否进行重新训练,PTQ 只需要少量校准数据,流程简单,而 QAT 需要插入伪量化节点重新训练/微调,流程更复杂,但精度通常也更高。
二、工程实践
如下图,模型量化的整体链路包括训练、量化、推理三部分,芯片厂商通常会提供针对自家芯片的量化工具/SDK,用于模型量化、格式转换、端侧部署等,如 NVIDIA、Intel、AMD、高通、华为、MTK 等
这里主要介绍红色框标注的 PyTorch 量化和高通 QNN 量化。
PyTorch 量化
目前的 PyTorch 2.x 版本对模型的量化提供了相对完善的支持,主要分为如下两种模式:
- Eager Mode:手动模式,beta阶段,需要开发者改动较多
- FX Graph Mode:全自动模式,prototype阶段,需要模型支持符号Trace
本文后面的示例都是 Eager Mode
基本概念
- Quantized Tensor:已量化tensor,属性字段有:
- qscheme:量化模式,如 torch.per_tensor_affine、torch.per_channel_symmetric
- dtype:量化数据类型,如 torch.quint8、torch.qint8、torch.float16
- scale/per_channel_scales:量化参数 scale
- zero_point/per_channel_zero_points:量化参数 zero point
- Observer:用于收集tensor的统计信息(如min、max),并计算量化参数(S、Z)
- FakeQuantize:模拟量化/反量化
- QConfig:量化配置(权重、激活可分别配置),可配置项包括:
- 不同类型的Observer、FakeQuantize
- dype
- qscheme
- quant_min/quant_max
- Quantized Engine:量化执行引擎,如FBGEMM、QNNPACK等
PTQ dynamic
假如我们的模型 M 定义如下:
class M(torch.nn.Module):
def __init__(self):
super().__init__()
self.fc = torch.nn.Linear(4, 4)
def forward(self, x):
x = self.fc(x)
return x
PTQ dynamic 量化只需要调用 torch.ao.quantization.quantize_dynamic 方法即可:
model_fp32 = M()
model_int8 = torch.ao.quantization.quantize_dynamic(
model_fp32, # the original model
{torch.nn.Linear}, # a set of layers to dynamically quantize
dtype=torch.qint8) # the target dtype for quantized weights
分别打印量化前后的模型如下:
model_fp32
M(
(fc): Linear(in_features=4, out_features=4, bias=True)
)
model_int8
M(
(fc): DynamicQuantizedLinear(in_features=4, out_features=4,
dtype=torch.qint8,
qscheme=torch.per_tensor_affine)
)
可以看到只是将浮点算子替换成了量化版本
PTQ static
为了量化激活,PTQ static 的步骤有:
1、模型中插入量化/反量化算子
class M(torch.nn.Module):
def __init__(self):
super().__init__()
self.quant = torch.ao.quantization.QuantStub()
self.conv = torch.nn.Conv2d(1, 1, 1)
self.relu = torch.nn.ReLU()
self.dequant = torch.ao.quantization.DeQuantStub()
def forward(self, x):
x = self.quant(x)
x = self.conv(x)
x = self.relu(x)
x = self.dequant(x)
return x
2、配置QConfig、配置算子融合
model_fp32 = M()
model_fp32.eval()
model_fp32.qconfig = torch.ao.quantization.get_default_qconfig('x86')
model_fp32_fused = torch.ao.quantization.fuse_modules(model_fp32, [['conv', 'relu']])
3、执行校准操作(输入校准数据进行推理)
model_fp32_prepared = torch.ao.quantization.prepare(model_fp32_fused)
input_fp32 = torch.randn(4, 1, 4, 4)
model_fp32_prepared(input_fp32)
4、转换为量化模型
model_int8 = torch.ao.quantization.convert(model_fp32_prepared)
同样地,我们可以打印中间步骤的模型结构:
model_fp32_prepared
M(
(quant): QuantStub(
(activation_post_process): HistogramObserver(min_val=-2.4940550327, max_val=3.4476957321))
(conv): ConvReLU2d(
(0): Conv2d(1, 1, kernel_size=(1, 1), stride=(1, 1))
(1): ReLU()
(activation_post_process): HistogramObserver(min_val=0.0, max_val=1.3560873270))
(relu): Identity()
(dequant): DeQuantStub()
)
可以看到,模型中增加了 QuantStub 和 DeQuantStub 用于量化/反量化,而 activation_post_process 则用于统计数值范围(这里是 HistogramObserver),并且进行conv+relu融合后,之前的 relu 变成了 Identity(),而 conv 则变成了 ConvReLU2d
model_int8
M(
(quant): Quantize(scale=tensor([0.0468]), zero_point=tensor([53]), dtype=torch.quint8)
(conv): QuantizedConvReLU2d(1, 1, kernel_size=(1, 1), stride=(1, 1),
scale=0.01067263912409544, zero_point=0)
(relu): Identity()
(dequant): DeQuantize()
)
量化为 int8 模型后,权重(conv)和激活(quant)节点都带上了量化系数 scale、zero_point,用于推理时的计算
QAT
PyTorch 的 QAT 步骤如下:
1、模型中插入量化/反量化算子
class M(torch.nn.Module):
def __init__(self):
super().__init__()
self.quant = torch.ao.quantization.QuantStub()
self.conv = torch.nn.Conv2d(1, 1, 1)
self.relu = torch.nn.ReLU()
self.dequant = torch.ao.quantization.DeQuantStub()
def forward(self, x):
x = self.quant(x)
x = self.conv(x)
x = self.relu(x)
x = self.dequant(x)
return x
2、配置QConfig、配置算子融合
model_fp32 = M()
model_fp32.eval()
model_fp32.qconfig = torch.ao.quantization.get_default_qat_qconfig('x86')
model_fp32_fused = torch.ao.quantization.fuse_modules(model_fp32, [['conv', 'relu']])
3、插入伪量化算子,进行QAT训练
model_fp32_prepared = torch.ao.quantization.prepare_qat(model_fp32_fused.train())
training_loop(model_fp32_prepared)
4、转换为量化模型
model_fp32_prepared.eval()
model_int8 = torch.ao.quantization.convert(model_fp32_prepared)
可以看到,对比 PTQ static 的区别主要是步骤3,调用 prepare_qat 插入伪量化算子,然后训练/微调,我们可以打印步骤3的模型结构如下:
model_fp32_prepared
M(
(quant): QuantStub(
(activation_post_process): FusedMovingAvgObsFakeQuantize(
fake_quant_enabled=tensor([1]), observer_enabled=tensor([1]), scale=tensor([1.]), zero_point=tensor([0], dtype=torch.int32), dtype=torch.quint8, quant_min=0, quant_max=127, qscheme=torch.per_tensor_affine, reduce_range=True
(activation_post_process): MovingAverageMinMaxObserver(min_val=inf, max_val=-inf)
))
(conv): ConvReLU2d(
1, 1, kernel_size=(1, 1), stride=(1, 1)
(weight_fake_quant): FusedMovingAvgObsFakeQuantize(
fake_quant_enabled=tensor([1]), observer_enabled=tensor([1]), scale=tensor([1.]), zero_point=tensor([0], dtype=torch.int32), dtype=torch.qint8, quant_min=-128, quant_max=127, qscheme=torch.per_channel_symmetric, reduce_range=False
(activation_post_process): MovingAveragePerChannelMinMaxObserver(min_val=tensor([]), max_val=tensor([]))
)
(activation_post_process): FusedMovingAvgObsFakeQuantize(
fake_quant_enabled=tensor([1]), observer_enabled=tensor([1]), scale=tensor([1.]), zero_point=tensor([0], dtype=torch.int32), dtype=torch.quint8, quant_min=0, quant_max=127, qscheme=torch.per_tensor_affine, reduce_range=True
(activation_post_process): MovingAverageMinMaxObserver(min_val=inf, max_val=-inf)
))
(relu): Identity()
(dequant): DeQuantStub()
)
可以看到模型中插入了三个 FusedMovingAvgObsFakeQuantize 伪量化算子,分别对应 ConvReLU2d 算子的输入、权重、输出。
Pytorch Eager 模式的量化,对模型的修改有:
- 在 forward 中添加量化/反量化算子
- 对不支持量化的算子,修改为支持的版本,如 ReLU6 修改为 ReLU 或 Clip
- 对模型中的一些数值计算,修改为对应 module 算子调用(需要存储量化参数)
其中数值计算包括如 add、cat、mul 等,如下面示例中的 forward,需要对加法进行修改,才能统计加法的数值范围,进而计算量化参数
class ResidualBlock(nn.Module):
def __init__(self, conv, shortcut):
super(ResidualBlock, self).__init__()
self.conv = conv
self.shortcut = shortcut
self.skip_add = nn.quantized.FloatFunctional()
def forward(self, x):
# return self.conv(x) + self.shortcut(x)
return self.skip_add.add(self.conv(x), self.shortcut(x))
PyTorch 的 QAT 训的一些经验技巧:
- 从 FP32 预训练模型进行 QAT finetune,通常比直接训练量化模型精度更高
- 适当以更小的 lr 进行 QAT finetune
- 在适当的 epoch 冻结量化参数、冻结 BatchNorm 的均值和方差估计,提高稳定性
num_train_batches = 20
# QAT finetune
for nepoch in range(8):
train_one_epoch(qat_model, criterion, optimizer, data_loader, torch.device('cpu'), num_train_batches)
if nepoch > 3:
# Freeze quantizer parameters
qat_model.apply(torch.ao.quantization.disable_observer)
if nepoch > 2:
# Freeze batch norm mean and variance estimates
qat_model.apply(torch.nn.intrinsic.qat.freeze_bn_stats)
最佳实践
下图是 PyTorch 官方推荐的量化最佳实践,基本的原则是先尝试 PTQ,精度不足再尝试 QAT 和混合精度。
在对某层进行量化精度分析时,可以直接对比量化前后算子的输出差异(如余弦相似度)
高通 QNN 量化
高通的 QNN 又名 Qualcomm® AI Engine Direct,是一个针对高通芯片的 AI 模型推理框架,完整文档可查看:https://docs.qualcomm.com/..
工具简介
QNN 支持将多种格式的模型转换为自有格式,然后运行在骁龙芯片的不同类型的 backend 上,如 CPU、GPU、HTP、DSP 等,骁龙8及以上支持的 HTP (Hexagon Tensor Processe) ,推理加速效果显著,实测相比CPU推理有5~10倍的性能提升。
下图是 QNN 的逻辑架构,支持用户自定义算子,用户的AI模型转换为算子图结构(Graph)后,再基于实际的 backend上进行 Compose、Execute、Finalize,其中 Compose 后可以缓存起来,用于下次快速加载(类似于OpenGL/Vulkan的shader编译缓存)
然后是 QNN SDK 具体的工作流,左边是开发端的模型处理,右边是端侧的实际执行
从这里可以看出整套工具链的一些特点:
- 算子包括 Skeleton 和 Stub 两部分,类似于接口和实现,不同的 backend 的有不同的 stub 实现;
- 用户模型拆分为图结构(.cpp)和权重(.bin)两个文件,.cpp 中同时包含了量化参数;
- 模型在具体的 backend 上可生成 Context Binary 缓存,下次可快速加载;
接下来我们看下具体的工具命令:
PTQ 示例
1、使用 converter 命令进行模型转换,得到 .cpp 和 .bin 文件,这里同时指定了量化参数和 PTQ 校准输入(–input_list)
${QNN_SDK_ROOT}/bin/x86_64-linux-clang/qnn-onnx-converter \
--input_network ${onnx_path} \
--input_list input_list_all.txt \
--act_bw 8 --weight_bw 8 --bias_bw 8 --param_quantizer symmetric \
--use_per_channel_quantization \
--output_path model_quant/my_model.quant.cpp
2、使用 generator 命令生成模型动态库,得到对应平台的 so 文件
${QNN_SDK_ROOT}/bin/x86_64-linux-clang/qnn-model-lib-generator \
-c model_quant/my_model.quant.cpp \
-b model_quant/my_model.quant.bin \
-o model_quant
3、使用 qnn-net-run 模拟运行(支持Linux/Android)
${QNN_SDK_ROOT}/bin/x86_64-linux-clang/qnn-net-run \
--backend ${QNN_SDK_ROOT}/lib/x86_64-linux-clang/libQnnHtp.so \
--model model_quant/x86_64-linux-clang/libmy_model.quant.so \
--input_list input_list_test.txt \
--output_dir output_quant
QAT 示例
QAT 与 PTQ 唯一区别在于模型转换时指定 quantization_overrides,这里 converter 逻辑可以理解为:计算权重、激活的量化参数时,优先使用 quantization_overrides 中指定的值,否则根据 input_list 校准数据进行推理&统计获得,即工具本身只能 PTQ,QAT 需要开发者导出量化参数后设置进来
${QNN_SDK_ROOT}/bin/x86_64-linux-clang/qnn-onnx-converter \
--input_network ${onnx_path} \
--input_list input_list_all.txt \
--act_bw 8 --weight_bw 8 --bias_bw 8 --param_quantizer symmetric \
--use_per_channel_quantization \
--quantization_overrides quantization_overrides.json \
--output_path model_quant/my_model.quant.cpp
quantization_overrides 需要开发者自行导出,比如经过 PyTorch 的 QAT 训练后的模型,导出 ONNX 后可以看到其中插入的量化/反量化节点,节点上的量化参数(scale、zero_point) 即是开发者需要导出的参数,按 QNN 规范写入 json 格式文件即可。
移动端部署
在使用工具完成模型的转换、生成后,移动端的部署如下图所示,开发者需要针对设备的硬件打包对应的库文件到 App 中,其中橙色部分为开发者生成,包括算法模型文件(基于.cpp、.bin生成)、整体调度逻辑 myApp.so,蓝色部分为 QNN SDK 提供的文件,包括 backend 实现库、算子库(Skel.so + Stub.so)、缓存生成库(Prepare.so),以及内置在 Android 系统库中的 RPC 库(libcdsprpc.so),需要 RPC 库的原因是 QNN 的推理都是在独立进程运行。
三、LLM 量化
针对 LLM 模型的量化是近年来研究的热点,基本原理与传统模型量化类似,但 LLM 模型有着自己的特点,比如参数量巨大,比如难以重新训练而主要是微调,这里介绍几个比较热门的 LLM 量化算法:
LLM.int8()
论文:https://arxiv.org/abs/2208.07339
LLM.int8() 主要是针对参数中的离群值(outliers) 进行处理:分析发现 outliers 主要分布在特定的几个维度,分离后用 FP16 乘法,其它维度用普通的 INT8 量化,最后把结果合并起来。整体上实现相对简单,精度高,但拆分、合并的过程对推理速度稍有影响。
SmoothQuant
论文:https://arxiv.org/abs/2211.10438
SmoothQuant 的核心思想是缩小激活,放大权重,使得激活更容易量化,通常来说由于各类 Norm 的存在,激活的波动范围会远大于权重,因此 SmoothQuant 从激活的参数中提取一个缩放系数,再乘到权重中,结果不变但压缩了激活的变换范围,从而减少了量化误差。
如下图是具体的计算示意,α 可以根据具体模型参数的分布进行调整。
QLoRA
论文:https://arxiv.org/abs/2305.14314
LoRA 是大模型微调的重要方式,可以有效提升微调效率,而 QLoRA 则现对基础模型进行4-bit量化,显著降低了模型大小和显存要求,也就降低了 LLM 微调门槛
QLoRA 的量化/反量化计算逻辑如下:
其主要有三个创新点:
- 使用 NF4 分位量化,本文前面原理部分已有详述
- 使用了双量化进一步减少模型大小:采用分块量化,每64个参数为一个block,共享归一化常数c2,然后每256块对c2进行 FP8 量化,这样每个参数的额外量化开销:8/64 + 32/(64 * 256) = 0.127 bit
- 基于 NVIDIA unified memory 实现分页优化器,GPU内存不足时使用CPU内存,进一步降低硬件要求
GPTQ
论文:https://arxiv.org/abs/2210.17323
GPTQ 与前面的量化方案都不一样,其主要想法是:从单层的角度考虑,找到一个量化过的权重,使得新老权重的输出差别最小:
具体的迭代方案则是:对某个 block 内的所有参数逐个量化,每个参数量化后,适当调整这个 block 内其他未量化的参数,以弥补量化造成的精度损失,该算法由90年代的剪枝算法发展而来:
- OBD (1990):引入 H 矩阵进行神经网络剪枝
- OBS (1993):新增权重删除补偿
- OBQ (2022):将 OBS 应用到模型量化,并增加分行计算
- GPTQ (2023):进一步提升量化速度
具体的计算过程较为复杂,感兴趣的可以查阅上述相关论文。