TVM Relay与Pass
TVM Relay与Pass
本文介绍TVM的Relay,如何基于Relay构建一个Conv+BN+ReLU的小网络, TVM中的Pass的工作机制,并较为详细的介绍了RemoveUnusedFunctions,ToBasicBlockNormalForm,EliminateCommonSubexpr三种Pass。其中Relay部分的详细介绍大部分引用自官方文档:
https://tvm.apache.org/docs/tutorials/get_started/introduction.html。
TVM是如何将ONNX前端模型转换为IR Module的,并且还剖析了TVM中的Relay算子和TOPI算子的扭转过程,知道了Relay算子的最终计算也是基于TOPI算子集合完成的。然后在基于ONNX模型结构了解TVM的前端示例程序中,还有一个很重要的细节即TVM的编译流程,没有详细介绍,即下面这几行代码:
######################################################################
# Relay Build
# -----------
# Compile the graph to llvm target with given input specification.
target = "llvm"
target_host = "llvm"
dev = tvm.cpu(0)
with tvm.transform.PassContext(opt_level=3):
lib = relay.build(mod, target=target, target_host=target_host, params=params)
这几行代码展示了TVM的编译流程,在编译流程里面不仅包含了基于Relay IR进行的优化策略,去除冗余的算子(也叫Pass),还包含了将Relay程序编译成特定后端(这里是llvm),可以执行的代码(codegen)。
本文将简单介绍一下Relay,然后再认识一下TVM中的Pass,解释with
tvm.transform.PassContext(opt_level=3)
类,具体完成了什么工作。至于code gen和详细的编译流程,由于TVM的水太深,下次再探索吧。
Relay介绍
这一节主要结合TVM的文档(https://tvm.apache.org/docs/dev/relay_intro.html
)来介绍一下NNVM的第二代Relay。Relay的设计目标有以下几点:
- 支持传统的数据流(DataFlow)风格编程。
- 支持functional-style scoping,并融合了编程语言领域的一些知识,带了一些新的特性(支持Let表达式,支持递归等等)
- 支持数据流风格和函数式风格混合编程。
使用Relay建立一个计算图
传统的深度学习框架使用计算图作为中间表示。计算图(或数据流图)是代表计算过程的有向无环图(DAG)。尽管由于缺少控制流,数据流图在计算能力方面受到限制,但简单性使其易于实现自动微分并针对异构执行环境进行编译(例如,在专用硬件上执行计算图的某些部分,即子图)。
使用Relay构建一个简单的计算图示例代码,以及对应的文本形式和AST抽象语法树
可以使用Relay来构建一个计算(DataFlow)图。具体来说,上面的代码显示了如何构造一个简单的两个节点的计算图,可以发现示例的代码和现有的Garph IR如NNVMv1没有太大区别,唯一的区别是在术语方面:
- 现有框架通常使用图和子图
- Relay使用函数,例如 – fn(%x),表示图
每个数据流节点都是Relay中的一个CallNode。通过Relay的Python DSL,可以快速构建计算图。在上面的代码,显示构造了一个Add节点,两个输入都指向%1。当一个深度学习框架对上面的计算图进行推理时,将会按照拓扑序进行计算,并且%1只会被计算一次。虽然事实对于深度学习框架的开发者来说,一件很自然的事情,只关心算法的研究员困惑。如果实现一个简单的vistor,打印结果并将结果视为嵌套的Call表达式,将是log(%x) + log(%x)。
当DAG中存在共享节点时,这种歧义是由程序语义的解释不同而引起的。在正常的函数式编程IR中,嵌套表达式被视为表达式树,并没有考虑%1实际上在%2中被重用了2次的事实。
Relay IR注意到了区别。其实深度学习框架用户经常使用这种方式构建计算图,其中经常发生DAG节点重用。然后当以文本格式打印Relay程序时,每行打印一个CallNode,并为每个CallNode分配一个临时ID(%1, %2),以便可以在程序的后续部分中引用每个公共节点。
Module:支持多个函数(Graphs)
上面介绍了如何构建一个数据流图为一个函数。然后一个很自然的问题,可以做到构建多个函数并相互调用吗?Relay允许将多个函数组合在一个Module中,下面的代码展示了一个函数调用另外一个函数的例子。
def @muladd(%x, %y, %z) {
%1 = mul(%x, %y)
%2 = add(%1, %z)
%2
}
def @myfunc(%x) {
%1 = @muladd(%x, 1, 2)
%2 = @muladd(%1, 2, 3)
%2
}
Module可以被看作Map<GlobalVar, Function>
,其中GlobalVar仅仅是一个表示函数名的ID,上面的程序中GlobalVar是@muladd
和@myfunc
。当一个CallNode被用来调用另外一个函数时,相应的GlobalVar被存在CallNode的OP中。包含了一个间接的等级关系---需要使用相应的GlobalVar,从Module中查找被调用函数的主体。在这种情况下,也可以直接将引用的函数存储为CallNode中的OP。那么,为什么需要引入GlobalVar呢?主要原因是为了解耦定义和声明,并支持了函数的递归和延迟声明。
def @myfunc(%x) {
%1 = equal(%x, 1)
if (%1) {
%x
} else {
%2 = sub(%x, 1)
%3 = @myfunc(%2)
%4 = add(%3, %3)
%4
}
}
在上面的例子中,@myfunc递归调用。使用GlobalVar @myfunc来表示函数,避免了数据结构中的循环依赖性。至此,已经介绍完了Relay中的基本概念。值得注意的是,相比NNVM,Relay在如下方面进行了改进:
- 有文本形式中间表示,便于开发和 debug
- 支持子图函数、联合模块,便于联合优化
- 前端用户友好,便于调优
Let Binding and Scopes
至此,已经介绍了如何用深度学习框架中的旧方法来构建计算图。这一节将讨论一个Relay的一个新的构造-let bindings。
Let binding被每一种高级的编程语言应用。在Relay中,一个拥有三个字段Let(var, value, body)
的数据结构。当计算一个Let表达式时,首先计算value部分,然后将其绑定到var,最后在body表达式中返回计算结果。
可以使用一系列的Let绑定,构造一个逻辑上等效于数据流程序的程序,下面的代码示例显示了用法:
嵌套的Let Binding被称作A-normal形式,作为函数式编程语言中的常用IR。通过上面的图可以发现,虽然这两个程序的语义完全等价,文本表示也一样(除了A-norm形式有let的前缀),但AST抽象语法树却不一样。
由于程序的优化,使用了这些AST数据结构,并对其进行了变换,这两种不同的结构,会影响到最终编译器生成的代码。比如,想要检测add(log(x), y)
模式。在数据流程序中,可以首先进入add节点,然后直接检查它的第一个参数是不是log。而在A-form的程序中,不能直接检查任何东西,因为add节点的输入是%v1
-需要维护一个映射表,将变量和绑定的值进行映射,然后查表才知道%v1
代表的是log。
可能需要Let Binding
Let Binding的一种关键用法是它可以指定计算的scope。看一下下面没有使用Let Binding的例子:
当尝试在该在哪里计算%1
节点时,问题就来了。特别的是,虽然文本格式似乎建议应该在if的scope之外,计算节点%1
,但AST却不建议这样做。实际上数据流图永远不会定义计算scope,这在语义上产生了一些歧义。
当有闭包时,这种歧义更加有趣,考虑下面的程序,该程序返回一个闭包。不知道在哪里计算%1
,可以在闭包的内部和外部。
fn (%x) {
%1 = log(%x)
%2 = fn(%y) {
add(%y, %1)
}
%2
}
Let Binding解决了这些问题,值的计算发生在let节点上。在这两个程序中,如果将%1 = log(%x)
改成let %v1 = log(%x)
,将计算位置明确指定为if scope和闭包之外。可以看到,Let Binding为计算端提供了更精确的范围,并且,在生成后端代码时会很有用(因为这种范围在IR中)。
另一方面,没有指定计算scope的数据流形式,也有其自身的优势,不需要担心在生成代码时,将let放到哪里。数据流格式还为后面决定将计算节点放到哪里的Passes,提供了更大的自由度。因此,在优化的初始阶段,如果发现数据流形式还是挺方便的,那么,使用数据流图的编码方法可能不是一个坏主意。目前在Relay中,也实现了很多针对数据流图的优化方式。
但是,当将IR lower到实际的运行时程序时,需要精确的计算scope。特别是当使用子函数和闭包时,要明确指定计算scope应在哪里发生。在后期执行特定的优化中,可以使用Let Binding来解决此问题。
对IR转换的影响
希望到目前为止,已经熟悉两种表示形式。大多数函数式编程语言都以A-normal形式进行分析,分析人员无需注意表达式是DAG。
Relay选择同时支持数据流形式和Let Binding。TVM相信让框架开发者选择熟悉的表达形式很重要。但是这确实对写通用的Passes产生了一些影响。由于这里还没介绍Passes,以及对Passes理解不深并且我没有使用过Let表达式来构建网络,就不继续介绍具体有哪些影响了。
详细内容可以参考:https://tvm.apache.org/docs/dev/relay_intro.html#let-binding-and-scopes
基于Relay构建一个自定义的神经网络示例
基于Relay的接口定义一个Conv+BN+ReLU的小网络,展示一下Relay接口应该如何使用,这里TVM版本是0.8.0.dev,代码如下:
#coding=utf-8
import tvm
from tvm import relay
import numpy as np
from tvm.contrib import graph_executor
# 构造BN
def batch_norm(data,
gamma=None,
beta=None,
moving_mean=None,
moving_var=None,
**kwargs):
name = kwargs.get("name")
kwargs.pop("name")
if not gamma:
gamma = relay.var(name + "_gamma")
if not beta:
beta = relay.var(name + "_beta")
if not moving_mean:
moving_mean = relay.var(name + "_moving_mean")
if not moving_var:
moving_var = relay.var(name + "_moving_var")
return relay.nn.batch_norm(data,
gamma=gamma,
beta=beta,
moving_mean=moving_mean,
moving_var=moving_var,
**kwargs)[0]
# 构造卷积
def conv2d(data, weight=None, **kwargs):
name = kwargs.get("name")
kwargs.pop("name")
if not weight:
weight = relay.var(name + "_weight")
return relay.nn.conv2d(data, weight, **kwargs)
# 构造卷积+BN+ReLU的simpleNet
def simplenet(data, name, channels, kernel_size=(3, 3), strides=(1, 1),
padding=(1, 1), epsilon=1e-5):
conv = conv2d(
data=data,
channels=channels,
kernel_size=kernel_size,
strides=strides,
padding=padding,
data_layout='NCHW',
name=name+'_conv')
bn = batch_norm(data=conv, epsilon=epsilon, name=name + '_bn')
act = relay.nn.relu(data=bn)
return act
data_shape = (1, 3, 224, 224)
kernel_shape = (32, 3, 3, 3)
dtype = "float32"
data = relay.var("data", shape=data_shape, dtype=dtype)
act = simplenet(data, "graph", 32, strides=(2, 2))
func = relay.Function(relay.analysis.free_vars(act), act)
print(func)
np_data = np.random.uniform(-1, 1, (1, 3, 224, 224))
params = {
"graph_conv_weight": tvm.nd.array(np.random.uniform(-1, 1, (32, 3, 3, 3)).astype(dtype)),
"graph_bn_gamma": tvm.nd.array(np.random.uniform(-1, 1, (32)).astype(dtype)),
"graph_bn_beta": tvm.nd.array(np.random.uniform(-1, 1, (32)).astype(dtype)),
"graph_bn_moving_mean": tvm.nd.array(np.random.uniform(-1, 1, (32)).astype(dtype)),
"graph_bn_moving_var": tvm.nd.array(np.random.uniform(-1, 1, (32)).astype(dtype)),
}
with tvm.transform.PassContext(opt_level=3):
lib = relay.build(func, "llvm", params=params)
dev = tvm.cpu(0)
dtype = "float32"
m = graph_executor.GraphModule(lib["default"](dev))
# set inputs
m.set_input("data", tvm.nd.array(np_data.astype(dtype)))
# execute
m.run()
# get outputs
tvm_output = m.get_output(0)
创建Relay Function,然后将所有的OP的权重信息,用params
字典存起来,这里的权重信息是随机初始化的。在编译Relay IR之前,先看优化前的IR长什么样:
fn (%data: Tensor[(1, 3, 224, 224), float32], %graph_conv_weight, %graph_bn_gamma, %graph_bn_beta, %graph_bn_moving_mean, %graph_bn_moving_var) {
%0 = nn.conv2d(%data, %graph_conv_weight, strides=[2, 2], padding=[1, 1, 1, 1], channels=32, kernel_size=[3, 3]);
%1 = nn.batch_norm(%0, %graph_bn_gamma, %graph_bn_beta, %graph_bn_moving_mean, %graph_bn_moving_var);
%2 = %1.0;
nn.relu(%2)
}
Relay IR是一个函数。
初识Pass
在上面构造simplenet的代码中,relay.build
外部包了一层tvm.transform.PassContext
,如下:
with tvm.transform.PassContext(opt_level=3):
lib = relay.build(func, "llvm", params=params)
实际上tvm.transform.PassContext
接口,就定义了Pass,如文档所示:
tvm.transform.PassContext用来控制对relay IR使用哪些Pass进行优化
Pass是TVM中基于Relay IR进行的一系列优化,类似于onnx-simplifier里面用到的onnxoptimizer,可以简化计算图,去除一些冗余的算子,提高模型的推理效率。TVM将所有的pass都抽象到了tvm/include/tvm/ir/transform.h文件中,主要包含PassContext,PassInfo,Pass,以及Sequential。
这里的PassContext即是上面Python接口对应的C++实现,包含了Pass执行依赖的一些参数如优化level,依赖特定Pass以及设置不使用某种指定Pass等。PassInfo是用来记录Pass信息的类,包含Pass的opy_level,name,以及当前Pass需要哪些前置Pass。而Pass类就执行pass的主体,这是一个基类,每种Pass具体的C++代码实现在tvm/src/relay/transforms中,都会继承Pass基类。最后,Sequential是一个container,装载所有Pass。
需要说明一下,不是所有的Pass都定义在tvm/src/relay/transforms这里,比如,下面的第一个例子就在tvm/src/relay/backend/vm文件夹里。接下来将几个Pass的例子,看看到底对Relay IR做了什么?
- RemoveUnusedFunctions
定义在tvm/src/relay/backend/vm/removed_unused_funcs.cc,这里的RemoveUnusedFunctions pass,核心的代码实现如下:
void VisitExpr_(const FunctionNode* func_node) final {
auto func = GetRef<Function>(func_node);
if (visiting_.find(func) == visiting_.end()) {
visiting_.insert(func);
for (auto param : func_node->params) {
ExprVisitor::VisitExpr(param);
}
ExprVisitor::VisitExpr(func_node->body);
}
}
IRModule RemoveUnusedFunctions(const IRModule& module, Array<runtime::String> entry_funcs) {
std::unordered_set<std::string> called_funcs{};
for (auto entry : entry_funcs) {
auto funcs = CallTracer(module).Trace(entry);
called_funcs.insert(funcs.cbegin(), funcs.cend());
}
auto existing_functions = module->functions;
for (auto f : existing_functions) {
auto it = called_funcs.find(f.first->name_hint);
if (it == called_funcs.end()) {
module->Remove(f.first);
}
}
return module;
}
pass就是去除Relay IR中的冗余节点,VisitExpr_就是完成了一个图的遍历,把没有遍历到的节点删掉。删除发生在RemoveUnusedFunctions函数中。
- ToBasicBlockNormalForm
Pass实现在tvm/src/relay/transforms/to_basic_block_normal_form.cc,代码实现如下:
Expr ToBasicBlockNormalFormAux(const Expr& e) {
// calculate all the dependency between nodes.
support::Arena arena;
DependencyGraph dg = DependencyGraph::Create(&arena, e);
/* The scope of the whole expr is global.
* The scope of any subexpr, is the lowest common ancestor of all incoming edge.
* We also record the set of expressions whose scope is lifted.
*/
std::pair<NodeScopeMap, ExprSet> scopes = CalcScope(dg);
return Fill::ToBasicBlockNormalForm(e, dg, &scopes.first, &scopes.second);
}
IRModule ToBasicBlockNormalForm(const IRModule& mod) {
DLOG(INFO) << "ToBBlock:" << std::endl << mod;
tvm::Map<GlobalVar, Function> updates;
auto funcs = mod->functions;
for (const auto& it : funcs) {
ICHECK_EQ(FreeVars(it.second).size(), 0) << "Expected no free variables";
if (const auto* n = it.second.as<FunctionNode>()) {
if (n->GetAttr<String>(attr::kCompiler).defined()) continue;
}
Expr ret = TransformF([&](const Expr& e) { return ToBasicBlockNormalFormAux(e); }, it.second);
updates.Set(it.first, Downcast<Function>(ret));
}
for (auto pair : updates) {
mod->Add(pair.first, pair.second, true);
}
DLOG(INFO) << "ToBBlock: transformed" << std::endl << mod;
return mod;
}
bool BasicBlockNormalFormCheck(const Expr& e) {
// calculate all the dependency between nodes.
support::Arena arena;
DependencyGraph dg = DependencyGraph::Create(&arena, e);
std::pair<NodeScopeMap, ExprSet> scopes = CalcScope(dg);
for (auto expr : scopes.second) {
LOG(FATAL) << "The expression below violates the basic block normal form in that "
<< "its scope should be lifted:\n"
<< expr;
}
return scopes.second.size() == 0;
}
ToBasicBlockNormalForm通过遍历Relay IR中的function,将每个function转换为基本块形式(即ToBasicBlockNormalFormAux函数),ToBasicBlockNormalFormAux函数分成以下几个部分:
- 调用DependencyGraph dg = DependencyGraph::Create(&arena, e)创建一个DependencyGraph,数据结构是一个表达式相互依赖的图结构。
- 通过std::pair<NodeScopeMap, ExprSet> scopes = CalcScope(dg)计算每个节点的scope,scope可以简单理解为由跳转指令如Ifnode,FunctionNode,LetNode等隔开的那些子图,一旦碰到这些节点,通过Relay Function创建DependencyGraph,就会为这种节点分配一个new_scope标志。然后CalcScope函数具体做了哪些事情,需要跟进去看一下:
std::pair<NodeScopeMap, ExprSet> CalcScope(const DependencyGraph& dg) {
NodeScopeMap expr_scope;
ExprSet lifted_exprs;
std::unordered_map<DependencyGraph::Node*, Expr> node_to_expr;
// 首先让每个节点都属于一个单独的scope
for (auto expr_node : dg.expr_node) {
node_to_expr[expr_node.second] = expr_node.first;
}
bool global_scope_used = false;
Scope global_scope = std::make_shared<ScopeNode>();
// 使用LCA算法来更新每个节点的真正scope
for (auto it = dg.post_dfs_order.rbegin(); it != dg.post_dfs_order.rend(); ++it) {
DependencyGraph::Node* n = *it;
auto iit = n->parents.head;
Scope s;
if (iit == nullptr) {
ICHECK(!global_scope_used);
s = global_scope;
global_scope_used = true;
} else {
s = expr_scope.at(iit->value);
const auto original_s = s;
iit = iit->next;
for (; iit != nullptr; iit = iit->next) {
s = LCA(s, expr_scope.at(iit->value));
}
if (s != original_s && node_to_expr.find(n) != node_to_expr.end()) {
// filter out exprs whose scope do not matter
Expr expr = node_to_expr[n];
if (!expr.as<OpNode>()) {
lifted_exprs.insert(expr);
}
}
}
if (n->new_scope) {
auto child_scope = std::make_shared<ScopeNode>(s);
expr_scope.insert({n, child_scope});
} else {
expr_scope.insert({n, s});
}
}
ICHECK(global_scope_used);
return std::make_pair(expr_scope, lifted_exprs);
}
函数首先让每个节点都属于一个单独的scope,然后使用LCA算法来更新每个节点的真正scope。简单介绍一下LCA算法,如何求取每个节点的scope的。
最近公共祖先简称 LCA(Lowest Common Ancestor)。两个节点的最近公共祖先,就是这两个点的公共祖先里面,离根最远的那个。为了方便,记某点集S=v1, v2, …, vn, 的最近
其实不看性质也没关系,了解LCA可以求图中两个节点的最近公共祖先即可。然后CalcScope函数的具体思路,先将每个节点初始化为一个单独的scope,然后,按照后DFS序遍历这些节点,对于每一个遍历到的节点(这里记作n),父亲节点iit是否存在,如果不存在,则说明当前节点是根节点, scope应该为global_scope。如果iit存在,那么遍历iit的子节点,看一下这些节点的scope的LCA表达式,如果,通过LCA求出来的表达式和iit节点的表达式完全相同,说明子图和当前节点是属于同一个scope的,否则就将当前节点插入到lifted_exprs,lifted_exprs是一个集合用来保存DependencyGraph里面的那些跳转指令节点,这也是为什么上面再插入节点到lifted_exprs之前,需要判断一下节点的类型是否为OpNode。另外,如果当前枚举的节点有new_scope标志,说明当前节点属于一个新的scope,需要为当前节点分配新的类型为ScopeNode的一个智能指针。
通过上面的算法,DependencyGraph中的节点和scope节点的关系,映射到了一个map中,并且scope节点也被建立起了一个树结构。最后调用Fill::ToBasicBlockNormalForm(e, dg, &scopes.first, &scopes.second);来创建一个Fill类,类包含了DependencyGraph以及scope相关的信息,通过ToBasicBlockNormalForm成员函数实现基本块转换。实现在tvm/src/relay/transforms/to_a_normal_form.cc
对Pass解释,(ToBasicBlockNormalForm
)的基本逻辑,通过VisitExpr函数遍历dependency节点,将具有相同scope的节点。,压入到同一个let_list中。Let_list文档中是这样解释的:
/*!
* \file let_list.h
* \brief LetList record let binding and insert let expression implicitly.
* using it, one can treat AST as value instead of expression,
* and pass them around freely without fear of AST explosion (or effect duplication).
* for example, if one write 'b = a + a; c = b + b; d = c + c', the AST will contain 8 'a'.
* if one instead write 'b = ll.Push(a + a); c = ll.Push(b + b); d = ll.Get(c + c);',
* the AST will contain 2 'a', as b and c are now variables.
Let_list使得抽象语法树简洁化,不会因为变量的复制导致树的爆炸。具有相同的scope的expr,约束到相同的let_list中,用一个var来表达,这样,就将表达式转化为var的形式。一个var也就对应了一个基本块。
- EliminateCommonSubexpr
最后,再看一个消除公共子表达式的Pass,所谓公共子表达式,就是具有相同的OP类型以及相同的参数,并且,参数的顺序都是完全相同的,那么这些表达式就可以合成一个公共子表达式。举个例子:
a = b + cd = b + c
可以看到这两个表达式时完全一致的,那么经过Pass之后,计算图就会消除其中一个表达式。代码实现在:tvm/src/relay/transforms/eliminate_common_subexpr.cc。这里定义了一个CommonSubexprEliminator类,类重载了两个Rewrite_函数,对expr进行遍历和重写。代码实现如下:
Expr Rewrite_(const CallNode* call, const Expr& post) final {
static auto op_stateful = Op::GetAttrMap<TOpIsStateful>("TOpIsStateful");
Expr new_expr = post;
const CallNode* new_call = new_expr.as<CallNode>();
ICHECK(new_call);
const OpNode* op = new_call->op.as<OpNode>();
StructuralEqual attrs_equal;
if (new_call->args.size() == 0 || op == nullptr || op_stateful.get(GetRef<Op>(op), false)) {
return new_expr;
}
if (fskip_ != nullptr && fskip_(new_expr)) {
return new_expr;
}
auto it = expr_map_.find(new_call->op);
if (it != expr_map_.end()) {
for (const Expr& candidate_expr : it->second) {
if (const CallNode* candidate = candidate_expr.as<CallNode>()) {
bool is_equivalent = true;
// attrs匹配
if (!attrs_equal(new_call->attrs, candidate->attrs)) {
continue;
}
// args匹配
for (size_t i = 0; i < new_call->args.size(); i++) {
if (!new_call->args[i].same_as(candidate->args[i]) &&
!IsEqualScalar(new_call->args[i], candidate->args[i])) {
is_equivalent = false;
break;
}
}
if (!is_equivalent) continue;
return GetRef<Call>(candidate);
}
}
}
expr_map_[new_call->op].push_back(new_expr);
return new_expr;
}
利用expr_map_std::unordered_map<Expr,
std::vector<Expr>, ObjectPtrHash, ObjectPtrEqual> expr_map_;
映射遍历过的具有相同op的expr,然后每次碰到相同op的表达式,都会对已经记录的expr进行匹配,匹配不仅包含OP的attrs属性,还包含参数列表,如果完全一样,说明这两个表达式就是公共表达式,就不返回新的表达式。这样就可以去掉Relay Function中的公共表达式了。
最开始加载的那个simplenet的Relay Function经过一些Pass之后具体变成什么样,需要留到后面再解答。
0x5. 小结
本文介绍了一下TVM的Relay并介绍了如何基于Relay构建一个Conv+BN+ReLU的小网络,然后介绍了一下TVM中的Pass的工作机制,并较为详细的介绍了RemoveUnusedFunctions,ToBasicBlockNormalForm,EliminateCommonSubexpr三种Pass。其中Relay部分的详细介绍大部分引用自官方文档:https://tvm.apache.org/docs/tutorials/get_started/introduction.html。
参考链接
- https://zhuanlan.zhihu.com/p/358437531
- https://zhuanlan.zhihu.com/p/91283238
- https://tvm.apache.org/docs/tutorials/get_started/introduction.html
- https://mp.weixin.qq.com/s/5JAWE9RTTXwDJR5HqlsCzA