YOLOV3剪枝源码阅读---模型部署加速

YOLOV3剪枝

论文:Network Slimming-Learning Efficient Convolutional Networks through Network Slimming

剪枝项目参考https://github.com/tanluren/yolov3-channel-and-layer-pruning

主要思路

  • 1、利用batch normalization中的缩放因子γ 作为重要性因子,即γ越小,所对应的channel不太重要,就可以裁剪(pruning)。

  • 2、约束γ的大小,在目标方程中增加一个关于γ的L1正则项,使其稀疏化,这样可以做到在训练中自动剪枝,这是以往模型压缩所不具备的。

剪枝过程

img
这里写图片描述

分为三部分,第一步,训练;第二步,剪枝;第三步,微调剪枝后的模型,循环执行

YOLOV3剪枝源码

1、正常剪枝

这部分分析来自该仓库https://github.com/coldlarry/YOLOv3-complete-pruning,但是更新的仓库也可以完成正常剪枝,prune.py。

使用了正常剪枝模式,不对short cut层(需要考虑add操作的维度一致问题)及上采样层(无BN)进行裁剪。

  • 1、找到需要裁剪的BN层的对应的索引。

  • 2、每次反向传播前,将L1正则产生的梯度添加到BN层的梯度中。

  • 3、设置裁剪率进行裁剪。

  • 将需要裁剪的层的BN层的γ参数的绝对值提取到一个列表并从小到大进行排序,若裁剪率0.8,则列表中0.8分位数的值为裁剪阈值。

  • 将小于裁剪阈值的通道的γ置为0,验证裁剪后的map(并没有将β置为0)。

  • 4、创建新的模型结构,β合并到下一个卷积层中BN中的running_mean计算。

  • 5、生成新的模型文件。

2、优化的正常剪枝

slim_prune.py,在正常剪枝模式的基础上,完成对shortcut层的剪枝,同时避免裁剪掉整个层。

  • 1、找到需要裁剪的BN层的对应的索引。
# 解析模型文件
def parse_module_defs2(module_defs):
    CBL_idx = []
    Conv_idx = []
    shortcut_idx=dict()
    shortcut_all=set()
    ignore_idx = set()
    for i, module_def in enumerate(module_defs):
        if module_def['type'] == 'convolutional':
            # 如果是卷积层中的BN层则将该层索引添加到CBL_idx
            if module_def['batch_normalize'] == '1': 
                CBL_idx.append(i)
            else:
                Conv_idx.append(i)
            if module_defs[i+1]['type'] == 'maxpool':
                #spp前一个CBL不剪
                ignore_idx.add(i)

        elif module_def['type'] == 'upsample':
            #上采样层前的卷积层不裁剪
            ignore_idx.add(i - 1)

        elif module_def['type'] == 'shortcut':
            # 根据cfg中的from层获得shortcut的卷积层对应的索引
            identity_idx = (i + int(module_def['from']))
            # 如果shortcut连接的是卷积层则直接添加索引
            if module_defs[identity_idx]['type'] == 'convolutional':

                #ignore_idx.add(identity_idx)
                shortcut_idx[i-1]=identity_idx
                shortcut_all.add(identity_idx)
            # 如果shortcut连接的是shortcut层,则添加前一层卷积层的索引
            elif module_defs[identity_idx]['type'] == 'shortcut':

                #ignore_idx.add(identity_idx - 1)
                shortcut_idx[i-1]=identity_idx-1
                shortcut_all.add(identity_idx-1)
            shortcut_all.add(i-1)


        # 得到需要剪枝的BN层的索引
    prune_idx = [idx for idx in CBL_idx if idx not in ignore_idx]

    return CBL_idx, Conv_idx, prune_idx,shortcut_idx,shortcut_all
  • 2、每次反向传播前,将L1正则产生的梯度添加到BN层的梯度中。

  • 3、设置裁剪率进行裁剪。

    • 将需要裁剪的层的BN层的γ参数的绝对值提取到一个列表并从小到大进行排序,若裁剪率0.8,则列表中0.8分位数的值为裁剪阈值。

      # 提取需要裁剪的层的BN参数
      bn_weights = gather_bn_weights(model.module_list, prune_idx)
      # 排序
      sorted_bn, sorted_index = torch.sort(bn_weights)
      # 分位数索引
      thresh_index = int(len(bn_weights) * opt.global_percent)
      thresh = sorted_bn[thresh_index].cuda()
      
    • 将小于裁剪阈值的通道提取出来;如果整层的通道γ均低于阈值,为了避免整层被裁剪,保留该层中γ值最大的几个(根据layer_keep参数进行设置,最小为1)通道。

      def obtain_filters_mask(model, thresh, CBL_idx, prune_idx):
          pruned = 0
          total = 0
          num_filters = []
          filters_mask = []
          for idx in CBL_idx:
              bn_module = model.module_list[idx][1]
              # 如果该层需要裁剪,则先确定裁剪后的最小通道数min_channel_num,然后根据裁剪阈值进行通道裁剪确定mask,如果整层的通道γ均低于阈值,为了避免整层被裁剪,留该层中γ值最大的几个(根据layer_keep参数进行设置,最小为1)通道。
              if idx in prune_idx:
                  weight_copy = bn_module.weight.data.abs().clone()
                  
                  channels = weight_copy.shape[0]
                  min_channel_num = int(channels * opt.layer_keep) if int(channels * opt.layer_keep) > 0 else 1
                  mask = weight_copy.gt(thresh).float()
                  
                  if int(torch.sum(mask)) < min_channel_num: 
                      _, sorted_index_weights = torch.sort(weight_copy,descending=True)
                      mask[sorted_index_weights[:min_channel_num]]=1. 
                  remain = int(mask.sum())
                  pruned = pruned + mask.shape[0] - remain
      
                  print(f'layer index: {idx:>3d} \t total channel: {mask.shape[0]:>4d} \t '
                          f'remaining channel: {remain:>4d}')
              # 如果该层不需要裁剪,则全部保留
              else:
                  mask = torch.ones(bn_module.weight.data.shape)
                  remain = mask.shape[0]
      
              total += mask.shape[0]
              num_filters.append(remain)
              filters_mask.append(mask.clone())
      
    • 合并shortcut层的mask,采用取并集的策略。

      def merge_mask(model, CBLidx2mask, CBLidx2filters):
          # 最后一层开始遍历
          for i in range(len(model.module_defs) - 1, -1, -1):
              mtype = model.module_defs[i]['type']
              if mtype == 'shortcut':
                  if model.module_defs[i]['is_access']:
                      continue
      
                  Merge_masks =  []
                  layer_i = i
                  # 循环的停止条件是到网络的feature map 发生下采样时
                  while mtype == 'shortcut':
                      # 标志为true
                      model.module_defs[layer_i]['is_access'] = True
      
                      # 如果前一层为卷积层,添加该层上一层卷积层通道的mask
                      if model.module_defs[layer_i-1]['type'] == 'convolutional':
                          bn = int(model.module_defs[layer_i-1]['batch_normalize'])
                          if bn:
                              Merge_masks.append(CBLidx2mask[layer_i-1].unsqueeze(0))
                      # 找到和该层shortcut连接的层的索引
                      layer_i = int(model.module_defs[layer_i]['from'])+layer_i
                      mtype = model.module_defs[layer_i]['type']
      
                      # 如果和shortcut连接的层为卷积层,则添加该层通道的mask;否则进入下一次while循环
                      if mtype == 'convolutional':
                          bn = int(model.module_defs[layer_i]['batch_normalize'])
                          if bn:
                              Merge_masks.append(CBLidx2mask[layer_i].unsqueeze(0))
      
                  # 综合考虑所有feature map 大小相同(即通道数相同,不发生下采样)的shortcut层对应的卷积层通道的mask,只要一个为true则全部不剪裁
                  if len(Merge_masks) > 1:
                      Merge_masks = torch.cat(Merge_masks, 0)
                      merge_mask = (torch.sum(Merge_masks, dim=0) > 0).float()
                  else:
                      merge_mask = Merge_masks[0].float()
      
                  layer_i = i
                  mtype = 'shortcut'
                  # 更新新的merge_mask
                  while mtype == 'shortcut':
      
                      if model.module_defs[layer_i-1]['type'] == 'convolutional':
                          bn = int(model.module_defs[layer_i-1]['batch_normalize'])
                          if bn:
                              CBLidx2mask[layer_i-1] = merge_mask
                              CBLidx2filters[layer_i-1] = int(torch.sum(merge_mask).item())
      
                      layer_i = int(model.module_defs[layer_i]['from'])+layer_i
                      mtype = model.module_defs[layer_i]['type']
      
                      if mtype == 'convolutional':
                          bn = int(model.module_defs[layer_i]['batch_normalize'])
                          if bn:
                              CBLidx2mask[layer_i] = merge_mask
                              CBLidx2filters[layer_i] = int(torch.sum(merge_mask).item())
      
      
  • 4、验证裁剪模型之后的MAP。

  • 5、实际裁剪模型参数,β合并到下一个卷积层中BN中的running_mean计算。验证MAP,比较模型参数量及inference速度

  • 6、创建新的模型结构,保存新的cfg及weights。

3、层剪枝

和优化的正常剪枝类似。这个策略是在之前的通道剪枝策略基础上衍生出来的,针对每一个shortcut层前一个CBL进行评价,对各层的Gmma均值进行排序,取最小的进行层剪枝。为保证yolov3结构完整,这里每剪一个shortcut结构,会同时剪掉一个shortcut层和它前面的两个卷积层。是的,这里只考虑剪主干中的shortcut模块。但是yolov3中有23处shortcut,剪掉8个shortcut就是剪掉了24个层,剪掉16个shortcut就是剪掉了48个层,总共有69个层的剪层空间;实验中对简单的数据集剪掉了较多shortcut而精度降低很少。

稀疏策略

scale参数默认0.001,根据数据集,mAP,BN分布调整,数据分布广类别多的,或者稀疏时掉点厉害的适当调小s;-sr用于开启稀疏训练;--prune 0适用于prune.py,--prune 1 适用于其他剪枝策略。稀疏训练就是精度和稀疏度的博弈过程,如何寻找好的策略让稀疏后的模型保持高精度同时实现高稀疏度是值得研究的问题,大的s一般稀疏较快但精度掉的快,小的s一般稀疏较慢但精度掉的慢;配合大学习率会稀疏加快,后期小学习率有助于精度回升。
注意:训练保存的pt权重包含epoch信息,可通过python -c "from models import *; convert('cfg/yolov3.cfg', 'weights/last.pt')"转换为darknet weights去除掉epoch信息,使用darknet weights从epoch 0开始稀疏训练。

1、恒定s

这是一开始的策略,也是默认的策略。在整个稀疏过程中,始终以恒定的s给模型添加额外的梯度,因为力度比较均匀,往往压缩度较高。但稀疏过程是个博弈过程,我们不仅想要较高的压缩度,也想要在学习率下降后恢复足够的精度,不同的s最后稀疏结果也不同,想要找到合适的s往往需要较高的时间成本。

bn_module.weight.grad.data.add_(s * torch.sign(bn_module.weight.data))

2、全局s衰减

关键代码是下面这句,在epochs的0.5阶段s衰减100倍。前提是0.5之前权重已经完成大幅压缩,这时对s衰减有助于精度快速回升,但是相应的bn会出现一定膨胀,降低压缩度,有利有弊,可以说是牺牲较大的压缩度换取较高的精度,同时减少寻找s的时间成本。当然这个0.5和100可以自己调整。注意也不能为了在前半部分加快压缩bn而大大提高s,过大的s会导致模型精度下降厉害,且s衰减后也无法恢复。如果想使用这个策略,可以在prune_utils.py中的BNOptimizer把下面这句取消注释。

# s = s if epoch <= opt.epochs * 0.5 else s * 0.01

3、局部s衰减

关键代码是下面两句,在epochs的0.5阶段开始对85%的通道保持原力度压缩,15%的通道进行s衰减100倍。这个85%是个先验知识,是由策略一稀疏后尝试剪通道几乎不掉点的最大比例,几乎不掉点指的是相对稀疏后精度;如果微调后还是不及baseline,或者说达不到精度要求,就可以使用策略三进行局部s衰减,从中间开始重新稀疏,这可以在牺牲较小压缩度情况下提高较大精度。如果想使用这个策略可以在train.py中把下面这两句取消注释,并根据自己策略一情况把0.85改为自己的比例,还有0.5和100也是可调的。策略二和三不建议一起用,除非你想做组合策略。

#if opt.sr and opt.prune==1 and epoch > opt.epochs * 0.5:
    # idx2mask = get_mask2(model, prune_idx, 0.85)

知识蒸馏

参考论文:Learning Efficient Object Detection Models with Knowledge Distillation。

核心思想:

  • 对于obj和分类损失:将学生模型和老师模型的obj和分类的输出展开为一维向量,计算KL散度损失
  • 对于Box损失:将学生模型xywh的输出x_offset,y_offset,w/grid_cell_w,h/grid_cell_h(这里是否有数量级的问题,似乎用归一化的欧式距离更好)分别和老师模型的输出、target计算L2距离,如果学生模型的输出,如果学生和老师更远,学生会再向target学习,而不是向老师学习。这时候老师的输出是hard label。
image.png
def distillation_loss2(model, targets, output_s, output_t):
    '''
    :param model:  学生模型
    :param targets: 标签
    :param output_s: 学生模型的输出
    :param output_t: 老师模型的输出
    :return: 附加Loss
    '''
    reg_m = 0.0
    T = 3.0
    Lambda_cls, Lambda_box = 0.0001, 0.001
        
    # KL 损失,衡量两个分布的差异
    criterion_st = torch.nn.KLDivLoss(reduction='sum')
    ft = torch.cuda.FloatTensor if output_s[0].is_cuda else torch.Tensor
    lcls, lbox = ft([0]), ft([0])
        # 标签转换
    tcls, tbox, indices, anchor_vec = build_targets(model, targets)
    reg_ratio, reg_num, reg_nb = 0, 0, 0
    for i, (ps, pt) in enumerate(zip(output_s, output_t)):  # layer index, layer predictions
        b, a, gj, gi = indices[i]  # image, anchor, gridy, gridx

        nb = len(b)
        if nb:  # number of targets
            pss = ps[b, a, gj, gi]  # prediction subset corresponding to targets
            pts = pt[b, a, gj, gi]

            psxy = torch.sigmoid(pss[:, 0:2])  # pxy = pxy * s - (s - 1) / 2,  s = 1.5  (scale_xy)
            psbox = torch.cat((psxy, torch.exp(pss[:, 2:4]) * anchor_vec[i]), 1).view(-1, 4)  # predicted box

            ptxy = torch.sigmoid(pts[:, 0:2])  # pxy = pxy * s - (s - 1) / 2,  s = 1.5  (scale_xy)
            ptbox = torch.cat((ptxy, torch.exp(pts[:, 2:4]) * anchor_vec[i]), 1).view(-1, 4)  # predicted box


            l2_dis_s = (psbox - tbox[i]).pow(2).sum(1)
            l2_dis_s_m = l2_dis_s + reg_m
            l2_dis_t = (ptbox - tbox[i]).pow(2).sum(1)
            l2_num = l2_dis_s_m > l2_dis_t
            lbox += l2_dis_s[l2_num].sum()
            reg_num += l2_num.sum().item()
            reg_nb += nb

        output_s_i = ps[..., 4:].view(-1, model.nc + 1)
        output_t_i = pt[..., 4:].view(-1, model.nc + 1)
        lcls += criterion_st(nn.functional.log_softmax(output_s_i/T, dim=1), nn.functional.softmax(output_t_i/T,dim=1))* (T*T) / ps.size(0)

    if reg_nb:
        reg_ratio = reg_num / reg_nb

    return lcls * Lambda_cls + lbox * Lambda_box, reg_ratio

自己项目实验的总结:

一、测试环境

  • 宿主机:Ubuntu 16.04, Docker环境:Ubuntu 16.04.6 LTS
  • CPU:32 Intel(R) Xeon(R) CPU E5-2630 v3 @ 2.40GHz
  • GPU:GTX 1080 8G
  • CUDA_CUDNN:10.1.243,7.6.3.30
  • Python:3.6.9
  • onnx:1.4.1
  • tensorrt:6.0.1.5
  • pytorch:1.3.0

二、测试模型(YOLOV3)

模型采用pytorch框架训练、剪枝,转换为darknet模型,再转换成ONNX模型,最后转换为tensorrt模型。将以下六个模型依次命名为model1、model2、...、model6。

  1. Pytorch模型:model.pt
  2. Pytorch剪枝且微调模型:model_075.weights(转换为darknet格式)
  3. Tensorrt 模型:model_416416.trt(由model1转换)
  4. Tensorrt 模型:model_256416.trt(由model1转换)
  5. Tensorrt 模型:model_075_256416.trt(由model2转换)
  6. Tensorrt 模型:model_075_416416.trt(由model2转换)

三、测试结果

通过采用不同的输入形状(矩形和正方形)、不同的输入尺寸(320、416、608)、不同Batch Size(1、4、8、16)下对同一张图片采取循环推理1000次取平均时间,比较模型推理速度的差异。

注1:矩形输入指不改变宽高比的情况下,最长边resize到320、416、608,短边采用(128,128,128)填充至32的倍数。如输入尺寸为(720,1080)的720P图像,320、416、608分别对应(192, 320)、(256, 416)、(352, 608)。

注2:model3-6括号中的时间代表纯前向推理时间,batch size 均为1。

Model rec(1/4/8/16) [ms] Square(1/4/8/16) [ms]
Model1(416) 14.4/32.8/59.7/108.9 17.9/51.9/97.6/166.6
Model1(608) 21.3/63.8/122.5/196.9 31.1/105.3/205.5/322.2
Model1(320) 14.1/22.2/38.2/64.5 14.8/32.9/60.7/106.7
Model2(416) 13.9/21.6/39.9/76.6 14.4/33.4/63.9/124.7
Model2(608) 13.9/40.2/76.8/151.3 19.2/67.4/131.1/259.5
Model2(320) 13.9/14.0/24.1/44.9 14.3/20.9/38.3/73.6
Model3和Model4(416) 10.8(9.4) 16.1(14.0)
Model5和Model6(416) 8.4(6.4) 12.1(9.7)

四、测试结论

  1. batch size 为 1 不能有效的利用显卡资源。通过多batch size 比较时间,矩形比正方形加速40%。(输入图片是16:9,矩形相对于正方形计算量少了43%)恰好证明了结论2。

  2. 当输入面积(N * H * W)大于一定值时,速度瓶颈才会出现在FLOPs上。详情请见,Roofline Model与深度学习模型的性能分析,模型的推理速度是由计算量为A且访存量为B的模型在算力为C且带宽为D的计算平台所能达到的理论性能上限E是多少决定的。

  3. Tensorrt 可以降低模型的访存量,加速25%,但是似乎对模型计算量的降低非常有限。

  4. 通道剪枝可以降低模型的计算量和访存量,加速35%,但是在batch size 为 1时加速不明显,说明这次通道剪枝并没有优化模型的计算密度。和K_iK_o的裁剪比例及大小有关。

  5. 若显卡的算力足够,提高batch size 为 1时的推理速度,核心是优化模型的访存量;提高多batch size时的推理速度核心是优化模型的计算量。

QA:

Q1:在做了矩形框输入和剪枝后,为什么batch size 为1时,320/416/608三种输入分辨率的前向推理的速度基本相同?


image.png

猜测:在计算量未达到GPU的瓶颈时,仅提高输入的分辨率而不改变模型,虽然计算密度基本不变(略微提高),但是实际带宽利用率会变大,所以FLOPS和FLOPs都会以相同的比例增长,因此最后的前向推理时间相同。
pi = \frac {K_i \times K_o \times M^2 \times F^2}{K_i \times M_2 + K_o \times F^2 }
其中\pi 表示计算密度,K_i 表示输入通道数,K_o 表示输出通道数,M^2 表示卷积核的平面尺寸,F^2 表示输出特征图的平面尺寸。仅当F^2 改变时,计算密度基本不变,但是\beta表示实际带宽利用率变大,使得在相同的计算密度下FLOPs增大。

Q2:多batch size进行推理,为什么加速明显?
猜测:在多batch size中,FLOPS成倍增长但是时间却没有成倍增长,所以FLOPs提高。可能和Q1一样能够提高实际带宽利用率,并且由于模型参数均一致可以放入缓存中,同时降低访存量,提高了计算密度。

pi = \frac {B \times K_i \times K_o \times M^2 \times F^2 }{K_i \times M_2 + B \times K_o \times F^2 }

Q3:通道剪枝之后的模型,在320/416和未进行剪枝对比,batch size 为 1时前向推理速度基本相同?
猜测:FLOPS降低但是时间基本相同,说明FLOPs也同比例降低。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容