本篇文章主要参考了TensorRT(5)-INT8校准原理,并添加了一些自己的见解。
Low Precision Inference
现有的深度学习框架,如Pytorch、Tensorflow在训练一个深度神经网络时,往往都会使用 float 32(Full Precise ,简称FP32)的数据精度来表示,权值、偏置、激活值等。若一个网络很深的话,比如像VGG,ResNet这种,网络参数是极其多的,计算量就更多了(比如VGG 19.6 billion FLOPS, ResNet-152 11.3 billion FLOPS)。如果多的计算量,如果都采用FP32进行推理,对于嵌入式设备来说计算量是不能接受的。解决此问题主要有两种方案:
- 模型压缩、剪枝。在这里不做讨论。
- 在部署推理时(inference)使用低精度数据,比如INT8。当然训练的时候仍然采取FP32的精度。
下面从经验上分析低精度推理的可行性。
实际上有些人认为,即便在推理时使用低精度的数据(比如INT8),在提升速度的同时,也并不会造成太大的精度损失,比如 Why are Eight Bits Enough for Deep Neural Networks? 以及Low Precision Inference with TensorRT 这两篇博文。
文章的作者认为网络在训练的过程中学习到了数据样本的模式可分性,同时由于数据中存在的噪声,使得网络具有较强的鲁棒性,也就是说在输入样本中做轻微的变动并不会过多的影响性能。与图像上目标间的位置、姿态、角度等变化相比,这些噪声引进的变动只是很少的一部分。但实际上这些噪声引进的变动同样会使各个层的激活值输出发生变动,然而却对结果影响不大,也就是说训练好的网络对这些噪声具有一定的容忍度(tolerance )。
正是由于在训练过程中使用高精度(FP32)的数值表示,才使得网络具有一定的容忍度。训练时使用高精度的数值表示,可以使得网络在每一步训练都会对参数进行少量的修正,这在网络最后收敛的时候是很重要的,因为收敛的时候要求修正量很小很小(一般训练初始 阶段学习率稍大,越往后学习率越小)。
那么如果使用低精度的数据来表示网络参数以及中间值的话,势必会存在误差,这个误差某种程度上可以认为是一种噪声。那也就是说,使用低精度数据引进的差异是在网络的容忍度之内的,所以对结果不会产生太大影响。
以上分析都是基于经验的,理论上的分析比较少,不过文章提到了两篇 paper,如下:
- Improving the speed of neural networks on CPUs
- Training deep neural networks with low precision multiplications
感兴趣的同学可以自行研究。
TensorRT 的INT8模式只支持计算能力为6.1的GPU(Compute Capability 6.1 ),比如: GP102 (Tesla P40 and NVIDIA Titan X), GP104 (Tesla P4), and GP106 GPUs,主要根源是这些GPU支持 DP4A硬件指令。DP4A下面会稍微介绍一下。
TensorRT INT8 Inference
首先看一下不同精度的动态范围:
动态范围 | 最小正数 | |
---|---|---|
FP32 | $−3.4×10^{38} +3.4×10^{38}$ | $1.4×10^{−45}$ |
FP16 | $−65504 +65504$ | $5.96×10^{−8}$ |
INT8 | $−128 +127$ | 1 |
实际上将FP32的精度降为INT8还是比较具有挑战性的。
Quantization
将FP32降为INT8的过程相当于信息再编码(re-encoding information ),就是原来使用32bit来表示一个tensor,现在使用8bit来表示一个tensor,还要求精度不能下降太多。将FP32转换为 INT8的操作需要针对每一层的输入张量(tensor)和 网络学习到的参数(learned parameters)进行。
首先能想到的最简单的映射方式就是线性映射(或称线性量化,linear quantization), 就是说映射前后的关系满足下式:
试验证明,偏置实际上是不需要的,因此去掉偏置,也就是
$sf$是每一层上每一个tensor的换算系数或称比例因子(scaling factor),因此现在的问题就变成了如何确定比例因子。然后最简单的方法是下图这样的:
- 简单的将一个tensor 中的 $-|max|$ 和 $|max|$ FP32 value 映射为 -127 和 127 ,中间值按照线性关系进行映射。
- 称这种映射关系为不饱和的(No saturation,$max$很大实际模型的激活值可能取不到),对称的。
但是试验结果显示这样做会导致比较大的精度损失。
下面这张图展示的是不同网络结构的不同layer的激活值分布,有卷积层,有池化层,他们之间的分布很不一样,因此合理的量化方式应该适用于不同的激活值分布,并且减小信息损失。因为从FP32到INT8其实就是一种信息再编码的过程。
上图的解释有思考如下:
- 上图是一些网络模型中间层的激活值统计,横坐标是激活值,纵坐标是统计数量的归一化表示,这里是归一化表示,不是绝对数值统计;
- 这个激活值统计针对的是一批图片,不同的图片输出的激活值不完全相同。所以图上并不是一条曲线而是多条曲线(一张图片对应一条曲线,或者称为散点图更好一点),只不过前面一部分重复在一块了(红色虚线圈起来的部分),说明对于不同图片生成的大部分激活值其分布是相似的;但是在激活值比较大时(红色实线圈起来的部分),曲线不重复了,一个激活值对应多个不同的统计量,这时的激活值分布就比较乱了。
- 后面这一部分在整个层中是占少数的(占比很小,比如10^-9, 10^-7, 10^-3),因此后面这一段完全可以不考虑到映射关系中去,保留激活值分布的主要部分。开始以为网络之所以能把不同类别的图片分开是由于后面实线部分的差异导致的,后来想了一下:这个并不包含Tensor空间位置的分布,只是数值上的分布,所以后面的应该对结果影响不大。
因此TensorRT的做法是:
- 这种做法不是将 $±|max|$ 映射为 $±127$,而是存在一个 阈值 $|T|$ ,将 $±|T|$ 映射为±127,显然这里 $|T|<|max|$。
- 超出 阈值 $±|T|$ 外的直接映射为阈值 $±127$。比如上图中的三个红色点,直接映射为$-127$。
- 称这种映射关系为饱和的(Saturate ),不对称的。
- 只要阈值选取得当,就能将分布散乱的较大的激活值舍弃掉,也就有可能使精度损失不至于降低太多。
网络的前向计算涉及到两部分数值:权值和激活值(weights 和activation,二者要做乘法运算,可以将激活值看作是某层的输出要和下一层的权重做乘法运算),Szymon Migacz也提到他们曾经做过实验,说对weights 做saturation变换没有什么变化,因此对于weights的INT8量化就使用的是不饱和的方式(饱和方式需要确定$|T|$,比较麻烦);而对activation做saturation变换就有比较显著的性能提升,因此对activation使用的是饱和的量化方式。
那现在的问题是如何确定$|T|$?假设有一个FP32的tensor,FP32肯定是能够表达这个tensor的最佳分布。现在我们要用一个不同的分布(INT8)来表达这个tensor,这个INT8 分布不是一个最佳的分布。饱和的INT8分布由于阈值|T|的取值会有很多种情况($128 \sim |max|$),其中肯定有一种情况是相对其他最接近FP32的,我们就是要把这种情况找出来。
那么就需要一个衡量指标来衡量不同的INT8 分布与原来的FP3F2分布之间的差异程度。这个衡量指标就是相对熵(relative entropy),又称为KL散度(Kullback–Leibler divergence,简称KLD),信息散度(information divergence),信息增益(information gain)。叫法实在太多了,最常见的就是相对熵,跟交叉熵也是有关系的。
假设我们要给一个信息进行完美编码,那么最短平均编码长度就是信息熵。
如果编码方案不一定完美(由于对概率分布的估计不一定正确),这时的平均编码长度就是交叉熵。
平均编码长度 = 最短平均编码长度 + 一个增量
交叉熵在深度学习中广泛使用,衡量了测试集标签分布和模型预测分布之间的差异程度。
编码方法不一定完美时,平均编码长度相对于最小值的增加量(即上面那个增量)是相对熵。
即 交叉熵=信息熵+相对熵
通俗的理解 信息熵,交叉熵,相对熵,参考:知乎:如何通俗的解释交叉熵与相对熵?
如何理解信息熵用来表示最短平均编码长度,参考: 如何理解用信息熵来表示最短的平均编码长度
在这里,FP32的tensor就是要表达的信息量,FP32也是最佳分布(可以认为最短编码长度32bit),现在要做的是使用INT8 来编码FP32的信息,同时要求INT8编码后差异尽可能最小。考虑两个分布 P(FP32)、Q(INT8)KL散度计算如下:
$P,Q$分别称为reference_distribution、quantize _distribution,$i$表示第$i$层。实际上这里也说明了每一层的tensor 的$|T|$值都是不一样的。确定每一层的$|T|$值的过程称为校准(Calibration )。
Calibration
上面已经说了 KL散度越小代表INT8编码后的信息损失越少。接下来来看看如何根据KL散度寻找最佳INT8分布。其实前面也已经提到了,如果要让最后的精度损失不大,是要考虑一些先验知识的,这个先验知识就是每一层在 FP32精度下的激活值分布,只有根据这个才能找到更加合理的阈值$|T|$。也就是说首先得有一个以FP32精度训练好的模型。基本上现有的深度学习框架都是默认FP32精度的,有些模型还支持FP16精度训练。所以基本上只要没有特别设定,训练出来的模型肯定是 FP32 的。
那激活值分布如何得到?这里的做法是从验证集选取一个子集作为校准集(Calibration Dataset),校准集应该具有代表性,多样性,最好是验证集的一个子集,不应该只是分类类别的一小部分。激活值分布就是从校准集中得到的。
按照NVIDIA 官方的说法:
Note: The calibration set must be representative of the input provided to TensorRT at runtime; for example, for image classification networks, it should not consist of images from just a small subset of categories. For ImageNet networks, around 500 calibration images is adequate.
对于ImageNet 数据集来说 校准集大小一般500张图片就够了(Szymon Migacz的演讲说用1000张),这里有点怀疑也有点震惊,没想到 ImageNet 1000个分类,100多万张图片,500张就够了,不过从2.5节的图表中的结果可以看出500张确实够了。
然后要做的是:
- 首先在校准集上进行 FP32 inference 推理;
- 对于网络的每一层(遍历):
- 收集这一层的激活值,并做直方图(histograms ),分成几个组别(bins)(官方给的一个说明使用的是2048组),分组是为了下面遍历|T| 时,减少遍历次数;
- 对于不同的阈值|T|进行遍历,因为这里|T|的取值肯定在第128-2047组之间,所以就选取每组的中间值进行遍历;
- 选取使得 KL_divergence(ref_distr, quant_distr) 取得最小值的 |T|。
- 返回一系列|T|值,每一层都有一个|T|。创建 CalibrationTable 。
解释一下:假设最后使得KL散度最小的|T|值是第200组的中间值,那么就把原来第0-200组的数值线性映射到0-128之间,超出范围的直接映射到128。
校准的过程可以参考一下这个:https://www.jianshu.com/p/43318a3dc715, 这篇文章提供了一个详细的根据KL散度来将原始信息进行编码的例子,包括直方图的使用。跟这里的校准过程极为相像。
下面是一个官方 GTC2017 PPT 中给的校准的伪代码:
1 | //首先分成 2048个组,每组包含多个数值(基本都是小数) |
解释一下第16行:
- 计算KL散度 KL_divergence(P, Q) 时,要求序列P和Q的长度一致,即 len(P) == len(Q);
- Candidate_distribution_Q 是将 P 线性映射到 128个bins得到的,长度为128。而reference_distribution_P 包含 i (i>=128)个 bins (bin[0] - bin[i-1] ),二者长度不等;
- 需要将 candidate_distribution_Q 扩展回 i 个bins 然后才能与 i个bins 的 reference_distribution_P计算KL散度。
举个简单的例子:
假设reference_distribution_P 包含 8 个bins(这里一个bin就只包含一个数据):
P = [ 1, 0, 2, 3, 5, 3, 1, 7]
我们想把它映射为 2 个bins,于是 4个一组合并:
[1 + 0 + 2 + 3 , 5 + 3 + 1 + 7] = [6, 16]
然后要成比例的 扩展回到 8个组,保留原来是0的组:
Q = [ 6/3, 0, 6/3, 6/3, 16/4, 16/4, 16/4, 16/4] = [ 2, 0, 2, 2, 4, 4, 4, 4]
然后对 P和Q进行标准化:
P /= sum(P) 、Q /= sum(Q)
最后计算散度:
result = KL_divergence(P, Q)
我们来看看 ResNet-152中 res4b30层校准前后的结果对比,图中那个白线就是|T|的取值(可能小于128,因为分成2048组是最前面的128组其值不一定大于128):
再看看其他几种网络的校准情况:
DP4A(Dot Product of 4 8-bits Accumulated to a 32-bit)
TensorRT 进行优化的方式是 DP4A (Dot Product of 4 8-bits Accumulated to a 32-bit),如下图:
这是PASCAL 系列GPU的硬件指令,INT8卷积就是使用这种方式进行的卷积计算。这个没搞太明白是怎么回事,参考这篇博客获取详细信息Mixed-Precision Programming with CUDA 8。下面是 官方 GTC2017 PPT 中给的INT8卷积计算的伪代码:
1 | // I8 input tensors: I8_input, I8_weights, INT8输入tensor |
它这个INT8卷积的计算是这样的,虽然输入的tensor已经降为 INT8,但是在卷积计算的时候用了DP4A的计算模式,卷积计算完之后是INT32的,然后又要转成 FP32,然后激活,最后再将FP32的转为INT8.
只知道这么计算会快很多,但不知道为什么,详情还是看Mixed-Precision Programming with CUDA 8 这个吧,我看的也是糊里糊涂的。
不过这个对于tensorRT的使用没啥影响,这个是很底层的东西,涉及到硬件优化。
Typical workflow in TensorRT
典型的工作流还是直接使用 GTC2017 PPT 原文说法吧:
- You will need:
- Model trained in FP32.
- Calibration dataset.
- TensorRT will:
- Run inference in FP32 on calibration dataset.
- Collect required statistics.
- Run calibration algorithm → optimal scaling factors.
- Quantize FP32 weights → INT8.
- Generate “CalibrationTable” and INT8 execution engine.
Results - Accuracy & Performance
精度并没有损失太多
速度提升还蛮多的,尤其是当 batch_size 大于1时,提升更明显
TITAN X GPU优化效果
DRIVE PX 2, dGPU 优化效果
Open challenges / improvements
一些开放式的提升和挑战:
- Unsigned int8 for activations after ReLU. 无符号 INT8 的映射。
- RNNs → open research problem. TensorRT 3.0开始已经支持RNN了。
- Fine tuning of saturation thresholds. 对阈值 |T|的 微调方法。
- Expose API for accepting custom, user provided scale factors. 开放API,使用户可以自定义 换算系数(比例因子)
这几个开放问题还是很值得研究的。
Conclusion
- 介绍了一种自动化,无参数的 FP32 到 INT8 的转换方法;
- 对称的,不饱和的线性量化,会导致精度损失较大;
- 通过最小化 KL散度来选择 饱和量化中的 阈值 |T|;
- FP32完全可以降低为INT8推理,精度几乎持平,速度有很大提升。