TVM代码生成codegen

2023-02-23,,

TVM代码生成codegen

硬件后端提供程序(例如Intel,NVIDIA,ARM等),提供诸如cuBLAS或cuDNN之类的内核库以及许多常用的深度学习内核,或者提供框架例,如带有图形引擎的DNNL或TensorRT,使用户以某种方式描述模型,实现高性能。此外,新兴的深度学习加速器还具有自己的编译器,内核库或runtime框架。

当用户尝试在新的内核库或设备上工作时,必须学习新的编程接口。对统一编程接口的需求变得越来越重要,使所有用户和硬件后端提供程序都在同一页面上。

为了与广泛使用的深度学习框架共享编程接口,许多硬件设备提供商已尝试将其设备后端集成到TensorFlow。由于TensorFlow没有为新的后端提供正式的后端接口,必须破解TensorFlow进行注册,这需要对许多源文件进行更改,从而使将来的维护变得困难。

本文演示了作为硬件后端提供程序,如何轻松利用自带代码生成(BYOC)框架,将硬件设备的内核库/编译器/框架集成到TVM。利用BYOC框架的最重要优点,设备的所有相关源文件都是独立的,设备的代码源/Runtime可插入TVM代码库。这意味着

1)使用代码源的TVM代码库将在上游兼容

2)TVM用户可以根据需要选择启用代码源/runtime。

在本文的其余部分中,首先说明可能需要带有BYOC的TVM的情况,然后概述BYOC编译和runtime流程。然后,分步说明如何使用英特尔DNNL(又名MKL-DNN,OneDNN),作为运行示例,将供应商库或执行引擎与BYOC集成到TVM。

将ASIC加速器带入TVM

首先,做一个场景来说明,为什么要将加速器引入TVM,以及BYOC框架可以期待哪些功能。

想象一下,刚刚构建了一个具有ARM CPU和出色的加速器的边缘设备平台,该平台为常见的图像分类模型,提供了出色的性能。换句话说,加速器在Conv2D,ReLU,GEMM和其他广泛使用的CNN算子上表现良好。

不幸的是,目标检测模型也越来越受欢迎,并且客户需要在平台上同时运行图像分类和目标检测模型。尽管加速器能够执行目标检测模型中的几乎所有算子,但缺少一个算子(例如,非最大抑制,NMS)。

让TVM执行不受支持的算子

由于TVM具有用于不同后端的多个代码源,开源社区很容易在短时间内在CPU或GPU上实现新的算子。理想情况下,如果将加速器的编译流程与BYOC集成到TVM,TVM将执行Relay图分区,以将部分图卸载到加速器,而其它图保持在TVM上。因此,可以申明平台能够运行所有模型,而不必担心新的算子。

自定义图形级优化

ASIC加速器必须具有编译流程。可能是以下情况之一:

生成图形表示并将其提供给图形引擎:可能拥有图形引擎,该引擎能够在加速器上执行图形(或神经网络模型)。例如,英特尔DNNL和NVIDIA TensorRT都使用引擎来运行整个图形或模型,因此能够1)减少算子之间的内存事务,以及2)通过算子融合优化图形执行。

为了实现以上两个优化,需要在编译期间处理图形。例如,Conv2D和偏差加法是TVM中的两个单独的算子,可能是加速器上的一个算子(具有偏差加法功能的Conv2D)。在这种情况下,需要通过将conv2d - add图形模式替换为your_conv2d_with_bias节点来优化图形。

如果编译流程属于这种情况,建议阅读本文中的所有其余部分,但跳过将DNNL带到TVM:C源代码生成。

生成汇编代码并将其编译为可执行的二进制文件:如果没有像前面那样的平台的端到端执行框架,则可能有编译器以ISA的汇编代码编译程序。为了将汇编代码提供给编译器,将需要一个代码生成器来从Relay图生成和优化汇编代码。

如果编译流程属于这种情况,建议阅读本文中的所有其余部分,但跳过将DNNL引入TVM:JSON Codegen / Runtime。

BYOC的工作方式

简要解释BYOC框架是如何工作的。简而言之,给定图1中的Relay图,BYOC框架执行以下步骤:

图1:原始Relay图。

1.图注解

制作用户提供的Relay图,第一步是在图中注释可能卸载到加速器的节点。需要遵循“将DNNL引入TVM:注释规则”,实现受支持的算子的白名单,或定制组合算子的图形模式列表。示例注释结果如图2所示。

图2:带注解的图。

2.图变换

第二步是基于注释对图形进行转换和优化。具体来说,BYOC执行以下转换。

2.1:合并编译器区域:如图2所示,图中现在有许多“区域”可以卸载到加速器中,实际上可以合并其中的一些区域,减少数据传输和内核启动开销。因此,步骤2.1使用贪婪算法来合并尽可能多的那些区域,同时保证功能正确性。结果如图3所示。

图3:合并编译器区域后。

2.2:分区图:对于上一步中的每个区域,创建一个带有属性的Relay函数,Compiler以指示该Relay函数应该完全卸载到加速器上,如图4所示。

图4:图分区之后。

3.代码生成

现在知道应该卸载Relay图的哪一部分了。将每个Relay功能依次发送Compiler=your_accelerator到代码生成器。代码生成器应将Relay函数编译为与编译流程相匹配的形式。可以是C源代码或任何文本格式。

最后,所有已编译的函数将与其它未卸载的Relay函数一起.so由TVM export_libraryPython API序列化为单个文件。换句话说,.so运行此流程后,用户将仅获得一个文件。

4.runtime

需要实现Runtime以初始化图形引擎(如果适用)并执行已编译的函数。在推理期间,当TVMRuntime遇到图4中的相应函数调用时,TVM Runtime(即图形Runtime或VM)将利用Runtime来调用已卸载的函数。Runtime负责使用给定的输入张量启动编译后的函数。数组并将结果填充到输出张量数组中。

在本文的其余部分,以DNNL为例,演示如何使用BYOC框架实现上述工作流程。本文中所有引用的代码和行号均基于TVM存储库的master分支commit 8a0249c。

将DNNL带到TVM:注释规则

BYOC框架提供了两种描述受支持的算子和模式的方法,可以同时使用。以DNNL为例,说明如何使用。将代码源的注释规则放在下python/tvm/relay/op/contrib/your_codegen_name.py。

单一运营商规则

可以使用BYOC API直观地指定加速器支持哪些Relay算子。例如,使用以下代码段构建一条规则,该规则说DNNL代码源支持Conv2D:

@tvm.ir.register_op_attr("nn.conv2d", "target.dnnl")

def _dnnl_conv2d_wrapper(attrs, args):

return True

这target.dnnl将向Relaynn.conv2d算子注册一个新属性。通过这种方式,BYOC注释可以target.dnnl()为图中的每个算子调用以检查DNNL代码源中是否支持。

另一方面,为每个算子编写上面的代码段可能很繁琐。对于DNNL实施,实现了一个辅助函数_register_external_op_helper,更简洁:

def _register_external_op_helper(op_name, supported=True):

@tvm.ir.register_op_attr(op_name, "target.dnnl")

def _func_wrapper(attrs, args):

return supported

return _func_wrapper

_register_external_op_helper("nn.batch_norm")

_register_external_op_helper("nn.conv2d")

_register_external_op_helper("nn.dense")

_register_external_op_helper("nn.relu")

_register_external_op_helper("add")

_register_external_op_helper("subtract")

_register_external_op_helper("multiply")

在上面的示例中,指定了DNNL代码源可以支持的算子列表。

图形模式规则

加速器或编译器可能已将某些模式(例如Conv2D + add + ReLU)优化为单个指令或API。在这种情况下,可以指定从图形模式到指令/ API的映射。对于DNNL,Conv2D API已经包含了偏差加法,并且允许连接下一个ReLU,可以将DNNL称为以下代码片段:

DNNLConv2d(const bool has_bias = false, const bool has_relu = false) {

// ... skip ...

auto conv_desc = dnnl::convolution_forward::desc(

dnnl::prop_kind::forward_inference,

dnnl::algorithm::convolution_direct,

conv_src_md, conv_weights_md, conv_bias_md, conv_dst_md,

strides_dims, padding_dims_l, padding_dims_r);

// Attach ReLU

dnnl::primitive_attr attr;

if (has_relu) {

dnnl::post_ops ops;

ops.append_eltwise(1.f, dnnl::algorithm::eltwise_relu, 0.f, 0.f);

attr.set_post_ops(ops);

}

auto conv2d_prim_desc = dnnl::convolution_forward::primitive_desc(

conv_desc, attr, engine_);

// ... skip ...

在这种情况下,除了用于单个conv2d,想映射图模式conv2d+relu到DNNLConv2d(false, true),并映射conv2d+add+relu到DNNLConv2d(true, true)。可以使用以下代码片段实现此目的:

def make_pattern(with_bias=True):

data = wildcard()

weight = wildcard()

bias = wildcard()

conv = is_op('nn.conv2d')(data, weight)

if with_bias:

conv_out = is_op('add')(conv, bias)

else:

conv_out = conv

return is_op('nn.relu')(conv_out)

@register_pattern_table("dnnl")

def pattern_table():

conv2d_bias_relu_pat = ("dnnl.conv2d_bias_relu", make_pattern(with_bias=True))

conv2d_relu_pat = ("dnnl.conv2d_relu", make_pattern(with_bias=False))

dnnl_patterns = [conv2d_bias_relu_pat, conv2d_relu_pat]

return dnnl_patterns

在DNNL示例中,实现了两个具有不同名称的模式,以便可以轻松地在代码生成中识别。注意,这些模式以Relay模式语言实现。

使用模式表,然后可以使用从Relay传递来执行

%1 = nn.conv2d(%data, %weight, ...)

%2 = add(%1, %bias)

%3 = nn.relu(%2)

%1 = fn(%input1, %input2, %input3,

Composite="dnnl.conv2d_bias_relu",

PartitionedFromPattern="nn.conv2d_add_nn.relu_") {

%1 = nn.conv2d(%input1, %input2, ...)

%2 = add(%1, %input3)

nn.relu(%2)

}

%2 = %1(%data, %weight, %bias)

因此,DNNL代码生成器可以获取模式名称conv2d_bias_relu并映射%1到DNNLConv2d(true, true)。

复合函数中还有一个名为“ PartitionedFromPattern”的属性。如果模式包含wildcard算子,这可能会有所帮助。例如,可能有一个模式表("conv2d_with_something", conv2d -> *):

def make_pattern(with_bias=True):

data = wildcard()

weight = wildcard()

conv = is_op('nn.conv2d')(data, weight)

return wildcard()(conv)

在这种情况下,将获得带有的复合函数Composite=conv2d_with_something,但是不知道实际匹配的图形。那就是PartitionedFromPattern起作用的地方。通过查看匹配图是否为conv2d -> add或conv2d -> relu,可以知道是否PartitionedFromPattern为nn.conv2d_add_或nn.conv2d_nn.relu_。

将DNNL引入TVM:Relay图转换

利用上一步中的注释规则,现在可以应用BYOCRelay传递列表,以将Relay图从图1转换为图4:

mod = create_relay_module_from_model() # Output: Figure 1

mod = transform.MergeComposite(pattern_table)(mod)

mod = transform.AnnotateTarget(["dnnl"])(mod) # Output: Figure 2

mod = transform.MergeCompilerRegions()(mod) # Output: Figure 3

mod = transform.PartitionGraph()(mod) # Output: Figure 4

可以看出,每个Relay传递都可以映射到在BYOC工作原理中引入的步骤。

将DNNL引入TVM:JSON代码生成/Runtime

现在,实现将Relay图序列化为JSON表示的DNNL代码源,然后实现DNNL JSONRuntime以反序列化并执行该图。如果尝试实现一个代码生成器来生成C兼容程序,则可能需要直接进入下一部分。

为了使DNNL JSON的代码生成/运行在TVM就这个例子中工作,确保DNNL可以在机器上,并与建立TVMset(USE_DNNL_CODEGEN ON)中config.cmake。

DNNL代码生成在中实现src/relay/backend/contrib/dnnl/codegen.cc。在此文件中以两种形式实现了DNNLUSE_JSON_RUNTIME代码生成,在跟踪代码时,可以专注于宏所覆盖的部分。

首先使用TVM注册API(L510),注册代码源。该注册使TVM编译引擎使用Compiler=<your codegen> 来分发Relay功能relay.ext.<your codegen>。然后,实现DNNL编译器(L490)的入口函数。阅读代码段中嵌入的注释以获取详细信息:

runtime::Module DNNLCompiler(const ObjectRef& ref) {

// "ref" should be the paritioned Relay function with kCompiler=dnnl.

CHECK(ref->IsInstance<FunctionNode>());

auto func = Downcast<Function>(ref);

// Get the function name as the symbol to match in runtime.

auto func_name = GetExtSymbol(func);

// Serialize the function to a JSON string (introduce later).

DNNLJSONSerializer serializer(func_name, func);

serializer.serialize();

std::string graph_json = serializer.GetJSON();

// The constant tensor names that have been bound to the module.

// All constant tensors will be serialzied along with the JSON graph

// when export_library is invoked.

auto params = serializer.GetParams();

// The function to create DNNL JSON runtime (introduce later).

const auto* pf = runtime::Registry::Get("runtime.DNNLJSONRuntimeCreate");

CHECK(pf != nullptr) << "Cannot find JSON runtime module to create";

// Create a DNNL runtime module that can run the serialized function.

auto mod = (*pf)(func_name, graph_json, params);

return mod;

}

TVM_REGISTER_GLOBAL("relay.ext.dnnl").set_body_typed(DNNLCompiler);

注意,每个Runtime模块仅负责一个Relay功能,这意味着可能在单个.so文件中包含多个DNNLRuntime模块。

DNNL JSON序列化

接下来,实现DNNL JSON序列化器(L429)。从BYOC JSON代码生成器(src / relay / backend / contrib / codegen_json / codegen_json.h)派生了它。DNNL JSON序列化程序中的特殊过程尝试,将组合函数调用序列化为DNNL JSON Runtime,可以解释的JSON节点。假设有一个与pattern匹配的复合函数dnnl.conv2d_relu,那么BYOC JSON代码生成器将生成以下JSON节点:

{

op: "kernel",

name: "dnnl.conv2d_relu",

inputs: [[0, 0, 0], [1, 0, 0]],

attrs: {

PartitionedFromPattern: ["nn.conv2d_nn.relu_"],

shape: [1, 32, 14, 14]

}

}

问题在于,在Runtime仍然需要Conv2D属性,例如padding和stride,但是BYOC JSON序列化器仅附加复合函数的属性,而不附加主体算子。另一方面,定制的DNNL JSON序列化程序将第一个也是唯一的Conv2D的属性附加到复合函数中,以生成以下JSON节点:

{

op: "kernel",

name: "dnnl.conv2d_relu",

inputs: [[0, 0, 0], [1, 0, 0]],

attrs: {

shape: [1, 32, 14, 14],

data_layout: ["NCHW"],

kernel_layout: ["OIHW"],

strides: [1, 1],

padding: [1, 1, 1, 1]

}

}

从DNNL JSON序列化器可以看出,可以自定义序列化器以生成JSON中的任何形式,只要JSON Runtime可以解释它们即可。

DNNL JSON Runtime

然后,实现DNNL JSON Runtime以解释和执行序列化的JSON图。放在下面src/runtime/contrib/dnnl/dnnl_json_runtime.cc。

同样,首先注册两个API来创建Runtime,以便可以在任何地方使用。在runtime.DNNLJSONRuntimeCreate被序列化后的上一部分中使用,并且runtime.module.loadbinary_dnnl_json装载时也可以使用.so了。

// Create a DNNL JSON runtime to interpret and execute the given JSON graph.

runtime::Module DNNLJSONRuntimeCreate(String symbol_name, String graph_json,

const Array<String>& const_names) {

auto n = make_object<DNNLJSONRuntime>(symbol_name, graph_json, const_names);

return runtime::Module(n);

}

TVM_REGISTER_GLOBAL("runtime.DNNLJSONRuntimeCreate")

.set_body_typed(DNNLJSONRuntimeCreate);

TVM_REGISTER_GLOBAL("runtime.module.loadbinary_dnnl_json")

.set_body_typed(JSONRuntimeBase::LoadFromBinary<DNNLJSONRuntime>);

现在,解释DNNL JSON Runtime实现。基本的类结构为:

class DNNLJSONRuntime : public JSONRuntimeBase {

const  char* type_key() const { return  "dnnl_json"; }

void Init(const Array<NDArray>& consts) override {

// Initialize the DNNL graph engine.

BuildEngine();

// Setup constants entries for weights.

CHECK_EQ(consts.size(), const_idx_.size())

<< "The number of input constants must match the number of required.";

SetupConstants(consts);

}

void Run() override {

// 1. Fill in the input buffers.

// 2. Invoke the engine through intepreting the stream.

// 3. Read and fill output buffers.

}

}

该Init功能是负责通过解释JSON图形字符串,建设DNNL引擎(见L93的BuildEngine),并填补了固定的权重,以相应的数据输入缓冲区(SetupConstant在JSON运行基类来实现,需要调用它在Init)。注意,即使运行了多次推理,该函数也只会被调用一次。

接下来,Run函数(L64)首先将输入张量(可能来自用户输入或恒定权重)写入在构建DNNL引擎时初始化的相应DNNL存储缓冲区。然后启动DNNL引擎以执行JSON图。最后,将DNNL输出存储缓冲区写回到相应的输出张量。

由于DNNL JSONRuntime中的其余实现都是DNNL特有的,不再细说。想强调一点,尽管DNNL JSONRuntime是一个很好的开始,但JSON Runtime可以完全自定义以满足要求。

将DNNL带到TVM:C源代码生成

现在,让实现DNNL代码生成器,该代码生成器生成C源代码,该源代码调用DNNL API来执行Relay图。注意,如果尝试实现一个代码生成器,生成其它图形表示形式(如JSON格式),则可能需要阅读将DNNL带到TVM:JSON代码生成器/Runtime,并跳过本节。

为了能够在TVM CODEGEN对这个例子的工作DNNL C源代码,确保DNNL可以在机器上,并与建立TVMset(USE_DNNL_CODEGEN C_SRC)中config.cmake。

DNNL代码生成在中实现src/relay/backend/contrib/dnnl/codegen.cc。由于在这个文件用于说明目的实现的代码,生成DNNL两种形式,可以专注于部分不被覆盖USE_JSON_RUNTIME宏跟踪代码时。

首先,使用TVM注册API(L510)注册代码源。该注册使TVM编译引擎使用Compiler=<your codegen> 来分发Relay功能relay.ext.<your codegen>。然后,实现DNNL编译器的入口函数(L490):

runtime::Module DNNLCompiler(const ObjectRef& ref) {

DNNLModuleCodegen dnnl;

return dnnl.CreateCSourceModule(ref);

}

TVM_REGISTER_GLOBAL("relay.ext.dnnl").set_body_typed(DNNLCompiler);

注意,每个Runtime模块仅负责一个Relay功能,这意味着可能在单个.so文件中包含多个DNNL Runtime模块。

然后,在L362中派生CSourceModuleCodegenBase实施。而负责其它模块级过程,如序列化的,只需要实现在所述DNNL代码生成函数(L389):DNNLModuleCodegenCSourceModuleCodegenBaseCreateCSourceModule

runtime::Module CreateCSourceModule(const ObjectRef& ref) override {

// Include headers

// ...skip...

code_stream_ << "#include <dnnl/dnnl_kernel.h>\n";

// ...skip...

// "ref" should be the paritioned Relay function with kCompiler=dnnl.

CHECK(ref->IsInstance<FunctionNode>());

auto res = GenDNNLFunc(Downcast<Function>(ref));

// "code" is the generated C code with DNNL APIs.

std::string code = code_stream_.str();

// "res" is a tuple of constant weights (symbols, values).

// All constant tensors will be serialzied along with the generated C code

// when export_library is invoked.

String sym = std::get<0>(res);

Array<String> variables = std::get<1>(res);

// Create a CSource module with all above artifacts.

const auto* pf = runtime::Registry::Get("runtime.CSourceModuleCreate");

CHECK(pf != nullptr) << "Cannot find csource module to create the external runtime module";

return (*pf)(code, "c", sym, variables);

}

接下来,实现GenDNNLFunc(L365)来使用DNNL API生成可编译的C代码,如下所示。参阅嵌入的注释,以获取与TVM C源Runtime模块兼容的功能接口的说明。

// The example Relay graph: conv2d -> add -> relu.

#include <cstdint>

#include <cstdlib>

#include <cstring>

#include <vector>

#include <tvm/runtime/c_runtime_api.h>

#include <tvm/runtime/container.h>

#include <tvm/runtime/packed_func.h>

#include <dlpack/dlpack.h>

#include <dnnl/dnnl_kernel.h>

using namespace tvm::runtime;

using namespace tvm::runtime::contrib;

// Execute the conv2d->add->relu graph with DNNL.

extern "C" void dnnl_0_(float* dnnl_0_i0, float* dnnl_0_i1,

float* dnnl_0_i2, float* out0) {

// Allocate intermediate buffers.

float* buf_0 = (float*)std::malloc(4 * 4608);

float* buf_1 = (float*)std::malloc(4 * 4608);

float* buf_2 = (float*)std::malloc(4 * 4608);

// Pre-implemented op-based DNNL functions.

dnnl_conv2d(dnnl_0_i0, dnnl_0_i1, buf_0, 1, 32, 14, 14, 32, 1, 0, 0, 3, 3, 1, 1);

dnnl_add(buf_0, dnnl_0_i2, buf_1, 1, 32, 12, 12);

dnnl_relu(buf_1, buf_2, 1, 32, 12, 12);

// Copy the final output to the corresponding buffer.

std::memcpy(out0, buf_2, 4 * 4608);

std::free(buf_0);

std::free(buf_1);

std::free(buf_2);

}

// The wrapper function with all arguments in DLTensor type.

extern "C" int dnnl_0_wrapper_(DLTensor* arg0,

DLTensor* arg1,

DLTensor* arg2,

DLTensor* out0) {

// Cast all DLTensor to primitive type buffers and invoke the above

// execution function.

dnnl_0_(static_cast<float*>(arg0->data),

static_cast<float*>(arg1->data),

static_cast<float*>(arg2->data),

static_cast<float*>(out0->data));

return 0;

}

// The TVM macro to generate TVM runtime compatible function "dnnl_0"

// from our generated "dnnl_0_wrapper_".

TVM_DLL_EXPORT_TYPED_FUNC(dnnl_0, dnnl_0_wrapper_);

注意,预先实现的基于op的DNNL函数位于src / runtime / contrib / dnnl / dnnl.cc中。

由于本文中的其余实现src/relay/backend/contrib/dnnl/codegen.cc都过于DNNL而无法在本文中进行详细介绍。主要思想是实现一个Relay图访问者(L138),访问给定的Relay函数并生成上面的C代码。只要代码生成器能够生成与TVM Runtime兼容的C代码,就可以完全自定义代码生成器以符合要求。

C源代码编译

可能已经注意到,输出的DNNLCompiler是一个带有生成的C代码的文本格式的模块,该模块尚未被编译gcc为可执行二进制文件。实际上,生成的C代码将在用户调用时进行编译export_libray(mod),如以下代码片段所示:

def update_lib(lib):

# Include the path of src/runtime/contrib/dnnl/dnnl.cc

test_dir = os.path.dirname(os.path.realpath(os.path.expanduser(__file__)))

source_dir = os.path.join(test_dir, "..", "..", "..")

contrib_path = os.path.join(source_dir, "src", "runtime", "contrib")

# Setup the gcc flag to compile DNNL code.

kwargs = {}

kwargs["options"] = ["-O2", "-std=c++14", "-I" + contrib_path]

tmp_path = util.tempdir()

lib_name = 'lib.so'

lib_path = tmp_path.relpath(lib_name)

# The generated C code with DNNL APIs is compiled to a binary lib.so.

lib.export_library(lib_path, fcompile=False, **kwargs)

# Load the lib.so back to a runtime module.

lib = runtime.load_module(lib_path)

return lib

with tvm.transform.PassContext(opt_level=3):

json, lib, param = relay.build(mod, target=target, params=params)

lib = update_lib(lib)

rt_mod = tvm.contrib.graph_runtime.create(json, lib, ctx)

将DNNL引入TVM:使用DNNL Codegen / Runtime构建TVM

最后,在构建TVM时创建cmake / modules / contrib / DNNL.cmake,包含DNNL代码源。出于演示目的,DNNL代码生成器在同一cmake文件中具有两个实现。只能根据需要专注于其中之一。

在准备好cmake文件之后,现在用户可以set(USE_DNNL_CODEGEN ON)在其中指定build/config.cmake启用DNNL代码生成。

TVM代码生成codegen的相关教程结束。

《TVM代码生成codegen.doc》

下载本文的Word格式文档,以方便收藏与打印。