原文
前 言
本系列的目是详细叙述当前移动端Int8的方方面面,从最底层的Int8的汇编层实现原理以及汇编性能优化手段,到中间层的移动框架的配套代码实现(标准就以NCNN为例吧),以及上层对应的PC端量化方法(各种论文思路)总结及实现,和最后模型端re-train的方法、策略及指标介绍。
此外,利用PowerPerf(一种专门针对ARM CPU应用性能优化而开发的调试工具)对卷积kernel(汇编算子)各项硬件参数指标进行量化优化也是本文的重点,旨在提炼出一套通用的汇编代码调优方法论,从而使得PowerPerf能适用于所有移动端CPU性能优化场景。
第一章 背景
1.1量化背景
尽管模型size在不断地压缩,但是其计算量通常还是有一两百MFLOPS的样子,这个计算量对于目前的(中低端)移动端CPU算力来说,还是有点吃力的,因此模型端做了最大的努力,移动端也要不甘示弱努力加油!
通常移动端加速的方案时分CPU派跟GPU派的,目前在低端机型上arm的mali GPU性能较差,所以基本配备的还是CPU方案,而中高端机其配备的GPU大部分就是高通的了,其性能整体比CPU强劲,因此,目前在不同的定位平台上不同的方案各有优势,各自根据具体的场景适配选择方案即可。
图1-1 ARM芯片规划图
上图1-1是前一阵子(2018.7.12)ARM在腾讯做介绍的ppt,可以看到性能可观!!!可以看到arm在cpu这块会针对性地加强AI能力,我们再看个截图:
图1-2 ARM芯片ML部分
看红色框内信息可知arm会在cpu里面加大AI的算力,目前GPU大概就比CPU强个几倍的样子!(高端机还不一定有这么多,特别时ARMV8-A64V3系列的点乘出来后)但是这里说的是50倍、80倍啊!简直不敢想象!未来可期!
在CPU端做优化,我们可以从上到下考虑:
- 最上面就是算法层,如可以用winograd从数学上减少乘法的数量(仅在大channel尺寸下有效);
- 框架实现层可以实现内存池、多线程等策略;
- 底层就是硬件相关细节了:硬件架构特性、pipeline、cache、内存数据重排、NEON汇编优化等。。。
底层的我之前已讨论过一部分,后面继续配合Powerperf进行补充,这里先讨论量化方法。
既然谈到量化,那么我们从最基础开始讲起,从对量化的3个问题开始进行研究:
- 为什么量化有用?
- 因为CNN对噪声不敏感。
2. 为什么用量化?
-
- 模型太大,比如alexnet就200MB,存储压力大的哟,必须要降一降温;
- 每个层的weights范围基本都是确定的,且波动不大,适合量化压缩;
- 此外,既减少访存又减少计算量,优势很大的啊!
3. 为什么不直接训练低精度的模型?
-
- 因为你训练是需要反向传播和梯度下降的,int8就非常不好做了,举个例子就是我们的学习率一般都是零点几零点几的,你一个int8怎么玩?
- 其次大家的生态就是浮点模型,因此直接转换有效的多啊!
从宏观上回答了这三个问题,那么接下来我就就可以从量化的原理开始逐步研究了,进而在汇编层实现kernnel(PowerPerf辅助进行调试),最终分别和NCNN、QNNPACK进行等标对比。
1.2量化方法综述
量化方法有很多,具体的可以看下面的链接,里面有很多的论文,可以慢慢去看,有时间把这些论文单独整理出一章出来进行总结。
量化方法汇总链接:
第二章 INT8量化算法原理
2.1 INT8量化原理
目前最简单的实现方案是英伟达的tensorRT方案,直接量化,无需retrain,实现简单;
其次就是谷歌的那套方案,稍显复杂需要retrain;
retrain的要求就是,你的权值、激活值(实测对最终精度的影响不是很大)都必须是分布比较均匀的,也就是方差不要太大。其次是能否控制每层的输出在一定的范围内,这对我们做int8量化时,溢出的处理很有帮助。
NVIDIA的方案是公开了的,但是并没有开源,也就是说你只能用他的那一套工具(tensorRT)来进行量化、部署,当然很正常的,我们也想用他的量化校准部分获取校准参数,然后移动端直接用,目前发现是导不出来这些中间参数的,而且也没源码,在安装包内的python借口也是调用的so文件;它们给的ppt链接如下:
接下来我在这章将详细分析其原理(python实现单独拿一篇来讲,因为里面有一些小细节需要说清楚),并将在实现过程中碰到的坑一一解剖之(也就是ppt中说的一个很简单的思路,但是在实现的时候是有很多小细节是不确定的,需要一一验证的,NVIDIA的ppt是不会告诉你这些细节的,需要我们来好好理解消化)。
我们首先整体过一遍原理:
我们的目的是把原来的float 32bit 的卷积操作(乘加指令)转换为int8的卷积操作,这样计算就变为原来的1/4,但是访存并没有变少哈,因为我们是在kernel里面才把float32变为int8进行计算的。
最粗糙、最本质的原理就是这个图:
很简单是吧!就是把你一个layer的激活值范围的给圈出来,然后按照绝对值最大值作为阀值(因此当正负分布不均匀的时候,是有一部分是空缺的,也就是一部分值域被浪费了;这里有个小坑就是,假如我的激活址全是正的,没有负值,那么你怎么映射呢?),然后把这个范围直接按比例给映射到正负128的范围内来,公式如下:
FP32 Tensor (T) = scale_factor(sf) * 8-bit Tensor(t) + FP32_bias (b)
通过实验得知(英伟达说的啊,没验证过~),bias值去掉对精度的影响不是很大,因此我们直接去掉:
T = sf * t
上面是简单的max-max 映射,这是针对均匀分布的,很明显的可以知道,只要数据分布的不是很均匀,那么精度损失是很大很明显的,于是很多情况下是这么干的:
这个图我们来粗略解读下:
- 为什么量化是可以保证原信息的?
这个原因就好比高清图跟低分辨率图的区别,只要你的目标是大体识别出图中是啥这一信息,那么低分辨率的图也是允许的。
你看网上的视频加入马赛克后还会不会影响你的判断呢?并不会,你会脑补出额外的细节,只有当满屏的马赛克的时候才会影响你的观影体验,因此这个打码,噢不,量化其实就是一个程度的问题,一个你能否接受的程度问题。
2. 为什么说最大值映射会精度损失严重?
你看值的分布,由于正负分布很不均匀,如果按照对称最大值映射(原意是为了尽可能多地保留原信息)的话,那么+max那边有一块区域就浪费了,也就是说scale到int8后,int8的动态范围就更小了,举个极限的例子就是量化后原本int8的动态范围只剩1bit了(就是正的样本没有,负的全部扎堆在一个很小的值附近),就是上面说到的满屏马赛克~这种情况下。。。那还表示个毛的原信息啊!
3. 为什么右边的饱和截取就ok呢?
因为非饱和截取的问题是当数据分布极不均匀的时候,有很多动态范围是被浪费的,也就是说打的马赛克很大!而饱和截取就是弥补这个问题的。
当你数据分布很不均匀的时候,如图左边比右边多,那么我把原始信息在影射之前就截断一部分,然后构成对称且分布良好的截断信息,再把这个信息映射到int8上去,那么就不会有动态范围资源被浪费了,也就是说马赛克打的比较细腻了~你可以估摸着脑补出细节画面了(我说的是商标打码~你们想到哪去了?!!黑人问号.jpg)~
像上图这样,先找一个阀值T,然后低于最低阀值的就全部都饱和映射到-127上,如上图的左边的三个红色的点就是这么处理的。
(这也就是一个很自然的思路对吧~把无关的高频细节给去掉,从而获取性能上的好处!网络图像压缩技术不就是这么整的么!PCA主成分、傅立叶分解的思路不都是这样的么!抓住事物的主要矛盾,忽略细节,从而提高整体性能!就像机器学习里的正则化优化不也是这样么,避免你过于钻到细节里面从而产生过拟合啊!这么一想,其实,我们人生不也是这样么?什么事情都得抠死理,钻牛角尖么?!!有时候主动放弃一些东西首先你的人生肯定会轻松很多,其次说不定会收获到更稳定的人生幸福值(泛化性能)呢!)
那么我们的问题就转换为如何寻找最优的阀值T使得精度的损失最小?
损失最小,不就是一个最优化问题嘛~回忆下研一时,老师上课说的。。。。
嗯~我忘记了。。。
但是,我们可以凭本能来思考啊!也就是一个正常人会怎么思考!
首先自然地,你会想到用一个模型来描述它吧!那么我们得先建模啊,建立一个模型来评估量化前后的精度损失,然后使得损失最小!
嗯。。。其实所谓的模型就是高数里面的函数嘛!比如椭圆公式就是一个椭圆模型的描述,里面的那些参数就是针对我们特定任务要求的值!
ok~那下一层就是我们如何设计这个模型函数呢?
嗯~其实目前我时肯定想不到怎么去用数学语言描述这个优化问题!毕竟我的数学应用能力还停留在考试中应用题的等级啊!说到底就是数学的功底和悟性不行!
但是,这完全不能阻挡我对数学的向往和追求啊!
当你体验到数学是如何巧妙描述一个复杂事情后你就会体验到她的奇妙~忍不住赞叹,妙啊!
咳~扯远了!我们还是来学习下NVIDIA的思路!
NVIDIA选择的是KL-divergence,其实就是相对熵,那为什么要选择相对熵呢?而不是其他的别的什么呢?因为相对熵表述的就是两个分布的差异程度,放到我们的情境里面来就是量化前后两个分布的差异程度,差异最小就是最好的了~因此问题转换为求相对熵的最小值!
这里放个公式啊!大学本科水平就能理解的,这里其实就是信息论里面的编码章节问题啊!下面有个链接,我看你骨骼惊奇,是个学习的奇才,传送过去复习下吧!
信息熵,交叉熵和相对熵 - PilgrimHui - 博客园www.cnblogs.com
这个是信息量熵的式子:
也就是log(1/p)的期望,即事件发生概率的倒数的期望;
熵越大就代表事件发生的不可能性大,因此里面包含的信息量就越大,比如,最近世界杯的梅西出局,这在大家的眼里是低概率事件吧,因此这件事一发生就有好多赌球的人说要上天台!这里面到底又没有啥内幕呢?引人深思。。。。嗯~你还说这个事情的信息量大不大?!!!
信息论里面的解释是:按照真实分布p来编码样本所需的编码长度的期望如下图,这就是信息熵H( p )了。
按照不真实分布q来编码样本所需的编码长度的期望如下图,这就是所谓的交叉熵H( p,q )
这里引申出KL散度D(p||q) = H(p,q) - H(p) =下图公式,也叫做相对熵,它表示两个分布的差异,差异越大,相对熵越大。
上面公式展开就是这样了:
最开始熵就是这么来的啊!然后在编码理论里面,把log的底变为2,出来的就是bit位数了啊!也就是说我来描述一段信息的信息量需要多少个bit(期望值)!
懂了?理解了?要不再举个例子?
从编码的角度来讲一下相对熵,即什么是KL-divergence以?及为什么要用KL-divergence?
假设我们有一系列的符号,知道他们出现的概率,如果我要对这些符号进行最优编码,我会用T bits来表示,T即为表示原信息的最优的bit位数。我们把这个编码叫为A;
现在我们有同样的符号集合,只是他们出现的概率变了,假如我还是用A编码来对这个符合集合进行编码的话,那么编码的位长T'就是次优的了,是大于原来的T值的。
(假设我有一系列的符号,我知道它们发生的概率。如果我要对这些符号进行最优的编码,我会用“T”来表示。注意,T是位的最优数。让我们把这段代码称为“A”。现在,我有相同的符号集但是它们发生的概率已经改变了。现在,符号有了新的概率,如果我用代码A来编码符号,编码的比特数将会是次优的,大于T。)
KL散度就是来精确测量这种最优和次优之间的差异(由于选择了错误的编码导致的)。在这里F32就是原来的最优编码,int8就是次优的编码,我们用KL散度来描述这两种编码之间的差异;
- 相对熵表示的是采用次优编码时你会多需要多少个bit来编码,也就是与最优编码之间的bit差;
- 而交叉熵表示的是你用次优编码方式时确切需要多少个bits来表示;
- 因此,最优编码所需要的bits=交叉熵-相对熵。
当然了,下面知乎上的这位作者讲解的通俗好多了,大家可以去看看啊~
为什么交叉熵(cross-entropy)可以用于计算代价?www.zhihu.com
我们再写个代码来验证下原理:
下面代码的过程就是生成两个随机分布,值在1到11之间,取10个值,然后计算这两个分布之间的KL值,当KL低于0.1时我们看一下它真实的分布,来直观感受下。
def get_KL():
# 随机生成两个离散型分布
x = [np.random.uniform(1, 11) for i in range(10)]
px = x / np.sum(x)
y = [np.random.uniform(1, 11) for i in range(10)]
py = y / np.sum(y)
KL = 0.0
for i in range(10):
KL += px[i] * np.log(px[i] / py[i])
if KL < 0.1:
print x
print y
return KL
图形显示就是如下:
上图是其中两组相对熵小于0.1的数据,可见,相对于其他的随机数据这个确实是蛮相似的。
好了,现在我假设你已经大概理解了,为什么要用相对熵来描述int8量化后的值分布跟f32的值分布之间的信息量丢失程度!
那公式有了,按照公式的要求,我们接下来就是要求概率了,即事件q(int8)分布的概率以及事件p(f32)分布的概率!
额~发现没有!我们遇到一个处理小细节了!我们的f32值分布是在一个范围内,我们怎么转换成频次或者说概率呢?
你看哈,int类的还比较好,因为最小的单位就是整型也就是1,0->1->2是直接跳跃的,因此我们计算整型数据集合的概率分布的时候直接统计求hist就好了,但是float呢?精度理论上是很小的呀,你总得定个边界吧?这样才好把数据归类啊,因此就类似于整型的我们分bin,我们分bin才能求bin的概率!那么分多少个bin呢?
NVIDAIA给的是2048个bin(maxnet代码里面给的是8000bins),比128bin要多,但是又不会多处太多从而迭代太多影响计算速度!
具体的实现细节及讨论下章再讲,这里只是粗略说一下。
2.2 INT8量化流程
你看下面是NVIDIA的运算流程:
int8 scale表获取流程
宏观处理流程如下,首先准备一个校准数据集,然后对每一层:
-
- 收集激活值的直方图;
- 基于不同的阀址产生不同的量化分布;
- 然后计算每个分布与原分布的相对熵,然后选择熵最少的一个,也就是跟原分布最像的一个。
此时阀值就选出来啦,对应的scale值也就出来了。
而其中最关键的就是校准算法部分了:
calibration:基于实验的迭代搜索阀值。
校准是其核心部分,应用程序提供一个样本数据集(最好是验证集的子集),称为“校准数据集”,它用来做所谓的校准。
在校准数据集上运行FP32推理。收集激活的直方图,并生成一组具有不同阈值的8位表示法,并选择具有最少kl散度的表示;kl-散度是在参考分布(即FP32激活)和量化分布之间(即8位量化激活)之间。
TRT.2.1提供了IInt8EntropyCalibrator,该接口需要由客户端实现,以提供校准数据集和一些用于缓存校准结果的样板代码。
2.3 INT8量化实现-校准算法
公式是:FP32 Tensor (T) = scale_factor(sf) * 8-bit Tensor(t),bias实验得知可去掉。
校准算法伪代码
首先看上图的原理,就是把大范围的一个值给缩小到一个小范围的值(注意是等比例的缩小)。
但是这里有个疑问啊,就是我收集的数据是正负都有的,那么这里的2048bins指的是正范围还是负范围呢?因为我看到后面都是量化的128bins里面去的,也就是说只管了int8(256)的一半!具体细节下篇文章中的代码实现部分详细分析;
这里看他的意思就是输入为[0, 2048] bins,然后想办法把这么大的分布给找到一个合理的阀值T然后把阀值内的bins映射到int8的128个bins里面来,最终而且信息熵损失是最少的。
怎么做的呢?
- 首先不断地截断参考样本P,长度从128开始到2048,为什么从128开始呢?因为截断的长度为128的话,那么我们直接一一对应就好了,完全不用衰减因子了;
- 将截断区外的值全部求和;
- 截断区外的值加到截断样本P的最后一个值之上;(截断区之外的值为什么要加到截断区内最后一个值呢?我个人理解就是有两个原因,其一是求P的概率分布时,需要总的P总值,其二将截断区之外的加到截断P的最后,这样是尽可能地将截断后的信息给加进来。)
- 求得样本P的概率分布;
- 创建样本Q,其元素的值为截断样本P的int8量化值;
- 将Q样本长度拓展到 i ,使得和原样本P具有相同长度;
- 求得Q的概率分布;
- 然后就求P、Q的KL散度值就好啦~
上面就是一个循环,不断地构造P和Q,并计算相对熵,然后找到最小(截断长度为m)的相对熵,此时表示Q能极好地拟合P分布了。
而阀值就等于(m + 0.5)*一个bin的长度;
2.4 INT8量化实现-校准实现(python)
下一篇详细说说代码实现的细节部分,以及相关的知识,比如我们为什么要做分布的smooth处理。
第三章INT8移动端实现
在这部分,我将以自顶向下的方式叙述INT8的kernel实现原理及过程,其中包括开源的NCNN INT8版本以及我优化后INT8版本(QNNPACK INT8版本在《QNNPACK调研》中有详细论述,此文略过仅在最后做数据对比)。
3.1 NCNN INT8实现
3.1.1 底层量化处理整体流程
在每层计算时是需要先将feature map量化到INT8,然后将weights量化到INT8,最后卷积计算得到INT32的输出值,输出值乘以scale(float)值反量化到float,然后加上浮点格式的bias作为下一层的输入。
3.1.2 量化前处理
NCNN的quantize.cpp函数里面有其量化的实现,所有的input,weights均是float32的,都通过此方法进行处理,核心代码是:
for (int i=0; i<size; i++)
{
outptr[i] = float2int8(ptr[i] * scale);
}
static inline signed char float2int8(float v)
{
int int32 = round(v);
if (int32 > 127) return 127;
if (int32 < -128) return -128;
return (signed char)int32;
}
代码的思路很直接,就是把你上位机校准程序得到的scale参数乘以浮点值,给压缩到int8的范围内来,然后直接float2int转换,最后做下边界处理,这样量化的前处理就处理好了。(注: 跟int16定点的处理方式没有本质的差异的,对比我之前的Int16量化原理分析文章。)
3.1.3 量化计算
NCNN的convolution_1x1_int8.h中有其实现,分别支持了Armv7跟Armv8,这里仅就Armv7进行讲解,最后再点出Armv8跟其的不同之处即可类推。
static void conv1x1s1_int8_neon(const Mat& bottom_blob,
Mat& top_blob,
const Mat& _kernel,
const Option& opt)
{
int size = top_blob.h * top_blob.w;
int remain = size & 7;
typedef void (*conv_func_int8)(const Mat&, Mat&, const Mat&, const Option&);
conv_func_int8 conv_func_table[8] =
{
conv1x1s1_neon_s8, //0
conv1x1s1_neon_s8, //1
conv1x1s1_neon_s8, //2
conv1x1s1_neon_s8, //3
conv1x1s1_neon_s8_left4, //4
conv1x1s1_neon_s8, //5
conv1x1s1_neon_s8, //6
conv1x1s1_neon_s8, //7
};
conv_func_int8 conv = conv_func_table[remain];
conv(bottom_blob, top_blob, _kernel, opt);
return;
}
这个是int8计算的统一入口,根据输出blob的size来对8求余,根据余数来决定使用哪种kernel实现,这也是常规的边界处理方法,这里只有在余数是4的情况下需要特别处理的,我们不看特例就只看通常情况下的conv1x1s1_neon_s8吧!
/*
* Convolution 1x1 quantized with int8,unroll 8 x 4
*/
static void conv1x1s1_neon_s8(const Mat& bottom_blob, Mat& top_blob,
const Mat& _kernel,
const Option& opt)
根据接口描述可知Armv7模式下是按照4*8展开的,具体如何展开我们进去一探究竟。
简化代码逻辑后流程如下:
int nn_outch = outch >> 2; //输出channel除以4;
for (int pp=0; pp<nn_outch; pp++)
{
int p = pp * 4;
Mat out0 = top_blob.channel(p); // 取出4channel位置。
Mat out1 = top_blob.channel(p+1);
Mat out2 = top_blob.channel(p+2);
Mat out3 = top_blob.channel(p+3);
out0.fill(0); // 填充为0;
out1.fill(0);
out2.fill(0);
out3.fill(0);
int q = 0;
for (; q+7<inch; q+=8)
{
int* outptr0 = out0; // 注意是按照int格式定位数据的!因为我们是按照int32输出
int* outptr1 = out1;
int* outptr2 = out2;
int* outptr3 = out3;
// 输入层是int8的,也就是在进行forward之前就已经把之quantize好了。输入channel取8个;
const signed char* r0 = bottom_blob.channel(q);
const signed char* r1 = bottom_blob.channel(q+1);
const signed char* r2 = bottom_blob.channel(q+2);
const signed char* r3 = bottom_blob.channel(q+3);
const signed char* r4 = bottom_blob.channel(q+4);
const signed char* r5 = bottom_blob.channel(q+5);
const signed char* r6 = bottom_blob.channel(q+6);
const signed char* r7 = bottom_blob.channel(q+7);
// 同样的kernel也是在初始化的时候就已经quantize好了。取4个kernel的前8个;
const signed char* kernel0 = (const signed char*)kernel + p*inch + q;
const signed char* kernel1 = (const signed char*)kernel + (p+1)*inch + q;
const signed char* kernel2 = (const signed char*)kernel + (p+2)*inch + q;
const signed char* kernel3 = (const signed char*)kernel + (p+3)*inch + q;
int size = outw * outh;
int nn = size >> 3;
int remain = size & 7;
if (nn > 0)
{
// 以汇编的方式从你选定的4个kernel位置,分别取8个*8bit到D寄存器;
asm volatile(
"vld1.s8 d18, [%0] \n"
"vld1.s8 d19, [%1] \n"
"vld1.s8 d24, [%2] \n"
"vld1.s8 d25, [%3] \n"
: "=r"(kernel0), // %0
"=r"(kernel1), // %1
"=r"(kernel2), // %2
"=r"(kernel3) // %3
: "0"(kernel0),
"1"(kernel1),
"2"(kernel2),
"3"(kernel3)
:
);
asm volatile(
"0: \n"
//ld r0-r7,分别预加载8个输入channel的H*W面中的前8个元素。
"pld [%5, #64] \n"
"vld1.s8 {d0}, [%5 :64]! \n" //r0
"pld [%6, #64] \n"
"vld1.s8 {d1}, [%6 :64]! \n" //r1
"pld [%7, #64] \n"
"vld1.s8 {d2}, [%7 :64]! \n" //r2
"pld [%8, #64] \n"
"vld1.s8 {d3}, [%8 :64]! \n" //r3
"pld [%9, #64] \n"
"vld1.s8 {d4}, [%9 :64]! \n" //r4
"pld [%10, #64] \n"
"vld1.s8 {d5}, [%10 :64]! \n" //r5
"pld [%11, #64] \n"
"vld1.s8 {d6}, [%11 :64]! \n" //r6
"pld [%12, #64] \n"
"vld1.s8 {d7}, [%12 :64]! \n" //r7
//###########################################
//load inch kernel_0 k0-k7
"vdup.s8 d8, d18[0] \n"
"vdup.s8 d9, d18[1] \n"
"vdup.s8 d10, d18[2] \n"
"vdup.s8 d11, d18[3] \n"
"vdup.s8 d12, d18[4] \n"
"vdup.s8 d13, d18[5] \n"
"vdup.s8 d14, d18[6] \n"
"vdup.s8 d15, d18[7] \n"
//mla
"vmull.s8 q8, d0, d8 \n"
"vmlal.s8 q8, d1, d9 \n"
"vmlal.s8 q8, d2, d10 \n"
"vmlal.s8 q8, d3, d11 \n"
"vmlal.s8 q8, d4, d12 \n"
"vmlal.s8 q8, d5, d13 \n"
"vmlal.s8 q8, d6, d14 \n"
"vmlal.s8 q8, d7, d15 \n"
//outptr0_s32
"pld [%1, #256] \n"
"vld1.32 {d20-d23}, [%1:128] \n" //outptr0_s32
"vaddw.s16 q10, q10, d16 \n"
"vaddw.s16 q11, q11, d17 \n"
"vst1.32 {d20-d23}, [%1:128]!\n"
//###########################################
//load inch kernel_1 k0-k7
"vdup.s8 d8, d19[0] \n"
"vdup.s8 d9, d19[1] \n"
"vdup.s8 d10, d19[2] \n"
"vdup.s8 d11, d19[3] \n"
"vdup.s8 d12, d19[4] \n"
"vdup.s8 d13, d19[5] \n"
"vdup.s8 d14, d19[6] \n"
"vdup.s8 d15, d19[7] \n"
//mla
"vmull.s8 q8, d0, d8 \n"
"vmlal.s8 q8, d1, d9 \n"
"vmlal.s8 q8, d2, d10 \n"
"vmlal.s8 q8, d3, d11 \n"
"vmlal.s8 q8, d4, d12 \n"
"vmlal.s8 q8, d5, d13 \n"
"vmlal.s8 q8, d6, d14 \n"
"vmlal.s8 q8, d7, d15 \n"
//outptr1_s32
"pld [%2, #256] \n"
"vld1.32 {d20-d23}, [%2:128] \n" //outptr1_s32
"vaddw.s16 q10, q10, d16 \n"
"vaddw.s16 q11, q11, d17 \n"
"vst1.32 {d20-d23}, [%2:128]!\n"
//############################################
//load inch kernel_2 k0-k7
"vdup.s8 d8, d24[0] \n"
"vdup.s8 d9, d24[1] \n"
"vdup.s8 d10, d24[2] \n"
"vdup.s8 d11, d24[3] \n"
"vdup.s8 d12, d24[4] \n"
"vdup.s8 d13, d24[5] \n"
"vdup.s8 d14, d24[6] \n"
"vdup.s8 d15, d24[7] \n"
//mla
"vmull.s8 q8, d0, d8 \n"
"vmlal.s8 q8, d1, d9 \n"
"vmlal.s8 q8, d2, d10 \n"
"vmlal.s8 q8, d3, d11 \n"
"vmlal.s8 q8, d4, d12 \n"
"vmlal.s8 q8, d5, d13 \n"
"vmlal.s8 q8, d6, d14 \n"
"vmlal.s8 q8, d7, d15 \n"
//outptr2_s32
"pld [%3, #256] \n"
"vld1.32 {d20-d23}, [%3:128] \n" //outptr2_s32
"vaddw.s16 q10, q10, d16 \n"
"vaddw.s16 q11, q11, d17 \n"
"vst1.32 {d20-d23}, [%3:128]!\n"
//#############################################
//load inch kernel_3 k0-k7
"vdup.s8 d8, d25[0] \n"
"vdup.s8 d9, d25[1] \n"
"vdup.s8 d10, d25[2] \n"
"vdup.s8 d11, d25[3] \n"
"vdup.s8 d12, d25[4] \n"
"vdup.s8 d13, d25[5] \n"
"vdup.s8 d14, d25[6] \n"
"vdup.s8 d15, d25[7] \n"
//mla
"vmull.s8 q8, d0, d8 \n"
"vmlal.s8 q8, d1, d9 \n"
"vmlal.s8 q8, d2, d10 \n"
"vmlal.s8 q8, d3, d11 \n"
"vmlal.s8 q8, d4, d12 \n"
"vmlal.s8 q8, d5, d13 \n"
"vmlal.s8 q8, d6, d14 \n"
"vmlal.s8 q8, d7, d15 \n"
//outptr3_s32
"pld [%4, #256] \n"
"vld1.32 {d20-d23}, [%4:128] \n" //outptr3_s32
"vaddw.s16 q10, q10, d16 \n"
"vaddw.s16 q11, q11, d17 \n"
"vst1.32 {d20-d23}, [%4:128]!\n"
//next
"subs %0, #1 \n"
"bne 0b \n"
: "=r"(nn), // %0
"=r"(outptr0), // %1
"=r"(outptr1), // %2
"=r"(outptr2), // %3
"=r"(outptr3), // %4
"=r"(r0), // %5
"=r"(r1), // %6
"=r"(r2), // %7
"=r"(r3), // %8
"=r"(r4), // %9
"=r"(r5), // %10
"=r"(r6), // %11
"=r"(r7) // %12
: "0"(nn),
"1"(outptr0),
"2"(outptr1),
"3"(outptr2),
"4"(outptr3),
"5"(r0),
"6"(r1),
"7"(r2),
"8"(r3),
"9"(r4),
"10"(r5),
"11"(r6),
"12"(r7)
: "cc", "memory", "q0", "q1", "q2", "q3", "q4", "q5", "q6", "q7", "q8", "q10", "q11", "q13", "q14", "q15"
);
}
分析:
上述代码总结如下图所示,先看整体架构,以CHW方式内存排布的输入输出blob以及kernnel如图所示,每次计算输出的4个channel(也就是计算4个kernnel),每个通道中计算H*W面上的8个元素,在输出的H*W中沿图示方向轮循计算完当前4(Cout)*H*W内的计算量(只计算完了8/Cin),然后再接着跳到Cin方向上的下一个r0~r7,再次轮回,周而复始,直至4(Cout)*H*W的所有计算量算完;
算完上一个部分后,进入下一个更大的轮回,也就是输出的下一个4(Cout)*H*W,直至宇宙毁灭。
图1-5 NCNN INT8方案
根据3x3s1优化的经验可知,上面重复需要把输出给读进读出,这样会造成更多的消耗,但是假如我按下图所示的以HWC的方式沿Cin方向纵向延伸呢?只有最后计算完才写回内存,这样就节省很多时间了!
图1-5 我认为会更好的方案
代码的循环架构如下:
For(Cout/4)
{
For(Cin/8)
{
For(Hout*Wout/8)
{
核心卷积;
}
边界卷积计算;
}
}
最核心的部分就是计算卷积了,这里剖析如下,这里每次是计算4个kernenl的前8个元素,而这4个kernnel的计算方式是一致的,因此我们随便选择一个讨论就行,假设计算k1的卷积是func(K1),那么上述的计算卷积就是:
核心卷积 = {
func(K1),
func(K2),
func(K3),
func(K4)
}
图1-5 核心卷积原理
小结:
- 加载kernel去掉了4(N)*8(Cin)*8bit=4D=2Q=256bit;
- 加载input去掉了8(Cin)*8(W)*8bit=8D=4Q=512bit;
- 由于每次计算输出H*W内的8个点,同时int8是无法向量-标量乘法的,因此需要把k1[0]扩展到一个D寄存器中,扩展为8个单元,再据此跟输入对应位置W方向上的八个元素相乘,从而得到8个输出,详细见下图,共消耗8D=4Q个矢量寄存器;
//load inch kernel_3 k0-k7
"vdup.s8 d8, d25[0] \n"
"vdup.s8 d9, d25[1] \n"
"vdup.s8 d10, d25[2] \n"
"vdup.s8 d11, d25[3] \n"
"vdup.s8 d12, d25[4] \n"
"vdup.s8 d13, d25[5] \n"
"vdup.s8 d14, d25[6] \n"
"vdup.s8 d15, d25[7] \n"
4. 累加的32bit输出共计需要2个Q寄存器,代码如下所示,先加载进来32bit值,然后把16bit的中间值累加上去,然后再写回内存;接着再开始HWC方式下的下一次Cin/8计算;
//outptr1_s32
"pld [%2, #256] \n"
"vld1.32 {d20-d23}, [%2:128] \n" //outptr1_s32
"vaddw.s16 q10, q10, d16 \n"
"vaddw.s16 q11, q11, d17 \n"
"vst1.32 {d20-d23}, [%2:128]!\n"
因此总共花费了12个Q寄存器,寄存器还没用完呐!优化不到位啊!
3.1.4 量化后处理
NCNN的dequantize.cpp函数里面有其去量化的实现,把卷积最后输出的int32乘以scale值放大回原来的值变为float,然后加上浮点格式的bias,最后输出作为下一层的输入feature map。
for (int i=0; i<h; i++)
{
const int* intptr = bottom_top_blob.row<const int>(i);
float* ptr = bottom_top_blob.row(i);
float bias = bias_data_size > 1 ? bias_data[i] : bias_data[0];
for (int j=0; j<w; j++)
{
ptr[j] = intptr[j] * scale + bias;
}
}
原理也很直接,输入一个INT32的Mat矩阵,然后由于float32也是32bit的,因此我以int指针intptr的方式从Mat的当前位置处加载32bit数进来到普通int寄存器然后乘以scale值就到了FP寄存器了,接着再加上bias,最后把这个浮点寄存器里面的值写回到你刚读数据的位置,这样就完美地节省内存了。(注:可能存在的问题是把数据从int通用寄存器搬运到浮点寄存器一般是V矢量指令,这种指令会消耗额外的cycles,因此性能不会很好就对了。)
3.2 优化INT8实现逻辑
这一章在后续再详细写写吧。
3.2.1优化-直接减少访存
我们把指令的排布不做改变,访存方式不做改变(保留CHW方式)仅修改访存size也即输入int8计算累加到int16进行输出,此时L1 cache的访问情况如下:
3.2.1优化-展开更充分
3.2.2优化-指令重排调整流水线
PowerPerf界面部分
等等。。。。后续慢慢写。。。
第四章 Ncnn Vs QNNPACK
路得一步一步走不是吗?我会慢慢补上来的。