在对TVM:编译流程一文中,从ONNX模型中读取模型并转换为relay IR,其中调用_convert_operator函数关于将onnx算子转换成Relay算子,其中如何实现当时直接跳过去了,本节将以卷积算子为例,看下Relay表达式是如何转换为TOPI算子并结合TVM的scheduler在后端上运行的

算子映射表

获取映射表的_get_convert_map()接口定义如下:

# _convert_map defines maps of name to converter functor(callable)
# for 1 to 1 mapping, use Renamer if nothing but name is different
# use AttrCvt if attributes need to be converted
# for 1 to N mapping(composed), use custom callable functions
# for N to 1 mapping, currently not supported(?)
def _get_convert_map(opset):
return {
# defs/experimental
"Identity": Renamer("copy"),
"Affine": Affine.get_converter(opset),
"BitShift": BitShift.get_converter(opset),
"ThresholdedRelu": ThresholdedRelu.get_converter(opset),
"ScaledTanh": ScaledTanh.get_converter(opset),
"ParametricSoftplus": ParametricSoftPlus.get_converter(opset),
"Constant": Constant.get_converter(opset),
"ConstantOfShape": ConstantOfShape.get_converter(opset),
# 'GivenTensorFill'
"FC": AttrCvt("dense", ignores=["axis", "axis_w"]),
"Scale": Scale.get_converter(opset),
# 'GRUUnit'
# 'ATen'
# 'ImageScaler'
"MeanVarianceNormalization": MeanVarianceNormalization.get_converter(opset),
# 'Crop'
# 'Embedding'
"Upsample": Upsample.get_converter(opset),
"SpatialBN": BatchNorm.get_converter(opset),

从注释看,当前支持两种映射:

  1. onnx算子到tvm算子一对一映射。这种情况是双方算子仅仅名字不同,其他都一致。算子映射接口为Renamer,返回对应的tvm算子表示;再使用AttrCvt将onnx属性转换tvm属性即可;

  2. onnx算子在tvm中需要多个算子组合来表示,此时需要实现特定的转换函数。

代码中get_converter即第二种情况。

算子转换

在处理onnx节点时,调用_convert_operatoronnx node转换为tvm relay ir,函数实现如下:

def _convert_operator(self, op_name, inputs, attrs, opset):
"""Convert ONNX operator into a Relay operator.
The converter must specify conversions explicitly for incompatible name, and
apply handlers to operator attributes. Parameters
----------
op_name : str
Operator name, such as Convolution, FullyConnected
inputs : list of tvm.relay.function.Function
List of inputs.
attrs : dict
Dict of operator attributes
opset : int
Opset version Returns
-------
sym : tvm.relay.function.Function
Converted relay function
"""
convert_map = _get_convert_map(opset)
if op_name in _identity_list:
sym = get_relay_op(op_name)(*inputs, **attrs)
elif op_name in convert_map:
sym = convert_map[op_name](inputs, attrs, self._params)
else:
raise NotImplementedError("Operator {} not implemented.".format(op_name))
return sym

可看到:

  • 1.首先获取算子映射表
  • 2.如果算子在_identity_list表中,调用get_relay_op得到转换后的算子表达
  • 3.否则,如果在算子转换映射表中,调用映射接口转换算子
  • 4.否则认为转换异常
  • 5.返回转换后的表达式

算子转换 -- _identity_list表和get_relay_op

  • _identity_list表

    在python/tvm/relay/frontend/onnx.py中,_identity_list表为空
# compatible operators that do NOT require any conversion.
_identity_list = []

所以_convert_operator中这个分支是走不到的。所有支持的框架里面,只有mxnet里面该表不为空:

# Note: due to attribute conversion constraint
# ops in the identity set must be attribute free
_identity_list = [
"abs",
"log",
"exp",
"erf",
"sqrt",
"floor",
"ceil",
"round",
"trunc",
"sign",
"sigmoid",
"negative",
"reshape_like",
"zeros_like",
"ones_like",
"cos",
"cosh",
"sin",
"sinh",
"tan",
"tanh",
"where",
]

从注释看是因为这些算子的属性转换限制,才单列了这些算子。

  • get_relay_op函数

函数定义如下:

def get_relay_op(op_name):
"""Get the callable function from Relay based on operator name.
Parameters
----------
op_name : str
The Relay operator name.
"""
if "." in op_name:
# explicit hierarchical modules
op = _op
try:
for opn in op_name.split("."):
op = getattr(op, opn)
except AttributeError:
op = None
else:
# try search op in various modules
for candidate in (_op, _op.nn, _op.image, _op.vision, _op.contrib):
op = getattr(candidate, op_name, None)
if op is not None:
break
if not op:
raise tvm.error.OpNotImplemented("Unable to map op_name {} to relay".format(op_name))
return op

从注释可知:是基于算子名称获取一个可调用的函数。

getattr(object, name[, default]) -> value

Get a named attribute from an object; getattr(x, 'y') is equivalent to x.y. When a default argument is given, it is returned when the attribute doesn't exist; without it, an exception is raised in that case.

又因为_op

from .. import op as _op

所以_oppython/tvm/relay/op模块。这个下面有所有的relay算子,并且做了归类,例如nn,image, vision,contrib。

get_relay_op的if分支检查下传入的op_name是不是用点号形式给出,比如relay.op.abs;else分支就到了nn,image, vision,contrib目录下去找是否有名为op_name的算子。

两个分支下,任一找到,都会返回算子的定义接口。所以返回的是跟传入的op_name同名的函数地址。例如op_name为abs时,对应的函数定义(python/tvm/relay/op/tensor.py):

 def abs(data):
"""Compute element-wise absolute of data.
Parameters
----------
data : relay.Expr
The input data
Returns
-------
result : relay.Expr
The computed result.
"""
return _make.abs(data)

所以_convert_operatorget_relay_op(op_name)(*inputs, ** attrs)就是调用了_make.abs(*inputs, ** attrs)_make.abs()执行的是src/relay/op/op_common.h中lambda函数体

#define RELAY_REGISTER_UNARY_OP(OpName)                                        \
TVM_REGISTER_GLOBAL("relay.op._make." OpName).set_body_typed([](Expr data) { \
static const Op& op = Op::Get(OpName); \
return Call(op, {data}, Attrs(), {}); \
}); \

回到前面,因为onnx的_identity_list表为空,所以算子转换不会走到get_relay_op。

算子转换 -- get_converter()

算子映射表中通过get_converter是这样用的:

"Conv": Conv.get_converter(opset)

其中get_converter()是类OnnxOpConverter的方法,而其他各种算子在tvm/relay/frontend/onnx.py中,定义自己的算子转换类时都是继承了OnnxOpConverter。例如:

class Conv(OnnxOpConverter):
"""Operator converter for Conv.""" @classmethod
def _impl_v1(cls, inputs, attr, params):
# Use shape of input to determine convolution type.
data = inputs[0]
......

调用的get_converter方法也就是OnnxOpConverter的。

OnnxOpConverter.get_converter的实现:

	@classmethod
def get_converter(cls, opset):
"""Get converter matches given opset.
Parameters
----------
opset: int
opset from model.
Returns
-------
converter, which should be `_impl_vx`. Number x is the biggest
number smaller than or equal to opset belongs to all support versions.
"""
# 当在继承自OnnxOpConverter的各算子转换类调用get_convertver的时候,这里的cls就是子类本身了。
# dir(cls)是获取子类的属性,
# for d in dir(cls) if "_impl_v" in d 就是遍历子类的属性,查找名称包含字符串_impl_v的属性和方法.
#int(d.replace("_impl_v", ""))是将找到的属性或者方法名中_impl_v部分去掉,并将剩余的部分转换为int类型
versions = [int(d.replace("_impl_v", "")) for d in dir(cls) if "_impl_v" in d]
# version是一个list,将当前传入的版本号opset加入到version表中,并从小到大排序
versions = sorted(versions + [opset])
# 遍历versions表,i为表单元序号,v为对应的单元值.找到所有版本号为opset的单元的下标.
# 因为表中至少有一个opset, 所以减1就得到的是和opset相等或者仅比opset小的那个版本号的下标.
# 所以这里就是找到和opset相等或者比opset小但是最接近opset的版本号
version = versions[max([i for i, v in enumerate(versions) if v == opset]) - 1]
# 返回该版本的_impl_v方法
if hasattr(cls, "_impl_v{}".format(version)):
return getattr(cls, "_impl_v{}".format(version))
raise NotImplementedError(
"opset version {} of {} not implemented".format(version, cls.__name__)
)

因为各算子的转换类定义了多个版本的转换函数,这些函数的函数名都是"_impl_v" + "版本号"的形式。这里get_converter是找到一个最接近但是不高于opset的版本的_impl_v方法,返回该方法的地址,也就是返回一个函数。

算子转换接口_impl_vxx

每个需要转换的算子都有一个或者多个版本的转换接口。我们以卷积算子为例,Conv类支持的_impl_vx方法:

    """Operator converter for Conv."""
@classmethod
def _impl_v1(cls, inputs, attr, params):
# Use shape of input to determine convolution type.
# 从传入的inputs参数中获取输入和卷积核数据,并推导各自的形状
data = inputs[0]
kernel = inputs[1]
input_shape = infer_shape(data)
ndim = len(input_shape) kernel_type = infer_type(inputs[1])
kernel_shapes = [get_const_tuple(kernel_type.checked_type.shape)]
# 如果onnx卷积属性中没有给出卷积核的形状,就使用inputs里面推导出来的形状
if "kernel_shape" not in attr:
attr["kernel_shape"] = kernel_shapes[0][2:]
# 如果onnx卷积算子设置了auto_pad属性
if "auto_pad" in attr:
# 对用的tvm卷积算子也使用onnx设置的auto_pad属性值
attr["auto_pad"] = attr["auto_pad"].decode("utf-8")
# 根据auto_pad属性值对数据进行填充处理
if attr["auto_pad"] in ("SAME_UPPER", "SAME_LOWER"):
# Warning: Convolution does not yet support dynamic shapes,
# one will need to run dynamic_to_static on this model after import
# 对输入数据进行填充,得到填充后的数据
data = autopad(
data,
attr.get("strides", [1] * (ndim - 2)),
attr["kernel_shape"],
attr.get("dilations", [1] * (ndim - 2)),
mode=attr["auto_pad"],
)
elif attr["auto_pad"] == "VALID":
attr["pads"] = [0 for i in range(ndim - 2)]
elif attr["auto_pad"] == "NOTSET":
pass
else:
msg = 'Value {} in attribute "auto_pad" of operator Conv is invalid.'
raise tvm.error.OpAttributeInvalid(msg.format(attr["auto_pad"]))
attr.pop("auto_pad") attr["channels"] = kernel_shapes[0][0]
out = AttrCvt(
# 返回的op_name是一个函数,返回当前算子对应的tvm算子名称.在AttrCvt.__call__方法中调用该函数,根据当前attr中kernel_shape
# 属性得到对应的TVM conv1d/conv2d/conv3d算子接口;然后算子接收([data, kernel], attr, params)
# 参数, 返回转换后的TVM表示out
op_name=dimension_picker("conv"),
# 参数转换表
transforms={
# 当前属性名 : 转换后的属性名
"kernel_shape": "kernel_size",
# 当前属性名 : (转换后的属性名, 转换后的默认值)
"dilations": ("dilation", 1),
# 当前属性名 : (转换后的属性名, 转换后的默认值)
"pads": ("padding", 0),
# 当前属性名 : (转换后的属性名, 转换后的默认值)
"group": ("groups", 1),
},
custom_check=dimension_constraint(),
)([data, kernel], attr, params) use_bias = len(inputs) == 3
# 如果输入中有偏置参数,则在表达式中添加偏置运算
if use_bias:
out = _op.nn.bias_add(out, inputs[2])
return out

在_impl_v1中对卷积的输入数据,卷积核参数,以及填充做了初步的处理,然后创建一个AttrCvt实例。传入的参数op_name是一个函数,在AttrCvt.__call__方法中会调用该方法,参数为当前卷积的attr。根据attr中的kernel_shape参数,判断当前是1d/2d/3d卷积,得到对应的tvm算子名称conv1d/conv2d/conv3d;传入的transforms参数,用作AttrCvt.__call__中对当前attr和权重参数转换,会转换为tvm的卷积需要的参数形式;custom_check参数用于检查参数,这里对于卷积来说,是检查当前卷积维度是否合法(1d/2d/3d)。

算子属性转换AttrCvt

AttrCvt.__call__方法大致流程是对参数进行检查,转换,然后调用get_relay_op得到算子对应的tvm接口函数,将当前算子的输入和变换后的参数输入接口,得到onnx node对应的tvm relay ir。

class AttrCvt(object):
def __init__(
self,
op_name,
transforms=None,
excludes=None,
disables=None,
ignores=None,
extras=None,
custom_check=None,
):
# 算子的新名字,op_name可以是一个字符串,也可以是一个返回字符串的函数
self._op_name = op_name
# 属性转换表,表项为属性转换字典,形式为"attr_name : new_attr_name",
# 或者"attr_name : (new_name, default_value, transform function)"
self._transforms = transforms if transforms else {}
# 不允许出现的属性集合,如果出现会抛出异常
self._excludes = excludes if excludes else []
# 转换后会被disable的属性集合
self._disables = disables if disables else []
# 转换过程中会被忽略的属性集合
self._ignores = ignores if ignores else []
# 转换后会被额外返回的属性
self._extras = extras if extras else {}
# 转换执行的检测函数,返回False会抛出异常
self._custom_check = custom_check def __call__(self, inputs, attrs, *args):
# 忽略待转换算子的这些属性
self._ignores.append("_output_shapes")
self._ignores.append("_input_shapes")
self._ignores.append("T")
self._ignores.append("use_cudnn_on_gpu")
self._ignores.append("_node_name")
self._ignores.append("is_training")
self._ignores.append("_target_layout") # apply custom check
# 如果算子转换传入了检测函数,则执行该检测函数
if self._custom_check:
func, msg = self._custom_check
if not func(attrs):
raise RuntimeError("Check failed: {}".format(msg))
# get new op_name
# 得到算子转换后的名字
if isinstance(self._op_name, str):
op_name = self._op_name
else:
assert callable(self._op_name), "op_name can either be string or callable"
op_name = self._op_name(attrs) # ignore 'tvm_custom' always
# 忽略tvm_custom属性
self._ignores.append("tvm_custom") # convert attributes
new_attrs = {}
# 遍历传入的待转换算子的属性
for k in attrs.keys():
# 如果属性在排除表中, 抛出异常
if k in self._excludes:
raise NotImplementedError(
"Attribute %s in operator %s is not" + " supported.", k, op_name
)
# 如果属性是要求disable的,打印debug日志
if k in self._disables:
logger.debug("Attribute %s is disabled in relay.sym.%s", k, op_name)
# 如果属性是要求忽略的,打印debug日志
elif k in self._ignores:
if k != "tvm_custom":
logger.debug("Attribute %s is ignored in relay.sym.%s", k, op_name)
# 如果属性在转换表中
elif k in self._transforms:
# 从转换表中该属性对应的转换dict,得到属性的新名字,新默认值和转换操作函数
# 如果转换表中没有给出转换函数,则将转换函数设置为lambda x: x,也就是直接返回参数
new_name, defaults, transform = self._parse_default(self._transforms[k])
# 如果没有给出默认值
if defaults is None:
# 那么必须是"attr_name:new_attr_name"形式,获取新属性名
new_attr = self._required_attr(attrs, k)
else:
# 从原始的属性表中查找该属性的值,如果没找到,则为新属性为None
new_attr = attrs.get(k, None)
if new_attr is None:
# 如果新属性为None,在新的属性表中添加该属性,值为转换表中得到的默认值
new_attrs[new_name] = defaults
else:
# 在新的属性表中添加该属性,调用转换函数得到新的属性值
new_attrs[new_name] = transform(new_attr)
else:
# copy
# 如果属性不在转换表中,直接原封不动的加入新属性表
new_attrs[k] = attrs[k]
# add extras
# 更新额外的属性
new_attrs.update(self._extras)
# 将输入和新属性表传入算子转换接口,返回转换后tvm relay ir
return get_relay_op(op_name)(*inputs, **new_attrs)

这个类核心就是调用了AttrCvt函数,完成了ONNX卷积算子到Relay 卷积算子的转换。这个转换包含了属性的转换以及根据layout对weights,inputs,outputs进行重排并返回一个Relay 卷积算子。(在tensorflow中倒是看到了对应代码的描述,在onnx模型转换中并没有看到类似的代码)

AttrCvt的调用位于python/tvm/relay/frontend/common.py文件中,根据类注释可知,这个类主要是实现了算子转换,即根据输入的op_name映射到relay的算子。具体过程是:先对传入的attrs进行检查,如有非法属性就报错,如果属性有相应的转换策略就直接转换(即上述代码中的transform),最后调用get_relay_op返回一个TVM Relay卷积算子。

Conv2d为例,这里get_relay_op(conv2d)将返回nn.conv2d

nn.py中conv2d的实现如下:

def conv2d(
data,
weight,
strides=(1, 1),
padding=(0, 0),
dilation=(1, 1),
groups=1,
channels=None,
kernel_size=None,
data_layout="NCHW",
kernel_layout="OIHW",
out_layout="",
out_dtype="",
):
r"""2D convolution. This operator takes the weight as the convolution kernel
and convolves it with data to produce an output. In the default case, where the data_layout is `NCHW`
and kernel_layout is `OIHW`, conv2d takes in
a data Tensor with shape `(batch_size, in_channels, height, width)`,
and a weight Tensor with shape `(channels, in_channels, kernel_size[0], kernel_size[1])`
to produce an output Tensor with the following rule: .. math:: \mbox{out}[b, c, y, x] = \sum_{dy, dx, k}
\mbox{data}[b, k, \mbox{strides}[0] * y + dy, \mbox{strides}[1] * x + dx] *
\mbox{weight}[c, k, dy, dx] Padding and dilation are applied to data and weight respectively before the computation.
This operator accepts data layout specification.
Semantically, the operator will convert the layout to the canonical layout
(`NCHW` for data and `OIHW` for weight), perform the computation,
then convert to the out_layout. Parameters
----------
data : tvm.relay.Expr
The input data to the operator. weight : tvm.relay.Expr
The weight expressions. strides : Optional[int, Tuple[int]]
The strides of convolution. padding : Optional[int, Tuple[int]]
The padding of convolution on both sides of inputs before convolution. dilation : Optional[int, Tuple[int]]
Specifies the dilation rate to be used for dilated convolution. groups : Optional[int]
Number of groups for grouped convolution. channels : Optional[int]
Number of output channels of this convolution. kernel_size : Optional[int, Tuple[int]]
The spatial of the convolution kernel. data_layout : Optional[str]
Layout of the input. kernel_layout : Optional[str]
Layout of the weight. out_layout : Optional[str]
Layout of the output, by default, out_layout is the same as data_layout out_dtype : Optional[str]
Specifies the output data type for mixed precision conv2d. Returns
-------
result : tvm.relay.Expr
The computed result.
"""
if isinstance(kernel_size, int):
kernel_size = (kernel_size, kernel_size)
if isinstance(strides, int):
strides = (strides, strides)
if isinstance(dilation, int):
dilation = (dilation, dilation)
# TODO enforce 4-way padding in topi/nn/conv2d after #4644 merged
# convert 2-way padding to 4-way padding
padding = get_pad_tuple2d(padding)
return _make.conv2d(
data,
weight,
strides,
padding,
dilation,
groups,
channels,
kernel_size,
data_layout,
kernel_layout,
out_layout,
out_dtype,
)

调用关系:conv2d() ->_make.conv2d(),在_make.py中实现了C++类到python类的接口暴露

import tvm._ffi
tvm._ffi._init_api("relay.op.nn._make", __name__)

这里__name__是一个python内置变量,表示当前模块的文件名(不包括.py),即tvm/relay/op/nn/_make。


tvm._ffi模块位于python/tvm/_ffi。函数_init_api的定义在python/tvm/_ffi/registry.py中

def _init_api(namespace, target_module_name=None):
"""Initialize api for a given module name
namespace : str
The namespace of the source registry
target_module_name : str
The target module name if different from namespace
"""
target_module_name = target_module_name if target_module_name else namespace
if namespace.startswith("tvm."):
_init_api_prefix(target_module_name, namespace[4:])
else:
_init_api_prefix(target_module_name, namespace)

这里传入的第一个参数namespace为relay.op.nn._make, target_module_name参数为tvm/relay/op/nn/_make。这样传入_init_api_prefix的两个参数将是 tvm.relay.op.nn._make和relay.op.nn._make。

def _init_api_prefix(module_name, prefix):
module = sys.modules[module_name] for name in list_global_func_names():
if not name.startswith(prefix):
continue fname = name[len(prefix) + 1 :]
target_module = module if fname.find(".") != -1:
continue
f = get_global_func(name)
ff = _get_api(f)
ff.__name__ = fname
ff.__doc__ = "TVM PackedFunc %s. " % fname
setattr(target_module, ff.__name__, ff)

module = sys.modules[module_name]获取的是tvm.relay.op.nn._make模块的句柄。list_global_func_names()定义在python/tvm/_ffi/registry.py中:

def list_global_func_names():
"""Get list of global functions registered.
Returns
-------
names : list
List of global functions names.
"""
plist = ctypes.POINTER(ctypes.c_char_p)()
size = ctypes.c_uint() check_call(_LIB.TVMFuncListGlobalNames(ctypes.byref(size), ctypes.byref(plist)))
fnames = []
for i in range(size.value):
fnames.append(py_str(plist[i]))
return fnames

接口种通过ctypes方式,调用C++库的TVMFuncListGlobalNames接口,得到的结果字符串数组plist,该数组为所有全局接口的函数名集合。TVMFuncListGlobalNames接口定义在src/runtime/registry.cc中

int TVMFuncListGlobalNames(int* out_size, const char*** out_array) {
API_BEGIN();
TVMFuncThreadLocalEntry* ret = TVMFuncThreadLocalStore::Get();
ret->ret_vec_str = tvm::runtime::Registry::ListNames();
ret->ret_vec_charp.clear();
for (size_t i = 0; i < ret->ret_vec_str.size(); ++i) {
ret->ret_vec_charp.push_back(ret->ret_vec_str[i].c_str());
}
*out_array = dmlc::BeginPtr(ret->ret_vec_charp);
*out_size = static_cast<int>(ret->ret_vec_str.size());
API_END();
}

函数中调用tvm::runtime::Registry::ListNames()得到函数名表:

std::vector<std::string> Registry::ListNames() {
Manager* m = Manager::Global();
std::lock_guard<std::mutex> lock(m->mutex);
std::vector<std::string> keys;
keys.reserve(m->fmap.size());
for (const auto& kv : m->fmap) {
keys.push_back(kv.first);
}
return keys;
}

可以看到,函数名都是从Manager类实例的fmap表的第一个元素。而且Manager还是个单实例类。而fmap的定义:

struct Registry::Manager {
// map storing the functions.
// We deliberately used raw pointer.
// This is because PackedFunc can contain callbacks into the host language (Python) and the
// resource can become invalid because of indeterministic order of destruction and forking.
// The resources will only be recycled during program exit.
std::unordered_map<std::string, Registry*> fmap;
// mutex
std::mutex mutex; Manager() {} static Manager* Global() {
// We deliberately leak the Manager instance, to avoid leak sanitizers
// complaining about the entries in Manager::fmap being leaked at program
// exit.
static Manager* inst = new Manager();
return inst;
}
};

从注释看,这个fmap是一个存储函数的map表。表单元的第一个元素是string类型。

回去继续往下看,_init_api_prefix中的get_global_func:

def get_global_func(name, allow_missing=False):
return _get_global_func(name, allow_missing) def _get_global_func(name, allow_missing=False):
handle = PackedFuncHandle()
check_call(_LIB.TVMFuncGetGlobal(c_str(name), ctypes.byref(handle))) if handle.value:
return _make_packed_func(handle, False) if allow_missing:
return None raise ValueError("Cannot find global function %s" % name)

_get_global_func中使用ctypes方式调用C++库中的TVMFuncGetGlobal函数:

int TVMFuncGetGlobal(const char* name, TVMFunctionHandle* out) {
API_BEGIN();
const tvm::runtime::PackedFunc* fp = tvm::runtime::Registry::Get(name);
if (fp != nullptr) {
*out = new tvm::runtime::PackedFunc(*fp); // NOLINT(*)
} else {
*out = nullptr;
}
API_END();
} const PackedFunc* Registry::Get(const std::string& name) {
Manager* m = Manager::Global();
std::lock_guard<std::mutex> lock(m->mutex);
auto it = m->fmap.find(name);
if (it == m->fmap.end()) return nullptr;
return &(it->second->func_);
}

TVMFuncGetGlobal调用了Registry::Get,从Manager的fmap表中,找到第一个元素为python传入的函数名的单元,从该单元的第二个元素中获取了函数指针。也就是根据函数名获取函数句柄。

搜索下谁在往fmap成员中写数据,可以看到是Registry::Register接口:

Registry& Registry::Register(const std::string& name, bool can_override) {  // NOLINT(*)
Manager* m = Manager::Global();
std::lock_guard<std::mutex> lock(m->mutex);
if (m->fmap.count(name)) {
ICHECK(can_override) << "Global PackedFunc " << name << " is already registered";
} Registry* r = new Registry();
r->name_ = name;
m->fmap[name] = r;
return *r;
}

可以看到调用Registry::Register接口接口时,如果name在fmap中不存在,就会创建一个Registry实例,加入Manager的fmap表,并返回新建的Registry实例。搜索Registry::Register接口的调用,在include/tvm/runtime/registry.h中有定义

/*!
* \brief Register a function globally.
* \code
* TVM_REGISTER_GLOBAL("MyPrint")
* .set_body([](TVMArgs args, TVMRetValue* rv) {
* });
* \endcode
*/
#define TVM_REGISTER_GLOBAL(OpName) \
TVM_STR_CONCAT(TVM_FUNC_REG_VAR_DEF, __COUNTER__) = ::tvm::runtime::Registry::Register(OpName)

这里调用Registry::Register接口,传入的是一个函数名。在代码中搜索TVM_REGISTER_GLOBAL宏的使用会有很多。


继续关注relay.op.nn._make.conv2d的,搜索到src/relay/op/nn/convolution.cc中代码:

conv2d的注册代码如下:


TVM_REGISTER_GLOBAL("relay.op.nn._make.conv2d")
.set_body_typed([](Expr data, Expr weight, Array<IndexExpr> strides, Array<IndexExpr> padding,
Array<IndexExpr> dilation, int groups, IndexExpr channels,
Array<IndexExpr> kernel_size, String data_layout, String kernel_layout,
String out_layout, DataType out_dtype) {
return MakeConv<Conv2DAttrs>(data, weight, strides, padding, dilation, groups, channels,
kernel_size, data_layout, kernel_layout, out_layout, out_dtype,
"nn.conv2d");
}); RELAY_REGISTER_OP("nn.conv2d")
.describe(R"code(2D convolution layer (e.g. spatial convolution over images). This layer creates a convolution kernel that is convolved
with the layer input to produce a tensor of outputs. - **data**: This depends on the `layout` parameter. Input is 4D array of shape
(batch_size, in_channels, height, width) if `layout` is `NCHW`.
- **weight**: (channels, in_channels, kernel_size[0], kernel_size[1])
- **out**: This depends on the `layout` parameter. Output is 4D array of shape
(batch_size, channels, out_height, out_width) if `layout` is `NCHW`. )code" TVM_ADD_FILELINE)
.set_attrs_type<Conv2DAttrs>()
.set_num_inputs(2)
.add_argument("data", "Tensor", "The input tensor.")
.add_argument("weight", "Tensor", "The weight tensor.")
.set_support_level(2)
.add_type_rel("Conv2D", Conv2DRel)
.set_attr<FInferCorrectLayout>("FInferCorrectLayout", ConvInferCorrectLayout<Conv2DAttrs>)
.set_attr<TOpPattern>("TOpPattern", kOutEWiseFusable);

TVM_REGISTER_GLOBAL这个宏定义将算子注册到一个全局对象中。可以看一下这个宏定义:

#define TVM_REGISTER_GLOBAL(OpName) \
TVM_STR_CONCAT(TVM_FUNC_REG_VAR_DEF, __COUNTER__) = ::tvm::runtime::Registry::Register(OpName)

可以看到注册的实现在Registry类中,这个类有一个Register成员函数,这个函数会通过全局manager来将算子注册进去:

Registry& Registry::Register(const std::string& name, bool can_override) {  // NOLINT(*)
Manager* m = Manager::Global();
std::lock_guard<std::mutex> lock(m->mutex);
if (m->fmap.count(name)) {
ICHECK(can_override) << "Global PackedFunc " << name << " is already registered";
} Registry* r = new Registry();
r->name_ = name;
m->fmap[name] = r;
return *r;
}

其中set_body将通过MakeConv构建一个conv算子,然后注册到registry中。在MakeConv中,首先根据传入的conv参数,包括strides,kernel,layout等,构建atrrs对象,然后根据op的名字从已经注册过的conv算子中得到conv的算子,然后再将attrs和op一起打包到call类中。即在tvm/src/relay/op/nn/convolution_make.h中的:


template <typename T>
inline Expr MakeConv(Expr data, Expr weight, Array<IndexExpr> strides, Array<IndexExpr> padding,
Array<IndexExpr> dilation, int groups, IndexExpr channels,
Array<IndexExpr> kernel_size, std::string data_layout,
std::string kernel_layout, std::string out_layout, DataType out_dtype,
std::string op_name) {
auto attrs = make_object<T>();
attrs->strides = std::move(strides);
attrs->padding = std::move(padding);
attrs->dilation = std::move(dilation);
attrs->groups = groups;
attrs->channels = std::move(channels);
attrs->kernel_size = std::move(kernel_size);
attrs->data_layout = std::move(data_layout);
attrs->kernel_layout = std::move(kernel_layout);
attrs->out_layout = std::move(out_layout);
attrs->out_dtype = std::move(out_dtype);
const Op& op = Op::Get(op_name);
return Call(op, {data, weight}, Attrs(attrs), {});
}

这里将卷积参数打包,生成一个Op实例,然后生成一个Call实例返回。

Call是继承了Expr类:

class Call : public Expr {
public:
/*!
* \brief The destructor
*/
~Call(); /*!
* \brief The constructor
* \param op The operator will be invoked.
* \param args The arguments of the call.
* \param attrs The attributes of the call node.
* \param type_args The type arguments passed to a polymorphic function.
* \param span The source span of the expression.
*/
TVM_DLL Call(Expr op, Array<Expr> args, Attrs attrs = Attrs(),
Array<Type> type_args = Array<Type>(), Span span = Span()); TVM_DEFINE_OBJECT_REF_METHODS(Call, RelayExpr, CallNode);
TVM_DEFINE_OBJECT_REF_COW_METHOD(CallNode);
};

Op算子是通过RELAY_REGISTER_OP注册到一个公共AttrRegistry中的。

在一个op类中实际上并没有包含这个op的计算过程,只是纳入了这个算子的输入输出以及属性的信息。

特别注意Relay OP并没有包含具体的计算过程!上面的一系列操作仅仅是拿到了Relay 卷积OP的IR以及输入和属性。那么这个OP的计算过程是在哪里完成的呢?TOPI

定义算子的compute函数

算子的compute函数是算子的计算过程实现。

nn.conv2d的算子算法实现入口为python/tvm/topi/nn/conv2d.py中定义的conv2d函数,调用了同文件中的conv接口,在该接口中实现了compute函数并调用:

def conv(
inp: te.Tensor,
filt: te.Tensor,
stride: Union[int, Sequence[int]],
padding: Union[int, Sequence[int]],
dilation: Union[int, Sequence[int]],
groups: int,
order: str,
out_dtype: Union[str, None] = None,
): ... def compute(*args):
nn, ff, *dim_indices = list(np.array(args)[permutation_to])
return te.sum(
temp.__getitem__(
tuple(
np.array(
[nn, ff // (num_filter // groups) * (in_channel // groups) + rc]
+ [
di * stride + r * dil
for di, stride, r, dil in zip(dim_indices, strides, rs, dilations)
]
)[permutation_from]
)
).astype(out_dtype)
* filt.__getitem__(tuple(np.array([ff, rc] + rs)[permutation_from_kernel])).astype(
out_dtype
),
# Schedules depend on reduction axes being in the same order as the
# layout, so we reorder here.
axis=np.array([rc, *rs])[permutation_from_reductions].tolist(),
) return te.compute(
list(np.array([batch, out_channel] + out_dimensions)[permutation_from]),
compute,
# tag is expected to be lowercase
tag=f"{'group_' if groups > 1 else ''}conv{dim}d_{order.lower()}",
name=f"{'group_' if groups > 1 else ''}conv{dim}d_{order.lower()}",
)

这里只是conv2d的默认compute,根据参数和输入数据的排布格式,在 python/tvm/topi/nn/conv2d.py中定义了对应的compute函数。

注册算子的compute函数和schedule

在实现算子的compute函数后,需要将这个compute函数加入relay算子中

在TVM中,这意味着我们不仅仅只是实现计算方法,还要给出对应的调度schedule策略,也就是为compute挑选合适的schedule。例如,当2d卷积是一个分组卷积时,我们会给它分配合适的计算方法和调度。conv2d的shedule定义在python/tvm/topi/generic/nn.py中,以schedule_conv2d_开头的函数定义了各种数据排布格式对应的调度策略,大部分都是使用了默认的调度方法

conv2dstrategy函数conv2d_strategy定义在python/tvm/relay/op/strategy/generic.py中。在该函数中,根据输入数据和卷积核的排布格式,给出各种排布组合的计算方法和调度。compute和schedule的组合即strategy。

这样,relay中就已经增加了我们的算子,便可以通过Relay Call Node来调用它。这一步我们要写一个接口,将参数传入算子,然后返回一个Relay Call Node。这个Node可以加入Relay的语法树。

不支持直接调用 Attrs和参数,所以这里用Op::Get从算子注册表中获取算子信息,作为参数传递给Call Nodenn.conv2d的Relay Call Node生成函数(src/relay/op/nn/convolution_make.h),即上面的MakeConv函数

当模板参数为Conv2DAttrs的时候,即生成的nn.conv2d的Relay Call Node。这里先是new了一个Conv2DAttrs,接收传入的各参数和属性;然后获取2d卷积注册信息,一并传给Call;最后返回CallNode类型实例的引用

在定义Relay Call Node函数后,我们要向Python注册一个接口来调用这个函数。这里注册是使用TVM_REGISTER_GLOBAL宏。注册后,在Python中就可以用relay.op._make.xxx(...)形式调用了。nn.conv2d的注册,该注册在本文开头



补充:

tvm relay function python调用C++

在Graphproto.from_onnx的最后,使用网络的输入输出和权重参数打包成一个Function实例,然后生成一个IRModule实例:

# 由模型输入, 输出表达式依赖的权重和输出表达式生成一个function
func = _function.Function([v for k, v in self._inputs.items()], outputs)
# 返回表达式和所有权重
return IRModule.from_expr(func), self._params

这两步也都是会调用到C++代码。先看_function.Function的流程:

@tvm._ffi.register_object("relay.Function")
class Function(BaseFunc):
def __init__(self, params, body, ret_type=None, type_params=None, attrs=None):
if type_params is None:
type_params = convert([]) self.__init_handle_by_constructor__(
_ffi_api.Function, params, body, ret_type, type_params, attrs
) def __call__(self, *args):
return Call(self, args, None, None)

__init__函数第二个参数body是函数体,而前面在调用_function.Function时传入的时outputs。这是因为outputs并不是网络或者函数的输出张量,而是输出的计算表达式,而且这个表达式描述的是从输入开始,一步一步的到输出的计算过程,也就是函数实现的所有计算过程了。所以这个outputs就是函数体。

__init__中调用了self.init_handle_by_constructor,参数_ffi_api.Function这种形式在前面算子调用流程中我们已经分析过,_ffi_api引入的是模块,Function是具体的函数,所以我们看下当前目录下的_ffi_api是什么模块, 见python/tvm/relay/_ffi_api.py:

import tvm._ffi

tvm._ffi._init_api("relay.ir", __name__)

模块为relay.ir,所以_ffi_api.Function就是relay.ir.Function。

搜索该标记符的注册TVM_REGISTER_GLOBAL("relay.ir.Function")可以看到:

TVM_REGISTER_GLOBAL("relay.ir.Function")
.set_body_typed([](tvm::Array<Var> params, Expr body, Type ret_type,
tvm::Array<TypeVar> ty_params, tvm::DictAttrs attrs) {
return Function(params, body, ret_type, ty_params, attrs);
});

也就是调用_ffi_api.Function会在C++端实例化一个Function。

在python的Function类中, _ffi_api.Function是作为参数传给self.__init_handle_by_constructor__,这个方法定义在python/tvm/_ffi/_ctypes/object.py中的基类ObjectBase中,而ObjectBase.__init_handle_by_constructor__调用的是

def __init_handle_by_constructor__(fconstructor, args):
"""Initialize handle by constructor"""
temp_args = []
values, tcodes, num_args = _make_tvm_args(args, temp_args)
ret_val = TVMValue()
ret_tcode = ctypes.c_int()
if (
_LIB.TVMFuncCall(
fconstructor.handle,
values,
tcodes,
ctypes.c_int(num_args),
ctypes.byref(ret_val),
ctypes.byref(ret_tcode),
)
!= 0
):
raise get_last_ffi_error()
_ = temp_args
_ = args
assert ret_tcode.value == ArgTypeCode.OBJECT_HANDLE
handle = ret_val.v_handle
return handle

看下TVMFuncCall的调用链

src/runtime/c_runtime_api.cc:

int TVMFuncCall(TVMFunctionHandle func, TVMValue* args, int* arg_type_codes, int num_args,
TVMValue* ret_val, int* ret_type_code) {
API_BEGIN(); TVMRetValue rv;
(*static_cast<const PackedFunc*>(func)).CallPacked(TVMArgs(args, arg_type_codes, num_args), &rv); ... } include/tvm/runtime/packed_func.h: inline void PackedFunc::CallPacked(TVMArgs args, TVMRetValue* rv) const { body_(args, rv); }

这里最后调用到的body_就是TVM_REGISTER_GLOBAL("relay.ir.Function").set_typed_body设置的lamabd函数体。

这里比较绕,我们理下:

  1. 首先将注册的relay.ir.Function作为参数传给了__init_handle_by_constructor__;

  2. __init_handle_by_constructor__调用了_LIB.TVMFuncCall;

  3. _LIB.TVMFuncCall相当于一个函数执行器,它执行了relay.ir.Function;

  4. relay.ir.Function的函数体被执行时,返回一个C++端的Function对象句柄。

tvm relay op IRModule python调用C++

onnx.py中GraphProto.from_onnx最后return IRModule.from_expr(func), self._params,这个from_expr代码在python/tvm/ir/module.py中:

    def from_expr(expr, functions=None, type_defs=None):
funcs = functions if functions is not None else {}
defs = type_defs if type_defs is not None else {}
return _ffi_api.Module_FromExpr(expr, funcs, defs)

这里直接调用_ffi_api.Module_FromExpr,python/tvm/ir/目录定义的模块名为ir(见python/tvm/ir/_ffi_api.py), 搜索对应的函数注册TVM_REGISTER_GLOBAL("ir.Module_FromExpr"),注册函数执行IRModule::FromExprFromExpr调用IRModule::FromExprInContext,生成一个C++端的IRModule实例



参考:

https://zhuanlan.zhihu.com/p/368940120

https://blog.csdn.net/zx_ros/article/details/123526147

https://blog.csdn.net/zx_ros/article/details/122931616

https://blog.csdn.net/zx_ros/article/details/122917673

TVM:解析TVM算子的更多相关文章

  1. Apache-Flink深度解析-JOIN 算子

    什么是JOIN 在<Apache Flink 漫谈系列 - SQL概览>中我对JOIN算子有过简单的介绍,这里我们以具体实例的方式让大家对JOIN算子加深印象.JOIN的本质是分别从N(N ...

  2. TVM优化Deep Learning GPU算子

    TVM优化Deep Learning GPU算子 高效的深度学习算子是深度学习系统的核心.通常,这些算子很难优化,需要HPC专家付出巨大的努力. 端到端张量IR / DSL堆栈TVM使这一过程变得更加 ...

  3. TVM设备添加以及代码生成

    因为要添加的设备是一种类似于GPU的加速卡,TVM中提供了对GPU编译器的各种支持,有openCl,OpenGL和CUDA等,这里我们选取比较熟悉的CUDA进行模仿生成.从总体上来看,TVM是一个多层 ...

  4. TVM自定义数据类型

    TVM自定义数据类型 本文将介绍"自定义数据类型"框架,该框架可在TVM中使用自定义数据类型. 介绍 在设计加速器时,关键是如何近似地表示硬件中的实数.这个问题具有长期的行业标准解 ...

  5. TVM:

    Hello TVM  发表于 2019-06-29 TVM 是什么?A compiler stack,graph level / operator level optimization,目的是(不同框 ...

  6. TVM图优化(以Op Fusion为例)

    首先给出一个TVM 相关的介绍,这个是Tianqi Chen演讲在OSDI18上用的PPThttps://files.cnblogs.com/files/jourluohua/Tianqi-Chen- ...

  7. TVM如何训练TinyML

    TVM如何训练TinyML 机器学习研究人员和从业人员对"裸机"(低功耗,通常没有操作系统)设备产生了广泛的兴趣.尽管专家已经有可能在某些裸机设备上运行某些模型,但是为各种设备优化 ...

  8. TVM代码生成codegen

    TVM代码生成codegen 硬件后端提供程序(例如Intel,NVIDIA,ARM等),提供诸如cuBLAS或cuDNN之类的内核库以及许多常用的深度学习内核,或者提供框架例,如带有图形引擎的DNN ...

  9. TVM优化GPU机器翻译

    TVM优化GPU机器翻译 背景 神经机器翻译(NMT)是一种自动化的端到端方法,具有克服传统基于短语的翻译系统中的弱点的潜力.最近,阿里巴巴集团正在为全球电子商务部署NMT服务. 将Transform ...

  10. 端到端TVM编译器(上)

    端到端TVM编译器(上) 摘要 将机器学习引入到各种各样的硬件设备中.AI框架依赖于特定于供应商的算子库,针对窄范围的服务器级gpu进行优化.将工作负载部署到新平台,例如手机.嵌入式设备和加速器(例如 ...

随机推荐

  1. @Scheduled参数及cron表达式解释

    @Scheduled支持以下8个参数:1.cron:表达式,指定任务在特定时间执行:2.fixedDelay:表示上一次任务执行完成后多久再次执行,参数类型为long,单位ms:3.fixedDela ...

  2. QT5笔记:1.UI文件设计与运行机制

    1.UI文件设计与运行机制 参考视频:https://www.bilibili.com/video/BV1AX4y1w7Nt #include "widget.h" #includ ...

  3. 光猫路由器设置-改桥接、开启UPNP和DMZ的教程

    疑难解答加微信机器人,给它发:进群,会拉你进入八米交流群 机器人微信号:bamibot 简洁版教程访问:https://bbs.8miyun.cn 以电信光猫举例,其他网络百度都有对应教程. 电信光猫 ...

  4. Codeforces Round 1006 (Div. 3) 比赛记录

    Codeforces Round 1006 (Div. 3) 比赛记录 比赛链接 这场的题目名称都很长啊~. 很简单的一场(毕竟是div3,能不简单嘛)赛时切掉了A - F,C题花的时间有点多,G题偶 ...

  5. rabbitmq的基本使用

    使用MQ的三大作用:1.同步变异步2.流量削峰3.解耦降低服务间的耦合性要不要使用MQ,需不需要使用MQ依据项目的需要做选择. 使用场景: 例如:注册用户时候,发送激活邮件.监控应用中抛出的异常,邮件 ...

  6. ppt 实用技巧总结

    全是图片且颜色单一 解决方案:在图片上添加图形,加图标 效果图 2.文本功能10个 解决方案:添加背景图片+文本+图标 效果图 只有文本 没有过多内容怎么办

  7. 介绍一个不知道怎么形容的小东西--Proxy

    what's this? The Proxy object is used to define custom behavior for fundamental operations (e.g. pro ...

  8. 这几天用ng5做了一个俄罗斯方块

    为什么要做这个东西呢? 嗯 为了证明自己会ng吧,最主要的是这几天的工作全是静态页,实在无聊枯燥的很.当然了,肯定还有其他妙不可言的原因,这里不想说出来. 我也忘记我是什么时候有想到用ng做俄罗斯方块 ...

  9. Centos安装完成后,ifconfig:command not found

    1.问题:在刚最小安装完centos7,想查看本机IP地址.然后运行ifconfig,结果弹出报错. 2.问题排查:首先我们了解是不是没有开启网卡导致的?我们可以通过一下3种方法来排查: 1)通过pi ...

  10. Linux下使用fdisk扩大分区容量

    磁盘容量有300GB,之前分区的时候只分了一个150GB的/data分区,现在/data分区已经不够用了. 需求:把这块磁盘剩余的150GB容量增加到之前的/data分区,并且保证/data分区原有的 ...