我的模型能跑多快——神经网络模型速度调研(一)

2021-08-23 10:35:07 浏览数 (1)

大家好我是老潘,一名热爱AI、乐于分享的程序员~

我的博客:https://oldpan.me/

宝藏内容分享:老潘的AI宝藏内容

前言

对于神经网络,我们更多谈的是其精度怎么样,有百分之零点几的提升。但是如果谈到速度的话,深度学习神经网络相比于传统的算法来说,速度恐怕没有那么快了。

那么我们什么时候需要提升速度呢?假如有以下的场景:

  • 将模型运行在手机上
  • 需要实时的场景,比如高速摄像机捕捉动作
  • 在嵌入式设备上运行

对于有桌面级显卡这种利器来说,速度似乎很容易得到很快,但是放到以上这些设备时,在有效的硬件上如果速度提升不上来,那么所设计的算法也就没什么用处了。

所谓提升速度,不谈论硬件级别的优化,对于神经网络来说无非也就两点:

  • 网络的设计
  • 输入数据的大小

输入数据大小我们姑且不谈,而神经网络的设计这一点就显得比较重要了,网络的设计可以细分为:网络模型权重的大小、网络运行过程中产生的中间变量的大小、网络设计中各种计算的执行速度等等这些都会对速度产生影响,一般来说,模型参数和模型速度是成正比的。

关于速度和精度来说,这往往是一个衡量,精度和速度一般无法兼顾,正如在工业界使用很火的YOLO和在学术界名声远扬的Mask-Rcnn,一个追求速度一个追求精度(当然速度的前提是精度在可接受范围之内)。

运算量

接触过ACM的童鞋一定知道时间复杂度空间复杂度这两个概念,时间复杂度即衡量一个算法运行的时间量级,而空间负责度则衡量一个算法所占用的空间大小。神经网络则类似,如何判断一个网络的速度快不快,最直观最直接地就是看其包含多少个浮点运算(当然与内存带宽也有关系)。

与这个概念密切相关的就是FLOPS(Floating-point operations per second,每秒执行的浮点运算数)。现在的桌面级显卡,大部分都是TFLOPs级别了,1TFLOP也就是每秒执行1,000,000,000,000次浮点运算。

矩阵乘法

在神经网络中,最常见的就是矩阵乘法:

正如下方的输入4×4的图像,卷积核为3×3,输出为2×2:

在计算机中将上述运算分解为:

结果中一个标量的计算过程可以用公式表示为:

代码语言:txt复制
y = w[0]*x[0]   w[1]*x[1]   w[2]*x[2]   ...   w[n-1]*x[n-1]
复制代码

w和x都是向量,w是权重,x是输入。最终的结果是y是一个标量(scalar)。这个运算称作multipy-accumulate operations。而其中的一个操作w[0]*x[0] ..(先乘后加)称为一个MACC(multipy-accumulate operation)。而上面的计算一共包含n个MACCs。

简而言之,两个n维向量的点乘所需要n个MACCs运算(其实可以说是n-1个,因为第一个不算,但是我们近似地认为是n个)。而n个MACCs运算包括2n-1个FLOPs(n个乘法和n-1个加法),我们近似为2n个FLOPs。

也就是说,两个n维向量的乘积所需要的FLOPs是2n个。

当然,在很多的硬件设施中(比如显卡),一个MACC就可以称作一个运算单位了,而不是将加法和乘法分开,因为硬件已经对其进行了大量的优化,我们之后在测一个卷积运算量就可以按照MACC这样的单位来计算了。

全连接层

全连接层是除了卷积层最常见的层,在全连接层中,输入数量为I和输出数量为O,这些节点一一相连,然后权重W保存在I x J的矩阵中,于是对于一个全连接层来说,其计算量为:

代码语言:txt复制
y = matmul(x,W)   b
复制代码

(来自:leonardoaraujosantos.gitbooks.io/artificial-…)

在上面的这个式子中(结合上图),我们的x维数I为3,x是一个3维的向量,输出y是二维的向量,因此权重W的数量就是3 x 2,最后加上偏置量b

那我们要计算全连接层一共执行了几个MACC,首先看全连接层中的运算matmul。这是一个矩阵运算。

矩阵运算说白了就是一堆乘法和加法的集合,我们输入的维度是I输出维度是O,其次中间的W的维度为I x O(在上图中是3x2)那么很简单,我们一共要做的就是I x O个MACCs,可以发现和权重矩阵的数量是一样的。

哦,还有个bias偏置b没有算里头,这个其实可以忽略不计了,在平时的计算量中这个偏置直接就不算了。

我们在看一些全连接层计算公式的时候,可能会发现计算中将偏置移到了矩阵中而不是先矩阵运算完再加偏置向量。也就是执行了一个 (I 1) x O的矩阵运算,这个就是为了计算步骤简便一些,对计算量没有任何影响。

也就是说,加入我们的全连接层有100个输入,200个输出,那么一共执行了100 x 200 = 20,000个MACCs。

通常,输入I向量输出O向量的时候,执行了I x J个MACCs和(2I - 1) x J个FLOPs。

全连接层就是向量之前的运算,通常会将全连接层放在卷积层的后面,而我们在编程计算这些值的时候都要对卷积后的值进行Flatten操作,相比大家应该很熟悉了,Flatten就是将一个(N,C,H,W)的张量变形为(N,I)的形状,从而去执行全连接运算。

激活函数

通常我们会在卷积层或者全连接层之后加一个非线性的激活函数,比如RELU或者Sigmoid。在这里我们使用FLOPs去衡量其计算量,因为激活函数不涉及到点乘操作,所以用不到MACCs

对于RELU来说:

代码语言:txt复制
y = max(x, 0)
复制代码

x为输入,这里的输入就是其他层的输出,假如其它层传递给RELU层n个向量,那么RELU层对这n个向量进行计算,也就是n个FLOPs。

对于sigmoid来说:

代码语言:txt复制
y = 1 / (1   exp(-x))
复制代码

上式包含了一个加法、一个减法、一个除法和一个取幂运算,我们将这些运算都归结为一个单独的FLOP(还有乘法、求根号等)。因此一个sigmoid的运算量为4个FLOPs。假如输入时n那个计算量为4 x n个FLOPs。

但一般我们只关心比较大的矩阵运算,像这种计算量一般也就忽略了。

卷积层

卷积层中主要的处理对象不是之前提到的向量,而是我们平常见到的(C,H,W)三通道的张量,其中C代表通道数,HW代表这个特征图的高和宽。

对于一个kernel为K的卷积层来说(这里只说方形的卷积层,我们平时用到的也都是方形的卷积),所需要的MACCs为:

代码语言:txt复制
K  x  K  x  Cin  x  Hout  x  Wout  x  Cout
复制代码

怎么来的:

  • 输出的特征图大小为Hout x Wout,由计算中的每个像素点组成
  • 权重(weights)和输入特征图的计算的窗口大小为K x K
  • 输入特征图的通道数为Cin
  • 对应每一个通道的卷积产生的通道数为Cout

这里忽略了偏置,通常我们在计算参数时会算上偏置,但是在计算FLOPs则直接忽略。

举个例子,假如输入三通道256*256的图像,使用的卷积核大小为3,卷积的层数为128,计算总共的运算量为:

代码语言:txt复制
256 x 256 x 3 x 3 x 3 x 128 = 226,492,416 
复制代码

差不多226M-FLOPs,计算量还是蛮大的。

以上使用的stride为1,也就是每隔一步在特征图上进行卷积操作,如果上述的卷积层的strid为2,那么相当于在一半大小的图像中进行卷积,上面的256×256则变成128×128

深度可分离卷积结构

深度可分离的卷积构架是众多高效网络的基本结构,例如MobileNet和Xception。都采用了depthwise-separable convolution的网络结构,该网络结构并不复杂,可以分为两个部分:

(来源于 machinethink.net/blog/mobile… )

需要注意下,下文中的深度可分离卷积对应是Depthwise Separable Convolution,它分别两个部分,分别是深度分离(depthwise)的卷积和点(pointwise)卷积(也就是所谓的1×1卷积)。

其中深度分离的卷积运算和普通的运算类似,只不过不再将三个通道(RGB)变成一个通道了(普通卷积核一般是对图像的三通道分别进行卷积再相加化为一个通道),这次是直接三个通道输入三个通道输出,也就是对应三个独立参数,不同参数内容的卷积,每一个卷积核对应一个通道(输入一个通道,输出一个通道)。

有一个称之为 depthwise channel multiplier 的概念,也就是深度分离通道放大器,如果这个放大器大于1,比如为5,那么一个卷积核就相当于输入一个通道输出5个通道了,这个参数就是调整模型大小的一个超参数。

执行的运算次数为:

K x K x C X Hout X Wout

注意相比之前普通的卷积运算可以说少乘了个C,运算量可以说是大大提升了。

举个例子,比如利用3x3的深度可分离卷积去对一张112 x 112的特征图做卷积操作,通道为64,那么我们所需要的MACCs为:

代码语言:txt复制
3 x 3 x 64 x 112 x 112 = 7,225,344
复制代码

对于点(pointwise)卷积运算来说,需要的运算量为:

代码语言:txt复制
Cin X Hout X Wout X Cout
复制代码

这里的K,核大小为1。

同样举个例子,假如我们有个112x112x64维数的数据,我们利用点分离卷积将其投射到128维中,去创建一个112x112x128维数的数据,这时我们需要的MACCs为:

代码语言:txt复制
64 x 112 x 112 x 128 = 102,760,448
复制代码

可以看到点分离运算所需要的运算量还大于深度分离运算。

我们将上述两个运算加起来和普通的3x3卷积操作运算相比:

代码语言:txt复制
3×3 depthwise          : 7,225,344
1×1 pointwise          : 102,760,448
depthwise separable    : 109,985,792 MACCs

regular 3×3 convolution: 924,844,032 MACCs
复制代码

可以发现速度提升了8倍(8.4)多~

但是这样比较有点不是很公平,因为普通的3x3卷积学习到的信息更加完整,可以学习到更多的信息,但是我们要知道在同等的计算量下,相比传统的3x3卷积,我们可以使用8个多的深度可分离卷积,这样比下来差距就显现出来了。

关于模型中的参数量计算请看这篇文章:浅谈深度学习:如何计算模型以及中间变量的显存占用大小。

我们整理一下,深度可分离具体需要的MACCs为:

代码语言:txt复制
(K x K x Cin X Hout X Wout)   (Cin x Hout x Wout x Cout)
复制代码

简化为:

代码语言:txt复制
Cin x Hout x Wout X (K x K   Cout)
复制代码

如果我们将其跟普通的3x3卷积对比的话就会发现,上式最后的 Cout在普通的3x3卷积中为x Cout。就这个小小的差别造成了性能上极大的差异。

深度可分离卷积核传统的卷积的提速比例可以认为为K x K(也就是卷积越大,提速越快),上面我们按照3x3卷积举例发现提速8.4倍,其实和3x3=9倍是相差无几的。

其实实际上的提速比例是:K x K x Cout / (K x K Cout) 另外需要注意的是,深度可分离卷积也可以像传统卷积一样,使用stride大于1,当这个时候深度可分离卷积的第一部分输出的特征大小会下降,而深度可分离的第二部分点卷积则保持输入卷积的维度。

上面介绍的深度可分离卷积是MobileNet V1中的经典结构,在MobileNet V2中,这个结构稍微变化了一下下,具体来说就是多了一个扩张和缩小的部分:

  • 第一个部分是1×1卷积,这个卷积用来在输入特征图像上添加更多的通道(这个可以理解为扩张层-expansion_layer)
  • 第二个部分就是已经提到的3×3深度分离卷积(depthwise)
  • 第三部分又是一个1×1卷积,这个卷积用来减少输入特征图像上的通道(这个称之为投射层-projection_layer,也就是所谓的瓶颈层-bottleneck convolution)

再讨论下上面这个结构的计算数量:

代码语言:txt复制
Cexp = (Cin × expansion_factor)

expansion_layer = Cin × Hin × Win × Cexp

depthwise_layer = K × K × Cexp × Hout × Wout

projection_layer = Cexp × Hout × Wout × Cout
复制代码

上式中的Cexp代表扩张层扩张后的层数,虽然不论是扩张层还是瓶颈层都不会改变特征图的H和W,但是其中的深度分离层如果stride大于1的话会发生改变,所以这里的Hin WinHout Wout有时候会不同。

将上式进行简化:

代码语言:txt复制
Cin x Hin X Win X Cexp   (K x K   Cout) x Cexp x Hout x Wout
复制代码

当stride=1的时候,上式简化为:(K x K Cout Cin) x Cexp x Hout x Wout

和之前MobileNet V1版的深度可分离卷积对比一下,我们同样使用112x112x64作为输入,取扩张参数(expansion_factor)为6,3x3的深度分离卷积的stride为1,这时V2版的计算量为:

代码语言:txt复制
(3 × 3   128   64) × (64 × 6) × 112 × 112 = 968,196,096
复制代码

可以发现,这个计算量貌似比之前的V1版大了很多,而且比普通的3x3卷积都大了不少,为什么,原因很简单,我们设置了扩张系数为6,这样的话我们计算了64 x 6 = 384个通道,比之前的64 -> 128学习到更多的参数,但是计算量却差不多。

批标准化-BatchNorm

批标准化可以说是现代神经网络中除了卷积操作之外必不可少的操作了,批标准化通常是放在卷积层或者全连接层之后,激活函数之前。对于上一个层中输出的y来说,批标准化采取的操作为:

代码语言:txt复制
z = gamma * (y - mean) / sqrt(variance   epsilon)   beta
复制代码

首先将上一次输出的y进行标准化(减去其平均值并处以方差,这里的epsilon是0.001从而避免计算问题)。但是我们又将标准化后的数于gamma相乘,加上beta。这两个参数是可学习的。

也就是对于每个通道来说,我们需要的参数为4个,也就是对于C个通道,批标准化需要学习C x 4个参数。

看来貌似需要计算的参数还不少,但是实际中我们还可以对其进行优化,将批标准化和卷积或者全连接层合并起来,这样的话速度会进一步提升,这里暂时先不讨论。

总之,我们在讨论模型计算量的时候,一般不讨论批标准化产生的计算量,因为我们在inference的时候并不使用它。

其他层

除了上述的一些基本层之外(卷积,全连接,特殊卷积,批标准化),池化层也会产生一部分计算量,但是相比卷积层和全连接层池化层产生的也可以忽略不计了,而且在新型的神经网络的设计中,池化层可以通过卷积层进行代替,所以我们一般来说对这些层并不着重讨论。

下一步

这篇文章仅仅是讨论了一些模型计算量的问题,一个网络运行的快否,与不仅与网络的计算量有关,网络的大小、网络参数精度的高低、中间变量的优化以及混合精度等等都可以作为提速的一部分,限于篇幅将在下一部分进行讨论。

撩我吧

  • 如果你与我志同道合于此,老潘很愿意与你交流
  • 如果你喜欢老潘的内容,欢迎关注和支持,点赞最好了~

老潘也整理了一些自己的私藏,希望能帮助到大家,神秘传送门。

有问题可以直接撩我~

文章来源于OLDPAN博客,欢迎来访:Oldpan博客

欢迎关注Oldpan博客公众号,持续酝酿深度学习质量文:

0 人点赞