目录
- 什么是量化
- 量化实现的原理
- 实战
- 准备数据
- 执行量化
- 验证量化
- 结语
什么是量化
量化是一种常见的深度学习技术,其目的在于将原始的深度神经网络权重从高位原始位数被动态缩放至低位目标尾数。例如从FP32(32位浮点)量化值INT8(8位整数)权重。
这么做的目的,是为了在不影响神经网络的精度为前提下,减少模型运行时的内存消耗,提升推理系统整体的吞吐量。
量化实现的原理
量化的实现本质上以一种基于动态缩放的数值运算,因此在量化中,有几个重要的参数:
- 缩放系数 ( s c a l e ) (scale) (scale):用于表述从高位缩放至低位的缩放系数,如果没有它,量化就不存在了
- x f x_f xf:代表输入的浮点高位值,一般是FP32或者FP64的输入值
那么,如何计算缩放系数 ( s c a l e ) (scale) (scale)呢?
首先,我们要找出输入值的最大值,原因是我们要找出整个输入的的量化范围,即:从哪里结束量化?因此,你可以使用 a m a x ( x f ) amax(x_f) amax(xf)来算出 x f x_f xf的极大值:
a m a x ( x f ) = m a x ( a b x ( x f ) ) amax(x_f) = max(abx(x_f)) amax(xf)=max(abx(xf))
找到最大值后,现在你要思考你需要多少位的量化,但通常在此之前,你需要算出你的输入数据最大可以容纳多少位数据:
n b i t = 2 ∗ a m a x ( x f ) n_{bit} = 2 *amax(x_f) nbit=2∗amax(xf)
在确定了这个值之后,除以你预期你的位数所能承载的最大数据量,就得到了缩放系数:
s c a l e = n b i t / p o w ( 2 , t b i t ) scale = n_{bit} / pow(2,t_{bit}) scale=nbit/pow(2,tbit)
好了,现在你有了两个量化过程中最重要的参数了,接下来就可以开始正式计算量化的结果了:
x q = C l i p ( R o u n d ( x f / s c a l e ) ) x_q = Clip(Round(x_f / scale)) xq=Clip(Round(xf/scale))
首先,我们需要将现有位数的输入除以我们得到的缩放系数,即得到了目标位数的浮点数据,但别忘了:我们在量化时通常是为了将浮点值操作量化为整数值操作,因此需要将其取整为整数。
那么? C l i p Clip Clip在做什么?因为我们不希望我们量化后结果的范围超出了目标位数的极大和极小值,因此使用 C l i p Clip Clip来裁切目标值为指定位数的极大和极小值。以INT8为例,则应该是:
x q = C l i p ( R o u n d ( x f / s c a l e ) , m i n = − 128 , m a x = 127 ) x_q = Clip(Round(x_f/scale),min=-128,max=127) xq=Clip(Round(xf/scale),min=−128,max=127)
实战
说完了原理,我们该如何在ONNX中使用静态量化呢?
在这里,我们需要使用onnxruntime
库来完成这个量化操作:
pip install onnxruntime-[target-ep]
其中,target-ep
代表你期望模型在哪个类型的计算设备运行,如:
- CUDA-GPU:则是
pip install onnxruntime-gpu
- DirectML:
pip install onnxruntime-directml
准备数据
在准备数据时,我们不能像以前那样直接使用Dict[str,ndarray]
的方式来调用静态量化,而是需要使用校准数据读取方式来读取:
from onnxruntime.quantization import (quantize_static, CalibrationDataReader,QuantType, QuantFormat, CalibrationMethod
)
from typing import *
# 创建一个DummpyDataReader类来继承CalibrationDataReader类
class DummyDataReader(CalibrationDataReader):def __init__(self, calibration_dataset:List[Dict[str,np.ndarray]]):self.dataset:List[Dict[str,np.ndarray]] = calibration_datasetself.enum_data:Any = None# 重载get_next迭代函数def get_next(self):if self.enum_data is None:self.enum_data:Iterator = iter(self.dataset)return next(self.enum_data, None)
接下来我们就可以准备输入数据了:
# 这里以Hubert Wav2Vec模型进行数据读取(1,audio_length)
# 采样率为16000Hz
import numpy as np
audio = np.load("./input.npy")
inputs = [{"feats": audio.astype(np.float32)},
]
执行量化
接下来我们就可以调用onnxruntime
为我们提供的quantize_static
函数了,在我们的实例中,会使用到如下的参数:
- model_input [str]:输入的模型位置,通常为FP32的ONNX模型权重
- model_output [str]:量化权重保存的位置
- calibration_data_reader [CalibrationDataReader]:我们刚刚创建的校准数据读取类
- quant_format [enum]:量化的格式,对于我们的实例中,使用
QDQ(Quantize => Dequantize)
,即显示量化和反量化格式,因为我们不希望自己手动去算量化,对吧?事实证明使用这个模式的情况ONNXRuntime会自动帮你料理 s c a l e scale scale和零值点的计算,以及后续的反量化等。 - activation_type [enum]:指定模型内部相关的激活函数使用什么数据类型来完成计算,在我们的例子中,
QINT8
相对合适,因为Wav2Vec
是从音频中来提取特征表述,因此有符号比无符号效果会好很多。 - weight_type [enum]:指定模型的权重是以什么数据类型来保存的,通常来说,如果你使用的是
quantize_dynamic
时,ONNXRuntime为了考虑兼容性,默认只会为你量化权重,而不会去管激活函数的量化。 - calibrate_method [enum]:校准方法,指定在反量化阶段以什么方式来完成数据校准,ONNXRuntime支持下述的校准方式:
- MinMax:极大极小值,这种校准方式适合基于特征表述的神经网络,如视觉模型,向量机
- Entropy:基于熵,这种校准方式更适合于不确定性量化,即模型复杂度高,无法直接观测模型内部数据变化的神经网络,例如Transformer。适合处理高维度数据,对于我们这次示例中的Hubert十分有效,因为Hubert最终输出的特征向量大小是 ( b × n × 768 ) (b \times n \times 768) (b×n×768)
- Percentile:基于百分位的数据校准模式,可以显著降低因量化产生的干扰值,但缺点就是容易***一刀切***,进而丢失数据
- Distribution:基于分布的数据校准模式,当你看到***分布***这两字儿,你大概心里也应该有个谱了:没错,它是基于数据在FP32状态下的分布状态来进行对应比例的缩放校准的,而这也正是它的问题所在,即每进行一次校准时都有参考来FP32状态下的数据分布从而计算出INT8下可能的数据分布,因此对于时延要求不大的任务:如Diffusion可以用这类校准。
接下来我们就可以调用quantize_static()
来执行静态量化了:
quantize_static(model_input="./hubert.onnx",model_output="./hubert_int8.onnx",calibration_data_reader=reader,quant_format=QuantFormat.QDQ,activation_type=QuantType.QInt8,weight_type=QuantType.QInt8,calibrate_method=CalibrationMethod.Entropy
)
之后你会看到这样的日志:
Collecting tensor data and making histogram ...
Finding optimal threshold for each tensor using 'entropy' algorithm ...
Number of tensors : 712
Number of histogram bins : 128 (The number may increase depends on the data it collects)
Number of quantized bins : 128
这在说明ONNXRuntime正在计算每个张量的最佳阈值和分布大小。
验证量化
接下来我们就可以正常读取这些模型写模型来看看不用位数下的输出精度了:
import onnxruntime as ort
# 加载FP32模型
model_fp32 = ort.InferenceSession("./hubert.onnx")
# 加载FP16模型
model_fp16 = ort.InferenceSession("./hubert_fp16.onnx")
# 加载INT8模型
model_int8 = ort.InferenceSession("./hubert_int8.onnx")
# 预测FP32
fp32_result = model_fp32.run(None,input_feed={"feats": audio.astype(np.float32)}
)
# 预测FP16
fp16_result = model_fp16.run(None,input_feed={"feats": audio.astype(np.float16)}
)
# 预测INT8
int8_result = model_int8.run(None,input_feed={"feats": audio.astype(np.float32)}
)# 绘制图像
import matplotlib.pyplot as plt
fig, ax = plt.subplots(3, 1, figsize=(8,6))ax[0].plot(fp32_result[0][0, 0, :], label="FP32")
ax[0].set_title("FP32 Output")ax[1].plot(fp16_result[0][0, 0, :], label="FP16")
ax[1].set_title("FP16 Output")ax[2].plot(int8_result[0][0, 0, :], label="INT8")
ax[2].set_title("INT8 Output")for a in ax:a.legend()a.grid()plt.tight_layout()
plt.show()
输出图像如下:
从图像也可以很明显的看出来:INT8的数据分布会更发散,虽然ONNXRuntime已经帮我们完成了反量化这一步骤。而FP16相比INT8则好看许多,虽然在浮点上位上少了很多表示位,但精度依然还是在线的,这也是量化时要权衡的问题:速度和精度,哪个对你的场景更重要?
结语
量化是一把双刃剑,虽然可以对比原来的推理环境实现大幅度的性能提升,但速度提升的代价就是精度的明显下降,因此在执行量化操作一定要权衡利弊,是否量化真的对你的场景真的很重要?你的任务是否真的很依赖那点儿因为降低精度而换回来的速度?