添加算子到 Relay#

在本文档中,将介绍在 Relay 中注册新的 TVM 算子所需的步骤。遵循这个 PR,它增加了 cumulative product 作为例子。PR 本身建立在另一个 PR 的基础上,后者添加了 cumulative sum 算子。

注册新的算子需要几个步骤:

  1. 添加属性节点,声明在编译时已知的固定参数

  2. 为集成到 Relay 类型系统中的运算编写类型关系。

  3. 使用 C++ 中的 RELAY_REGISTER_OP 宏为编译器注册算子的属性、类型和其他提示

  4. 编写算子的计算方式

  5. 注册 Relay 算子的 compute, schedule

  6. 定义 C++ 函数,为算子生成 call 节点,并为该函数注册 Python API 钩子

  7. 将上面的 Python API 钩子包装在更整洁的接口中

  8. 为新的 Relay 算子编写测试

1. 定义属性节点#

属性是固定的参数,应该在编译时就知道。卷积算子的 stride 和 expand 是属于卷积算子属性节点的字段的一个适当的例子。

属性应该定义在 include/tvm/relay/attrs/ 文件夹下的文件中

最终希望创建一个算子,它的接口可以在最终的 python 接口中清楚地看到:

def cumprod(data, axis=None, dtype=None, exclusive=None):
    """Numpy style cumprod op. Return the cumulative inclusive product of the elements along
    a given axis.
    Parameters
    ----------
    data : relay.Expr
        The input data to the operator.
    axis : int, optional
        Axis along which the cumulative product is computed. The default (None) is to compute
        the cumprod over the flattened array.
    dtype : string, optional
        Type of the returned array and of the accumulator in which the elements are multiplied.
        If dtype is not specified, it defaults to the dtype of data.
    exclusive : bool, optional
        If true will return exclusive product in which the first element is not
        included. In other terms, if true, the j-th output element would be
        the product of the first (j-1) elements. Otherwise, it would be the product of
        the first j elements. The product of zero elements will be 1.
    Returns
    -------
    result : relay.Expr
        The result has the same size as data, and the same shape as data if axis is not None.
        If axis is None, the result is a 1-d array.
    """

实现 cumsum() 类似的接口。

因此,当在 include/tvm/relay/attrs/transform.h 中定义属性时,选择算子的 axis、累积 dtype 和 exclusivity 作为结构的适当字段。

/*! \brief Attributes used in cumsum and cumprod operator */
struct ScanopAttrs : public tvm::AttrsNode<ScanopAttrs> {
  Integer axis;
  DataType dtype;
  Bool exclusive = Bool(false);
  TVM_DECLARE_ATTRS(ScanopAttrs, "relay.attrs.ScanopAttrs") {
    TVM_ATTR_FIELD(axis).describe("The axis to operate over").set_default(NullValue<Integer>());
    TVM_ATTR_FIELD(dtype).describe("Output data type").set_default(NullValue<DataType>());
    TVM_ATTR_FIELD(exclusive)
        .describe("The first element is not included")
        .set_default(Bool(false));
  }
};

2. 编写类型关系#

为了实现注册算子的灵活性以及在 Relay 中表示类型时更大的表达性和粒度,使用输入和输出类型之间的关系对算子进行类型划分。这些关系表示为接受输入类型和输出类型列表(这些类型中的任何一个都可能是不完整的)的函数,并返回满足该关系的输入和输出类型列表。这包括可以在编译时静态确定的形状信息。从本质上讲,算子的关系除了计算输出类型外,还可以强制执行所有必要的类型规则(即通过检查输入类型)。

累积乘法算子 和 sum 算子的类型关系可以在 src/relay/op/tensor/transform.cc 中找到:

TVM_REGISTER_NODE_TYPE(ScanopAttrs);
bool ScanopRel(const Array<Type>& types, int num_inputs, const Attrs& attrs, const TypeReporter& reporter) {
    // types: [data, output]
    ICHECK_EQ(types.size(), 2) << "Expects two types, one for the input and another for the output";
    const auto* data = types[0].as<TensorTypeNode>();
    if (data == nullptr) {
        ICHECK(types[0].as<IncompleteTypeNode>())
        << "Scanop: expect input type to be TensorType but get " << types[0];
        return false;
    }

    const auto* param = attrs.as<ScanopAttrs>();

    auto dtype = param->dtype;
    if (dtype.is_void()) {
        dtype = data->dtype;
    }

    if (param->axis.defined()) {
        reporter->Assign(types[1], TensorType(data->shape, dtype));
    } else {
        auto prod = data->shape[0];
        for (size_t i = 1; i < data->shape.size(); ++i) {
            prod = prod * data->shape[i];
        }
        reporter->Assign(types[1], TensorType({prod}, dtype));
    }

    return true;
}

3. 将 Arity 和 Attributes 关联到运算#

然后,注册新 ops 的名称,并用调用接口注解它们。C++ 中的 RELAY_REGISTER_OP 宏允许开发人员在 Relay 中指定关于算子的以下信息:

  • Arity(参数数量)

  • 位置参数的名称和描述

  • 支持水平(1 表示内部 intrinsic;较高的数字表示较少的积分或外部支持的算子)”

  • 算子的类型关系

  • 优化运算时有用的其他注解。

再一次把它添加到 src/relay/op/tensor/transform.cc 中:

RELAY_REGISTER_OP("cumsum")
    .describe(
        R"doc(Return the cumulative sum of the elements along a given axis.)doc" TVM_ADD_FILELINE)
    .set_num_inputs(1)
    .add_argument("data", "Tensor", "The input tensor.")
    .set_support_level(3)
    .add_type_rel("Cumsum", ScanopRel)
    .set_attr<TOpPattern>("TOpPattern", kOpaque);

RELAY_REGISTER_OP("cumprod")
    .describe(
        R"doc(Return the cumulative product of the elements along a given axis.)doc" TVM_ADD_FILELINE)
    .set_num_inputs(1)
    .add_argument("data", "Tensor", "The input tensor.")
    .set_support_level(3)
    .add_type_rel("Cumprod", ScanopRel)
    .set_attr<TOpPattern>("TOpPattern", kOpaque);

在这种情况下,TOpPattern 是对编译器关于算子的计算模式的提示,这可能对融合算子很有用。kOpaque 告诉 TVM 不要费心尝试融合这个算子。

4. 定义算子的计算#

虽然已经为运算定义了接口,但仍然需要定义如何执行累积加法和乘法的实际计算。

编写此代码超出了本教程的范围。现在,假设有经过良好测试的运算计算实现。关于如何做到这一点的更多细节,建议查看关于 张量表达式的教程TVM 的算子目录(topi),并查看示例累积加法和乘法实现在 python/tvm/topi/scan.py 和 gpu 版本在 python/tvm/topi/cuda/scan.py 中找到。

5. 使用 Relay 挂钩计算和策略#

在实现了计算函数之后,现在需要将其粘贴到 Relay 运算上。在 TVM 中,这不仅意味着定义计算,还意味着定义运算的调度。策略是一种选择要使用哪个计算和哪个调度的方法。例如,对于二维卷积,可能会认为是在进行 depthwise 卷积并分配到更有效的计算和调度结果。然而在我们的例子中,除了在我们的 CPU 和 GPU 实现之间调度之外,我们没有这样的需求。在 python/tvm/relay/op/strategy/generic.pypython/tvm/relay/op/strategy/cuda.py 中,添加了以下策略:

def wrap_compute_scanop(topi_compute):
    """Wrap scanop style topi compute"""

    def _compute_scanop(attrs, inputs, _):
        return [topi_compute(inputs[0], attrs.axis, attrs.dtype, attrs.exclusive)]

    return _compute_scanop


@override_native_generic_func("cumsum_strategy")
def cumsum_strategy(attrs, inputs, out_type, target):
    """cumsum generic strategy"""
    strategy = _op.OpStrategy()
    strategy.add_implementation(
        wrap_compute_scanop(topi.cumsum),
        wrap_topi_schedule(topi.generic.schedule_extern),
        name="cumsum.generic",
    )
    return strategy


@override_native_generic_func("cumprod_strategy")
def cumprod_strategy(attrs, inputs, out_type, target):
    """cumprod generic strategy"""
    strategy = _op.OpStrategy()
    strategy.add_implementation(
        wrap_compute_scanop(topi.cumprod),
        wrap_topi_schedule(topi.generic.schedule_extern),
        name="cumprod.generic",
    )
    return strategy

@cumsum_strategy.register(["cuda", "gpu"])
def cumsum_strategy_cuda(attrs, inputs, out_type, target):
    """cumsum cuda strategy"""
    strategy = _op.OpStrategy()
    strategy.add_implementation(
        wrap_compute_scanop(topi.cuda.cumsum),
        wrap_topi_schedule(topi.cuda.schedule_scan),
        name="cumsum.cuda",
    )
    return strategy


@cumprod_strategy.register(["cuda", "gpu"])
def cumprod_strategy_cuda(attrs, inputs, out_type, target):
    """cumprod cuda strategy"""
    strategy = _op.OpStrategy()
    strategy.add_implementation(
        wrap_compute_scanop(topi.cuda.cumprod),
        wrap_topi_schedule(topi.cuda.schedule_scan),
        name="cumprod.cuda",
    )
    return strategy

在每个策略中,我们在 add_implementation() 中定义编写的计算和要使用的调度。最后将策略和计算与 python/tvm/relay/op/_transform.py 中定义的 Relay 算子链接起来:

# cumsum
@_reg.register_compute("cumsum")
def compute_cumsum(attrs, inputs, output_type):
    """Compute definition of cumsum"""
    return [topi.cumsum(inputs[0], attrs.axis, attrs.dtype, attrs.exclusive)]


_reg.register_strategy("cumsum", strategy.cumsum_strategy)
_reg.register_shape_func("cumsum", False, elemwise_shape_func)

# cumprod
@_reg.register_compute("cumprod")
def compute_cumprod(attrs, inputs, output_type):
    """Compute definition of cumprod"""
    return [topi.cumprod(inputs[0], attrs.axis, attrs.dtype, attrs.exclusive)]


_reg.register_strategy("cumprod", strategy.cumprod_strategy)
_reg.register_shape_func("cumprod", False, elemwise_shape_func)

在给定动态形状张量的情况下,形状函数用于确定输出形状。在这种情况下,告诉 TVM 输出形状将与输入形状相同。

6. 创建 Relay 调用节点并暴露 Python 钩子#

有了可运行的运算,只需要通过 Relay 调用节点正确地调用它。这一步只需要编写一个函数,它接受算子的参数(作为 Relay 表达式),并向 算子返回调用节点(即,应该放在对算子的调用所在的 Relay AST 中的节点)。

目前不支持调用属性和类型参数(最后两个字段),因此使用 Op::Get 从算子注册表中获取算子的信息并将参数传递给调用节点就足够了,如下所示。在 src/relay/op/tensor/transform.cc

Expr MakeCumsum(Expr data, Integer axis, DataType dtype, Bool exclusive) {
    auto attrs = make_object<ScanopAttrs>();
    attrs->dtype = dtype;
    attrs->axis = axis;
    attrs->exclusive = exclusive;
    static const Op& op = Op::Get("cumsum");
    return Call(op, {data}, Attrs(attrs), {});
}

TVM_REGISTER_GLOBAL("relay.op._make.cumsum").set_body_typed(MakeCumsum);

Expr MakeCumprod(Expr data, Integer axis, DataType dtype, Bool exclusive) {
    auto attrs = make_object<ScanopAttrs>();
    attrs->dtype = dtype;
    attrs->axis = axis;
    attrs->exclusive = exclusive;
    static const Op& op = Op::Get("cumprod");
    return Call(op, {data}, Attrs(attrs), {});
}

TVM_REGISTER_GLOBAL("relay.op._make.cumsum").set_body_typed(MakeCumprod);

其中 TVM_REGISTER_GLOBAL 通过 relay.op._make.cumsum(...)relay.op._make.cumsum(...) 在 Python 中公开 MakeCumsumMakeCumprod 函数。

7. 包括更干净的 Python API 钩子#

这通常是 Relay 中的约定,通过 TVM_REGISTER_GLOBAL 导出的函数应该包装在单独的 Python 函数中,而不是直接在 Python 中调用。对于我们的算子,在 python/tvm/relay/op/transform.py 中公开了这个更干净的接口

def cumsum(data, axis=None, dtype=None, exclusive=None):
    return _make.cumsum(data, axis, dtype, exclusive)

def cumprod(data, axis=None, dtype=None, exclusive=None):
    return _make.cumprod(data, axis, dtype, exclusive)

注意,这些 Python 包装器也可能是为算子提供更简单接口的好机会。例如,concat 算子被注册为只接受一个算子,即一个包含要链接的张量的元组,但 Python 包装器将这些张量作为参数,并在生成调用节点之前将它们组合成一个元组:

def concat(*args):
    """Concatenate the input tensors along the zero axis.

    Parameters
    ----------
    args: list of Tensor

    Returns
    -------
    tensor: The concatenated tensor.
    """
    tup = Tuple(list(args))
    return _make.concat(tup)

8. 编写单元测试!#

这是不言自明的!一些单元测试示例可以在 tests/python/relay/test_op_level3.py 中找到,以获取累积加法和乘积算子。

其他主题#

梯度算子#

梯度算子对于在 Relay 中编写可微程序非常重要。虽然 Relay 的 autodiff 算法可以微分 first-class 语言结构,但算子是不透明的。

“Python 和 C++ 都可以用来编写梯度算子,但我们的例子集中在 Python 上,因为它更常用。”

在 Python 中添加梯度#

Python 梯度算子的集合可以在 python/tvm/relay/op/_tensor_grad.py 中找到。介绍两个代表性的例子: sigmoidmultiply

@register_gradient("sigmoid")
def sigmoid_grad(orig, grad):
    """Returns [grad * sigmoid(x) * (1 - sigmoid(x))]."""
    return [grad * orig * (ones_like(orig) - orig)]

这里的输入是原始的算子 orig 和梯度 grad 进行累加。返回列表,其中第 i 个下标处的元素是算子对第 i 个输入的导数。一般来说,梯度函数会返回包含基本算子输入元素数量的列表。

在进一步分析这个定义之前,首先应该回忆一下 sigmoid 函数的导数: \(\frac{\partial \sigma}{\partial x} = \sigma(x)(1 - \sigma(x))\)。上面的定义看起来与数学定义相似,但有一个重要的补充,将在下面描述。

术语 orig * (ones_like(orig) - orig) 直接匹配导数,因为 orig 在这里是 sigmoid 函数,但我们感兴趣的不仅仅是如何计算这个函数的梯度。我们感兴趣的是将这个梯度与其他梯度组合起来,这样我们就可以在整个程序中累积梯度。这就是 grad 项的由来。在表达式 grad * orig * (ones_like(orig) - orig) 中,乘 grad 指定如何用迄今为止的梯度组合导数。

现在,我们考虑 multiply,一个稍微有趣一点的例子:

@register_gradient("multiply")
def multiply_grad(orig, grad):
    """Returns [grad * y, grad * x]"""
    x, y = orig.args
    return [collapse_sum_like(grad * y, x),
            collapse_sum_like(grad * x, y)]

在本例中,返回的列表中有两个元素,因为 multiply 是二元算子。回想一下,如果 \(f(x, y) = xy\),偏导数是 \(\frac{\partial f}{\partial x} = y\)\(\frac{\partial f}{\partial y} = x\)

multiply 是 one required step,而 sigmoid 不需要,因为 multiply 具有广播语义。因为 grad 的形状可能与输入的形状不匹配,所以使用 collapse_sum_like 来获取 grad * <var> 项的内容,并使形状与我们要微分的输入的形状匹配。

在 C++ 中添加梯度#

在 C++ 中添加梯度与在 Python 中添加梯度类似,但注册的接口略有不同。

首先,确保包含 src/relay/transforms/pattern_utils.h。它提供了在 Relay AST 中创建节点的辅助函数。然后,以与 Python 示例中类似的方式定义梯度:

tvm::Array<Expr> MultiplyGrad(const Expr& orig_call, const Expr& output_grad) {
    const Call& call = orig_call.Downcast<Call>();
    return { CollapseSumLike(Multiply(output_grad, call.args[1]), call.args[0]),
             CollapseSumLike(Multiply(output_grad, call.args[0]), call.args[1]) };
}

注意,在 C++ 中,不能像在 Python 中那样使用相同的算子重载,而且需要向下强制转换,因此实现更加冗长。即便如此,可以很容易地验证这个定义与 Python 中前面的示例相对应。

现在,不使用 Python 装饰器,而是需要为 “FPrimalGradient” 在基本算子注册的末尾附加 set_attr 调用,以便注册梯度。

RELAY_REGISTER_OP("multiply")
    // ...
    // Set other attributes
    // ...
    .set_attr<FPrimalGradient>("FPrimalGradient", MultiplyGrad);