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(
intx =
1; x < in.cols
-1; x ++)
for(
inty =
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(
intx =
0; x < in.cols; x ++)
for(
inty =
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 <algorithm> 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<int> output = gradient.realize(4, 4);
这个逻辑用C++来表示即为:
for(
inty =
0; y <
4; y++) {
for(
intx =
0; x <
4; x++) {
printf(
"Evaluating at x = %d, y = %d: %d\n", x, y, x + y); } }
而上述实现的Halide伪代码为:
producegradient:
for
y:
for
x:
gradient(...)
=
...
Halide默认的计算顺序是行优先的,也就是x代表每一行的元素位置,y代表每一列的元素位置:
如果将其中y和x的计算顺序换一下:
// 将y的顺序提到x之前
gradient.reorder(y, x);
最终的计算过程就为列优先:
相应的伪代码为:
producegradient_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(
inty =
0; y <
4; y++) {
for(
intx_outer =
0; x_outer <
2; x_outer++) {
for(
intx_inner =
0; x_inner <
2; x_inner++) {
intx = 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(
intfused =
0; fused <
4*
4; fused++) {
inty = fused /
4;
intx = 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(
inty_outer =
0; y_outer <
2; y_outer++) {
for(
intx_outer =
0; x_outer <
2; x_outer++) {
for(
inty_inner =
0; y_inner <
4; y_inner++) {
for(
intx_inner =
0; x_inner <
4; x_inner++) {
intx = x_outer *
4+ x_inner;
inty = 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(
inty =
0; y <
4; y++) {
for(
intx_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(
inty =
0; y <
4; y++) {
for(
intx_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(
inttile_index =
0; tile_index <
4; tile_index++) {
inty_outer = tile_index /
2;
intx_outer = tile_index %
2;
for(
inty_inner =
0; y_inner <
4; y_inner++) {
for(
intx_inner =
0; x_inner <
4; x_inner++) {
inty = y_outer *
4+ y_inner;
intx = 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(
inttile_index =
0; tile_index <
6*
4; tile_index++) {
inty_outer = tile_index /
4;
intx_outer = tile_index %
4;
for(
inty_inner_outer =
0; y_inner_outer <
64/
2; y_inner_outer++) {
for(
intx_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_outer*4; 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_outer*2; { // 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的思想,去实现神经网络算子的优化,并且取得了不错的效果。