基于PPQ的CNN卷积神经网络INT8型量化感知训练应用小结
作者:互联网
1、引言
对于在FPGA端侧进行CNN卷积神经网络加速,合适的量化方法不仅能够有效的提升DSP在单位周期内的操作数,同样也能够降低对存储空间、片内外交互带宽、逻辑资源等的需求。例如采用16Bit量化方式,每个DSP可以进行1次乘法运算;采用8Bit量化方式,DSP可以进行2次乘法运算,这个在之前的博客里有提到(https://www.cnblogs.com/ruidongwu/p/15713090.html)。
如果要实现INT8类型的量化,那么很关键的一点是选择合适的量化算法,很显然如果采用直接量化,势必会带来极大的精度损失,甚至有可能导致量化后的网络根本不可用,因此量化感知训练将量化的过程作为激活函数的一部分,输入少部分测试图片用于权重矫正,能够降低由于量化带来的计算误差,并且在精度上能够有很好的保障。其实在Tensorflow和Pytorch里面已经集成有很好的量化感知训练策略,但是这些策略更多是面向一部分平台,并且能够支持的量化方式是有限的。例如Tensorflow中通过8Bit量化感知训练得到的TFLite文件,对于权重数据采用的是非对称量化策略,也许在部分平台已经取得了很好的效果,但是在笔者当前课题所研究的面向FPGA平台的CNN加速场景并不是最优的选择。因此,选择适合FPGA平台的量化算法与量化策略,能够简化FPGA中加速器的开发流程。
综上所示,为了更好的开发适用于FPGA平台的量化策略,本文以商汤高性能计算团队(HPC)在OpenPPL开源项目中的PPQ量化工具(https://github.com/openppl-public/ppq)为例,实现对称的Power of 2量化方式的分析与原理性代码演示。
PS:笔者认为PPQ是见过最好的量化工具,没有之一。感兴趣的网友可以通过B站的视频来进一步了解(https://space.bilibili.com/289239037)
2、量化方式与原理分析
根据量化方式的不同,分为对称/非对称、整数(Power of 2)/非整数、线性/非线性、逐层/逐通道(per Tensor/Channel)等模式的随意组合,详细情况可通过商汤在B站发布的视频(https://www.bilibili.com/video/BV1fB4y1m7fJ)来进一步了解。
那么上述量化策略的使用,对于FPGA而言,效果最好的当然是对称+整数+线性+逐层的量化方式,当然逐通道也是可以的,理论上逐通道要比逐层的量化误差更小。
因此,接下来将对适合FPGA的量化策略进行理论分析,假设卷积计算的公式如下,其中$ \odot $为卷积操作,$A_n$为第n层卷积的输入特征图,$A_{n+1}$为第n层卷积的输出特征图,$W_n$为第n层卷积的卷积核权重,$B_n$为第n层卷积的偏置,
${A_{n + 1}} = {A_n} \odot {W_n} + {B_n}$
那么根据所采用的量化方式为Power of 2量化,对应量化因子为$S$,卷积计算可替换为如下计算过程:
${A_{n + 1}}{S_{{a_{n + 1}}}} = {A_n}{S_{{a_n}}} \odot {W_n}{S_{{w_n}}} + {B_n}{S_{{b_n}}}$
${S_x} = {2^x}$
而上述公式可进一步修改为:
${A_{n + 1}}{S_{{a_{n + 1}}}} = \left( {{A_n} \odot {W_n}} \right){S_{{a_n}}}{S_{{w_n}}} + {B_n}{S_{{b_n}}}$
不失一般性,如果我们将偏置和INT8卷积放在一起,那么上述计算公式可进一步更新为:
${A_{n + 1}} = \left( {{A_n} \odot {W_n} + {B_n}\frac{{{S_{{b_n}}}}}{{{S_{{a_n}}}{S_{{w_n}}}}}} \right)\frac{{{S_{{a_n}}}{S_{{w_n}}}}}{{{S_{{a_{n + 1}}}}}}$
然后将上述尺度因子转换为指数运算:
${A_{n + 1}} = \left( {{A_n} \odot {W_n} + {B_n}{2^{{b_n} - {a_n} - {w_n}}}} \right){2^{{a_n} + {w_n} - {a_{n + 1}}}}$
对于任意的2的$x$指数运算,在硬件逻辑上可进一步简化为:
$\left\{ \begin{array}{l}R \times {2^x} = R < < \left| x \right|,x \ge 0\\R \times {2^x} = R > > \left| x \right|,x < 0\end{array} \right. $
而在PPQ中典型的NXP_INT8量化模式中,对于偏置数据$B_n$的处理方式为:经过左移$ \beta $位至相同尺度,然后与卷积的结果进行累加。由于累加后结果需要考虑不同层中特征图数据的量化尺度$A_n$与$A_{n+1}$,对应累加后数据的右移$ \alpha $位操作。考虑到累加后数据存在溢出的情况,需要针对移位数据进行尺度空间范围内的截断操作。因此上述计算过程最终简化为:
$ \left\{ \begin{array}{l} {A_{n + 1}} = clip\left[ {\left( {{A_n} \odot {W_n} + {B_n} < < \beta } \right) > > \alpha } \right]\\ \alpha = {a_{n + 1}} - {a_n} - {w_n} = \left| {{a_n} + {w_n} - {a_{n + 1}}} \right|\\ \beta = {b_n} - {a_n} - {w_n} = \left| {{a_n} + {w_n} - {b_n}} \right| \end{array} \right. $
那么传统的INT8型量化后数据在3x3卷积计算流程如下:在整个计算中,只存在INT8类型乘法、32Bit加法、移位与截断操作,非常适合FPGA硬件电路的运算。等效的伪代码运行如下:
1 #define R row 2 #define C column 3 #define M input_channel 4 #define N output_channel 5 signed char W0[N][M][3][3];//weight 6 signed short int B0[N];//bias 7 signed char A0[M][R][C];//input feature map 8 signed char A1[N][R][C];//output feature map 9 10 unsigned char W0_S[N];//weight scale 11 unsigned char B0_S[N];//bias scale 12 unsigned char A0_S;//input scale 13 unsigned char A1_S;//output scale 14 15 void ConvFunc(void) 16 { 17 for(int n=0; n<N; n++) 18 for(int r=0; r<R; r++) 19 for(int c=0; c<C; c++) 20 { 21 int sum = B0[n]; 22 sum <<= W0_S[n]+A0_S-B0[n];//bias scale shift 23 for(int m=0; m<M; m++) 24 for(int k0=0; k0<3; k0++) 25 for(int k1=0; k1<3; k1++) 26 { 27 if(is_range()) 28 sum += A0[m][r+k0-1][c+k1-1]*W0[n][m][k0][k1]; 29 } 30 sum >>= W0_S[n]+A0_S-A1_S;//layer scale shift 31 A1[n][r][c] = clip(sum); 32 } 33 }
3、基于Mnist数据集的分类网络量化示例
本节使用Mnist数据集进行实际的示例,所有的代码与权重都会在文章末尾提供下载地址。
首先我们需要基于pytorch训练一个浮点类型的网络实现手写字符分类,其中网络训练代码如下:
1 ''' 2 @Time : 2022.04.23 3 @Author : wuruidong 4 @Email : wuruidong@hotmail.com 5 @FileName: LeNet_onnx.py 6 @Software: python pytorch=1.6.0 ppq=0.6.3 onnx=1.8.1 7 @Cnblogs : https://www.cnblogs.com/ruidongwu 8 ''' 9 import torch as tf 10 import torch.nn as nn 11 import torch.nn. functional as F 12 import torch.optim as optim 13 from torch.autograd import Variable 14 from torchvision import datasets, transforms 15 16 # convenience class to keep track of averages 17 class Metric(object): 18 def __init__(self, name): 19 self.name = name 20 self.sum = tf.tensor(0.) 21 self.n = tf.tensor(0.) 22 def update(self, val): 23 self.sum += val.cpu() 24 self.n += 1 25 @property 26 def avg(self): 27 return self.sum / self.n 28 29 class LeNet(tf.nn.Module): 30 def __init__(self): 31 super(LeNet, self).__init__() 32 self.conv1 = nn.Conv2d(1, 4, 3, 1) 33 self.bn1 = nn.BatchNorm2d(4) 34 self.relu1 = nn.ReLU(inplace=False) # <== Module, not Function! 35 self.pool1 = nn.MaxPool2d(2) 36 37 self.conv2 = nn.Conv2d(4, 8, 3, 1) 38 self.bn2 = nn.BatchNorm2d(8) 39 self.relu2 = nn.ReLU(inplace=False) # <== Module, not Function! 40 #self.pad = nn.ZeroPad2d(padding=(1,0,1,0)) 41 self.pool2 = nn.MaxPool2d(2, padding=1) 42 43 self.conv3 = nn.Conv2d(8, 16, 3, 1) 44 self.bn3 = nn.BatchNorm2d(16) 45 self.relu3 = nn.ReLU(inplace=False) # <== Module, not Function! 46 self.pool3 = nn.MaxPool2d(2) 47 48 self.fc1 = nn.Linear(64, 10) 49 50 def forward(self, x): 51 x = self.conv1(x) 52 x = self.bn1(x) 53 x = self.relu1(x) # <== Module, not Function! 54 x = self.pool1(x) 55 56 x = self.conv2(x) 57 x = self.bn2(x) 58 x = self.relu2(x) # <== Module, not Function! 59 #x = self.pad(x) 60 x = self.pool2(x) 61 62 x = self.conv3(x) 63 x = self.bn3(x) 64 x = self.relu3(x) # <== Module, not Function! 65 x = self.pool3(x) 66 67 x = tf.flatten(x, 1) 68 x = self.fc1(x) 69 #output = F.log_softmax(x, dim=1) # <== the softmax operation does not need to be quantized, we can keep it as it is 70 output = x 71 return output 72 73 def test(model, device, test_loader, integer=False, verbose=False): 74 model.eval() 75 test_loss = 0 76 correct = 0 77 test_acc = Metric('test_acc') 78 79 with tf.no_grad(): 80 for data, target in test_loader: 81 if integer: # <== this will be useful when we get to the 82 data *= 255 # IntegerDeployable stage 83 data, target = data.to(device), target.to(device) 84 output = model(data) 85 test_loss += F.nll_loss(output, target, reduction='sum').item() # sum up batch loss 86 pred = output.argmax(dim=1, keepdim=True) # get the index of the max log-probability 87 correct += pred.eq(target.view_as(pred)).sum().item() 88 test_acc.update((pred == target.view_as(pred)).float().mean()) 89 90 test_loss /= len(test_loader.dataset) 91 return test_acc.avg.item() * 100. 92 93 device = tf.device("cuda" if tf.cuda.is_available() else "cpu") 94 print('current device is', device) 95 96 train_loader = tf.utils.data.DataLoader( 97 datasets.MNIST('../data', train=True, download=False, transform=transforms.Compose([ 98 transforms.ToTensor() 99 ])), 100 batch_size=128, shuffle=True 101 ) 102 test_loader = tf.utils.data.DataLoader( 103 datasets.MNIST('../data', train=False, transform=transforms.Compose([ 104 transforms.ToTensor() 105 ])), 106 batch_size=128, shuffle=False 107 ) 108 109 110 model = LeNet() 111 if tf.cuda.is_available(): 112 model = model.cuda() 113 114 criterion = nn.CrossEntropyLoss() 115 optimizer = optim.SGD(model.parameters(), lr=1e-2) 116 117 losses = [] 118 acces = [] 119 eval_losses = [] 120 eval_acces = [] 121 122 for e in range(10): 123 train_loss = 0 124 train_acc = 0 125 model.train() 126 for im, label in train_loader: 127 im = Variable(im)*255 128 label = Variable(label) 129 if tf.cuda.is_available(): 130 im = im.cuda() 131 label = label.cuda() 132 out = model(im) 133 loss = criterion(out, label) 134 135 optimizer.zero_grad() 136 loss.backward() 137 optimizer.step() 138 139 train_loss += loss.item() 140 141 _, pred = out.max(1) 142 num_correct = (pred==label).sum().item() 143 144 acc = num_correct/im.shape[0] 145 train_acc += acc 146 147 losses.append(train_loss/len(train_loader)) 148 acces.append(train_acc/len(train_loader)) 149 150 eval_loss = 0 151 eval_acc = 0 152 model.eval() 153 154 acc = test(model, device, test_loader, integer=True) 155 print(e, "FullPrecision accuracy: %.02f%%" % acc) 156 157 158 tf.save(model.state_dict(), 'LeNet.pth') 159 160 161 dummy_input = tf.randn(16, 1, 28, 28, device='cuda') 162 input_names = [ "input" ] 163 output_names = [ "output" ] 164 165 tf.onnx.export(model, dummy_input, "LeNet.onnx", verbose=True, input_names=input_names, output_names=output_names) 166 print('Application is over!')
上面的这个网络其实结构比较简单,最后需要在working文件夹下导出model.onnx模型,该模型用来作为PPQ量化训练的输入模型。
然后再使用PPQ来完成量化训练,对应Python代码如下:
1 ''' 2 @Time : 2022.04.23 3 @Author : wuruidong 4 @Email : wuruidong@hotmail.com 5 @FileName: ppq_onnx.py 源文件参考PPQ中ProgramEntrance.py脚本 6 @Software: python pytorch=1.6.0 ppq=0.6.3 onnx=1.8.1 7 @Cnblogs : https://www.cnblogs.com/ruidongwu 8 ''' 9 from ppq import * 10 from ppq.api import * 11 from torch.utils.data import DataLoader 12 from torchvision import datasets, transforms 13 14 # modify configuration below: 15 WORKING_DIRECTORY = 'working' # choose your working directory 16 TARGET_PLATFORM = TargetPlatform.NXP_INT8 # choose your target platform 17 MODEL_TYPE = NetworkFramework.ONNX # or NetworkFramework.CAFFE 18 INPUT_LAYOUT = 'chw' # input data layout, chw or hwc 19 NETWORK_INPUTSHAPE = [16, 1, 28, 28] # input shape of your network 20 CALIBRATION_BATCHSIZE = 16 # batchsize of calibration dataset 21 EXECUTING_DEVICE = 'cuda' # 'cuda' or 'cpu'. 22 REQUIRE_ANALYSE = True 23 DUMP_RESULT = False 24 25 # ------------------------------------------------------------------- 26 # SETTING 对象用于控制 PPQ 的量化逻辑 27 # 当你的网络量化误差过高时,你需要修改 SETTING 对象中的参数来进行特定的优化 28 # ------------------------------------------------------------------- 29 SETTING = UnbelievableUserFriendlyQuantizationSetting( 30 platform = TARGET_PLATFORM, finetune_steps = 2500, 31 finetune_lr = 1e-3, calibration = 'percentile', 32 equalization = True, non_quantable_op = None) 33 SETTING = SETTING.convert_to_daddy_setting() 34 35 print('正准备量化你的网络,检查下列设置:') 36 print(f'WORKING DIRECTORY : {WORKING_DIRECTORY}') 37 print(f'TARGET PLATFORM : {TARGET_PLATFORM.name}') 38 print(f'NETWORK INPUTSHAPE : {NETWORK_INPUTSHAPE}') 39 print(f'CALIBRATION BATCHSIZE: {CALIBRATION_BATCHSIZE}') 40 41 42 mnist = datasets.MNIST('../data', train=False, transform=transforms.Compose([transforms.ToTensor()])) 43 mnist_data = mnist.data.view(-1, 1, 28, 28).float() 44 dataset_len = mnist_data.shape[0] 45 #mnist_data = mnist_data/255 46 calibration_dataset = mnist_data 47 48 dataloader = DataLoader( 49 dataset=calibration_dataset, 50 batch_size=32, shuffle=True) 51 52 print('网络正量化中,根据你的量化配置,这将需要一段时间:') 53 quantized = quantize( 54 working_directory=WORKING_DIRECTORY, setting=SETTING, 55 model_type=MODEL_TYPE, executing_device=EXECUTING_DEVICE, 56 input_shape=NETWORK_INPUTSHAPE, target_platform=TARGET_PLATFORM, 57 dataloader=dataloader, calib_steps=256) 58 59 # ------------------------------------------------------------------- 60 # 如果你需要执行量化后的神经网络并得到结果,则需要创建一个 executor 61 # 这个 executor 的行为和 torch.Module 是类似的,你可以利用这个东西来获取执行结果 62 # 请注意,必须在 export 之前执行此操作。 63 # ------------------------------------------------------------------- 64 executor = TorchExecutor(graph=quantized) 65 # output = executor.forward(input) 66 67 # ------------------------------------------------------------------- 68 # 导出 PPQ 执行网络的所有中间结果,该功能是为了和硬件对比结果 69 # 中间结果可能十分庞大,因此 PPQ 将使用线性同余发射器从执行结果中采样 70 # 为了正确对比中间结果,硬件执行结果也必须使用同样的随机数种子采样 71 # 查阅 ppq.util.fetch 中的相关代码以进一步了解此内容 72 # 查阅 ppq.api.fsys 中的 dump_internal_results 函数以确定采样逻辑 73 # ------------------------------------------------------------------- 74 if DUMP_RESULT: 75 dump_internal_results( 76 graph=quantized, dataloader=dataloader, 77 dump_dir=WORKING_DIRECTORY, executing_device=EXECUTING_DEVICE) 78 79 # ------------------------------------------------------------------- 80 # PPQ 计算量化误差时,使用信噪比的倒数作为指标,即噪声能量 / 信号能量 81 # 量化误差 0.1 表示在整体信号中,量化噪声的能量约为 10% 82 # 你应当注意,在 graphwise_error_analyse 分析中,我们衡量的是累计误差 83 # 网络的最后一层往往都具有较大的累计误差,这些误差是其前面的所有层所共同造成的 84 # 你需要使用 layerwise_error_analyse 逐层分析误差的来源 85 # ------------------------------------------------------------------- 86 print('正计算网络量化误差(SNR),最后一层的误差应小于 0.1 以保证量化精度:') 87 reports = graphwise_error_analyse( 88 graph=quantized, running_device=EXECUTING_DEVICE, steps=256, 89 dataloader=dataloader, collate_fn=lambda x: x.to(EXECUTING_DEVICE)) 90 for op, snr in reports.items(): 91 if snr > 0.1: ppq_warning(f'层 {op} 的累计量化误差显著,请考虑进行优化') 92 93 if REQUIRE_ANALYSE: 94 print('正计算逐层量化误差(SNR),每一层的独立量化误差应小于 0.1 以保证量化精度:') 95 layerwise_error_analyse(graph=quantized, running_device=EXECUTING_DEVICE, steps=256, 96 interested_outputs=None, 97 dataloader=dataloader, collate_fn=lambda x: x.to(EXECUTING_DEVICE)) 98 99 print('网络量化结束,正在生成目标文件:') 100 export(working_directory=WORKING_DIRECTORY, 101 quantized=quantized, platform=TargetPlatform.ONNXRUNTIME) 102 #使用NXP_INT8导出浮点权重表示方法的模型,使用ONNXRUNTIME导出带有原始整形权重表示方法的模型 103 104 # 如果你需要导出 CAFFE 模型,使用下面的语句 105 #export(working_directory=WORKING_DIRECTORY, 106 # quantized=quantized, platform=TARGET_PLATFORM, 107 # input_shapes=[NETWORK_INPUTSHAPE])
完成上述操作以后,我们可以得到量化后的quantized.onnx文件。如果使用ONNXRUNTIME导出方式,还可以进一步看到每一层权重的整形数据表示。如下图所示:
上面的权重数据即为推理中所需要的原始整形数据,对应的scale也都是2的指数,方面层与层之间的量化与反量化操作,即通过简单的移位截断完成数据尺度的转换。
同时,笔者也使用了导出为NXP_IN8类型的onnx文件,并且编写测试代码,查看量化后网络推理结果是能够正确完成分类,对应测试代码如下:
1 ''' 2 @Time : 2022.04.23 3 @Author : wuruidong 4 @Email : wuruidong@hotmail.com 5 @FileName: my_onnxruntime.py 6 @Software: python pytorch=1.6.0 onnxruntime=1.3.0 7 @Cnblogs : https://www.cnblogs.com/ruidongwu 8 ''' 9 import onnxruntime 10 from torch.utils.data import DataLoader 11 from torchvision import datasets, transforms 12 import numpy as np 13 14 sess = onnxruntime.InferenceSession("working/quantized.onnx") 15 16 mnist = datasets.MNIST('../data', train=False) 17 mnist_label = mnist.targets.view(-1, 16).numpy() 18 print(mnist_label[0]) 19 20 mnist_data = mnist.data.view(-1, 16, 1, 28, 28).float() 21 dataset_len = mnist_data.shape[0] 22 23 output = sess.run(['output'], {'input' : mnist_data[0].numpy()}) 24 25 out = np.array(output) 26 out = np.squeeze(out) 27 print(out.shape) 28 print(np.argmax(out, 1))
4、完整版前向推理(C语言)
为了进一步了解量化后网络前向推理的细节,笔者当时也是考虑到验证上述理论思路是否理解到位,因此使用C语言编写前向推理过程,并验证推理结果是否正确,测试结果肯定是通过了。受限于篇幅原因,笔者将上述所有代码和模型文件公布出来,方便大家对INT8类型的量化训练、PPQ工具有一个初步的了解,也希望能起到一个抛砖引玉的作用。由于笔者能力有限,上述理解不可避免存在瑕疵与疏忽的地方,如有错误,还行各位网友不吝赐教,一定会更改其中存在的问题,力求给大家带来一份高质量的理解。
最后,特别感谢商汤科技HPC团队的Jzz对笔者在量化过程中所遇到问题的详细解答,也感谢能够提供这么高质量的量化工具,感谢贡献PPQ的所有人(https://github.com/openppl-public/ppq)。
标签:self,PPQ,import,CNN,量化,data,INT8,mnist 来源: https://www.cnblogs.com/ruidongwu/p/16180991.html