当前位置: 首页 > 工具软件 > Halide > 使用案例 >

快速入门Halide

司寇正志
2023-12-01

Halide代码流程:

  • 声明函数,变量,表达式
  • 用变量、表达式等实现算法
  • 使用调度策略对算法进行调度调优
  • 调用函数的realize成员函数,对函数进行实现
  • 写出数据
example 1:
Halide::Func gradient;
// Func对象表示了一个pipeline阶段。它是一个纯函数,定义了每个像素点对应的值。
Halide::Var x, y;
// Var对象是Func的定义域,或者说是Func的参数。它们本身没有任何意义。Var用来索引
Halide::Expr e = x + y;
// 义了一个 x + y的表达式。变量重载了算数运算符,因此变量进行运算的结果是一个Expr对象
gradient(x, y) = e;
// 在像素点x,y处,图像的像素值为表达式e的结果  gradient(x, y) = x + y;

// 告诉Halide在在指定的区域进行计算。这里的domain可以理解为像素点的范围。
Halide::Buffer<int32_t> output = gradient.realize(800, 600);
output(i, j) !=  i + j
example 2:
Halide::Buffer<uint8_t> input = load_image("s.png");
//定义Func对象,Func对象表示我们将要进行的图像亮度提升pipeline
Halide::Func brighter;
//定义操作图像像素的索引,即Var(变量)x(column),y(row),c(channel)
// x,y为坐标索引,c为颜色通道索引。
Halide::Var x, y, c;
//value表达式表示c通道(x,y)坐标处的像素值
Halide::Expr value = input(x, y, c);
// 为了进行浮点计算,先将数据类型转换成单精度浮点类型
value = Halide::cast<float>(value);
// 将c通道(x,y)坐标处的像素值放大1.5倍
value = value * 1.5f;
// 为了防止数据溢出,将放大后的像素值clip到[0,255]区间,并转换成8位无符号整型
value = Halide::min(value, 255.0f);
value = Halide::cast<uint8_t>(value);
//定义函数,将亮度提升后的像素值,赋值给函数对象的(x,y,c)点
brighter(x, y, c) = value;
// brighter(x, y, c) = Halide::cast<uint8_t>(min(input(x, y, c) * 1.5f, 255));
// 上述所有操作知识在内存中建立Halide程序,告诉Halide怎么去进行算法操作,即对算法进行了定义。
// 实际上还没有开始进行任何像素的处理。甚至Halide程序还没有进行编译

// 现在将要实现函数。输出图像的尺寸必须和输入图像的尺寸相匹配。
//如果我们只想提高输入图像部分区域像素点的亮度,可以指定一个小一点的尺寸。
//如果需要一个更大尺寸的输出,Halide在运行时会抛出一个错误告诉我们,边界超出输入图像。
Halide::Buffer<uint8_t> output =
    brighter.realize(input.width(), input.height(), input.channels());
// 写下被处理过的图像
save_image(output, "brighter.png");

调试

  • 设置环境变量HL_DEBUG_CODEGEN=1,此时运行程序会打印出编译的不同阶段和最终pipeline的伪代码
  • 设置HL_DEBUG_CODEGEN=2,此时会输出Halide编译的各个不同阶段,而且会输出llvm最终生成的字节码
  • 也提供HTML形式的伪代码输出,支持语法高亮,代码折叠,翻遍大规模复杂pipeline的阅读
Func gradient("gradient");
Var x("x"), y("y");
gradient(x, y) = x + y;
// 给Func和Var的构造函数传入一个string类型的名字
Buffer<int> output = gradient.realize(8, 8);
gradient.compile_to_lowered_stmt("gradient.html", {}, HTML);
  • 用tracing,print,print_when调试
  1. Func.trace_stores() 跟踪函数运行时计算结果
  2. Func.parallel(y) 在某个domain方向多线程并行计算
  3. print() 打印所关注表达式的值
  4. print_when() 打印在指定条件为真情况下的值,也可用于屏蔽条件为假时的输出
Var x("x"), y("y");
Func gradient("gradient");
gradient(x, y) = x + y;
gradient.trace_stores();  // 跟踪所有的函数计算值

// 在Halide中,由于算法和调度解耦合,算法的调度并不影响算法的描述

gradient.parallel(y); // 在y方向并行执行for循环

// trace_stores()函数打印函数值,内置的print函数可以答应表达式(Expr)对象的值
// 仅仅需要关注表达式中的一个条目,我们可以在这个条目上加上print函数
Func g;
g(x, y) = sin(x) + print(cos(y))
// 如果需要查看中间某个特定的结果,可以调用条件打印函数,打印出在特定条件下,表达式的结果。
// print_when(bool_expr, expr, context)
// 如果 bool_expr == ture: 返回expr,打印context内容
Func f;
Expr e = cos(y);
e = print_when(x == 37 && y == 42, e, "<- this is cos(y) at x, y == (37, 42)");
// 如果你想要查看表达式是否和你想象中一样,可以用c++的输出流将表达式结果打印到标准输出上,检查是否如预期一致。

向量化,并行化,平铺,数据分块

//5默认遍历像素的顺序是行优先,即内层循环沿着行方向,外层循环沿着列方向
Func gradient("gradient");
gradient(x, y) = x + y;
gradient.print_loop_nest(); //打印出是执行的哪种循环调度。
// 通过reorder函数来改变函数遍历的顺序,将(y)置于内层循环.也就是说y遍历x遍历更快。是一种列优先的遍历方法
gradient.reorder(y, x);

// split调度,将一个大循环,拆解成一个外部循环和一个内部循环;
Var x_outer, x_inner;
// split将x拆成x_outer,x_inner,内循环的长度为2
gradient.split(x, x_outer, x_inner, 2);
// 和split相反的是fuse,它将两个变量融合成一个变量
Var fused;
gradient.fuse(x, y, fused);

// tile 是指将图像数据拆分成小图像块
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);

// 向量化调度
// 计算4倍宽的向量
Var x_outer, x_inner;
gradient.split(x, x_outer, x_inner, 4);
gradient.vectorize(x_inner);
//  gradient.vectorize(x, 4);

// tile的外层循环融合成一个整体循环,在此基础上调用多核并行
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);

函数更新

//6指定区域上执行函数
Func gradient("gradient");
Var x("x"), y("y");
gradient(x, y) = x + y;
Buffer<int> result(8, 8);
gradient.realize(result);
//realize隐含地做了如下三件事
// 1) 生成可以在任何矩形上进行计算的代码
// 2) 分配一个新的8x8的图像存储空间
// 3) 遍历x, y从(0, 0) 到 (7, 7) 把生成的结果保存到图像中
// 4) 返回结果图像

//想要在5x7的矩形区域上计算梯度,而且起始坐标为(100, 50)
// 创建一个表示5x7图像的矩形区域
Buffer<int> shifted(5, 7); // 在构造函数中指定尺寸
shifted.set_min(100, 50); // 指定,计算区域的其实坐标(左上角坐标)
gradient.realize(shifted);

//9
Var x("x"), y("y");
Func f;
f(x, y) = x + y;
//点更新:
f(3, 7) = 42;
//行更新:
f(x, 0) = f(x, 0) * f(x, 10);
//列更新:
f(0, y) = f(0, y) / f(3, y);
//约束区域更新: 
RDom r(0, 50); 
f(x, r) = f(x, r) * f(x, r);
// 在非矩形区域上的更新
Func circle("circle");
Var x("x"), y("y");
circle(x, y) = x + y;
//我们需要的区域的区域是以(3,3)为中心,半径为3的圆形区域,再次区域上做更新
//我们首先需要定义一个框住圆形区域的最小边界的盒子(矩形区域)。
RDom r(0, 7, 0, 7);
//上这个矩形盒子区域可以是尺寸的,只要它能够覆盖我们要使用的区域即可。这个盒子框住所需要的区域越紧越好。
// 用RDom::where在给出的矩形区域上来定义所需要的约束区域,从而更新只在指定条件为真的区域上进行
r.where((r.x - 3)*(r.x - 3) + (r.y - 3)*(r.y - 3) <= 10);
// 定义了更新区域,接下来定义更新操作
circle(r.x, r.y) *= 2;
Buffer<int> halide_result = circle.realize(7, 7);

调度

  1. default
Var x("x"), y("y");
Func producer("producer_default"), consumer("consumer_default");
producer(x, y) = sin(x * y);
consumer(x, y) = (producer(x, y) +
                  producer(x, y+1) +
                  producer(x+1, y) +
                  producer(x+1, y+1))/4;
consumer.realize(4, 4);
//8 Halide默认的调度策略是inline策略,所谓的inline策略就是生产者-消费者模型中的消费者需要用到
//for y 
//    for x
//        produce what comsumer need consumer compute
// consumer(x, y) = (sin(x * y) +
//                   sin(x * (y + 1)) +
//                   sin((x + 1) * y) +
//                   sin((x + 1) * (y + 1))/4);
// producer根本没有出现,而是内联到comsumer内部了
  1. compute_root()调度
Func producer("producer_root"), consumer("consumer_root");
producer(x, y) = sin(x * y);
consumer(x, y) = (producer(x, y) +
                  producer(x, y+1) +
                  producer(x+1, y) +
                  producer(x+1, y+1))/4;

// 告诉Halide在consumer之前计算完所有producer
producer.compute_root();
consumer.realize(4, 4);
// producer 发生在consumer之前
 producer.compute_root()策略默认的内联调度策略
临时内存分配25个float0
数据读取640
数据存储4116
调用sin函数2564
  1. 折中处理
Func producer("producer_y"), consumer("consumer_y");
producer(x, y) = sin(x * y);
consumer(x, y) = (producer(x, y) +
                  producer(x, y+1) +
                  producer(x+1, y) +
                  producer(x+1, y+1))/4;
// consumer每个行循环(y循环)计算producer
// 这里的a.compute_at(b,c)理解为在b的c循环里插入a的计算,
//compute_at将producer的计算放在consumer里y的for循环内部
producer.store_root(); //最外层循环开辟buffer存储所有的producer数据
producer.compute_at(consumer, y); // 在每一行y循环计算producer
consumer.realize(4, 4);
  1. 总结
  • 默认的调度策略是内联模式,即消费者需要什么数据,生产者立即计算对应的数据, 有大量的计算冗余
  • producer.compute_root(),生产者在消费者之间将所有数据计算完毕,然后消费者才开始计算,这样做计算冗余性小,需要更多的存储空间
  • producer.compute_at(consumer, y), 在y循环层面缓存producer数据,是计算冗余和存储空间二者的一个折中
  • producer.store_root().compute_at(consumer, y),缓存所有数据,计算冗余性降低,同时生产之后立即被消费者消费

编译

  • 建立Halide pipeline,并且将它编译成静态链接库和相应的头文件
  • 使用静态链接库来运行整个pipeline
// We'll define a simple one-stage pipeline:
Func brighter;
Var x, y;
// 定义输入变量
Param<uint8_t> offset;
// 定义输入图像,构造函数的第一个变量定义图像数据类型,第二个变量定义图像通道数。
//灰度图像为2通道的。当前Halide最大支持4通道的输入输出。
ImageParam input(type_of<uint8_t>(), 2);
// 如果是即时编译的话,这些就是int类型数据和Buffer,但是由于一次编译pipeline,然后对任何参数
// 都能工作,我们需要Param对象,它可以当做表达式一样使用,ImageParam可以当做Buffer一样使用
// Define the Func.
brighter(x, y) = input(x, y) + offset;
// Schedule it.
brighter.vectorize(x, 16).parallel(y);
// This time, instead of calling brighter.realize(...), which
// would compile and run the pipeline immediately, we'll call a
// method that compiles the pipeline to a static library and header.
// 需要显示生命对应的输入参数
brighter.compile_to_static_library("lesson_10_halide", {input, offset}, "brighter");
EXPORT void Halide::Pipeline::compile_to_static_library (
const std::string &filename_prefix,                    //静态链接库和头文件名字的前缀
const std::vector< Argument > & args,                  // 函数的参数
const std::string & fn_name,                           // 函数名称
const Target &  target = get_target_from_environment() // 指定编译成哪种平台的代码,   x86/arm/...
)       
// 在给定参数的情况下将对应文件编译成静态链接库和头文件
#include "lesson_10_halide.h"
// int brighter(halide_buffer_t *_input_buffer, uint8_t _offset, halide_buffer_t *_brighter_buffer);
// 查看生成器生成的头文件中函数的声明格式,按此格式调用函数
Halide::Runtime::Buffer<uint8_t> input(640, 480), output(640, 480);
int offset = 5;
int error = brighter(input, offset, output); // 调用静态库中的代码

// 跨平台编译
// 在compile_to_file的target变量中制定特定的编译目标平台即可
// 制定安卓os和arm cpu的例子
Target target;
target.os = Target::Android; // The operating system
target.arch = Target::ARM;   // The CPU architecture
target.bits = 32;            // The bit-width of the architecture
std::vector<Target::Feature> arm_features; // A list of features to set
target.set_features(arm_features);
// We then pass the target as the last argument to compile_to_file.
brighter.compile_to_file("lesson_11_arm_32_android", args, "brighter", target);

生成器

将Halide的pipeline封装到可以重复使用的生成器中,用特定的命令行接口调用那样,从Halide::Generator继承得到一个子类,由这个子类结构化生成器达到相同的目的。
class MyFirstGenerator : public Halide::Generator<MyFirstGenerator> {
public:
    // 定义Halide输入变量为流水线的公有变量。
    // 它们在生成器生成的函数中的顺序同他们声明时的顺序一致。
    Input<uint8_t> offset{"offset"};
    Input<Buffer<uint8_t>> input{"input", 2};

    // 输出变量同样声明为公有变量。
    Output<Buffer<uint8_t>> brighter{"brighter", 2};

    Var x, y;
    // 定义generate,在这个函数中定义算法描述的的pipeline
    // 这个函数的名字就是generate,如同main函数一样,名字规定就是这样,不要变动。
    void generate() {
        // 之前调用compile_to_file函数。这里只需要定义整个pipeline
        brighter(x, y) = input(x, y) + offset;

        // Schedule it.
        brighter.vectorize(x, 16).parallel(y);
    }
};

// 通过调用tools/GenGen.cpp内的函数来编译这个文件。
// 这个cpp文件定义了main函数,提供了对应的接口来使用自定义的生成器类。
// 我们需要按照如下格式来告诉gengen.cpp中的代码如何编译你的生成器类
HALIDE_REGISTER_GENERATOR(MyFirstGenerator, my_first_generator)
g++ lesson_15*.cpp ../tools/GenGen.cpp -g -std=c++11 -fno-rtti -I ../include -L ../bin -lHalide -lpthread -ldl -o lesson_15_generate
        class MyGenerator : public Halide::Generator<MyGenerator>
        {
            Input a, b; //...
            Output c, d; //...
            GeneratorParam<T> ...
        
            void generate()
            {
        
            }
        };
        
        HALIDE_REGIRTER_GENERATOR(MyGenerator, my_generator)
./lesson_15_generate -g my_first_generator -o . target=host
generator参数
-g 生成器名:选择使用那个生成器来执行,生成对应的头文件和库
-o 目录:指定生成文件的目录
-f 函数名:指定生成的函数名,默认是生成器的名字
target=... :用来指定编译的目标平台

BoundaryConditions类

// 7防止数据越界
Buffer<uint8_t> input = load_image("images/rgb.png");
// 将输入图像包裹在一个Func函数里面,防止图像访问像素点越界
Func clamped("clamped");
// 定义一个表达式,将x夹在[0, input.width()-1]闭区间里
Expr clamped_x = clamp(x, 0, input.width()-1);
Expr clamped_y = clamp(y, 0, input.height()-1);
clamped(x, y, c) = input(clamped_x, clamped_y, c);
// 定义clamped方法可以更简单地通过调用BoundaryConditions的成员函数达到目的
clamped = BoundaryConditions::repeat_edge(input);
 类似资料: