RepVGG(Re-parameterization VGG): Making VGG-style ConvNets Great Again

  • Post author:
  • Post category:其他

参考:RepVGG网络简介_太阳花的小绿豆的博客-CSDN博客

参考:Repvgg详解及其实现(pytorch)_一方热衷.的博客-CSDN博客

论文作者知乎:RepVGG:极简架构,SOTA性能,让VGG式模型再次伟大(CVPR-2021) – 知乎

官方开源代码:https://github.com/DingXiaoH/RepVGG

本文部分内容参考自其他作者博客,如有侵权请联系删除。


        尽管很多复杂的卷积神经网络模型比简单网络获得了更好的性能,但是这些复杂网络也有显著的缺点:

  1. 复杂的多分支网络结构设计(如ResNet的残差模块,Inception网络),导致模型能难实现,降低模型推理性能,增加显卡内存占用
  2. 一些轻量化的操作,如ShuffleNet中使用的通道shuffle,以及MobileNet中使用的深度可分离卷积操作,这些操作虽然可以降低模型的参数量,但是增加了访问内存的次数,并且这些操作不能很好的被一些设备支持(通常3×3卷积被优化和支持的最好)

MobileNetV1《MobileNets: Efficient Convolutional Neural Networks for Mobile Vision Applications》_胖胖大海的博客-CSDN博客

《MobileNetV2: Inverted Residuals and Linear Bottlenecks》_胖胖大海的博客-CSDN博客_invertedresidual

        单纯基于模型的参数量,浮点计算量FLOPs来衡量模型的处理效率和推理速度是不准确的,比如MobileNet使用了深度可分离卷积,大幅降低了参数量和浮点计算量,但是增加了访问内存的次数,导致模型的推理速度提升并没有达到理想的程度。比如下图中EfficientNet的FLOPs计算量和参数量更小,但是处理速度确并不一定快。

        在学术论文中通常喜欢使用模型的参数量和浮点运算量FLOPs来衡量模型的大小以及处理速度,但是本文作者提出,参数量和FLOPs并不能真正反映出模型真实的推理速度。另外两个影响推理速度的重要因素分别是:

  1. 模型访问内存的次数Memory Access Cost(MAC),Multi-Branch模型每个分支都要访问内存,都要保存特征图,虽然有的分支参数量和计算量并不高(比如1×1卷积分支,Identity分支,分组卷积等),但是访问内存的次数以及占用的内存大小都增加了
  2. 模型的并行化程度,Multi-Branch模型不同branch的速度不同,但是需要等待其他分支,导致算力资源的浪费,并行度不高

RepVGG的优点:

  1. RepVGG的模型在推理阶段,是一个想VGG网络一样扁平化的网络,没有任何分支结构,这样的模型占用更少的显卡内存,访问内存的次数更少,计算并行度更高,所以计算效率就高
  2. RepVGG的模型在推理阶段网络中只包括3×3的卷积操作和ReLU激活操作,3×3的卷积操作执行效率很高的
  3. RepVGG网络模型没有经过特殊的设计,比如NAS搜索等

多分支Multi-branch模型为什么效果好?

  1. 从特征融合的层面理解,不同的分支学习到了不同的表征,融合之后的表征能力更强
  2. 从特征和梯度复用的层面理解,比如ResNeXt和DenseNet,多个分支之间可以进行特征和梯度的复用
  3. 从集成学习的层面理解,比如ResNet里面的short-cut连接,每遇到一个short-cut,模型就可能有两种可能,这样从头到尾模型就有2的N次方种可能,就像是将2的N次方个模型的结果进行综合集成

单分支扁平化的模型为什么快?

  1. 只有一个分支不存在特征复制,占用更少的显卡内存
  2. 由于不存在其他分支访问特征,访问内存的次数更少
  3. 由于不存在其他分支进行并行计算,所以不用等待其他分支处理完
  4. 扁平化的模型算子种类更单一,比如RepVGG里面只有3×3卷积和ReLU,执行效率更高

这里贴上论文作者知乎解答:

        鉴于多分支模型训练性能好,推理性能差,单分支扁平化模型训练性能差,推理性能好的情况,将二者综合,试图构建一种网络模型,在模型训练阶段使用多分支训练获得更好的训练性能,在模型推理阶段将训练好的多分支模型恒等转换为单分支的扁平化模型,推理阶段的网络模型中只有3×3的卷积和ReLU激活这两种操作。这其中的核心问题就是如何把多分支的模型转换为一个单分支的模型?RepVGG里面把这个过程叫做结构重参数化技术。

1、Conv3x3 + BN –> Conv3x3:

        BatchNorm在推理阶段的计算涉及4组参数,每组参数的数量和特征的维度相同,使用这4组参数计算BatchNorm。对于2D卷积的结果,特征的维度大小就是输出的feature map的通道数。将Conv3x3 + BN融合成为一步Conv3x3,重新设置Conv3x3中的权重和偏置参数。

from collections import OrderedDict
import numpy as np
import torch
import torch.nn as nn


def Conv3x3BNToConv3x3(g=2, in_channels=4, out_channels=4, tol=1e-4):
    """
    Conv3x3 + BN -> Conv3x3
    1、对于2D卷积的结果,特征的维度大小就是输出的feature map的通道数
    2、BatchNorm在推理阶段的计算涉及4组参数,每组参数的数量和特征的维度相同,使用这4组参数计算BatchNorm
    3、将Conv3x3 + BN融合成为一步Conv3x3,重新设置Conv3x3中的权重和偏置参数
    :return:
    """
    torch.random.manual_seed(0)
    f1 = torch.randn(1, in_channels, 3, 3)
    module = nn.Sequential(OrderedDict(
        # 原始卷积不使用偏置参数
        conv=nn.Conv2d(in_channels=in_channels, out_channels=out_channels, kernel_size=3, stride=1, padding=1, bias=False, groups=g),
        bn=nn.BatchNorm2d(num_features=out_channels)
    ))

    # fuse conv + bn
    # 获取原始卷积权重
    kernel = module.conv.weight
    # 获取BN的均值,nn.Buffer
    running_mean = module.bn.running_mean
    # 获取BN的方差
    running_var = module.bn.running_var
    # 获取BN学习的权重参数
    gamma = module.bn.weight
    # 获取BN学习的偏置参数
    beta = module.bn.bias
    # BN计算时为了防止除0异常使用的数值稳定参数
    eps = module.bn.eps
    # 计算BN的标准差
    std = (running_var + eps).sqrt()
    print("kernel: {}".format(kernel.shape))
    print("running_mean: {}".format(running_mean.shape))
    print("running_var: {}".format(running_var.shape))
    print("gamma: {}".format(gamma.shape))
    print("beta: {}".format(beta.shape))
    print("eps: {}".format(eps))
    print("std: {}".format(std.shape))
    print(gamma, beta, std)

    # 计算卷积和BN融合之后对原卷积权重的缩放系数
    t = (gamma / std).reshape(-1, 1, 1, 1)  # [ch] -> [ch, 1, 1, 1]
    # 对原始卷积的权重进行缩放
    kernel = kernel * t
    # 计算卷积和BN融合之后卷积操作的偏置
    bias = beta - running_mean * gamma / std
    fused_conv = nn.Conv2d(in_channels=in_channels, out_channels=out_channels, kernel_size=3, stride=1, padding=1, bias=True, groups=g)
    # 将计算得到的卷积权重和偏置赋给新的卷积操作
    fused_conv.load_state_dict(OrderedDict(weight=kernel, bias=bias))
    module.eval()
    fused_conv.eval()
    with torch.no_grad():
        out1 = module(f1).detach().cpu().numpy()
        out2 = fused_conv(f1).detach().cpu().numpy()
        print(out1)
        print(out2)
    print(np.allclose(out1, out2, rtol=tol, atol=tol))

2、Conv1x1 + BN -> Conv3x3:

  1. 将1×1卷积核补0变成3×3卷积核,为了保证卷积之后的输出特征图大小不变,给原始特征图的四周进行padding,padding的大小为1,把Conv1x1 + BN -> Conv3x3 + BN
  2. 使用第一步Conv3x3 + BN -> Conv3x3方法进行融合计算,得到Conv1x1 + BN -> Conv3x3 + BN -> Conv3x3
from collections import OrderedDict
import numpy as np
import torch
import torch.nn as nn


def Conv1x1BNToConv3x3(g=2, in_channels=128, out_channels=128, tol=1e-4):
    """
    Conv1x1 + BN -> Conv3x3 + BN -> Conv3x3
    1、将1x1卷积核补0变成3x3卷积核,为了保证卷积之后的输出特征图大小不变,给原始特征图的四周进行padding,
    padding的大小为1,把Conv1x1 + BN -> Conv3x3 + BN
    2、使用Conv3x3BNToConv3x3方法进行融合计算,把Conv3x3 + BN -> Conv3x3
    :return:
    """
    torch.random.manual_seed(0)
    f1 = torch.randn(1, in_channels, 3, 3)

    module = nn.Sequential(OrderedDict(
        # 原始卷积不使用偏置参数
        conv=nn.Conv2d(in_channels=in_channels, out_channels=out_channels, kernel_size=1, stride=1, padding=0, bias=False, groups=g),
        bn=nn.BatchNorm2d(num_features=out_channels)
    ))

    # fuse conv + bn
    # 获取原始1x1卷积权重
    kernel = module.conv.weight
    # 获取BN的均值,nn.Buffer
    running_mean = module.bn.running_mean
    # 获取BN的方差
    running_var = module.bn.running_var
    # 获取BN学习的权重参数
    gamma = module.bn.weight
    # 获取BN学习的偏置参数
    beta = module.bn.bias
    # BN计算时为了防止除0异常使用的数值稳定参数
    eps = module.bn.eps
    # 计算BN的标准差
    std = (running_var + eps).sqrt()

    # 初始化全为0的3x3卷积核
    # 当使用分组卷积时,每个卷积核的通道数等于输入通道数除以分组数
    weight = torch.zeros(out_channels, in_channels // g, 3, 3, dtype=torch.float)
    # 将1x1卷积核放在3x3卷积核的中间,相当于对原始1x1卷积补0得到3x3卷积核
    weight[:, :, 1:2, 1:2] = kernel.data
    print("kernel: {}".format(kernel.data))
    print("weight: {}".format(weight))
    # 计算卷积和BN融合之后对原卷积权重的缩放系数
    t = (gamma / std).reshape(-1, 1, 1, 1)  # [ch] -> [ch, 1, 1, 1]
    kernel_new = torch.nn.Parameter(weight * t, requires_grad=True)
    print("kernel new: {}".format(kernel_new.data))
    # 计算卷积和BN融合之后卷积操作的偏置
    bias_new = beta - running_mean * gamma / std

    fused_conv = nn.Conv2d(in_channels=in_channels, out_channels=out_channels, kernel_size=3, stride=1, padding=1, bias=True, groups=g)
    # 将计算得到的卷积权重和偏置赋给新的卷积操作
    fused_conv.load_state_dict(OrderedDict(weight=kernel_new, bias=bias_new))
    module.eval()
    fused_conv.eval()
    with torch.no_grad():
        out1 = module(f1).detach().cpu().numpy()
        out2 = fused_conv(f1).detach().cpu().numpy()
    print(out1)
    print(out2)
    print(np.allclose(out1, out2, rtol=tol, atol=tol))

3、BN -> Conv3x3:

  1. 由于只有一个BN,没有卷积操作,先构建一个恒等的卷积操作,卷积核的大小为1×1,第n个卷积核的第n个通道权重为1,其余通道权重为0
  2. 然后使用与Conv1x1 + BN -> Conv3x3相同的方法,把1×1的卷积核补0扩展成3×3的卷积核,Conv1x1 + BN -> Conv3x3 + BN
  3. 然后使用第一步Conv3x3 + BN -> Conv3x3方法进行融合计算,得到BN -> Conv1x1 + BN -> Conv3x3 + BN -> Conv3x3
from collections import OrderedDict
import numpy as np
import torch
import torch.nn as nn


def BNToConv3x3Group(g=2, in_channels=4, out_channels=4, tol=1e-4):
    """
    BN -> Conv1x1 + BN -> Conv3x3 + BN -> Conv3x3
    1、由于只有一个BN,没有卷积操作,先构建一个恒等的卷积操作,卷积核的大小为1x1,第n个卷积核的第n个通道权重为1,其余通道权重为0
    2、然后使用与Conv1x1BNToConv3x3相同的方法,把1x1的卷积核补0扩展成3x3的卷积核,Conv1x1 + BN -> Conv3x3 + BN
    3、然后使用Conv3x3BNToConv3x3进行融合计算,把Conv3x3 + BN -> Conv3x3
    :return:
    """
    torch.random.manual_seed(0)
    f1 = torch.randn(1, in_channels, 3, 3)

    bn = nn.BatchNorm2d(num_features=out_channels)
    # 获取BN的均值,nn.Buffer
    running_mean = bn.running_mean
    # 获取BN的方差
    running_var = bn.running_var
    # 获取BN学习的权重参数
    gamma = bn.weight
    # 获取BN学习的偏置参数
    beta = bn.bias
    # BN计算时为了防止除0异常使用的数值稳定参数
    eps = bn.eps
    # 计算BN的标准差
    std = (running_var + eps).sqrt()
    # 计算BN -> Conv3x3 + BN之后卷积权重的缩放系数
    t = (gamma / std).reshape(-1, 1, 1, 1)
    # 计算BN -> Conv3x3 + BN之后卷积的偏置
    bias = beta - running_mean * gamma / std

    # 设置卷积核,如果第n个卷积核的第n个通道的最中心一个元素权重为1,其余权重均为0
    # 当使用分组卷积时,每个卷积核的通道数等于输入通道数除以分组数
    # 同时要在分组卷积中保持恒等映射的效果,那么就要求在每个分组中,第n个卷积核的第n个通道的最中心一个元素权重为1,其余权重均为0
    weight = torch.zeros(out_channels, in_channels // g, 3, 3, dtype=torch.float)
    for i in range(in_channels):
        # if g == in_channels:
        #     j = 0
        # elif g == 1:
        #     j = i
        # else:
        #     j = i % (in_channels // g)
        j = i % (in_channels // g)
        weight[i, j, 1:2, 1:2] = 1
    kernel = torch.nn.Parameter(weight * t, requires_grad=True)
    print("kernel: {}".format(kernel.data))

    conv = nn.Conv2d(in_channels=in_channels, out_channels=out_channels, kernel_size=3, stride=1, padding=1, bias=True, groups=g)
    conv.load_state_dict(OrderedDict(weight=kernel, bias=bias))
    conv.eval()
    bn.eval()
    with torch.no_grad():
        out1 = bn(f1).detach().cpu().numpy()
        out2 = conv(f1).detach().cpu().numpy()
    print(out1)
    print(out2)
    print(np.allclose(out1, out2, rtol=tol, atol=tol))

4、多分支Conv3x3融合成一个Conv3x3

        现在三个分支都转换成了Conv3x3操作,并且输出的特征图形状相同,由于卷积操作具有可加性,多分支的卷积结果相加,就等于多分支的卷积权重相加,偏置相加构成一个新的卷积操作,然后对输入特征图做一次卷积,这样就把三次卷积压缩成一次卷积了。

# coding:utf-8
from collections import OrderedDict
import numpy as np
import torch
import torch.nn as nn


def FuseConv3x3(g=2, in_channels=4, out_channels=4, tol=1e-4):
    """
    将多个Conv3x3卷积合并为一个Conv3x3卷积
    将多个并行的Conv3x3卷积输出结果相加,等同于先将多个Conv3x3卷积的权重相加,偏置相加构成一个新的卷积操作,然后作用于输入特征图
    :return:
    """
    torch.random.manual_seed(0)
    f1 = torch.randn(1, in_channels, 3, 3)

    conv1 = nn.Conv2d(in_channels=in_channels, out_channels=out_channels, kernel_size=3, stride=1, padding=1, bias=True, groups=g)
    conv2 = nn.Conv2d(in_channels=in_channels, out_channels=out_channels, kernel_size=3, stride=1, padding=1, bias=True, groups=g)
    conv3 = nn.Conv2d(in_channels=in_channels, out_channels=out_channels, kernel_size=3, stride=1, padding=1, bias=True, groups=g)
    fuse_conv = nn.Conv2d(in_channels=in_channels, out_channels=out_channels, kernel_size=3, stride=1, padding=1, bias=True, groups=g)

    kernel = conv1.weight + conv2.weight + conv3.weight
    bias = conv1.bias + conv2.bias + conv3.bias
    fuse_conv.load_state_dict(OrderedDict(weight=kernel, bias=bias))

    conv1.eval()
    conv2.eval()
    conv3.eval()
    fuse_conv.eval()
    with torch.no_grad():
        out1 = conv1(f1).detach().cpu().numpy()
        out2 = conv2(f1).detach().cpu().numpy()
        out3 = conv3(f1).detach().cpu().numpy()
        fuse_out = fuse_conv(f1).detach().cpu().numpy()
    print(out1 + out2 + out3)
    print(fuse_out)
    print(np.allclose(out1 + out2 + out3, fuse_out, rtol=tol, atol=tol))


if __name__ == '__main__':
    # Conv3x3BNToConv3x3(g=128, in_channels=128, out_channels=128, tol=1e-6)
    # Conv1x1BNToConv3x3(g=1, in_channels=128, out_channels=128, tol=1e-6)
    # BNToConv3x3Group(g=128, in_channels=128, out_channels=128, tol=1e-6)
    FuseConv3x3(g=128, in_channels=128, out_channels=128, tol=1e-5)

备注:RepVGG是为GPU和专用硬件设计的高效模型,追求高速度、省内存,较少关注参数量和理论计算量。在低算力设备上,可能不如MobileNet和ShuffleNet系列适用。 


另外再补充两个卷积操作的合并方式,在DBB-Net和ResRep论文中有用到:

5、Conv3x3 + Conv1x1融合成一个Conv3x3

def conv3x3Conv1x1ToConv3x3():
    # 3x3卷积
    conv1 = nn.Conv2d(in_channels=1, out_channels=4, kernel_size=3, padding=0, bias=False, stride=1)
    # 1x1卷积
    conv2 = nn.Conv2d(in_channels=4, out_channels=4, kernel_size=1, padding=0, bias=False, stride=1)

    # 先3x3,在1x1
    model = nn.Sequential(
        conv1,
        conv2
    )

    out1 = model(data)
    print(out1.shape)

    weights = F.conv2d(conv1.weight.permute(1, 0, 2, 3), conv2.weight).permute(1, 0, 2, 3)
    print(weights.shape)
    conv3 = nn.Conv2d(in_channels=1, out_channels=4, kernel_size=3, padding=0, bias=False, stride=1)
    conv3.weight.data = weights
    out2 = conv3(data)
    print(out2.shape)

    print((out1 - out2).sum())

6、Conv1x1 + Conv3x3融合成一个Conv3x3

def conv1x1Conv3x3ToConv3x3():
    # 1x1卷积
    conv1 = nn.Conv2d(in_channels=1, out_channels=4, kernel_size=1, padding=0, bias=False, stride=1)
    # 3x3卷积
    conv2 = nn.Conv2d(in_channels=4, out_channels=4, kernel_size=3, padding=0, bias=False, stride=1)

    # 先1x1,在3x3
    model = nn.Sequential(
        conv1,
        conv2
    )
    out1 = model(data)
    print(out1.shape)

    weights = F.conv2d(conv2.weight, conv1.weight.permute(1, 0, 2, 3))
    print(weights.shape)
    conv3 = nn.Conv2d(in_channels=1, out_channels=4, kernel_size=3, padding=0, bias=False, stride=1)
    conv3.weight.data = weights
    out2 = conv3(data)
    print(out2.shape)

    print((out1 - out2).sum())


版权声明:本文为cxx654原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。