编译器|解析卷积的高速计算中的细节,一步步代码带你飞

作者:Manas Sahni
编译:ronghuaiyang
导读卷积是深度学习中的基础运算 , 那么卷积运算是如何加速到这么快的呢 , 掰开揉碎了给你看 。
编译器|解析卷积的高速计算中的细节,一步步代码带你飞文章插图
在我不太破旧的笔记本电脑CPU上 , 使用TensorFlow这样的库 , 我可以(最多)在10-100毫秒内运行大多数常见的CNN模型 。 在2019年 , 即使是智能手机也能在不到半秒的时间内运行“重”CNN(比如ResNet)模型 。 所以 , 想象一下当给我自己的卷积层的简单实现计时的时候 , 我很惊讶 , 发现它为一个单层花费了2秒!
现代深度学习库对大多数操作都具有生产级的、高度优化的实现 , 这并不奇怪 。 但这些库究竟是什么魔法?他们如何能够将性能提高100倍?究竟怎样才能“优化”或加速神经网络的运行呢?在讨论高性能/高效DNNs时 , 我经常会问(也经常被问到)这些问题 。
在这篇文章中 , 我将尝试带你了解在DNN库中卷积层是如何实现的 。 它不仅是在模型中最常见的和最重的操作 , 我还发现卷积高性能实现的技巧特别具有代表性——一点点算法的小聪明 , 非常多的仔细的调优和低层架构的开发 。
我在这里介绍的很多内容都来自Goto等人的开创性论文:Anatomy of a high-performance matrix multiplication , 该论文为OpenBLAS等线性代数库中使用的算法奠定了基础 。
最原始的卷积实现
“过早的优化是万恶之源”——Donald Knuth
在进行优化之前 , 我们先了解一下基线和瓶颈 。 这是一个朴素的numpy/for循环卷积:
''' Convolve `input` with `kernel` to generate `output` input.shape = [input_channels, input_height, input_width] kernel.shape = [num_filters, input_channels, kernel_height, kernel_width] output.shape = [num_filters, output_height, output_width] ''' for filter in 0..num_filters for channel in 0..input_channels for out_h in 0..output_height for out_w in 0..output_width for k_h in 0..kernel_height for k_w in 0..kernel_width output[filter, channel, out_h, out_h] += kernel[filter, channel, k_h, k_w] * input[channel, out_h + k_h, out_w + k_w]这是一个6层嵌套的for循环(如果你迭代多个输入批次 , 则为7个) 。 我们还没有研究步幅 , 膨胀 , 或其他参数 。 如果我在这里输入MobileNet的第一层的大小 , 然后用普通的C语言运行它 , 它会花费惊人的22秒!使用最积极的编译器优化 , 如' -O3 '或' -Ofast ' , 它减少到2.2秒 。 但这对于第一层来说仍然非常慢 。
如果我使用Caffe运行相同的层呢?这台电脑只用了18毫秒 。 这比100倍的加速还要快!整个网络在我的CPU上运行大约100毫秒 。
瓶颈是什么 , 我们应该从哪里开始优化?
最内部的循环执行两个浮点运算(乘法和加法) , 对于我使用的大小 , 它执行了大约8516万次 , 也就是说 , 这个卷积需要1.7亿个浮点运算(MFLOPs) 。 根据英特尔的数据 , 我的CPU的最高性能是每秒800亿次 , 也就是说 , 理论上它可以在0.002秒内完成这项工作 。 显然 , 我们离这个目标还很远 , 而且很明显 , 这里的原始处理能力已经足够了 。
理论峰值没有达到(从来没有)的原因是内存访问也需要时间—如果不能快速获得数据 , 那么仅仅快速处理数据是不够的 。 事实证明 , 上面嵌套的for循环使得数据访问模式非常困难 , 这使得缓存利用率很低 。
正如你将看到的 , 在整个讨论过程中反复出现的一个问题是 , 我们如何访问正在操作的数据 , 以及这些数据如何与存储方式相关联 。
一些先决条件
FLOP/s
我们对“性能”或速度的度量是吞吐量 , 以每秒浮点计算次数度量 。 具有更多浮点操作的更大操作自然会运行得更慢 , 因此FLOP/s速率可以使用更一致的方式来比较性能 。
我们也可以用它来了解我们离CPU的最高性能有多近 。 在我的笔记本电脑CPU上:

  • 有2个phsyical core
  • 每个核的频率为2.5 GHz , 即每秒2.5×109个CPU周期
  • 在每个周期 , 它可以处理32FLOPs(使用AVX和FMA还会更多)
其峰值性能为 GFLOP/s 。 这是我的CPU的理论峰值 。 同样 , 对于单个内核 , 这个数字是80GFLOP/s 。
存储顺序和行主序
虽然我们从逻辑上把矩阵/图像/张量看作多维的 , 但它们实际上存储在线性的一维计算机内存中 。 我们必须定义一个约定 , 该约定规定如何将这些多维数据展开到线性存储中 , 反之亦然 。
大多数现代DL库使用行主序存储 。 这意味着同一行的连续元素彼此相邻存储 。 更一般地说 , 对于多维 , 行主序意味着当线性扫描内存时 , 第一个维度的变化最慢 。