Halide视觉神经网络优化
概述
Halide是用C++作为宿主语言的一个图像处理相关的DSL(Domain Specified Language)语言,全称领域专用语言。主要的作用为在软硬层面上(与算法本身的设计无关)实现对算法的底层加速,有必要对其有一定的了解。因为不论是传统的图像处理方法亦或是深度学习应用都使用到了halide的思想。
其中,在OpenCV(传统图像处理库)中部分算法使用了Halide后端,而TVM(神经网络编译器)也是用了Halide的思想去优化神经网络算子。

Halide到底是什么?看上面那张图,同样的一个算法处理(局部拉普拉斯变换),使用直接的C++语言写出来算法速度很慢,Adobe公司使用3个月对这个算法进行了优化(手工优化)使这个算法的速度快了10倍,但是如果使用了Halide,只需要几行代码,就可以使这个算法比之前普通直接的算法快上20倍。
一句话来说,Halide大大节省了手动优化底层算法的时间,只需要关心算法的设计。
Halide为什么可以优化算法
Halide的特点是其图像算法的计算的实现(Function和Expression)和这些计算在计算硬件单元上的调度(Schedule)是分离的,其调度以Function为单位。最终将整个图像算法转换为高效率的多层for循环,for循环的分部数据范围划分和数据加载都是由Halide来完成的,而且可以实现数据的加载和算法计算的Overlay,掩盖数据加载导致的延迟。Halide的Schedule可以由程序员来指定一些策略,指定硬件的buffer大小,缓冲线的相关设置,可以根据不同的计算硬件的特性,实现高效率的计算单元的调度,而图像算法的计算实现却不需要修改。
决定算法在某个硬件平台上执行时性能的“三角力量”如下。

其中,算法本身的设计是一方面,一个好的算法往往效率会高很多。而另外一个方面就是算法中计算顺序的组织,而Halide可以改变的就是算法在某个硬件平台上的计算顺序:

其中Halide可以在硬件平台上为算法实现并行和良好的缓存一致性:
示例
Halide中的经典模糊化(blurred)图像的例子,演示一下(以下代码也可以在自己的电脑上测试观察结果),这里用OpenCV来对图像进行操作进行演示:
首先设计一个可以对图像进行模糊的操作函数:
// in为输入原始图像 blury为输出模糊后的图像
void box_filter_3x3(const Mat &in, Mat &blury) { Mat blurx(in.size(), in.type()); for(int x = 1; x < in.cols-1; x ++) for(int y = 0 ; y < in.rows; y ++) blurx.at<uint8_t >(y, x) = static_cast<uint8_t>( (in.at<uint8_t >(y, x-1) + in.at<uint8_t >(y, x) + in.at<uint8_t >(y, x+1)) / 3); for(int x = 0; x < in.cols; x ++) for(int y = 1 ; y < in.rows-1; y ++) blury.at<uint8_t >(y, x) = static_cast<uint8_t>( (blurx.at<uint8_t >(y-1, x) + blurx.at<uint8_t >(y, x) + blurx.at<uint8_t >(y+1, x)) / 3); }
对图像模糊操作很简单,首先在x轴上对每个像素点,以及周围的两个点进行求和平均,然后再到y轴上进行同样的操作,这样相当于一个3×3平均卷积核,对整个图像进行操作,这里就不进行详细描述了。
准备一张(1920,1080)的图像,对其进行100次上述操作,并记录时间,发现Time used:4521.72 ms。
然后,简单改变一下执行次序,将上述循环嵌套中的x和y的顺序改变一下:
Mat blurx(in.size(), in.type()); // 这里进行了嵌套的变换 for(int y = 0 ; y < in.rows; y ++) for(int x = 1; x < in.cols-1; x ++) blurx.at<uint8_t >(y, x) = static_cast<uint8_t>( (in.at<uint8_t >(y, x-1) + in.at<uint8_t >(y, x) + in.at<uint8_t >(y, x+1)) / 3); // 这里进行了嵌套的变换 for(int y = 1 ; y < in.rows-1; y ++) for(int x = 0; x < in.cols; x ++) blury.at<uint8_t >(y, x) = static_cast<uint8_t>( (blurx.at<uint8_t >(y-1, x) + blurx.at<uint8_t >(y, x) + blurx.at<uint8_t >(y+1, x)) / 3); }
同样,执行100次并记录时间:发现Time used:3992.35 ms,可以发现,下面的模糊操作执行的速度比上面的快一些。当然,这只是一副示例图像,如果这张图像的长宽差距比较大(例如1:10)、亦或是要某一个时刻处理几万次这样的操作,一旦量级起来,那么这两者的差距就不是一点半点了。
硬件原理
这差别和算法本身没什么关系,而与硬件的设计是有巨大关系,例如并行性和局部性。
下面是Adobe工程师对上述的算法在硬件层面上极致优化结果,比之前的算法快了10倍,其中用到了SIMD(单指令多数据流)、以及平铺(Tiling)、展开(Unrolling)和向量化(Vectorization)等常用技术。充分利用了硬件的性能,不改变算法本身设计的前提下,最大化提升程序执行的速度。

官方示例
Halide作为一个DSL,很容易就可以使用它,这里将其源码下下来并进行编译。完成之后,就可以使用它了(这里省略编译步骤,可自行在官网查阅):
首先引用Halide头文件,以及其它的文件。
#include “Halide.h”
#include <stdio.h> #include using namespace Halide;
初次使用Halide之前,首先需要知道halide中的一些语法:

然后,利用Halide定义两个变量,这两个变量单独使用时,没有任何意义,同时用字符串x和y为两个变量起了名字:
Var x(“x”), y(“y”);
然后利用Func 定义一个待执行的function,并起名为gradient。
Func gradient(“gradient”);
这时定义function中每个点的执行逻辑,对于(x,y)这个点执行的逻辑为x + y。
其中x和y都是Var,而x + y这个操作在赋予给gradient的时候会自动转化为Expr类型,这里可以理解为将x + y这个代数表达式的逻辑,赋予了gradient,最后,通过realize函数来执行整个逻辑:
gradient(x, y) = x + y; // realize 即为实现这个操作 到了这一步才会对上述的操作进行编译并执行 Buffer output = gradient.realize(4, 4);
这个逻辑用C++来表示即为:
for (int y = 0; y < 4; y++) { for (int x = 0; x < 4; x++) { printf(“Evaluating at x = %d, y = %d: %d\n”, x, y, x + y); } }
而上述实现的Halide伪代码为:
produce gradient:
for y:
for x:
gradient(…) = …
Halide默认的计算顺序是行优先的,也就是x代表每一行的元素位置,y代表每一列的元素位置:

如果将其中y和x的计算顺序换一下:
// 将y的顺序提到x之前
gradient.reorder(y, x);
最终的计算过程就为列优先:

相应的伪代码为:
produce gradient_col_major:
for x:
for y:
gradient_col_major(…) = …
拆分 Split
可以对每个维度进行拆分,假如依然是行优先计算,但是对x轴进行拆分,将其拆成,一个外循环,一个里循环,y轴不进行变动:
Var x_outer, x_inner;
gradient.split(x, x_outer, x_inner, 2);
这时对应的C++代码实现为:
for (int y = 0; y < 4; y++) { for (int x_outer = 0; x_outer < 2; x_outer++) { for (int x_inner = 0; x_inner < 2; x_inner++) { int x = x_outer * 2 + x_inner; printf(“Evaluating at x = %d, y = %d: %d\n”, x, y, x + y); } } }
融合 fuse
或者不进行拆分,对x和y两个轴进行融合:
Var fused;
gradient.fuse(x, y, fused);
此时对应的C++实现代码为:
for (int fused = 0; fused < 4*4; fused++) { int y = fused / 4; int x = fused % 4; printf(“Evaluating at x = %d, y = %d: %d\n”, x, y, x + y); }
但是要知道,上述拆分和融合操作,只是对Halide所能进行的操作进行一下演示,而是,这种操作方式并没有实际用处,也就是说实际中的计算顺序并没有改变。
平铺 tile
这一步中就要进入Halide中比较重要的部分了,这一步中将x和y轴以4为因子间隔进行划分,并且重新对计算的路径进行重排序:
Var x_outer, x_inner, y_outer, y_inner;
gradient.split(x, x_outer, x_inner, 4); gradient.split(y, y_outer, y_inner, 4); gradient.reorder(x_inner, y_inner, x_outer, y_outer); // 上面的步骤其实可以简化成 gradient.tile(x, y, x_outer, y_outer, x_inner, y_inner, 4, 4);
对应的C++计算代码为:
for (int y_outer = 0; y_outer < 2; y_outer++) { for (int x_outer = 0; x_outer < 2; x_outer++) { for (int y_inner = 0; y_inner < 4; y_inner++) { for (int x_inner = 0; x_inner < 4; x_inner++) { int x = x_outer * 4 + x_inner; int y = y_outer * 4 + y_inner; printf(“Evaluating at x = %d, y = %d: %d\n”, x, y, x + y); } } } }
可视化一下就是这个样子(注意这里的示例大小为(8,8)):

这种平铺的好处是可以充分利用相邻的像素,例如在模糊中,会使用重叠的输入数据(也就是存在一个元素使用两次的情况),如果采用这种计算方式,可以大大加快计算性能。
向量化 vector
向量化即使用cpu中的SIMD技术,一次性计算多个数据,充分利用硬件的特点,例如在x86中,可以利用SSE技术来实现这个功能。
在Halide中,首先将x轴的循环嵌套按照,内侧循环因子4的方式,拆分为两个(也就是内侧循环x执行四次,外侧根据总数进行计算,下例是2*4=8),然后将内侧的x循环转化为向量的形式:
Var x_outer, x_inner;
gradient.split(x, x_outer, x_inner, 4); gradient.vectorize(x_inner);
用C++来表示即为:
for (int y = 0; y < 4; y++) { for (int x_outer = 0; x_outer < 2; x_outer++) { // The loop over x_inner has gone away, and has been // replaced by a vectorized version of the // expression. On x86 processors, Halide generates SSE // for all of this. int x_vec[] = {x_outer * 4 + 0, x_outer * 4 + 1, x_outer * 4 + 2, x_outer * 4 + 3}; int val[] = {x_vec[0] + y, x_vec[1] + y, x_vec[2] + y, x_vec[3] + y}; printf(“Evaluating at <%d, %d, %d, %d>, <%d, %d, %d, %d>:” " <%d, %d, %d, %d>\n", x_vec[0], x_vec[1], x_vec[2], x_vec[3], y, y, y, y, val[0], val[1], val[2], val[3]); } }
可视化后就比较明显了,外部x每一行执行两次,内侧x变为向量的形式,一个指令集就可以执行完成:

展开 unrolling
如果在图像中,多个像素同时共享有重叠的数据,这个时候就可以将循环展开,从而使那些可以共享使用的数据,只计算一次亦或是只加载一次。
在下面中将x轴拆分为内侧和外侧,因为每次内侧的数值增长都是从0到1,如果将内测循环的x轴展开,就不需要每次循环到这里再读取内测循环的x的值了:
Var x_outer, x_inner;
gradient.split(x, x_outer, x_inner, 2); gradient.unroll(x_inner);
相应的C++代码为:
printf(“Equivalent C:\n”); for (int y = 0; y < 4; y++) { for (int x_outer = 0; x_outer < 2; x_outer++) { // Instead of a for loop over x_inner, we get two // copies of the innermost statement. { int x_inner = 0; int x = x_outer * 2 + x_inner; printf(“Evaluating at x = %d, y = %d: %d\n”, x, y, x + y); } { int x_inner = 1; int x = x_outer * 2 + x_inner; printf(“Evaluating at x = %d, y = %d: %d\n”, x, y, x + y); } } }
融合、平铺、并行 Fusing, tiling, and parallelizing
这一步中,将融合、平铺和并行操作都融合到一起,来对一个8×8的图像进行操作。首先,将x轴和y轴都按照4因子进行平铺操作。随后将外侧的y和外侧的x轴循环,进行融合(2+2=4),再将这个融合后的操作进行并行操作,也就是同时执行这四个(2+2=4)操作:
Var x_outer, y_outer, x_inner, y_inner, tile_index; gradient.tile(x, y, x_outer, y_outer, x_inner, y_inner, 4, 4); gradient.fuse(x_outer, y_outer, tile_index); gradient.parallel(tile_index);
相应的C++代码为:
// This outermost loop should be a parallel for loop, but that’s hard in C.
for (int tile_index = 0; tile_index < 4; tile_index++) { int y_outer = tile_index / 2; int x_outer = tile_index % 2; for (int y_inner = 0; y_inner < 4; y_inner++) { for (int x_inner = 0; x_inner < 4; x_inner++) { int y = y_outer * 4 + y_inner; int x = x_outer * 4 + x_inner; printf(“Evaluating at x = %d, y = %d: %d\n”, x, y, x + y); } } }
可视化后的结果,可以看到8×8中左上、左下、右上、右下四个区域是几乎同时进行的(tile_index),而每个区域和之前tile那一节的计算方式是一样的,只不过这次换成了并行计算:

整合
这次来点大点的图像,输入的图像大小为350 x 250,对其进行最优化的操作:
首先将其按照64 x 64的因子进行平铺,其次融合y轴和x轴外侧的循环操作数,最后对其进行并行操作
(这里注意下,可以看到350或者250并不能被64整除,这个不用担心,Halide会自动处理多余或者不够的部分)。
Var x_outer, y_outer, x_inner, y_inner, tile_index; gradient_fast .tile(x, y, x_outer, y_outer, x_inner, y_inner, 64, 64) .fuse(x_outer, y_outer, tile_index) .parallel(tile_index); // 可以这样连续使用.写,因为对象函数返回的是对象本身的引用
这样还不够,上面已经将整个图像平铺为6*4个部分,而这一步中对每个平铺后的部分再进行一次平铺操作,这次将每个小块按照4×2的形式平铺为,其中y_inner_outer分成两个(每个为y_pairs),x_inner_outer分成四个(每个为x_vectors),然后将每个x_vectors并行化,将y_pairs展开。
Var x_inner_outer, y_inner_outer, x_vectors, y_pairs;
gradient_fast
.tile(x_inner, y_inner, x_inner_outer, y_inner_outer, x_vectors, y_pairs, 4, 2) .vectorize(x_vectors) .unroll(y_pairs);
以下可视化的结果为:

对应的c++展示代码为:
for (int tile_index = 0; tile_index < 6 * 4; tile_index++) { int y_outer = tile_index / 4; int x_outer = tile_index % 4; for (int y_inner_outer = 0; y_inner_outer < 64/2; y_inner_outer++) { for (int x_inner_outer = 0; x_inner_outer < 64/4; x_inner_outer++) { // We’re vectorized across x int x = std::min(x_outer * 64, 350-64) + x_inner_outer4; int x_vec[4] = {x + 0, x + 1, x + 2, x + 3}; // And we unrolled across y int y_base = std::min(y_outer * 64, 250-64) + y_inner_outer2; { // y_pairs = 0 int y = y_base + 0; int y_vec[4] = {y, y, y, y}; int val[4] = {x_vec[0] + y_vec[0], x_vec[1] + y_vec[1], x_vec[2] + y_vec[2], x_vec[3] + y_vec[3]}; // Check the result. for (int i = 0; i < 4; i++) { if (result(x_vec[i], y_vec[i]) != val[i]) { printf(“There was an error at %d %d!\n”, x_vec[i], y_vec[i]); return -1; } } } { // y_pairs = 1 int y = y_base + 1; int y_vec[4] = {y, y, y, y}; int val[4] = {x_vec[0] + y_vec[0], x_vec[1] + y_vec[1], x_vec[2] + y_vec[2], x_vec[3] + y_vec[3]}; // Check the result. for (int i = 0; i < 4; i++) { if (result(x_vec[i], y_vec[i]) != val[i]) { printf(“There was an error at %d %d!\n”, x_vec[i], y_vec[i]); return -1; } } } } } }
到这里Halide中的基本操作就介绍完毕了。
其它
如果用Halide来写文章一开头,描述的模糊(blur)算法的话,会是这个样子:
Func blur_3x3(Func input) {
Func blur_x, blur_y; Var x, y, xi, yi; // The algorithm - no storage or order blur_x(x, y) = (input(x-1, y) + input(x, y) + input(x+1, y))/3; blur_y(x, y) = (blur_x(x, y-1) + blur_x(x, y) + blur_x(x, y+1))/3; // The schedule - defines order, locality; implies storage blur_y.tile(x, y, xi, yi, 256, 32) .vectorize(xi, 8).parallel(y); blur_x.compute_at(blur_y, x).vectorize(x, 8); return blur_y; }
这段著名的代码同时就在官方的主页上挂着,算是一个比较好的示例。
Halide的特点
Halide这个底层优化库有几个比较亮眼的特点:
Explicit programmer control
The compiler does exactly what you say.
Schedules cannot influence correctness.
Exploration is fast and easy.
明确的程序控制,也就是说,如何按照这个计算的顺序(与算法本身无关)是确定的,一旦已经设定好就不会再改变。
Stochastic search (autotuning)
Pick your favorite high-dimensional search.
而自动搜索,则是每个具有搜索空间的优化器都可以使用的,因为每次进行优化操作的时候,优化的因子都是不确定的,对于不同的硬件来说,不同的配置可能导致的执行速度也不一样。因此自动随机搜索空间因子是有必要的。
元编程
Halide的思想与元编程有着密切的关系,不仅是其设计思路或者是其执行思路,都遵循了元编程的思想,也就是代码在编译之前并没有明确的执行逻辑,只有编译过后,才会形成执行逻辑。

其它相关
halide既然作为与算法无关的底层优化器,与深度学习的结合应用肯定也是非常多的。OpenCV库就使用了halide去优化底层的神经网络算子,相应的benchmark结论,发现使用了halide的神经网络运行,速度竟然不如普通的C++实现版。首先,表明这个原因与halide本身的设计无关,但是,与halide优化和神经网络算子的兼容性有关,如果想要利用halide真正的实现加速,还是需要等待一段时间了。

后记
本文只是简单介绍了Halide的基本知识,对于想要深入理解Halide的童鞋可以看官方的教程或者阅读源码,不论是设计算法的算法工程师亦或是在相关硬件平台上实现移植功能的底层工程师,Halide的思想都是值得去借鉴和回味的。
另外提一下,Halide的运行有两种方式,一种是JIT的模式,另一种是AOT的模式。JIT模式使用起来比较方便,可以直接将算法和Halide的代码生成generator封装成一个类,在程序的其他部分调用这个类即可。在嵌入式环境和交叉编译环境下一般使用AOT模式,此时需要调用compiler函数,将算法代码和Halide的代码生成generator编译位目标机器的代码,生成一个.o目标文件和.h头文件。然后在独立的目标机器的应用的工程的源代码中,通过头文件调用算法实现的计算函数,并在build的时候链接上.o文件,这样就得到一个可以在目标机器上运行的用Halide实现算法的程序了。一般DSP上都是这种方式来做的。
Halide的利用范围很广,之所以想要深入了解Halide是因为使用了TVM库,TVM借助了Halide的思想,去实现神经网络算子的优化,并且取得了不错的效果。

Halide视觉神经网络优化相关推荐

  1. Paper:论文解读《Adaptive Gradient Methods With Dynamic Bound Of Learning Rate》中国本科生提出AdaBound的神经网络优化算法

    Paper:论文解读-<Adaptive Gradient Methods With Dynamic Bound Of Learning Rate>中国本科生(学霸)提出AdaBound的 ...

  2. 神经网络优化器的选择_神经网络:优化器选择的重要性

    神经网络优化器的选择 When constructing a neural network, there are several optimizers available in the Keras A ...

  3. 一文概览神经网络优化算法

    点击上方"小白学视觉",选择加"星标"或"置顶" 重磅干货,第一时间送达 一.机器学习的优化 机器学习的优化(目标),简单来说是:搜索模型的 ...

  4. 从梯度下降到 Adam!一文看懂各种神经网络优化算法

    来源丨量子位 编辑丨极市平台 导读 在调整模型更新权重和偏差参数的方式时,你是否考虑过哪种优化算法能使模型产生更好且更快的效果?应该用梯度下降,随机梯度下降,还是Adam方法?这篇文章介绍了不同优化算 ...

  5. 人工智能实践:TensorFlow笔记学习(四)—— 神经网络优化

    神经网络优化  大纲 4.1 损失函数 4.2 学习率 4.3 滑动平均 4.4 正则化 4.5 神经网络搭建八股 目标 掌握神经网络优化方法 4.1 损失函数 神经元模型:用数学公式表示为:,f为激 ...

  6. 如何理解神经网络优化中Momentem能够缓解hessian矩阵病态的问题

    如何理解神经网络优化中Momentem能够缓解hessian矩阵病态的问题? 1.首先介绍一下,矩阵的病态问题 矩阵病态主要是因为矩阵向量之间相关性太大,在二维上说就是矩阵向量之间的夹角太小,导致这两 ...

  7. 各种神经网络优化算法:从梯度下降到Adam方法

    在调整模型更新权重和偏差参数的方式时,你是否考虑过哪种优化算法能使模型产生更好且更快的效果?应该用梯度下降,随机梯度下降,还是Adam方法? 这篇文章介绍了不同优化算法之间的主要区别,以及如何选择最佳 ...

  8. 【深度学习】从梯度下降到 Adam!一文看懂各种神经网络优化算法

    王小新 编译自 Medium 量子位 出品 | 公众号 QbitAI,编辑:AI有道 在调整模型更新权重和偏差参数的方式时,你是否考虑过哪种优化算法能使模型产生更好且更快的效果?应该用梯度下降,随机梯 ...

  9. 神经网络优化中的Weight Averaging

    ©PaperWeekly 原创 · 作者|张子逊 研究方向|神经网络剪枝.NAS 在神经网络优化的研究中,有研究改进优化器本身的(例如学习率衰减策略.一系列 Adam 改进等等),也有不少是改进 no ...

最新文章

  1. Transformers2.0让你三行代码调用语言模型,兼容TF2.0和PyTorch
  2. BZOJ 1927 星际竞速
  3. 数据中心防火要把“十关”
  4. idea设置启动时打开欢迎页
  5. leveldb 学习。
  6. 微信小程序实现时间戳转为时间格式
  7. 深入理解 操作系统 LRU算法(以洛谷P1540题为例)
  8. [翻译]超炫列表动画的实现
  9. 涂鸦智能传感合作伙伴大会圆满落幕,打造安防传感新生态
  10. Docker必备国内镜像
  11. 熊猫DataFrame from_dict()–字典到DataFrame
  12. Java之数据库基础理论
  13. list排序sort降序_list.sort和高阶函数sorted
  14. zTree展开树节点
  15. 2018 最新 cnki账号 中国知网账号 帐号 免费入口 用户名 密码 万方 维普
  16. go获取当前工作路径
  17. ARM9的存储结构~~大端存储和小端存储
  18. python飞机大战实验报告心得_Python学习之路「第七篇」-Pygame之飞机大战1
  19. Django学习笔记(五)
  20. 高通与安卓水火不容 Android音频系统缺陷测评

热门文章

  1. 2022-2028年中国康养地产行业市场需求前景及投资战略分析报告
  2. 2022-2028年中国电子陶瓷行业深度调研及投资前景预测报告
  3. pretty_errors_python
  4. 输入输出导入导出问题
  5. Python3 try-except、raise和assert解析
  6. TensorFlow XLA加速编译器
  7. 常用的Java GC算法
  8. 腾讯云https认证
  9. [JAVAEE] 初识ThymeLeaf
  10. 导入第三方工程报错的处理方法