博主在上面文章的基础上进行合并整理,包括从这些文章的评论里提取有用的信息,帮助大家理解INT8量化的原理,加深巩固认识,也方便自己复习。
强烈推荐一下第一篇知乎回答,我自己来回看了包括评论有三遍左右进行理解加深,答主写得清晰有趣。
就是把原来的float 32bit 的卷积操作(乘加指令)转换为int8的卷积操作,这样计算就变为原来的1/4,但是访存并没有变少哈,因为我们是在kernel里面才把float32变为int8进行计算的。
简单的将一个tensor 中的 -|max| 和 |max| FP32 value 映射为 -127 和 127 ,中间值按照线性关系进行映射。但是试验结果显示这样做会导致比较大的精度损失。
把一个layer的激活值范围的给圈出来,然后按照绝对值最大值作为阀值(因此当正负分布不均匀的时候,是有一部分是空缺的,也就是一部分值域被浪费了;这里有个小坑就是,假如我的激活值全是正的,没有负值,那么你怎么映射呢?),然后把这个范围直接按比例给映射到正负127的范围内来,公式如下:
FP32 Tensor (T) = scale_factor(sf) * 8-bit Tensor(t) + FP32_bias (b)
缺点:这是针对均匀分布的,很明显的可以知道,只要数据分布的不是很均匀,那么精度损失是很大很明显的(因为不均匀的地方就会出现空缺,值域也被浪费掉)。
为什么说最大值映射会精度损失严重???
你看值的分布,由于正负分布很不均匀,如果按照对称最大值映射(原意是为了尽可能多地保留原信息)的话,那么+max那边有一块区域就浪费了,也就是说scale到int8后,int8的动态范围就更小了,举个极端的例子就是量化后原本int8的动态范围只剩1bit了(就是正的样本没有,负的全部扎堆在一个很小的值附近),就是上面说到的满屏马赛克~这种情况下。。。那还表示个毛的原信息啊!
(这里其实没映射前这个值很详细吗,32bit呢,虽然都聚集到一个部分(使得最后映射到负的一个很小值附近),但是没有映射前仍然表示了很多信息啊,现在你映射了,就这么一小块信息,肯定表示不了了呀)
官方给的图:
下面这张图展示的是不同网络结构的不同layer的激活值分布,有卷积层,有池化层,他们之间的分布很不一样,因此合理的量化方式应该适用于不同的激活值分布,并且减小 信息损失。因为从FP32到INT8其实就是一种信息再编码的过程。
该文博主的理解的直接使用线性量化的方式导致精度损失比较大的原因是:
- 上图是一些网络模型中间层的 激活值统计,横坐标是激活值,纵坐标是统计数量的归一化表示,这里是归一化表示,不是绝对数值统计;
- 这个激活值统计 针对的是一批图片,不同的图片输出的激活值不完全相同。所以图上并不是一条曲线而是多条曲线(一张图片(输入到网络进行学习的图片)会对应一条曲线(或者说一条散点线)),只不过前面一部分重复在一块了(红色虚线圈起来的部分),说明对于不同图片生成的大部分激活值其分布是相似的;但是在激活值比较大时(红色实线圈起来的部分),曲线不重复了,一个激活值对应多个不同的统计量,这时的激活值分布就比较乱了。
- 后面这一部分在整个层中是占少数的(占比很小,比如10^-9, 10^-7, 10^-3),因此后面这一段完全可以不考虑到映射关系中去,保留激活值分布的主方向。开始我以为网络之所以能把不同类别的图片分开是由于后面红色实线圈起来的部分的差异导致的,后来想了一下:这个并不包含空间位置的分布,只是数值上的分布,所以后面的应该对结果影响不大。
那现在的问题是如何确定|T|?我们来思考一下,现在有一个FP32的tensor,FP32肯定是能够表达这个tensor的最佳分布。现在我们要用一个不同的分布(INT8)来表达这个tensor,这个 INT8 分布不是一个最佳的分布。饱和的INT8分布由于阈值 |T|的取值会有很多种情况(128−|max|),其中肯定有一种情况是相对其他最接近FP32的,我们就是要把这种情况找出来。
为什么说这个阈值在128-|max|之间?你想啊,如果你的激活值在128之内的话,那直接线性映射了,不需要找阈值了,所以我们考虑的情况自然是阈值在128-|max|之间的
既然如此,我们就需要一个衡量指标来衡量不同的 INT8 分布与原来的FP3F2分布之间的差异程度。这个衡量指标就是相对熵(relative entropy),又称为KL散度(Kullback–Leibler divergence,简称KLD),信息散度(information divergence),信息增益(information gain)。叫法实在太多了,最常见的就是相对熵。
这个过程同时也告诉了我们,要做INT8量化,需要准备哪些东西——原来的未量化的模型、一个校准数据集、进行量化过程的校准器。
Calibration Dataset
KL散度越小代表 INT8编码后的信息损失越少。如何根据KL散度寻找最佳INT8分布?其实前面我们也已经提到了,如果要让最后的精度损失不大,是要考虑一些先验知识的,这个先验知识就是每一层在 FP32精度下的激活值分布,只有根据这个才能找到更加合理的阈值|T|。
也就是说首先得有一个以FP32精度训练好的模型。那激活值分布如何得到?难道我们要将FP32的模型先在所有的测试集(或验证集)上跑一边记录下每一层的FP32激活值,然后再去推断 |T|?
基本上现有的深度学习框架都是默认FP32精度的。有些模型还支持FP16精度训练,貌似 Caffe2和MXNet是支持FP16的,其他的不太清楚。所以基本上只要没有特别设定,训练出来的模型肯定是 FP32 的。
这里的做法是从验证集选取一个子集作为校准集(Calibration Dataset ),校准集应该具有代表性,多样性,最好是验证集的一个子集,不应该只是分类类别的一小部分。激活值分布就是从校准集中得到的。
由上面的处理流程得出每一层网络的阈值|T|,创建 CalibrationTable 。后面在INT8上面推断的时候,直接取table里的阈值进行量化。
每一层获取阈值|T|的详细做法:
上面就是一个循环,不断地构造P和Q,并计算相对熵,然后找到最小(截断长度为m)的相对熵,此时表示Q能极好地拟合P分布了。
而阀值就等于(m + 0.5)*一个bin的长度;
+0.5是因为选取每组bin的中间值进行遍历,所以后面计算具体的阈值时,就要算进去。(每个bin里面有很多值的)
伪代码:
//首先分成 2048个组,每组包含多个数值(基本都是小数)
Input: FP32 histogram H with 2048 bins: bin[ 0 ], …, bin[ 2047 ]
For i in range( 128 , 2048 ): // |T|的取值肯定在 第128-2047 组之间,取每组的中点
reference_distribution_P = [ bin[ 0 ] , ..., bin[ i-1 ] ] // 选取前 i 组构成P,i>=128
outliers_count = sum( bin[ i ] , bin[ i+1 ] , … , bin[ 2047 ] ) //边界外的组
reference_distribution_P[ i-1 ] += outliers_count //边界外的组加到边界P[i-1]上,没有直接丢掉
P /= sum(P) // 归一化
// 将前面的P(包含i个组,i>=128),映射到 0-128 上,映射后的称为Q,Q包含128个组,
// 一个整数是一组
candidate_distribution_Q = quantize [ bin[ 0 ], …, bin[ i-1 ] ] into 128 levels
//这时的P(包含i个组,i>=128)和Q向量(包含128个组)的大小是不一样的,无法直接计算二者的KL散度
//因此需要将Q扩展为 i 个组,以保证跟P大小一样
expand candidate_distribution_Q to ‘ i ’ bins
Q /= sum(Q) // 归一化
//计算P和Q的KL散度
divergence[ i ] = KL_divergence( reference_distribution_P, candidate_distribution_Q)
End For
//找出 divergence[ i ] 最小的数值,假设 divergence[m] 最小,
//那么|T|=( m + 0.5 ) * ( width of a bin )
Find index ‘m’ for which divergence[ m ] is minimal
threshold = ( m + 0.5 ) * ( width of a bin )
众所周知,一个训练好的深度学习模型,其数据包含了权重(weights)和偏移(biases)两部分,在其进行前向推理(forward)时,中间会根据权重和偏移产生激活值(activation)。
网络的前向计算涉及到两部分数值:权值和激活值(weights 和activation,二者要做乘法运算),Szymon Migacz 也提到他们曾经做过实验,说对weights 做saturation 没有什么变化,因此 对于weights的int8量化就使用的是不饱和的方式;而对activation做saturation就有比较显著的性能提升,因此对activation使用的是饱和的量化方式。
这其实很好理解,因为权重通常分别较为均匀直接最大值非饱和映射和费劲力气找阈值再进行饱和映射,其量化后的分布很可能是极其相似的,而激活值分布不均,寻找一个合适的阈值进行饱和映射就显得比较重要了;
上图可以看出,校准过程我们是不用参与的,全部都由TensorRT内部完成,但是,我们需要告诉校准器如何获取一个batch的数据,也就是说,我们需要重写校准器类中的一些方法。
这一部分博主将结合使用tensorRT进行INT8量化来加速yolov5这个过程来详细讲如何编写,顺便介绍相关知识。
1. 为什么右边的饱和截取就ok呢?
因为非饱和截取的问题是当数据分布极不均匀的时候,有很多动态范围是被浪费的,也就是说打的马赛克很大!而饱和截取就是弥补这个问题的。
当你数据分布很不均匀的时候,如图左边比右边多,那么我把原始信息在影射之前就截断一部分,然后构成对称且分布良好的截断信息,再把这个信息映射到int8上去,那么就不会有动态范围资源被浪费了,也就是说马赛克打的比较细腻了~你可以估摸着脑补出细节画面了(我说的是商标打码~你们想到哪去了?!!黑人问号.jpg)~
像上图这样,先找一个阀值T,然后低于最低阀值的就全部都饱和映射到-127上,如上图的左边的三个红色的点就是这么处理的。
2. 对称量化和非对称量化
其实量化过程很简单,更高精度的向低精度的范围进行映射。
公式(1)中的bias,在nvidia的文章中,bias=0(在他的文章中提到并不需要偏置)就是对称量化的概念,0量化后还是0; bias /= 0 的情况就是非对称量化,但是这其中需要注意的是bias的需要是整型,因为在深度学习的模型中,有太多的0-padding存在了,若是bias非整型,那么在量化过程中会有大量的数值0的精度收到损失。
对称量化的话就是量化零点需要跟浮点零点对齐,不然padding做起来会很难受
3. 逐层量化和逐通道量化
从字面非常容易理解两个量化的区别,我们也看到在nvidia的方法中使用了逐层量化的方法,每一层采用同一个阈值来进行量化。逐通道量化就是对每一层每个通道都有各自的阈值,对精度有一个很好的提升。
4. 这里的2048bins指的是正范围还是负范围呢?后面都是量化的128bins里面去的,也就是说只管了int8(256)的一半?
是因为数据收集的都是ReLU之后的,暂时实测问题不大啊,所以直接管正半边就好啦~但是严谨的做法就是正负数据都收集,分bins的时候正负都得考虑,参考maxnet的实现
5. 对激活值的量化,通过kl确定阈值,那么权重本身如何量化?
谷歌白皮书,还有英伟达官方是这么说的:他们做了大量的测试发现,weights直接最大值量化就好。
尝试解释一下:因为weights的分布一般比较均匀因此直接最大值量化也是没问题。
6. 前面说“我们的目的是把原来的float 32bit 的卷积操作(乘加指令)转换为int8的卷积操作,这样计算就变为原来的1/4,但是访存并没有变少哈,因为我们是在kernel里面才把float32变为int8进行计算的。”这是存储的时候还是32位吗?这样是不是意味着存储没有减少还是以float32存储
weights存储是以float32存储的,但是计算的时候是先转为int8然后计算的。
(所以我们可以提前把这个给转好嘛,放到某个位置,后面计算卷积的时候来取就行了,这样就是:转换一次无限使用,这样来看总体的带宽不就提高了吗!?
我们所谓的模型对移动端部署来说其实就是一堆的weights值而已,这些weights你可以存为float格式,也可以存为int8格式,精度不一样而已,现在我们为了最大的灵活性就直接存为float,然后inference的时候可以灵活配置是跑量化还是跑非量化模式!
7. 为什么量化只用在conv呢?这样不是每做一次conv都有一次量化和反量化?不能后面的op都支持量化运算?在整个网络里减少量化和反量化的操作?还是必须每做一次量化都必须反量化一次降低精度损失
对啊,因为每一层的数据分布都是不一样的,因此其scale必须不一样才能降低量化损失,比如每一层的的weights,假如有N个,其分布的差异也是很大的呀,这也就是为什么我们在weights上是做per-channel量化的了