Relay 探究
1. 前言
Relay 是 TVM 中用来替代 NNVM 的模块,其本身被认为是 NNVM 第二代。在设计上,Relay 被认为相对 NNVM 有以下优势:
有文本形式中间表示,便于开发和 debug
支持子图函数、联合模块,便于联合优化
前端用户友好
其介绍信息可以在这里 找到。相比于最初的 NNVM ,Relay 融合进了编程语言领域的知识,带来了许多新的特色(如 let 表达式,支持递归等等),这也使得 Relay 既具有一个编程语言的特色,也具有常规深度学习网络构建的能力。关于其语言特色的介绍,将在后续文章中展现。本文的重点放在 Relay 如何构建神经网络计算图并在计算图上做图优化。
2. 使用
在构建神经网络的时候,Relay使用起来与 NNVM 非常相似,比如下面的例子就是一个Conv2d + BatchNorm + ReLU 的简单网络:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 def batch_norm_infer (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) def conv_block (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_infer(data=conv, epsilon=epsilon, name=name + '_bn' ) act = relay.nn.relu(data=bn) return act
为了运行上述网络,可以写下述代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 data_shape = (1 , 3 , 224 , 224 ) kernel_shape = (32 , 3 , 3 , 3 ) dtype = "float32" data = relay.var("data" , shape=data_shape, dtype=dtype) act = conv_block(data, "graph" , 32 , strides=(2 , 2 )) func = relay.Function(relay.ir_pass.free_vars(act), act) target = "cuda" ctx = tvm.context(target, 0 ) net = relay.ir_pass.infer_type(func) shape_dict = { v.name_hint : v.checked_type for v in net.params} params = {} for k, v in shape_dict.items(): if k == "data" : continue init_value = np.random.uniform(-1 , 1 , v.concrete_shape).astype(v.dtype) params[k] = tvm.nd.array(init_value, ctx=ctx) with relay.build_config(opt_level=3 ): graph, lib, params = relay.build(net, target, params=params) module = runtime.create(graph, lib, ctx) data_tvm = tvm.nd.array((np.random.uniform(-1 , 1 , size=data_shape)).astype(dtype)) module.set_input('data' , data_tvm) module.set_input(**params) module.run() output = module.get_output(0 )
这里面大部分代码都在做数据准备工作,我们关注的调用是relay.build
,这里面含有如何从 Relay 程序编译出可执行的代码的过程。再继续探究之前,我们先窥探一下优化前的 IR 的样子:
1 2 3 4 5 6 7 8 9 10 11 12 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 ], 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 %3 = nn.relu(%2 ) %3 }
3. 追踪 relay.build
3.1 Part I
与 NNVM 相似,Relay 先会寻找 AutoTVM 是否有预先 tune 好的参数记录,如果没有就使用 fallback 的 context:
1 2 3 4 5 6 7 if isinstance(autotvm.DispatchContext.current, autotvm.FallbackContext): if isinstance(target, dict): tophub_context = autotvm.tophub.context(list(target.values())) else : tophub_context = autotvm.tophub.context(target) else : tophub_context = autotvm.util.EmptyContext()
接下来所有的操作都在 tophub_context 的 scope 之下(with tophub_context:
)。值得一提的是 Relay 考虑了异构情景下的代码生成,用户可以指定多个生成代码的目标(target)。
3.2 Part II
下面进行目标无关优化:
1 func = optimize(func, target, params)
跟踪进 optimize 函数,首先进行的优化是:
1 2 3 if cfg.pass_enabled("SimplifyInference" ): func = ir_pass.infer_type(func) func = ir_pass.simplify_inference(func)
这里做的是针对 inference 情景下的特殊优化,回忆我们在对 NNVM 的探究 中,NNVM 在目标无关优化中第一步做的是 layout 的变换,而 Relay 则是调换了优化顺序,将 layout 变换放在了后面。
这一步做的优化仍然是将 BatchNorm 展开以及去掉 dropout,与 NNVM 是相似的。
优化后的 IR 为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 fn (%data: Tensor[(1 , 3 , 224 , 224 ), float32]) -> Tensor[(1, 32, 112, 112), float32] { %0 = meta[relay.Constant][0 ] %1 = nn.conv2d(%data, %0 , strides=[2 , 2 ], padding=[1 , 1 ], channels=32 , kernel_size=[3 , 3 ]) %2 = meta[relay.Constant][1 ] %3 = add(%2 , 1e-05 f) %4 = sqrt(%3 ) %5 = divide(1 f, %4 ) %6 = meta[relay.Constant][2 ] %7 = multiply(%5 , %6 ) %8 = expand_dims(%7 , axis=1 , num_newaxis=2 ) %9 = multiply(%1 , %8 ) %10 = meta[relay.Constant][3 ] %11 = negative(%10 ) %12 = multiply(%11 , %7 ) %13 = meta[relay.Constant][4 ] %14 = add(%12 , %13 ) %15 = expand_dims(%14 , axis=1 , num_newaxis=2 ) %16 = add(%9 , %15 ) %17 = nn.relu(%16 ) %17 }
其次进行的优化是去除公共子表达式:
1 2 3 4 5 6 7 8 9 if cfg.pass_enabled("EliminateCommonSubexpr" ): def fskip (expr) : if isinstance(expr, _expr.Call) and expr.op.name == 'cast' and \ expr.attrs.dtype == 'int32' : return True return False func = ir_pass.infer_type(func) func = ir_pass.eliminate_common_subexpr(func, fskip)
然后是分支卷积优化:
1 2 3 if cfg.pass_enabled("CombineParallelConv2D" ): func = ir_pass.infer_type(func) func = ir_pass.combine_parallel_conv2d(func)
这部分优化会将具有相同输入的卷积合并成一个大的卷积运算。
接着是常量传播优化,与 NNVM 相似:
1 2 3 4 5 6 7 8 9 if cfg.pass_enabled("FoldConstant" ): func = ir_pass.fold_constant(func) if cfg.pass_enabled("FoldScaleAxis" ): func = ir_pass.infer_type(func) func = ir_pass.backward_fold_scale_axis(func) func = ir_pass.infer_type(func) func = ir_pass.forward_fold_scale_axis(func) func = ir_pass.fold_constant(func)
常量传播优化后的 IR 是:
1 2 3 4 5 6 7 8 9 fn (%data: Tensor[(1 , 3 , 224 , 224 ), float32]) -> Tensor[(1, 32, 112, 112), float32] { %0 = meta[relay.Constant][0 ] %1 = nn.conv2d(%data, %0 , strides=[2 , 2 ], padding=[1 , 1 ], channels=32 , kernel_size=[3 , 3 ]) %2 = meta[relay.Constant][1 ] %3 = add(%1 , %2 ) %4 = nn.relu(%3 ) %4 }
下面是进行规范化,想法在于将一些特殊运算转换成等价的常规算子运算,便于后续的分析优化:
1 2 3 if cfg.pass_enabled("CanonicalizeOps" ): func = ir_pass.infer_type(func) func = ir_pass.canonicalize_ops(func)
其实 Relay 也只实现了将 bias_add 转换为 expand_dim + broadcast_add 这一种形式的转换。
最后是 layout 变换和常量传播:
1 2 3 4 5 6 7 8 9 10 11 if cfg.pass_enabled("AlterOpLayout" ): if isinstance(target, _target.Target): func = ir_pass.infer_type(func) with target: func = ir_pass.alter_op_layout(func) elif isinstance(target, dict): warnings.warn("AlterOpLayout pass is not enabled for heterogeneous" " execution yet." ) if cfg.pass_enabled("FoldConstant" ): func = ir_pass.fold_constant(func)
目前的实现里,layout 变换有些 bug ,不支持异构编译。
3.3 Part III
1 2 3 4 5 6 if isinstance(target, dict): func, target = _run_device_annotation_passes(func, target, fallback_device) func = ir_pass.infer_type(func) func = ir_pass.fuse_ops(func, cfg.opt_level)
接下来首先为异构编译执行一个指定 pass ,根据异构 target 改写计算图,在需要的位置插入数据搬移节点(Device Copy Node)。之后便是进行图融合优化。其优化内容几乎与 NNVM 一样,都是基于算子的 pattern (kElemWise, kBroadcast,kInjective, kCommReduce, kOutEWiseFusable, kOpaque)和融合规则 rule (kUknown, kFuseToMaster, kRealize)来运行融合算法的,可以参考上一篇关于NNVM的文章 ,这里不再赘述。
优化后的 IR 是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 fn (%data: Tensor[(1 , 3 , 224 , 224 ), float32]) -> Tensor[(1, 32, 112, 112), float32] { %0 = meta[relay.Constant][0 ] %1 = meta[relay.Constant][1 ] %2 = fn(%p0: Tensor[(1 , 3 , 224 , 224 ), float32], %p1: Tensor[(32 , 3 , 3 , 3 ), float32], %p2: Tensor[(32 , 1 , 1 ), float32]) -> Tensor[(1, 32, 112, 112), float32] { %3 = nn.conv2d(%p0, %p1, strides=[2 , 2 ], padding=[1 , 1 ], channels=32 , kernel_size=[3 , 3 ]) %4 = add(%3 , %p2) %5 = nn.relu(%4 ) %5 } %6 = %2 (%data, %0 , %1 ) %6 }
3.4 Part IV
1 2 3 4 5 func = ir_pass.infer_type(func) graph_gen = _graph_gen.GraphRuntimeCodegen(mod=None , target=target) graph_json, lowered_funcs, params = graph_gen.codegen(func) mod = _tvm_build_module( lowered_funcs, target=target, target_host=target_host)
最后是生成代码并返回。生成代码过程中,对于 kernel 的生成,会将所有 Call Node 对应的 Relay IR 先转换为 TVM IR 然后找到 TOPI 中注册的 Compute 与 Schedule,利用 TVM 生成代码。
4. 总结
Relay 作为 NNVM 的进阶版本,同时具有编程语言的特点和深度学习图构造的能力,借助 TVM 代码生成工具以及 TOPI 中丰富的算子库,可以完成一系列深度学习编译部署工作。对于 Relay 的掌握应该从两个方面入手,一是其作为编程语言所具有的特征,比如它的函数式风格和支持自动微分;另一方面要理解它在构造深度学习网络计算图并优化时做了什么。本文的内容只涉及第二点。仅从第二点上分析,Relay 和 NNVM 的优化思路别无二致,多出来的优化有两点:并行分支卷积的合并以及算子规范化。此外,Relay 还多考虑了异构场景下的代码生成,这一点在 NNVM 中是没有的。当然,在实现这些算法时,Relay 的技术和 NNVM 差别很大,Relay 将优化过程看作对 IR 的变换,使用 ExprMutator 完成一系列 IR 变换,这一点和 TVM 很像。而 NNVM 将计算图独立地看作了一个结构,在其上运行优化算法,并没有引入编程语言的特点。因此 Relay 的实现对于程序语言领域的研究者来说更加优美和易懂。