在上一篇《CNN模型计算量估计》一文中,我们对常见层的计算量(MACC,FLOPS)做了分析和估算,但这只是模型性能估计这整个故事的一部分。内存带宽(bandwidth)是另一部分,大部分情况下,它比计算次数更重要!本文继续对CNN模型的内存访问量做介绍和分析。
欢迎探讨,本文持续维护。
N/A
在当前的计算机架构中,内存的访问比CPU中执行单个计算要慢得多(需要更多的时钟周期)—— 大约100或更多倍!上一篇《CNN模型计算量估计》刚刚看到这些神经网络进行了大量计算,但它们执行了多少次内存访问?
关于内存访问速度和CPU运算速度的分析,可以参考《深入理解计算机系统》、《计算机体系结构量化分析方法》
对于网络中的每个层,CPU需要:
-
首先,从主存储器读取输入向量或特征图;
-
然后,计算点积——这也涉及从主存中读取层的权重;
-
最后,将计算出的结果作为新的矢量或特征图写回主存储器。
这涉及大量的内存访问。由于内存非常慢(相对于CPU计算速度而言),因此该层执行的内存读/写操作量也会对其速度产生很大影响——可能比计算次数更大。
网络每层学习的参数或权重存储在主存储器中。通常,模型的权重越少,运行的速度就越快。
正如我们在《CNN模型计算量估计》所讨论的,全连接层将其权重保持在大小I × J矩阵中,其中I是输入神经元的数量和J是输出的数量。它还有一个大小J的偏置量。所以这一层的权重总共有(I + 1) × J。
今天使用的大多数卷积层都有正方形内核,因此对于具有内核大小K和Cin输入通道的卷积层,每个滤波器都有权重K × K × Cin。该层将具有Cout滤波器/输出通道,因此权重总数K × K × Cin × Cout加上额外的Cout个偏置值。
通常,卷积层的权重数量小于全连接层。
例如:具有4096个输入和4096个输出的全连接层具有(4096+1) × 4096 = 16.8M权重。具有3×3内核的卷积层和48个滤波器在64 × 64具有32通道的输入图像上工作,权重大小为3 × 3 × 32 × 48 + 48 = 13,872。
请注意,此示例中卷积层的输入实际上比完全连接层的输入大32倍,输出大48倍。因此,Conv层可以处理更多数据,但权重却减少了1000倍。很明显,全连接层是内存权重访问的负担!
有用的结论:由于权值共享,卷积层一般占网络更少的权重参数数量,但是更多的计算量。
注意:全连接和卷积层实际上非常相似(可以参考本黑板报文章《D#0025-CNN中使用卷积代替全连接》)。
我们可以使用全连接层实现卷积层,反之亦然。卷积可以看成是一个全连接层,绝大多数连接设置为0——每个输出仅连接到K × K输入
而不是所有输出,并且所有输出对这些连接使用相同的值。这就是卷积层对内存更有效的原因,因为它们不存储未使用的连接的权重。
每个权重的字节长度也很重要。桌面级计算机使用32位浮点数,每个浮点数占4个字节。在iOS上,使用16位浮点数(“半精度”)更为常见,每次只占用2个字节。它们的精度要低得多,但从好的方面来说它们更快,特别是因为iPhone和iPad GPU只有16位ALU。但是使用8位权重甚至1位权重都可能比这更低。
区分权重的存储格式与用于计算的格式也很重要。如果您将权重存储为8位量化值,GPU内核将首先将它们转换回浮点数,然后使用浮点值进行计算。(尽管有些工具包具有卷积层,可以直接使用量化数字。)
在计算点积期间发生的累加精度也很重要。即使使用16位浮点数,使用32位浮点数执行点积也是有意义的,然后将结果转换回16位。这样,在对数字做加法时不会丢失任何精度。但它也比使用16位数字进行累加要慢。
读取内存很慢,因此权重较少的层将比具有更多权重的同一层更快。不仅因为它具有更少的MACC,而且因为它访问主存储器来读取权重的次数更少。
对于具有相同数量的权重的两个层,但是一个使用float32而另一个使用float16,具有较小权重的那个将更快,但是以准确性为代价。
实际上,已有证据证明,在部署时16位浮点数(甚至8位)足以用于卷积神经网络。你会失去一点精度,但平均而言,这些精度误差会被抵消,模型仍会给出正确的结果。
在文献中,经常会看到模型的复杂性,其中列出了MACC(或FLOPS)的数量和训练参数的数量。但是,这忽略了一个重要的指标:层的输入读取的内存量,以及写入该层输出执行的内存访问次数。
我们将在这里假设读取单个输入值计为“一次内存访问”,写入单个输出值也算作一次内存访问。这在实践中不一定正确:在很多支持SIMD指令的CPU中,一次可以读入多个字节的数据,但这不应该影响本节的计算。这里只想提出一些描述给定模型的内存访问量的数值的估算方式,这样我们就可以比较两个模型的内存访问量。
同样,这些数字只是近似值,因为我们并不确切知道CPU/GPU内核是如何工作的。
注意:与CPU一样,GPU也可以进行缓存以加速内存读写。GPU内核还可以将少量内存读入本地或“线程组”存储,以便更快地访问。
一般来讲,CPU/GPU内核已经过优化,可以尽可能高效地读写内存,因此这里给出的数字是理论上限,而不是精确数字。
假设卷积层的输入形状是Hin x Win x Cin图像,输出特征图形状Hout x Wout x Cout那么,对于每个输出特征图的像素来说,需要访问输入特征图次数为每个卷积核的参数的个数:K x K x Cin。所以,此卷积层需要访问内存(读取输入特征)的次数为(K × K × Cin) x (Hout x Wout x Cout)。(当然,一个聪明的GPU内核程序员将有办法优化这一点。每个GPU线程可以计算多个输出像素而不是一个,允许它多次重复使用一些输入值,总体上需要更少的内存读取,所有这些优化都将平等地应用于所有模型。因此,即使我的公式不是100%正确,它们的误差是常数级的,因此仍然可用于比较模型。)
对于计算得到的特征图的输出,如果此特定卷积层的步幅为2,滤波器为32个,则它会写入具有112×112×32个值的输出特征图。那么需要112 x 112 x 32 = 401,408次内存访问。
对于本层卷积的参数从内存中读取,因为参数数量很少,可以直接认为只读取一次,存储在缓存中。这里读取次数为K x K x Cin x Cout + Cout。
总结下来,每个层将进行以下总内存访问:
input = (K × K × Cin) x (Hout x Wout x Cout)
output = Hout × Wout × Cout
weights = K × K × Cin × Cout + Cout
具体举例来说,如果是一副输入224 x 224 x 3的图片,经过stride = 2,K = 3的卷积,输出112 x 112 x 32的特征图,那么有:
input = 3 × 3 × 3 × 112 × 112 × 32 = 10,838,016(96.42%)
output = 112 × 112 × 32 = 401,408(3.57%)
weights = 3 × 3 × 3 × 32 + 32 = 896(0.01%)
total = 11,240,320
有这个例子我们可以看到,卷积层主要的内存访问发生在把输入特征图反复搬运到CPU参与计算,把计算得到的输出特征图写入内存和权重的读取带来的内存访问,可以忽略不计。顺便说一句,我们这里假设了权重只被读取一次并缓存在本地CPU/GPU内存中,因此它们可以在CPU/GPU线程之间共享,并将重新用于每个输出像素。
对于网络中较深的层,具有28 x 28 x 256个输入和28 x 28 x 512个输出,K = 3,stride = 1,那么:
input = 3 × 3 × 256 × 28 × 28 × 512 = 924,844,032(99.83%)
output = 28 × 28 × 512 = 401,408(0.04%)
weights = 3 × 3 × 256 × 512 + 512 = 1,180,160(0.13%)
total = 926,425,600
即使特征图的宽度和高度现在较小,它们也会有更多的通道。这就是为什么权重的计算更多,因为由于通道数量的增加,权重会越来越多。但是主要的内存访问依然是把输入特征图反复搬运到CPU参与计算。
如果使用深度可分离卷积呢?使用跟前面相同的输入和输出大小,计算3×3深度卷积层和1×1逐点层的内存访问次数:
DepthWise layer
input = 3 × 3 × 1 x 28 × 28 × 256 = 1,806,336
output = 28 × 28 × 256 = 200,704
weights = 3 × 3 × 1 x 256 + 256 = 2,560
total = 2,009,600(1.91%)
PointWise layer
input = 1 × 1 × 256 × 28 × 28 × 512 = 102,760,448
output = 28 × 28 × 512 = 401,408
weights = 1 × 1 × 256 × 512 + 512 = 131,584
total = 103,293,440(98.09%)
total of both layers = 105,303,040
可以看到深度可分离卷积它的内存访问量减少到大约原来的926425600 / 105303040 = 8.80倍(几乎是K × K倍),这就是使用深度可分层的好处。还可以看到Depth-Wise层的内存访问成本非常便宜,几乎可以忽略不计。
在PyTorch和大多数训练框架中,经常会看到Conv2D层后面跟着一个应用ReLU的激活层。这对训练框架来说很好,提供了灵活性,但是让ReLU成为一个单独的层是浪费的,特别是因为这个函数非常简单。
示例:对28 × 28 × 512卷积层的输出应用ReLU :
input = 28 × 28 × 512 = 401,408
output = 28 × 28 × 512 = 401,408
weights = 0
total = 802,816
首先,它需要从卷积层读取特征图每个像素,然后对其应用ReLU,最后将结果写回内存。当然,这非常快,因为它几乎与将数据从一个内存位置复制到另一个内存位置相同,但这样的操作有些浪费。
因此,激活函数通常与卷积层融合。这意味着卷积层在计算出点积之后直接应用ReLU,然后才能写出最终结果。这节省了一次读取和一次写入存储器的昂贵时钟开销。
同理,对于BN层来说,将BN层融合进卷积层也是一种在实践中经常用到的策略。
内存访问次数 | 备注 | |
---|---|---|
全连接层 | I次读输入,(I + 1) × J次读权重,J次写结果 | 访问次数不多 |
激活函数(ReLU) | Hin x Win x Cin | 融合,且比例很小,通常不关心 |
卷积层 | input = (K × K × Cin) x (Hout x Wout x Cout) output = Hout × Wout × Cout | |
weights = K × K × Cin × Cout + Cout | 大多数在input的消耗上 | |
BN层 | N/A | BN融合,通常不关心 |
如上表,本文讨论了在CNN网络中常见的层的内存读取次数,没有涉及到的层也可以参考本文的分析方法来算。后面在工作中遇到新的模型的时候,不需要实际实现它就可以按照本文的讨论粗略地估计模型在内存访问上的可行性;更重要的是,以上分析在自己设计、改造网络的时候也有很强的指导性作用。
最后需要强调的一点是,关于模型计算量和内存访问次数两者是怎么对最终模型的速度产生影响是一个比较复杂的问题。有些人认为参数数量少或者计算量少的模型,就比计算量大的模型快,这是不对的。有时候(如果不是很多时候的话),内存速度和Cache才是制约因素。如果在模型性能、计算量、内存访问量之间做取舍和平衡,还需要做很多工作。