一起实践神经网络量化系列教程(一)!

2023-10-19 11:12:30 浏览数 (1)

开篇

老潘刚开始接触神经网络量化是2年前那会,用NCNN和TVM在树莓派上部署一个简单的SSD网络。那个时候使用的量化脚本是参考于TensorRT和NCNN的PTQ量化(训练后量化)模式,使用交叉熵的方式对模型进行量化,最终在树莓派3B 上部署一个简单的分类模型(识别剪刀石头布静态手势)。

这是那会的一篇文章,略显稚嫩哈哈:

  • 一步一步解读神经网络编译器TVM(二)——利用TVM完成C 端的部署

转眼间过了这么久啦,神经网络量化应用已经完全实现大面积落地了、相比之前成熟多了!

我工作的时候虽然也简单接触过量化,但感觉还远远不够,趁着最近项目需要,重新再学习一下,也打算把重新学习的路线写成一篇系列文,分享给大家。

本篇系列文的主要内容计划从头开始梳理一遍量化的基础知识以及代码实践。因为老潘对TensorRT比较熟悉,会主要以TensorRT的量化方式进行描述以及讲解。不过TensorRT由于是闭源工具,内部的实现看不到,咱们也不能两眼一抹黑。所以也打算参考Pytorch、NCNN、TVM、TFLITE的量化op的现象方式学习和实践一下。

当然这只是学习计划,之后可能也会变动。对于量化我也是学习者,既然要用到这个技术,必须要先理解其内部原理。而且接触了挺长时间量化,感觉这里面学问还是不少。好记性不如烂笔头,写点东西记录下,也希望这系列文章在能够帮助大家的同时,抛砖引玉,一起讨论、共同进步。

参考了以下关于量化的一些优秀文章,不完全统计列了一些,推荐感兴趣的同学阅读:

  • 神经网络量化入门--基本原理
  • 从TensorRT与ncnn看卷积网络int8量化
  • 模型压缩:模型量化打怪升级之路 - 1 工具篇
  • NCNN Conv量化详解(一)

当然在学习途中,也认识了很多在量化领域经验丰富的大佬(田子宸、JermmyXu等等),嗯,这样前进路上也就不孤单了。

OK,废话不多说开始吧。

Why量化

我们都知道,训练好的模型的权重一般来说都是FP32也就是单精度浮点型,在深度学习训练和推理的过程中,最常用的精度就是FP32。当然也会有FP64、FP16、BF16、TF32等更多的精度:

FP32 是单精度浮点数,用8bit 表示指数,23bit 表示小数;FP16半精度浮点数,用5bit 表示指数,10bit 表示小数;BF16是对FP32单精度浮点数截断数据,即用8bit 表示指数,7bit 表示小数。TF32 是一种截短的 Float32 数据格式,将 FP32 中 23 个尾数位截短为 10 bits,而指数位仍为 8 bits,总长度为 19 (=1 8 10) bits。

对于浮点数来说,指数位表示该精度可达的动态范围,而尾数位表示精度。之前老潘的一篇文章中提到,FP16的普遍精度是~5.96e−8 (6.10e−5) … 65504,而我们模型中的FP32权重有部分数值是1e-10级别。这样从FP32->FP16会导致部分精度丢失,从而模型的精度也会下降一些。

其实从FP32->FP16也是一种量化,只不过因为FP32->FP16几乎是无损的(CUDA中使用__float2half直接进行转换),不需要calibrator去校正、更不需要retrain

而且FP16的精度下降对于大部分任务影响不是很大,甚至有些任务会提升。NVIDIA对于FP16有专门的Tensor Cores可以进行矩阵运算,相比FP32来说吞吐量提升一倍。

实际点来说,量化就是将我们训练好的模型,不论是权重、还是计算op,都转换为低精度去计算。因为FP16的量化很简单,所以实际中我们谈论的量化更多的是INT8的量化,当然也有3-bit、4-bit的量化,不过目前来说比较常见比较实用的,也就是INT8量化了,之后老潘的重点也是INT8量化。

那么经过INT8量化后的模型:

  • 模型容量变小了,这个很好理解,FP32的权重变成INT8,大小直接缩了4倍
  • 模型运行速度可以提升,实际卷积计算的op是INT8类型,在特定硬件下可以利用INT8的指令集去实现高吞吐,不论是GPU还是INTEL、ARM等平台都有INT8的指令集优化
  • 对于某些设备,使用INT8的模型耗电量更少,对于嵌入式侧端设备来说提升是巨大的

所以说,随着我们模型越来越大,需求越来越高,模型的量化自然是少不了的一项技术。

如果你担心INT8量化对于精度的影响,我们可以看下NVIDIA量化研究的一些结论:

出自《INTEGER QUANTIZATION FOR DEEP LEARNING INFERENCE: PRINCIPLES AND EMPIRICAL EVALUATION》,文末有下载链接。

量化现状

量化技术已经广泛应用于实际生产环境了,也有很多大厂开源了其量化方法。不过比较遗憾的是目前这些方法比较琐碎,没有一套比较成熟比较完善的量化方案,使用起来稍微有点难度。不过我们仍可以从这些框架中学习到很多。

Google

谷歌是比较早进行量化尝试的大厂了,感兴趣的可以看下Google的白皮书Quantizing deep convolutional networks for efficient inference: A whitepaper以及Quantization and Training of Neural Networks for Efficient Integer-Arithmetic-Only Inference

TensorFlow很早就支持了量化训练,而TFLite也很早就支持了后训练量化,感兴趣的可以看下TFLite的量化规范,目前TensorRT支持TensorFlow训练后量化的导出的模型。

TensorRT

TensorRT在2017年公布了自己的后训练量化方法,不过没有开源,NCNN按照这个思想实现了一个,也特别好用。不过目前TensorRT8也支持直接导入通过ONNX导出的QTA好的模型,使用上方便了不少,之后老潘会重点讲下。

NVIDIA自家也推出了针对Pytorch的量化工具(为什么没有TensorFlow,因为TF已经有挺好用的官方工具了),支持PTQ以及QTA,称为Pytorch Quantization,之后也会提到。

TVM

TVM有自己的INT8量化操作,可以跑量化,我们也可以添加自己的算子。不过TVM目前只支持PTQ,可以通过交叉熵或者percentile的方式进行校准。不过如果动手能力强的话,应该可以拿自己计算出来的scale值传入TVM去跑,应该也有人这样做过了。

比较有参考意义的一篇:

  • ViT-int8 on TVM:提速4.6倍,比TRT快1.5倍

...

当然还有很多优秀的量化框架,想看详细的可以看这篇,后续如果涉及到具体知识点老潘也会再提到。

量化基本知识

进入主题前需要提两个概念,也就是量化的两个重要过程,一个是量化(Quantize),另一个是反量化(Dequantize):

  • 量化就是将浮点型实数量化为整型数(FP32->INT8)
  • 反量化就是将整型数转换为浮点型实数(INT8->FP32)

量化和反量化操作在最终的模型推理中都会用到,接下来我们就具体说下。

之后实数就代表我们的FP32浮点数,而整数就代表INT8整型数。

量化操作

比如有一个FP32的浮点型数字x=5.234x=5.234x=5.234,然后我们需要把这个数变为整型,也就是要量化它,怎么搞。我们可以把这个数字乘上一个量化系数sss,比如s=100s=100s=100,那么量化后的值xq=x∗s=5.234∗100=523.4x_q = x*s=5.234*100=523.4xq​=x∗s=5.234∗100=523.4,然后我们对这个数字进行四舍五入(也就是round操作)最终为

xq=round(x∗s)=round(5.234∗100)=523x_q = round(x*s)=round(5.234*100)=523xq​=round(x∗s)=round(5.234∗100)=523

这样就行了吗,523有点大啊,我们的整型INT8的范围是[-128,127],无符号INT8的范围也才[0-255],这个量化后的值有点放不下呀。

怎么办,当然是要截断了,假设我们的INT8范围是[−2b−1,2b−1−1][−2^{b−1}, 2^{b−1} − 1][−2b−1,2b−1−1],因为我们使用的是INT8,所以这里的b=8b=8b=8,那么上述的式子又可以变为:

xq=clip(round(x∗s),−2b−1,2b−1−1)=clip(round(5.234∗100),−128,127)=127x_q = clip(round(x*s),−2^{b−1}, 2^{b−1} − 1)=clip(round(5.234*100),-128,127)=127xq​=clip(round(x∗s),−2b−1,2b−1−1)=clip(round(5.234∗100),−128,127)=127

这样就结束了么?

当然没有,刚才的这个数字x=5.234x=5.234x=5.234,被映射到了127,那么如果是x=0x=0x=0呢?貌似直接带入算出来也是0,但是这样做对么?

基于线性量化的对称量化和非对称量化

对不对的关键在于我们是否是采用对称量化,什么是对称量化呢?这里的对称指的是以0为中心进行量化(还有另一种说法,这里老潘先略过),然后0两边的动态范围都是一样的。

可以看上图,左边是非对称量化,右边是对称量化(也称为Affine quantization和Scale quantization)。可以观察到:

  • 对称量化的实数0也对应着整数的0,而非对称量化的实数0不一定对应着整数0,而是z。
  • 对称量化实数的范围是对称的([−α,α][-alpha,alpha][−α,α]),而非对称量化的则不对称([−β,α][-beta,alpha][−β,α])
  • 对称量化整数的范围是对称的([-127,127]),而非对称量化的则不对称([-128,127])

所以上述的非对称量化过程可以简述为f(x)=s⋅x zf (x) = s · x zf(x)=s⋅x z,其中zzz是zero-point,这个数字就代表实数0映射到整数是多少,而对称量化则是f(x)=s⋅xf (x) = s · xf(x)=s⋅x。

这样就明白了刚才的问题:如果是x=0x=0x=0呢?貌似直接带入算出来也是0,如果我们采用的是对称量化,那就没问题!

需要说明一点,不论是非对称还是对称量化,是基于线性量化(也可以称作均匀量化)的一种。线性量化将FP32映射到INT8数据类型,每个间隔是相等的,而不相等的就称为非线性量化。非线性量化因为对部署并不是很友好,虽然能够更好地捕捉到权重分布的密集点,但感觉用的并不多,这里也就先不多说了。

关于详细的非对称量化,对称量化对比可以参考这篇文章:

  • Affine Quantization vs Scale Quantization

对称量化

接下来的重点是对称量化,也就是TensorRT中使用的量化方式,这里的范围也就是[-127,127],因为只比[-128,127]少了一个范围,所以实际量化中并没有太大的影响。

话说回来,上文量化操作中,量化系数随便说了个s=100s=100s=100,这个当然是不对的,这个sss需要根据我们的实际数据分布来计算。

如上式,αalphaα代表当前输入数据分布中的实数最大值,因为是对称,因此实际范围是[−α,α][-alpha,alpha][−α,α]。而b=8b=8b=8代表INT8量化,那么上述的量化公式就是之前提到的对称量化公式。

可以对比下非对称和对称的量化公式,对称量化因为z=0z=0z=0,所以公式简化了很多。

对于对称量化,假设当前根据权重分布,选取的αalphaα为4,那么s=127/α=127/4=31.75s=127/{alpha}=127/4=31.75s=127/α=127/4=31.75。

如下式子,在反量化的时候我们需要将反向操作一番,将量化后的结果乘以1/s1/s1/s重新变为浮点型。这里其实也就相当于乘以α/127alpha/127α/127,因为有1/s=1/(127/α)=α/1271/s=1/({127/{alpha}})=alpha/1271/s=1/(127/α)=α/127。

那么实际操作过程中,scale系数是怎么用呢?或者说sss这个量化系数是怎么作用于所有的输入、所有的权重呢?

一般量化过程中,有pre-tensorpre-channel两种方式,pre-tensor显而易见,就是对于同一块输入(比如某个卷积前的输入tensor)我们采用一个scale,该层所有的输入数据共享一个scale值;而pre-channel呢一般是作用于权重,比如一个卷积的权重维度是[64,3,3,3](输入通道为3输出通道为64,卷积核为3x3),pre-channel就是会产生64个scale值,分别作用于该卷积权重参数的64个通道。

为什么权重不能是pre-tensor呢?这个对精度的影响太大了,所以一般不用。输入就可以pre-tensor?当然可以,也经过测试了,对精度的影响不是很大,完全可以用。

那为什么权重必须是pre-channel呢?不能是每个权重值都有自己的scale么?呃,这个问题嘛,首先可以想到,这个计算量,应该挺大,其次嘛,让我们分析一下。

卷积操作量化

铺垫了这么多,那么接下来说下量化最核心的操作吧,量化过程中最核心的操作当然是卷积量化

我们都知道卷积操作可以拆分为im2col sgemm,而大部分的计算都在矩阵运算也就是sgemm中,我们量化的重点也就是这个操作。以前是FP32计算,而现在变成INT8去计算,这是怎么转换的呢?

接下来重点分析一下量化公式!注意!这个很重要!

首先,矩阵相乘可以表示为Y=XWY = X WY=XW,X为输入W为权重,Y为输出。偏置bias一般可以去掉,对精度影响也不大,所以就先不考虑了。

注意看上图输入X的维度为[m,p]而W的维度为[p,n],因此i的范围为[0,m),k的范围为[0,p)。W和Y同理。这里的输入和权重都是FP32精度,也就是实数。

而对应的INT8精度的输入和权重为,q下标就代表quantize也就是量化:

接下来,我们把矩阵公式细粒度拆成一个一个计算,也就是行和列每个元素相乘然后求和:

首先是最左边,xikx_{ik}xik​和wkjw_{kj}wkj​分别代表浮点型的输入和权重,iii代表第iii行,kkk代表第kkk列,因此xikx_{ik}xik​代表第iii行,第kkk列的元素,wkiw_{ki}wki​同理。两者相乘求和就可以得到yijy_{ij}yij​,可以看到这里求和的范围是ppp,kkk从1到ppp变化。

进一步,两个浮点型的运算可以被近似为INT8反量化后的运算,进一步等于量化后的运算:

可以看到上式每个元素都有自己的scale值,也就是sss,而我们也必须把x和w的scale值提取到前面才能让x和w实现INT8类型的矩阵运算

这里可以发现,如果想要把这两个scale元素,也就是sx,is_{x,i}sx,i​和sw,js_{w,j}sw,j​提出来,那么这个kkk必须干掉,这里可以暂停一下想下为什么?

当把k去除将s取出来之后,我们发现sx,is_{x,i}sx,i​和sw,js_{w,j}sw,j​分别代表输入的第iii行的scale和权重的第jjj列的scale值,这样输入的每一行必须共享scale,而权重的每一列也必须共享scale!

那么pre-channel又是怎么来的呢?

还记得老潘之前说过的im2col sgemm操作吗(如果不记得强烈建议去看看),其中的sgemm是这样的,需要注意,下图左边的kernel矩阵,每一行代表一个输出通道的kernel集合(这里因为输入图像是三通道的,因此kernel有三个,不同颜色代表一个kernel):

这就是pre-channel或者详细点就是per-output-channel也就是卷积输出通道,我们对每一个卷积权重的输出通道那一维进行量化,然后共享一个scale,这也就呼应了上述的公式!

后记

到此我们已经讲述了量化的基本概念以及卷积量化的实际操作是什么样的,当然想说的还有很多...就是现在实在写不动了,关于非对称量化的公式以及为什么非对称量化计算量比较大,就放到第二期再说吧。文中提到的一些资料,号内回复”量化“即可获取。

后续文章会继续说明其他量化的操作细节以及实际部署中的代码细节,涉及到TensorRT以及Pytorch和TVM,感兴趣的不妨持续关注老潘~

也欢迎大家一起讨论,如有错误也欢迎指正。

0 人点赞