Files
wehub-resource-sync 26446540fa
Lint / lint (push) Has been cancelled
CI / MacOS (push) Has been cancelled
CI / Windows (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:36:25 +08:00

6128 lines
224 KiB
Python

# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ruff: noqa: E501, E731, E741, RUF005
"""ONNX: Open Neural Network Exchange importer for Relax.
This module implements the required functionality to read ONNX models
and convert them into equivalent Relax functions. The entry point that encapsulates
this functionality is the function from_onnx.
In order to extend the functionality of the importer, you can add new
operators to the operator registry. The operator registry is a dictionary
that maps operator names to operator converters. The registry is defined
in the _get_converter_map function. To add a new operator, you can define
a new class that inherits from the OnnxOpConverter class and implement
the _impl method.
By default, ONNX defines models in terms of dynamic shapes. The ONNX importer
retains dynamic shapes upon import, and when possible, the compiler attempts to
convert the model to use static shapes at compile time.
If this fails, there may still be dynamic operations in the model.
Not all TVM kernels currently support dynamic shapes, please file an issue on
github.com/apache/tvm/issues if you hit an error with dynamic kernels.
"""
import contextlib
import functools
import math
import operator
import re
import warnings
from collections.abc import Callable
from typing import Any
import numpy as _np
try:
import onnx.onnx_ml_pb2
except ImportError as err:
raise ImportError(
"onnx is required by the ONNX frontend. Install it with: pip install onnx"
) from err
import tvm_ffi
import tvm
from tvm import relax, tirx, topi
from tvm.ir import IRModule
from tvm.ir.supply import UniqueNameSupply
from tvm.runtime import DataType, DataTypeCode
from tvm.topi.utils import get_const_tuple
from ..common import autopad
def _relax_dtype_is_floating_point(dtype: str) -> bool:
"""Whether a Relax dtype string is a floating point type."""
try:
code = DataType(dtype).type_code
except (ValueError, TypeError, RuntimeError):
return False
return (
code == DataTypeCode.FLOAT
or code == DataTypeCode.BFLOAT
or (code >= DataTypeCode.Float8E3M4 and code <= DataTypeCode.Float4E2M1FN)
)
def get_type(elem_type: str | int) -> str:
"""Converts onnx integer datatype to numpy datatype"""
# If a string was passed instead of a tensor type, it does not need
# conversion and can be returned.
if isinstance(elem_type, str):
return elem_type
try:
from onnx.helper import ( # pylint: disable=import-outside-toplevel
tensor_dtype_to_np_dtype,
)
except ImportError as exception:
raise ImportError(f"Unable to import onnx which is required {exception}")
return str(tensor_dtype_to_np_dtype(elem_type))
def get_constant(
var: relax.Constant | relax.Var,
params: list[dict[str, relax.Var]],
) -> relax.Constant | relax.Var:
"""Attempt to convert a variable to a constant if possible.
This is the primary function meant to interact with params.
Parameters
----------
var: Union[relax.Constant, relax.Var]
The input value to try to convert to a constant.
params: List[Dict[str, relax.Var]]
The parameters for the graph. Contains both the global registry of nodes
for the graph and the parameter dictionary. The global registry is updated
with a constant value if possible.
Returns
-------
var : Union[relax.Constant, relax.Var]
The input value converted to a constant if possible. If the value
isn't found in params, the input variable is returned unmodified.
"""
# Params is actually both the graph nodes and param dictionary, unpack them.
graph_nodes, params = params
# Convert if possible
if isinstance(var, relax.Var) and var.name_hint in params:
# When converting a parameter to a constant, update references to it as well.
_, value = params[var.name_hint]
const_value = relax.const(value)
graph_nodes[var.name_hint] = const_value
return const_value
# Otherwise return variable.
else:
return var
def get_value(token, value_dict: dict[str, tvm.tirx.Var]) -> int | tvm.tirx.Var:
"""Convert a token to an integer constant or symbolic variable.
Parameters
----------
token: str
current token to decode.
value_dict: Dict
The dictionary mapping ValueInfoProto names to symbolic variables.
Returns
-------
Union[int, tvm.tirx.Var]
The decoded token
"""
try:
return int(token)
except ValueError:
if token not in value_dict or token == "?":
value_dict[token] = tvm.tirx.Var(token, "int64")
value = value_dict[token]
return value
def parse_shape_name(name: str, value_dict: dict[str, tvm.tirx.Var]) -> tirx.Expr | tvm.tirx.Var:
"""Converts expressions in the shape dimension name to prim expressions.
Parameters
----------
name: str
name of shape dimension.
value_dict: Dict
The dictionary mapping ValueInfoProto names to symbolic variables.
Returns
-------
Union[tirx.Expr, tvm.tirx.Var]
The expression of the shape dimension.
"""
tokens = re.split(r"(\+|\-|\*|\/\/|\/)", name.replace(" ", ""))
operators = {
"+": operator.add,
"-": operator.sub,
"*": operator.mul,
"/": operator.floordiv, # is floordiv since the operands are always int
"//": operator.floordiv,
}
value_stack = []
operator_stack = []
for token in tokens:
if token in operators:
operator_stack.append(token)
else:
value = get_value(token, value_dict)
if value_stack and operator_stack:
prev_value = value_stack.pop()
op = operator_stack.pop()
result = operators[op](prev_value, value)
value_stack.append(result)
else:
value_stack.append(value)
if value_stack:
return value_stack[0]
else:
raise Exception("Shape dimension could not be inferred")
def get_info(
info_proto: onnx.onnx_ml_pb2.ValueInfoProto, value_dict: dict[str, tvm.tirx.Var]
) -> tuple[str, list, str, list, dict]:
"""Extract the shape from a ValueInfoProto.
Parameters
----------
info_proto: onnx.onnx_ml_pb2.ValueInfoProto
The ValueInfoProto to extract the info from.
value_dict: Dict
The dictionary mapping ValueInfoProto names to symbolic variables.
Returns
-------
Tuple[str, List, str, List, Dict]
The name, shape, type, and shape name of the ValueInfoProto, and the
value_dict.
"""
shape = []
shape_name = []
for dim in info_proto.type.tensor_type.shape.dim:
name = dim.dim_param
value = dim.dim_value
if value is None or value == 0:
value = parse_shape_name(name, value_dict)
shape_name.append(name)
else:
shape_name.append(value)
shape.append(value)
name = info_proto.name
if info_proto.type.tensor_type.elem_type:
dtype = get_type(info_proto.type.tensor_type.elem_type)
else:
dtype = None
return name, shape, dtype, shape_name, value_dict
def get_numpy(tensor_proto: onnx.onnx_ml_pb2.TensorProto) -> _np.ndarray:
"""Grab data in TensorProto and convert to numpy array."""
try:
from onnx.numpy_helper import to_array # pylint: disable=import-outside-toplevel
except ImportError as exception:
raise ImportError(f"Unable to import onnx which is required {exception}")
return to_array(tensor_proto)
def get_prim_expr_list(
inputs: relax.Constant | relax.ShapeExpr,
) -> list[int | tirx.Expr]:
"""Attempt to convert a variable to list of Expr if possible.
Parameters
----------
inputs : Union[relax.Constant, relax.ShapeExpr, tvm.tirx.Expr]
The input value to try to convert to a list of Expr.
Returns
-------
ret : List[Union[int, tirx.Expr]]
The input value converted to a list of Expr if possible.
"""
if isinstance(inputs, relax.Constant):
np_value = inputs.data.numpy()
if np_value.ndim != 1:
raise ValueError(f"Cannot cast {type(inputs)} to list of Expr")
return np_value.tolist()
elif isinstance(inputs, relax.ShapeExpr):
return inputs.values
elif tvm.ir.is_prim_expr(inputs):
return [inputs]
else:
raise ValueError(f"Cannot cast {type(inputs)} to list of Expr")
class onnx_input(list): # pylint: disable=invalid-name
"""A list that returns None when out-of-bounds indices are accessed."""
def __getitem__(self, item):
if isinstance(item, slice):
if item.stop is None:
stop = len(self)
else:
stop = item.stop
indices = list(range(stop)[item])
return [self[i] for i in indices]
if isinstance(item, int):
return list(self)[item] if item < len(self) else None
raise TypeError(f"list indices must be integers or slices, not {type(item).__name__}")
# pylint: disable=invalid-name, len-as-condition, unused-argument, too-many-lines, redefined-builtin
class OnnxOpConverter:
"""A helper class for holding the common logic for ONNX op converters.
Each converter maps to a single ONNX op and defines the equivalent
functionality using Relax expressions. The converter can define multiple versions
of the op and the version is selected based on the opset version of the model.
"""
@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.
"""
impl_versions = sorted(int(d.replace("_impl_v", "")) for d in dir(cls) if "_impl_v" in d)
# Select the largest implemented version that is <= opset.
# If opset is below all implementations, fall back to the smallest.
candidates = [v for v in impl_versions if v <= opset]
version = max(candidates) if candidates else impl_versions[0]
if hasattr(cls, f"_impl_v{version}"):
return getattr(cls, f"_impl_v{version}")
raise NotImplementedError(f"opset version {version} of {cls.__name__} not implemented")
class QuantizeLinear(OnnxOpConverter):
@classmethod
def _impl_v10(cls, bb, inputs, attr, params):
x, scale = inputs[0], inputs[1]
zp = inputs[2] if len(inputs) > 2 and inputs[2] is not None else None
axis = attr.get("axis", 1)
if hasattr(x.ty, "ndim") and x.ty.ndim <= 1 and axis == 1:
axis = 0
out_dtype = "uint8" if zp is None else zp.ty.dtype.dtype
if zp is None:
zp = relax.const(0, out_dtype)
return relax.op.quantize(x, scale, zp, axis=axis, out_dtype=out_dtype)
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
x, scale = inputs[0], inputs[1]
zp = inputs[2] if len(inputs) > 2 and inputs[2] is not None else None
axis = attr.get("axis", 1)
if hasattr(x.ty, "ndim") and x.ty.ndim <= 1 and axis == 1:
axis = 0
out_dtype = "uint8" if zp is None else zp.ty.dtype.dtype
if zp is None:
zp = relax.const(0, out_dtype)
return relax.op.quantize(x, scale, zp, axis=axis, out_dtype=out_dtype)
class DequantizeLinear(OnnxOpConverter):
@classmethod
def _impl_v10(cls, bb, inputs, attr, params):
x, scale = inputs[0], inputs[1]
zp = inputs[2] if len(inputs) > 2 and inputs[2] is not None else None
axis = attr.get("axis", 1)
if hasattr(x.ty, "ndim") and x.ty.ndim <= 1 and axis == 1:
axis = 0
if zp is None:
zp = relax.const(0, x.ty.dtype.dtype)
return relax.op.dequantize(x, scale, zp, axis=axis, out_dtype="float32")
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
x, scale = inputs[0], inputs[1]
zp = inputs[2] if len(inputs) > 2 and inputs[2] is not None else None
axis = attr.get("axis", 1)
if hasattr(x.ty, "ndim") and x.ty.ndim <= 1 and axis == 1:
axis = 0
if zp is None:
zp = relax.const(0, x.ty.dtype.dtype)
return relax.op.dequantize(x, scale, zp, axis=axis, out_dtype="float32")
class DynamicQuantizeLinear(OnnxOpConverter):
@classmethod
def _impl_v11(cls, bb, inputs, attr, params):
x = inputs[0]
x_dtype = x.ty.dtype.dtype
qmin = relax.const(0, x_dtype)
qmax = relax.const(255, x_dtype)
x_max = relax.op.maximum(qmin, relax.op.max(x))
x_min = relax.op.minimum(qmin, relax.op.min(x))
y_scale = relax.op.divide(relax.op.subtract(x_max, x_min), qmax)
zp_fp = relax.op.subtract(qmin, relax.op.divide(x_min, y_scale))
y_zero_point = relax.op.astype(relax.op.round(relax.op.clip(zp_fp, 0, 255)), "uint8")
y = relax.op.quantize(x, y_scale, y_zero_point, axis=0, out_dtype="uint8")
return relax.Tuple([y, y_scale, y_zero_point])
class MatMul(OnnxOpConverter):
"""Converts an onnx MatMul node into an equivalent Relax expression."""
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
return relax.op.matmul(inputs[0], inputs[1])
class MatMulInteger16(OnnxOpConverter):
"""Converts an ONNX MatMulInteger16 node into an equivalent Relax expression."""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
if len(inputs) != 2:
raise ValueError(f"MatMulInteger16 expects two inputs, but got {len(inputs)}")
a, b = inputs
valid_types = ["int16", "uint16"]
a_dtype = a.ty.dtype.dtype
b_dtype = b.ty.dtype.dtype
if a_dtype not in valid_types:
raise ValueError(
"MatMulInteger16 expects input A to have int16 or uint16 dtype, "
f"but got {a.ty.dtype}"
)
if b_dtype not in valid_types:
raise ValueError(
"MatMulInteger16 expects input B to have int16 or uint16 dtype, "
f"but got {b.ty.dtype}"
)
out_dtype = "uint32" if a_dtype == "uint16" and b_dtype == "uint16" else "int32"
return relax.op.matmul(
relax.op.astype(a, out_dtype),
relax.op.astype(b, out_dtype),
)
def _to_numpy(x):
if tvm.ir.is_prim_expr(x):
if isinstance(x, tirx.IntImm | tirx.FloatImm):
return _np.array(x.value)
return x
else:
return x.data.numpy()
class _EmptyOptional:
"""Sentinel object that preserves an empty ONNX Optional during import."""
def __init__(self, type_proto: onnx.onnx_ml_pb2.TypeProto):
self.type_proto = type_proto
def _is_empty_optional(value: Any) -> bool:
"""Returns whether the given value represents an empty ONNX Optional."""
return isinstance(value, _EmptyOptional)
class BinaryBase(OnnxOpConverter):
"""Converts an onnx BinaryBase node into an equivalent Relax expression."""
numpy_op: Callable = None
relax_op: Callable = None
@classmethod
def base_impl(cls, bb, inputs, attr, params):
"""Base implementation for binary operations."""
if cls.numpy_op is None or cls.relax_op is None:
raise ValueError("Numpy and Relax operators must be defined for BinaryBase.")
if all([not isinstance(inp, tvm.ir.Call | relax.Var) for inp in inputs]):
has_prim_expr = any([tvm.ir.is_prim_expr(inp) for inp in inputs])
x = _to_numpy(inputs[0])
y = _to_numpy(inputs[1])
output = cls.numpy_op(x, y) # pylint: disable=not-callable
if has_prim_expr:
if hasattr(output, "item"):
output = output.item()
return relax.prim_value(output)
if x.dtype == y.dtype:
# no numpy precision widening
output = output.astype(x.dtype)
if all([isinstance(inp, relax.Constant) for inp in inputs]):
return relax.const(output, output.dtype) # pylint: disable=not-callable
return cls.relax_op(inputs[0], inputs[1]) # pylint: disable=not-callable
class Add(BinaryBase):
"""Converts an onnx Add node into an equivalent Relax expression."""
numpy_op = _np.add
relax_op = relax.op.add
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
return cls.base_impl(bb, inputs, attr, params)
class Sub(BinaryBase):
"""Converts an onnx Sub node into an equivalent Relax expression."""
numpy_op = _np.subtract
relax_op = relax.op.subtract
@classmethod
def _impl_v7(cls, bb, inputs, attr, params):
return cls.base_impl(bb, inputs, attr, params)
class Mul(BinaryBase):
"""Converts an onnx Mul node into an equivalent Relax expression."""
numpy_op = _np.multiply
relax_op = relax.op.multiply
@classmethod
def _impl_v7(cls, bb, inputs, attr, params):
return cls.base_impl(bb, inputs, attr, params)
class Div(BinaryBase):
"""Converts an onnx Div node into an equivalent Relax expression."""
numpy_op = _np.divide
relax_op = relax.op.divide
@classmethod
def _impl_v7(cls, bb, inputs, attr, params):
try:
lhs_code = DataType(inputs[0].ty.dtype.dtype).type_code
rhs_code = DataType(inputs[1].ty.dtype.dtype).type_code
except (AttributeError, ValueError, TypeError, RuntimeError):
return cls.base_impl(bb, inputs, attr, params)
lhs_is_integer = lhs_code == DataTypeCode.INT or lhs_code == DataTypeCode.UINT
rhs_is_integer = rhs_code == DataTypeCode.INT or rhs_code == DataTypeCode.UINT
if not (lhs_is_integer and rhs_is_integer):
return cls.base_impl(bb, inputs, attr, params)
if isinstance(inputs[1], relax.Constant) and bool(_np.any(inputs[1].data.numpy() == 0)):
raise ValueError("ONNX Div with integer inputs encountered divisor value 0.")
return cls.base_impl(bb, inputs, attr, params)
class Pow(BinaryBase):
"""Converts an onnx Pow node into an equivalent Relax expression."""
numpy_op = _np.power
relax_op = relax.op.power
@classmethod
def _impl_v7(cls, bb, inputs, attr, params):
return cls.base_impl(bb, inputs, attr, params)
class Mod(BinaryBase):
"""Converts an onnx Mod node into an equivalent Relax expression."""
numpy_op = _np.mod
relax_op = relax.op.mod
@classmethod
def _impl_v10(cls, bb, inputs, attr, params):
if attr.get("fmod", 0) == 0:
cls.numpy_op = _np.fmod
cls.relax_op = relax.op.floor_mod
else:
cls.numpy_op = _np.mod
cls.relax_op = relax.op.mod
return cls.base_impl(bb, inputs, attr, params)
class And(BinaryBase):
"""Converts an onnx And node into an equivalent Relax expression."""
numpy_op = _np.logical_and
relax_op = relax.op.logical_and
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
return cls.base_impl(bb, inputs, attr, params)
class Or(BinaryBase):
"""Converts an onnx Or node into an equivalent Relax expression."""
numpy_op = _np.logical_or
relax_op = relax.op.logical_or
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
return cls.base_impl(bb, inputs, attr, params)
class Xor(BinaryBase):
"""Converts an onnx Xor node into an equivalent Relax expression."""
numpy_op = _np.logical_xor
relax_op = relax.op.logical_xor
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
return cls.base_impl(bb, inputs, attr, params)
class Less(BinaryBase):
"""Converts an onnx Less node into an equivalent Relax expression."""
numpy_op = _np.less
relax_op = relax.op.less
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
return cls.base_impl(bb, inputs, attr, params)
class LessOrEqual(BinaryBase):
"""Converts an onnx LessEqual node into an equivalent Relax expression."""
numpy_op = _np.less_equal
relax_op = relax.op.less_equal
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
return cls.base_impl(bb, inputs, attr, params)
class Greater(BinaryBase):
"""Converts an onnx Greater node into an equivalent Relax expression."""
numpy_op = _np.greater
relax_op = relax.op.greater
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
return cls.base_impl(bb, inputs, attr, params)
class GreaterOrEqual(BinaryBase):
"""Converts an onnx GreaterEqual node into an equivalent Relax expression."""
numpy_op = _np.greater_equal
relax_op = relax.op.greater_equal
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
return cls.base_impl(bb, inputs, attr, params)
class Equal(OnnxOpConverter):
"""Converts an onnx Equal node into an equivalent Relax expression."""
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
if all([isinstance(inp, relax.Constant) for inp in inputs]):
output = inputs[0].data.numpy() == inputs[1].data.numpy()
return relax.const(output, output.dtype)
elif all([isinstance(inp, relax.Constant | relax.ShapeExpr) for inp in inputs]):
lhs = get_prim_expr_list(inputs[0])
rhs = get_prim_expr_list(inputs[1])
if len(lhs) != len(rhs):
raise ValueError("Cannot compare two tensors with different shapes")
output = [tvm_ffi.structural_equal(l, r) for l, r in zip(lhs, rhs)]
return relax.const(output, "bool")
return relax.op.equal(inputs[0], inputs[1])
class BitwiseBase(BinaryBase):
"""Converts an onnx BitwiseBase node into an equivalent Relax expression."""
@classmethod
def base_impl(cls, bb, inputs, attr, params):
"""Base implementation for bitwise operations."""
valid_types = ["int8", "int16", "int32", "int64", "uint8", "uint16", "uint32", "uint64"]
for num, inp in enumerate(inputs):
if inp.ty.dtype.dtype not in valid_types:
raise ValueError(
f"Bitwise operations expect all inputs to have integer types, "
f"got {inp.ty.dtype} for input {num}"
)
return super().base_impl(bb, inputs, attr, params)
class BitwiseAnd(BitwiseBase):
"""Converts an onnx BitwiseAnd node into an equivalent Relax expression."""
numpy_op = _np.bitwise_and
relax_op = relax.op.bitwise_and
@classmethod
def _impl_v18(cls, bb, inputs, attr, params):
return cls.base_impl(bb, inputs, attr, params)
class BitwiseOr(BitwiseBase):
"""Converts an onnx BitwiseOr node into an equivalent Relax expression."""
numpy_op = _np.bitwise_or
relax_op = relax.op.bitwise_or
@classmethod
def _impl_v18(cls, bb, inputs, attr, params):
return cls.base_impl(bb, inputs, attr, params)
class BitwiseXor(BitwiseBase):
"""Converts an onnx BitwiseXor node into an equivalent Relax expression."""
numpy_op = _np.bitwise_xor
relax_op = relax.op.bitwise_xor
@classmethod
def _impl_v18(cls, bb, inputs, attr, params):
return cls.base_impl(bb, inputs, attr, params)
class BitwiseNot(OnnxOpConverter):
"""Converts an onnx BitwiseNot node into an equivalent Relax expression."""
@classmethod
def _impl_v18(cls, bb, inputs, attr, params):
if isinstance(inputs[0], relax.Constant):
return relax.const(_np.bitwise_not(inputs[0].data.numpy()), inputs[0].ty.dtype)
return relax.op.bitwise_not(inputs[0])
class BitShift(BitwiseBase):
"""Converts an onnx BitShift node into an equivalent Relax expression."""
@classmethod
def _impl_v11(cls, bb, inputs, attr, params):
direction = attr.get("direction", "LEFT").decode("ascii")
if direction == "LEFT":
cls.numpy_op = _np.left_shift
cls.relax_op = relax.op.left_shift
elif direction == "RIGHT":
cls.numpy_op = _np.right_shift
cls.relax_op = relax.op.right_shift
else:
raise ValueError("Unsupported Shift Direction: " + direction)
return cls.base_impl(bb, inputs, attr, params)
class Sigmoid(OnnxOpConverter):
"""Converts an onnx Sigmoid node into an equivalent Relax expression."""
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
return relax.op.sigmoid(inputs[0])
def _normalize_legacy_softmax_axis(axis: int, rank: int, op_name: str) -> int:
"""Normalize axis for ONNX Softmax/LogSoftmax/Hardmax opset <= 12 semantics.
Legacy semantics allow axis in [-rank, rank], where axis == rank means the
last dimension after flattening has extent 1.
"""
if axis < -rank or axis > rank:
raise ValueError(f"{op_name} axis {axis} is out of range for rank {rank}.")
if axis < 0:
axis += rank
return axis
def _shape_product(dims: list[int | tirx.Expr]) -> int | tirx.Expr:
"""Compute product of a list of shape dims (supports symbolic dims)."""
prod = 1
for dim in dims:
if isinstance(dim, tirx.IntImm):
dim = int(dim.value)
prod = prod * dim
return prod
def _legacy_softmax_prepare(
data: relax.Expr, axis: int, op_name: str
) -> tuple[relax.Expr, tuple[int | tirx.Expr, ...]] | None:
"""Build legacy 2D view for Softmax-family opset <= 12 semantics.
Returns (reshaped_data, original_shape). If rank/shape isn't statically
available, returns None so caller can choose a permissive fallback.
"""
rank = _get_known_tensor_rank(data)
if rank is None:
return None
axis = _normalize_legacy_softmax_axis(axis, rank, op_name)
ty = data.ty
if not isinstance(ty, relax.TensorType):
return None
if not isinstance(ty.shape, relax.ShapeExpr):
return None
original_shape = list(ty.shape.values)
if len(original_shape) != rank:
return None
dim0 = _shape_product(original_shape[:axis])
dim1 = _shape_product(original_shape[axis:])
flattened = relax.op.reshape(data, (dim0, dim1))
return flattened, tuple(original_shape)
def _get_axis_extent(data: relax.Expr, axis: int, op_name: str) -> tuple[int, int | tirx.Expr]:
"""Return normalized axis and axis extent when rank/shape are known."""
rank = _get_known_tensor_rank(data)
if rank is None:
raise ValueError(f"{op_name} requires a statically known input rank.")
normalized_axis = _normalize_constant_axes([axis], rank, op_name)[0]
ty = data.ty
if isinstance(ty, relax.TensorType) and isinstance(ty.shape, relax.ShapeExpr):
axis_extent = ty.shape.values[normalized_axis]
if isinstance(axis_extent, tirx.IntImm):
axis_extent = int(axis_extent.value)
return normalized_axis, axis_extent
raise ValueError(f"{op_name} requires a statically known axis extent.")
class Softmax(OnnxOpConverter):
"""Converts an onnx Softmax node into an equivalent Relax expression."""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
axis = attr.get("axis", 1)
prepared = _legacy_softmax_prepare(inputs[0], axis, "Softmax")
if prepared is None:
warnings.warn(
"Softmax opset<=12 fallback: static rank/shape is unavailable, "
"falling back to axis-based softmax semantics."
)
return relax.op.nn.softmax(inputs[0], axis=axis)
flattened, original_shape = prepared
out = relax.op.nn.softmax(flattened, axis=-1)
return relax.op.reshape(out, original_shape)
_impl_v11 = _impl_v1
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
axis = attr.get("axis", -1)
return relax.op.nn.softmax(inputs[0], axis=axis)
class LogSoftmax(OnnxOpConverter):
"""Converts an onnx LogSoftmax node into an equivalent Relax expression."""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
axis = attr.get("axis", 1)
prepared = _legacy_softmax_prepare(inputs[0], axis, "LogSoftmax")
if prepared is None:
warnings.warn(
"LogSoftmax opset<=12 fallback: static rank/shape is unavailable, "
"falling back to axis-based log_softmax semantics."
)
return relax.op.nn.log_softmax(inputs[0], axis=axis)
flattened, original_shape = prepared
out = relax.op.nn.log_softmax(flattened, axis=-1)
return relax.op.reshape(out, original_shape)
_impl_v11 = _impl_v1
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
axis = attr.get("axis", -1)
return relax.op.nn.log_softmax(inputs[0], axis=axis)
class Hardmax(OnnxOpConverter):
"""Converts an onnx Hardmax node into an equivalent Relax expression."""
@classmethod
def _hardmax_impl(cls, *args):
"""Hardmax core implementation.
Compatibility note:
- New signature: _hardmax_impl(bb, data, axis)
- Legacy signature: _hardmax_impl(data, axis)
"""
if len(args) == 3:
bb, data, axis = args
elif len(args) == 2:
bb = None
data, axis = args
else:
raise TypeError("Hardmax._hardmax_impl expects (bb, data, axis) or (data, axis).")
if bb is not None:
data = bb.normalize(data)
normalized_axis, axis_extent = _get_axis_extent(data, axis, "Hardmax")
dtype = data.ty.dtype
argmax = relax.op.argmax(data, axis=normalized_axis)
on_value = relax.prim_value(tvm.tirx.const(1.0, dtype))
off_value = relax.prim_value(tvm.tirx.const(0.0, dtype))
return relax.op.one_hot(argmax, on_value, off_value, axis_extent, normalized_axis)
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
axis = attr.get("axis", 1)
prepared = _legacy_softmax_prepare(inputs[0], axis, "Hardmax")
if prepared is None:
warnings.warn(
"Hardmax opset<=12 fallback: static rank/shape is unavailable, "
"falling back to axis-based hardmax semantics."
)
hardmax_input = inputs[0]
hardmax_axis = axis
original_shape = None
else:
hardmax_input, original_shape = prepared
hardmax_axis = -1
out = cls._hardmax_impl(bb, hardmax_input, hardmax_axis)
return out if original_shape is None else relax.op.reshape(out, original_shape)
_impl_v11 = _impl_v1
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
axis = attr.get("axis", -1)
return cls._hardmax_impl(bb, inputs[0], axis)
class Transpose(OnnxOpConverter):
"""Converts an onnx Transpose node into an equivalent Relax expression."""
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
data = inputs[0]
axes = attr.get("perm", None)
if hasattr(data.ty, "ndim"):
input_ndim = data.ty.ndim
elif hasattr(data.ty, "shape") and data.ty.shape:
input_ndim = len(data.ty.shape)
else:
if isinstance(data, relax.Constant):
input_ndim = data.data.numpy().ndim
else:
input_ndim = None
if input_ndim == 0:
return data
if input_ndim is not None and axes is not None:
if len(axes) != input_ndim:
raise ValueError(
f"Transpose: number of axes in perm attribute ({len(axes)}) "
f"must equal the number of input tensor dimensions ({input_ndim})"
)
if isinstance(data, relax.Constant):
output = _np.transpose(data.data.numpy(), axes)
return relax.const(output, output.dtype)
return relax.op.permute_dims(data, axes)
class Unsqueeze(OnnxOpConverter):
"""Converts an onnx Unsqueeze node into an equivalent Relax expression."""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
axes = list(attr.get("axes"))
inputs = inputs + [relax.const(axes, "int64")]
return cls._impl_v13(bb, inputs, attr, params)
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
data = inputs[0]
axes = get_constant(inputs[1], params)
data_ndim = _get_known_tensor_rank(data)
if tvm.ir.is_prim_expr(data) and isinstance(axes, relax.Constant):
constant_axes = _normalize_constant_axes(
list(map(int, axes.data.numpy().tolist())), 1, "Unsqueeze"
)
if constant_axes == [0]:
return relax.ShapeExpr([data])
raise NotImplementedError("Unsqueeze with symbolic scalar inputs only supports axis 0.")
if isinstance(data, relax.Constant) and isinstance(axes, relax.Constant):
constant_axes = _normalize_constant_axes(
list(map(int, axes.data.numpy().tolist())),
data.data.numpy().ndim + axes.data.numpy().size,
"Unsqueeze",
)
constant_axes = sorted(constant_axes)
expanded = data.data.numpy()
output_rank = expanded.ndim + len(constant_axes)
new_shape = []
input_dims_iter = iter(expanded.shape)
for i in range(output_rank):
if i in constant_axes:
new_shape.append(1)
else:
new_shape.append(next(input_dims_iter))
expanded = expanded.reshape(new_shape)
return relax.const(expanded, data.ty.dtype)
if isinstance(axes, relax.Constant):
if data_ndim is None:
raise ValueError("Unsqueeze requires a statically known input rank.")
constant_axes = _normalize_constant_axes(
list(map(int, axes.data.numpy().tolist())),
data_ndim + axes.data.numpy().size,
"Unsqueeze",
)
constant_axes = sorted(constant_axes)
for axis in constant_axes:
data = relax.op.expand_dims(data, axis=axis)
return data
if data_ndim is None:
raise ValueError("Unsqueeze with dynamic axes requires a statically known input rank.")
axes_len = _get_known_tensor_length(axes)
if axes_len is None:
raise ValueError("Unsqueeze requires a statically known axes length.")
data_shape = bb.normalize(relax.op.shape_of(data))
data_shape_tensor = bb.normalize(relax.op.shape_to_tensor(data_shape))
output_shape_tensor = _build_unsqueezed_shape_tensor(bb, data_shape_tensor, axes, data_ndim)
output_shape = _tensor_to_shape_expr(
bb, output_shape_tensor, data_ndim + axes_len, "unsqueeze_dim"
)
return relax.op.reshape(data, output_shape)
class Concat(OnnxOpConverter):
"""Convert an onnx Concat node into an equivalent Relax expression."""
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
axis = attr.get("axis", 0)
_, param_dict = params
def is_shape_like(x: Any) -> bool:
if isinstance(x, relax.ShapeExpr):
return True
elif isinstance(x, relax.Constant):
return x.ty.ndim == 1 and x.ty.dtype == "int64"
else:
return False
# Resolve 1D-int64 param Vars to constants only for the shape-like
# fast path; tensor fallback keeps the original Vars so runtime
# weights aren't folded under keep_params_in_input=True.
def resolve(x):
if isinstance(x, relax.Var) and x.name_hint in param_dict:
arr = param_dict[x.name_hint][1].numpy()
if arr.ndim == 1 and arr.dtype == _np.int64:
return relax.const(arr, "int64")
return x
resolved = [resolve(inp) for inp in inputs]
# If all inputs are shape expr, perform computation directly.
if all([is_shape_like(inp) for inp in resolved]):
const_inputs = []
for inp in resolved:
if isinstance(inp, relax.ShapeExpr):
const_inputs.extend(inp.values)
elif isinstance(inp, relax.Constant):
const_inputs.extend(inp.data.numpy().tolist())
else:
raise NotImplementedError(f"Unsupported input type: {type(inp)}")
return relax.ShapeExpr(const_inputs)
# If all inputs are constant, perform computation directly.
if all([isinstance(inp, relax.Constant) for inp in inputs]):
const_inputs = []
for inp in inputs:
const_inputs.append(inp.data.numpy())
out = _np.concatenate(const_inputs, axis=axis)
dtype = inputs[0].ty.dtype
return relax.const(out, dtype)
return relax.op.concat(inputs, axis=axis)
class Cast(OnnxOpConverter):
"""Convert an onnx Cast node into an equivalent Relax expression."""
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
to_type = get_type(attr["to"])
if isinstance(inputs[0], relax.ShapeExpr):
shape = inputs[0]
if all([isinstance(x, tirx.IntImm) for x in shape]):
shape = [int(x) for x in shape]
return relax.const(shape, to_type)
if isinstance(inputs[0], relax.Constant):
output = inputs[0].data.numpy().astype(to_type)
return relax.const(output, to_type)
if tvm.ir.is_prim_expr(inputs[0]):
if isinstance(inputs[0], tirx.IntImm | tirx.FloatImm):
return tvm.tirx.const(inputs[0].value, to_type)
return inputs[0].astype(to_type)
try:
np_dst = _np.dtype(str(to_type))
except Exception:
return relax.op.astype(inputs[0], to_type)
if np_dst.kind in ("i", "u"):
src = inputs[0]
src_dtype = getattr(getattr(src, "ty", None), "dtype", None) or getattr(
src, "dtype", None
)
if src_dtype is not None and _relax_dtype_is_floating_point(src_dtype):
x_sanitized = bb.emit(
relax.op.where(
relax.op.logical_not(relax.op.isfinite(src)),
relax.const(0.0, src_dtype),
src,
)
)
dst_str = str(to_type)
if dst_str.startswith("uint"):
signed = False
bits = int(dst_str[4:])
elif dst_str.startswith("int"):
signed = True
bits = int(dst_str[3:])
else:
return relax.op.astype(x_sanitized, to_type)
if bits == 64:
return relax.op.astype(x_sanitized, to_type)
temp_dtype = "int64" if bits >= 32 else "int32"
t = relax.op.astype(x_sanitized, temp_dtype)
if bits == 32:
two_pow = relax.const(1 << bits, temp_dtype)
uw = relax.op.floor_mod(t, two_pow)
else:
mask_val = (1 << bits) - 1
mask = relax.const(mask_val, temp_dtype)
uw = relax.op.bitwise_and(t, mask)
if signed:
half = 1 << (bits - 1)
half_c = relax.const(half, temp_dtype)
if bits == 32:
two_pow = relax.const(1 << bits, temp_dtype)
else:
two_pow = relax.op.add(mask, relax.const(1, temp_dtype))
wrapped = relax.op.where(
relax.op.greater_equal(uw, half_c),
relax.op.subtract(uw, two_pow),
uw,
)
else:
wrapped = uw
return relax.op.astype(wrapped, to_type)
return relax.op.astype(inputs[0], to_type)
class Gather(OnnxOpConverter):
"""Convert an onnx Gather node into an equivalent Relax expression."""
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
# Unpack inputs
data = inputs[0]
indices = inputs[1]
axis = attr.get("axis", 0)
# If all inputs are constant, we can compute directly.
if all([isinstance(inp, relax.Constant) for inp in [data, indices]]):
output = _np.take(data.data.numpy(), indices.data.numpy(), axis=axis)
return relax.const(output, output.dtype)
# If input is a shape expression, take a value from that shape and return it as a constant.
if isinstance(data, relax.ShapeExpr):
assert isinstance(indices, relax.Constant), (
"Only constant indices supported for shape gather."
)
np_index = indices.data.numpy()
if len(np_index.shape) == 1:
np_index = np_index[0]
np_index = int(np_index)
shape_val = data[np_index]
return relax.prim_value(shape_val)
indices_dtype = indices.ty.dtype.dtype
if not indices_dtype.startswith("uint"):
data_shape = bb.normalize(relax.op.shape_of(data))
data_shape_tensor = bb.normalize(relax.op.shape_to_tensor(data_shape))
axis_extent = bb.normalize(
relax.op.take(data_shape_tensor, relax.const(axis, "int64"), axis=0, mode="wrap")
)
if indices_dtype != "int64":
axis_extent = bb.normalize(relax.op.astype(axis_extent, indices_dtype))
indices = bb.normalize(
relax.op.where(
relax.op.less(indices, relax.const(0, indices_dtype)),
relax.op.add(indices, axis_extent),
indices,
)
)
return relax.op.take(data, indices, axis)
class GatherElements(OnnxOpConverter):
"""Convert an onnx GatherElements node into an equivalent Relax expression."""
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
axis = attr.get("axis", 0)
return relax.op.gather_elements(inputs[0], inputs[1], axis)
class GatherND(OnnxOpConverter):
"""Convert an onnx GatherND node into an equivalent Relax expression."""
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
batch_dims = attr.get("batch_dims", 0)
return relax.op.gather_nd(inputs[0], inputs[1], batch_dims)
class Scatter(OnnxOpConverter):
"""Convert an onnx Scatter node into an equivalent Relax expression."""
@classmethod
def _impl_v9(cls, bb, inputs, attr, params):
axis = attr.get("axis", 0)
return relax.op.scatter_elements(inputs[0], inputs[1], inputs[2], axis=axis)
@classmethod
def _impl_v11(cls, bb, inputs, attr, params):
raise ValueError("Scatter is deprecated in ONNX 11")
def _get_onnx_reduction(attr, valid_reductions: list[str]):
reduction = attr.get("reduction", None)
reduction = reduction or b"update"
if isinstance(reduction, bytes):
reduction = reduction.decode("utf-8")
reduction = "update" if reduction == "none" else reduction
if reduction not in valid_reductions:
raise ValueError(f"Only {valid_reductions} reductions are supported, but got {reduction}")
return reduction
class ScatterElements(OnnxOpConverter):
"""Convert an onnx ScatterElements node into an equivalent Relax expression."""
@classmethod
def _impl_v11(cls, bb, inputs, attr, params):
axis = attr.get("axis", 0)
return relax.op.scatter_elements(inputs[0], inputs[1], inputs[2], axis=axis)
@classmethod
def _impl_v16(cls, bb, inputs, attr, params):
axis = attr.get("axis", 0)
reduction = _get_onnx_reduction(attr, ["update", "add", "mul"])
return relax.op.scatter_elements(
inputs[0], inputs[1], inputs[2], axis=axis, reduction=reduction
)
@classmethod
def _impl_v18(cls, bb, inputs, attr, params):
axis = attr.get("axis", 0)
reduction = _get_onnx_reduction(attr, ["update", "add", "mul", "min", "max"])
return relax.op.scatter_elements(
inputs[0], inputs[1], inputs[2], axis=axis, reduction=reduction
)
class ScatterND(OnnxOpConverter):
"""Convert an onnx ScatterND node into an equivalent Relax expression."""
@staticmethod
def _reduction_check(attr, valid_reductions: list[str]):
return _get_onnx_reduction(attr, valid_reductions)
@classmethod
def _impl_v11(cls, bb, inputs, attr, params):
return relax.op.scatter_nd(inputs[0], inputs[1], inputs[2])
@classmethod
def _impl_v16(cls, bb, inputs, attr, params):
reduction = cls._reduction_check(attr, ["update", "add", "mul"])
return relax.op.scatter_nd(inputs[0], inputs[1], inputs[2], reduction)
@classmethod
def _impl_v18(cls, bb, inputs, attr, params):
reduction = cls._reduction_check(attr, ["update", "add", "mul", "min", "max"])
return relax.op.scatter_nd(inputs[0], inputs[1], inputs[2], reduction)
class Compress(OnnxOpConverter):
"""Convert an onnx Compress node into an equivalent Relax expression."""
@classmethod
def _impl_v11(cls, bb, inputs, attr, params):
tensor, condition = inputs
axis = attr.get("axis", None)
# Change one hot tensor to indices e.g. [0, 1, 1, 0, 1] -> [1, 2, 4]
if condition.ty.dtype != "bool":
raise ValueError("Condition tensor is expected to be a boolean tensor")
if condition.ty.ndim != 1:
raise ValueError("Condition tensor is expected to be a 1D boolean tensor")
indices = relax.op.nonzero(condition)
num_nonzero = tirx.Var("num_nonzero", "int64")
indices = bb.match_cast(indices, relax.TensorType([1, num_nonzero], "int64"))
indices = relax.op.reshape(indices, [-1])
if axis is not None:
return relax.op.take(tensor, indices, axis=axis)
# if axis is None, flatten input tensor before selection
tensor = relax.op.reshape(tensor, (-1,))
return relax.op.take(tensor, indices, axis=0)
class Size(OnnxOpConverter):
"""Convert an onnx Size node into an equivalent Relax expression."""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
return relax.op.size(inputs[0])
class EyeLike(OnnxOpConverter):
"""Convert an onnx EyeLike node into an equivalent Relax expression."""
@classmethod
def _impl_v9(cls, bb, inputs, attr, params):
k = attr.get("k", 0)
input_dtype = inputs[0].ty.dtype.dtype
if "dtype" in attr and get_type(attr["dtype"]) != input_dtype:
raise ValueError(
f"dtype mismatch between input ({input_dtype}) and attribute ({attr['dtype']})"
)
return relax.op.eye_like(inputs[0], k, input_dtype)
class Gemm(OnnxOpConverter):
"""Convert an onnx Gemm node into an equivalent Relax expression."""
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
alpha = attr.get("alpha", None)
beta = attr.get("beta", None)
transA = attr.get("transA", False)
transB = attr.get("transB", False)
A = inputs[0]
B = inputs[1]
C = inputs[2]
dtype = A.ty.dtype
# Compute Y = alpha * A X B + beta * C
if alpha is not None and alpha != 1.0:
A = relax.op.multiply(A, relax.const(alpha, dtype=dtype))
if transA:
A = relax.op.permute_dims(A, [1, 0])
if transB:
B = relax.op.permute_dims(B, [1, 0])
Y = relax.op.matmul(A, B)
if C is not None:
if beta is not None and beta != 1.0:
C = relax.op.multiply(C, relax.const(beta, dtype=dtype))
Y = relax.op.add(Y, C)
return Y
class Reshape(OnnxOpConverter):
"""Convert an onnx Reshape node into an equivalent Relax expression."""
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
data = inputs[0]
new_shape = get_constant(inputs[1], params)
if isinstance(data, relax.ShapeExpr):
# Preserve identity flatten for shape values to keep shape-specialized
# handling in downstream shape-construction patterns.
if isinstance(new_shape, relax.Constant):
new_shape_values = new_shape.data.numpy().tolist()
if new_shape_values == [-1]:
return data
# Other reshape targets follow regular int64 tensor reshape semantics.
data = bb.normalize(relax.op.shape_to_tensor(data))
if isinstance(data, relax.Constant) and isinstance(new_shape, relax.Constant):
out = _np.reshape(data.data.numpy(), new_shape.data.numpy().tolist())
return relax.const(out, out.dtype)
if isinstance(new_shape, relax.Constant):
new_shape = new_shape.data.numpy().tolist()
out = relax.op.reshape(data, new_shape)
return out
class Where(OnnxOpConverter):
"""Convert an onnx Where node into an equivalent Relax expression."""
@classmethod
def _impl_v16(cls, bb, inputs, attr, params):
if all([isinstance(inp, relax.Constant) for inp in inputs]):
np_inputs = [inp.data.numpy() for inp in inputs]
output = _np.where(*np_inputs)
return relax.const(output, output.dtype)
if all([isinstance(inp, relax.Constant | relax.ShapeExpr) for inp in inputs]):
condition, x, y = [get_prim_expr_list(inp) for inp in inputs]
if len(condition) != len(x) or len(condition) != len(y):
raise ValueError("Cannot broadcast condition to x and y")
output = [x if c else y for c, x, y in zip(condition, x, y)]
return relax.ShapeExpr(output)
return relax.op.where(inputs[0], inputs[1], inputs[2])
class Clip(OnnxOpConverter):
"""Converts an onnx Clip node into an equivalent Relax expression."""
@staticmethod
def _sanitize_nan_clip_bound(bb, bound: relax.Expr, *, for_min: bool) -> relax.Expr:
"""ONNX/ORT treat NaN clip bounds as unbounded; plain max/min with NaN poisons output."""
dtype = bound.ty.dtype
if not _relax_dtype_is_floating_point(dtype):
return bound
repl = -_np.inf if for_min else _np.inf
return bb.emit(relax.op.where(relax.op.isnan(bound), relax.const(repl, dtype), bound))
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
min = float(attr.get("min", -_np.inf))
max = float(attr.get("max", _np.inf))
results = inputs[0]
results = bb.emit_te(topi.maximum, results, min)
results = bb.emit_te(topi.minimum, results, max)
return results
@classmethod
def _impl_v11(cls, bb, inputs, attr, params):
# Opset 11 changed Clip from attribute-based min/max to input-based.
return cls._impl_v13(bb, inputs, attr, params)
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
x: Any = inputs[0]
results = x
if inputs[1] is not None:
lo = cls._sanitize_nan_clip_bound(bb, inputs[1], for_min=True)
results = bb.emit_te(topi.maximum, results, lo)
if inputs[2] is not None:
hi = cls._sanitize_nan_clip_bound(bb, inputs[2], for_min=False)
results = bb.emit_te(topi.minimum, results, hi)
return results
class Shape(OnnxOpConverter):
"""Converts an onnx Equal node into an equivalent Relax expression."""
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
data_info = inputs[0].ty
if isinstance(data_info, relax.ShapeType):
if data_info.ndim == -1:
raise ValueError("The ndim of ShapeExpr is expected to a real number, but got -1.")
return relax.ShapeExpr([data_info.ndim])
# If no shape is defined in the type, it must be computed at runtime.
if not data_info.shape:
data_shape = bb.normalize(relax.op.shape_of(inputs[0]))
return data_shape
return data_info.shape
class Trilu(OnnxOpConverter):
"""Given a 2-D matrix or batches of 2-D matrices, returns the upper or
lower triangular part of the tensor(s)
"""
@classmethod
def _impl_v14(cls, bb, inputs, attr, params):
upper = attr.get("upper", True)
x = inputs[0]
k = inputs[1] if len(inputs) > 1 else 0
if len(inputs) > 1:
k = get_constant(inputs[1], params)
if isinstance(k, relax.Constant):
k = int(k.data.numpy().item())
else:
raise ValueError("Currently only support constant k for Trilu op.")
else:
k = 0
if upper:
return relax.op.triu(x, k)
else:
return relax.op.tril(x, k)
class Relu(OnnxOpConverter):
"""Converts an onnx Relu node into an equivalent Relax expression."""
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
return relax.op.nn.relu(inputs[0])
class Elu(OnnxOpConverter):
"""Converts an onnx Elu node into an equivalent Relax expression."""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
alpha = float(attr.get("alpha", 1.0))
return relax.expr.const(-alpha) * relax.op.nn.relu(
relax.expr.const(1.0) - relax.op.exp(inputs[0])
) + relax.op.nn.relu(inputs[0])
class Selu(OnnxOpConverter):
"""Converts an onnx Selu node into an equivalent Relax expression."""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
alpha = attr.get("alpha", 1.67326319217681884765625)
gamma = attr.get("gamma", 1.05070102214813232421875)
return relax.const(gamma) * (
relax.const(-alpha) * relax.op.nn.relu(relax.const(1.0) - relax.op.exp(inputs[0]))
+ relax.op.nn.relu(inputs[0])
)
class Mish(OnnxOpConverter):
"""Converts an onnx Mish node into an equivalent Relax expression.
mish(x) = x * tanh(softplus(x)) = x * tanh(ln(1 + e^{x}))
"""
@classmethod
def _impl_v18(cls, bb, inputs, attr, params):
dtype = inputs[0].ty.dtype
return inputs[0] * relax.op.tanh(
relax.op.log(relax.const(1.0, dtype) + relax.op.exp(inputs[0]))
)
class PRelu(OnnxOpConverter):
"""Converts an onnx PRelu node into an equivalent Relax expression.
f(x) = slope * x for x < 0, x for x >= 0
"""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
x = inputs[0]
slope = inputs[1]
x_shape = x.ty.shape
slope_shape = slope.ty.shape
ndim = len(x_shape)
s_ndim = len(slope_shape)
if all(ss == 1 for ss in slope_shape) or s_ndim == 1:
slope = relax.op.reshape(slope, (slope_shape[0],))
return relax.op.nn.prelu(x, slope, ndim - 1)
if s_ndim == ndim:
non_one_axes = [i for i, ss in enumerate(slope_shape) if ss != 1]
# Must have only ONE non-broadcast axis
if len(non_one_axes) != 1:
raise ValueError(
f"Invalid PRelu slope shape (multiple non-broadcast dims): {slope_shape}"
)
axis = non_one_axes[0]
slope = relax.op.reshape(slope, (slope_shape[axis],))
return relax.op.nn.prelu(x, slope, axis)
raise ValueError(f"Unsupported PRelu slope shape: {slope_shape}")
class ThresholdedRelu(OnnxOpConverter):
"""Converts an onnx ThresholdedRelu node into an equivalent Relax expression.
f(x) = x for x > alpha, 0 otherwise
"""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
x = inputs[0]
alpha = attr.get("alpha", 1.0)
return relax.op.greater(x, relax.const(alpha)).astype("float32") * x
class LeakyRelu(OnnxOpConverter):
"""Converts an onnx LeakyRelu node into an equivalent Relax expression.
f(x) = x for x > 0, alpha * x otherwise
"""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
x = inputs[0]
alpha = attr.get("alpha", 0.01)
return relax.op.nn.leakyrelu(x, alpha)
class Gelu(OnnxOpConverter):
"""Operator converter for Gelu.
Supports both Microsoft onnxruntime contrib opset and ONNX Opset 20+.
gelu(x) = 0.5x(1 + erf(x/sqrt(2)))
When approximate="tanh" (ONNX Opset 20):
gelu(x) = 0.5x(1 + tanh(sqrt(2/pi)(x + 0.044715x^3)))
"""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
return relax.op.nn.gelu(inputs[0])
@classmethod
def _impl_v20(cls, bb, inputs, attr, params):
approximate = attr.get("approximate", b"none").decode("utf-8")
if approximate == "tanh":
return relax.op.nn.gelu_tanh(inputs[0])
if approximate == "none":
return relax.op.nn.gelu(inputs[0])
raise ValueError(f"Unsupported approximate mode for Gelu: {approximate}")
class FastGelu(OnnxOpConverter):
"""Operator converter for FastGelu from Microsoft onnxruntime contrib opset.
fast_gelu(x) = 0.5x(1 + tanh(sqrt(2/pi)(x + 0.044715x^3)))
= 0.5x(1 + tanh((sqrt(2/pi)x + 0.044715(sqrt(2/pi)x^3)))
= 0.5x(1 + tanh(c1 * x + c2 * x^3)))
, where
c1 = sqrt(2/pi)
c2 = 0.044715 * sqrt(2/pi)
"""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
x = inputs[0]
if len(inputs) > 1 and inputs[1] is not None:
bias = inputs[1]
bias_shape = bias.ty.shape
assert len(bias_shape) == 1, "bias term must be a 1D tensor"
x = bb.emit(relax.op.add(x, bias))
# Declare consts
const_dtype = x.ty.dtype
half = relax.const(0.5, dtype=const_dtype)
one = relax.const(1.0, dtype=const_dtype)
const1 = relax.const(math.sqrt(2 / math.pi), dtype=const_dtype)
const2 = relax.const(0.044715 * math.sqrt(2 / math.pi), dtype=const_dtype)
# Compute FastGelu
term1 = bb.emit(relax.op.multiply(half, x))
term2 = bb.emit(relax.op.multiply(const1, x))
# use x^3 = x * x * x instead of pow(x, 3) for better performance
x_cubed = bb.emit(relax.op.multiply(relax.op.multiply(x, x), x))
term3 = bb.emit(relax.op.multiply(const2, x_cubed))
tanh = bb.emit(relax.op.tanh(relax.op.add(term2, term3)))
return bb.emit(relax.op.multiply(term1, relax.op.add(one, tanh)))
class BiasGelu(OnnxOpConverter):
"""Operator converter for BiasGelu from Microsoft onnxruntime contrib opset.
bias_gelu(x, b) = 0.5(x + b)(1 + erf((x + b)/sqrt(2)))
"""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
inp = relax.op.add(inputs[0], inputs[1])
return relax.op.nn.gelu(inp)
class Shrink(OnnxOpConverter):
"""Converts an onnx Shrink node into an equivalent Relax expression.
f(x) = x + bias if x > lambd, x - bias if x < -lambd, 0 otherwise
"""
@classmethod
def _impl_v9(cls, bb, inputs, attr, params):
x = inputs[0]
dtype = x.ty.dtype
lambd = relax.const(attr.get("lambd", 0.5), dtype)
bias = relax.const(attr.get("bias", 0.0), dtype)
zeros = relax.op.zeros_like(x)
return relax.op.where(x > lambd, x - bias, zeros) + relax.op.where(
x < -lambd, x + bias, zeros
)
class Conv(OnnxOpConverter):
"""Convert an onnx Conv node into an equivalent Relax expression."""
@classmethod
def _impl_v11(cls, bb, inputs, attr, params):
data = inputs[0]
if hasattr(inputs[0].ty, "ndim"):
ndim = inputs[0].ty.ndim
else:
ndim = len(inputs[0].ty.shape)
if "kernel_shape" not in attr:
attr["kernel_shape"] = inputs[1].ty.shape.values[2:]
if ndim == 3:
op = relax.op.nn.conv1d
data_layout = "NCW"
kernel_layout = "OIW"
elif ndim == 4:
op = relax.op.nn.conv2d
data_layout = "NCHW"
kernel_layout = "OIHW"
elif ndim == 5:
op = relax.op.nn.conv3d
data_layout = "NCDHW"
kernel_layout = "OIDHW"
else:
raise NotImplementedError("Ndim > 5 not supported for convolution.")
if "auto_pad" in attr:
attr["auto_pad"] = attr["auto_pad"].decode("utf-8")
if attr["auto_pad"] in ("SAME_UPPER", "SAME_LOWER"):
data = autopad(
bb,
inputs[0],
attr.get("strides", [1] * (ndim - 2)),
attr["kernel_shape"],
attr.get("dilations", [1] * (ndim - 2)),
mode=attr["auto_pad"],
deconv=False,
)
elif attr["auto_pad"] == "VALID":
attr["pads"] = [0 for _ in range(ndim - 2)]
elif attr["auto_pad"] == "NOTSET":
pass
else:
msg = (
f'Value {attr["auto_pad"]} in attribute "auto_pad" of operator Conv is invalid.'
)
raise tvm.error.OpAttributeInvalid(msg)
attr.pop("auto_pad")
conv_out = bb.normalize(
op(
data=data,
weight=inputs[1],
strides=attr.get("strides", 1),
padding=attr.get("pads", 0),
dilation=attr.get("dilations", 1),
groups=attr.get("group", 1),
data_layout=data_layout,
kernel_layout=kernel_layout,
)
)
if inputs[2] is not None:
bias = relax.op.reshape(inputs[2], [1, -1] + [1] * (ndim - 2))
conv_out = relax.op.add(conv_out, bias)
return conv_out
class ConvTranspose(OnnxOpConverter):
"""Converts an onnx ConvTranspose node into an equivalent Relax expression."""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
if hasattr(inputs[0].ty, "ndim"):
ndim = inputs[0].ty.ndim
else:
ndim = len(inputs[0].ty.shape)
if ndim == 3:
op = relax.op.nn.conv1d_transpose
data_layout = "NCW"
kernel_layout = "IOW"
elif ndim == 4:
op = relax.op.nn.conv2d_transpose
data_layout = "NCHW"
kernel_layout = "IOHW"
elif ndim == 5:
op = relax.op.nn.conv3d_transpose
data_layout = "NCDHW"
kernel_layout = "IODHW"
else:
raise NotImplementedError("Ndim > 5 not supported for convolution.")
spatial_dims = ndim - 2
strides = attr.get("strides", [1] * spatial_dims)
dilations = attr.get("dilations", [1] * spatial_dims)
output_padding = attr.get("output_padding", [0] * spatial_dims)
if "kernel_shape" in attr:
kernel_shape = list(attr["kernel_shape"])
else:
kernel_shape = [int(s) for s in inputs[1].ty.shape.values[2:]]
# Resolve `auto_pad` per ONNX ConvTranspose spec. Unlike Conv, the spec
# derives `pads` from `output_shape`/`strides` when auto_pad is SAME_*,
# so we cannot reuse `autopad()` (which pads the input data instead).
if "auto_pad" in attr:
auto_pad = attr["auto_pad"]
if isinstance(auto_pad, bytes):
auto_pad = auto_pad.decode("utf-8")
if auto_pad in ("SAME_UPPER", "SAME_LOWER"):
# Per ONNX ConvTranspose spec, when output_shape is unspecified
# the target output size is `input_size * stride`. Substituting
# this into the spec's total_padding formula cancels the
# input-size term, leaving a value that depends only on the
# kernel/dilation/stride/output_padding attributes. Avoiding the
# input shape keeps the converter usable when spatial dims are
# symbolic (`tir.Var`).
pads_begin: list[int] = []
pads_end: list[int] = []
for i in range(spatial_dims):
total_pad = (
(kernel_shape[i] - 1) * dilations[i] + 1 + output_padding[i] - strides[i]
)
total_pad = max(total_pad, 0)
if auto_pad == "SAME_UPPER":
pad_begin = total_pad // 2
else:
pad_begin = total_pad - total_pad // 2
pads_begin.append(pad_begin)
pads_end.append(total_pad - pad_begin)
attr["pads"] = pads_begin + pads_end
elif auto_pad == "VALID":
attr["pads"] = [0] * (2 * spatial_dims)
elif auto_pad == "NOTSET":
pass
else:
raise tvm.error.OpAttributeInvalid(
f'Value {auto_pad} in attribute "auto_pad" of operator '
"ConvTranspose is invalid."
)
attr.pop("auto_pad")
conv_out = op(
data=inputs[0],
weight=inputs[1],
strides=strides,
padding=attr.get("pads", 0),
output_padding=output_padding,
dilation=dilations,
groups=attr.get("group", 1),
data_layout=data_layout,
kernel_layout=kernel_layout,
)
if inputs[2] is not None:
bias = relax.op.reshape(inputs[2], [1, -1] + [1] * (ndim - 2))
conv_out = relax.op.add(conv_out, bias)
return conv_out
class Erf(OnnxOpConverter):
"""Converts an onnx Erf node into an equivalent Relax expression."""
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
return relax.op.erf(inputs[0])
class CumSum(OnnxOpConverter):
"""Converts an onnx CumSum node into an equivalent Relax expression."""
@classmethod
def _impl_v14(cls, bb, inputs, attr, params):
data = inputs[0]
axis_input = get_constant(inputs[1], params)
exclusive = attr.get("exclusive", 0) != 0
if isinstance(axis_input, relax.Constant):
axis_data = axis_input.data.numpy()
if axis_data.ndim == 0:
axis = int(axis_data.item())
elif axis_data.ndim == 1 and axis_data.shape[0] == 1:
axis = int(axis_data.item())
else:
raise ValueError(
"CumSum axis input must be a scalar (0-D) or a single-element 1-D tensor, "
f"got shape {axis_data.shape}"
)
elif isinstance(axis_input, relax.Var):
axis_shape = axis_input.ty.shape if hasattr(axis_input.ty, "shape") else None
raise ValueError(
"CumSum with non-constant axis input is not supported yet. "
"ONNX permits runtime axis tensors, but Relax/TE currently requires a compile-time "
f"constant axis for cumsum/flip. Got axis shape {axis_shape}"
)
else:
raise TypeError("CumSum axis input must be a Constant or Var")
if attr.get("reverse", 0) != 0:
data = bb.emit_te(topi.flip, data, axis=axis)
data = relax.op.cumsum(data, axis, exclusive=exclusive)
data = bb.normalize(data)
if attr.get("reverse", 0) != 0:
data = bb.emit_te(topi.flip, data, axis=axis)
return data
class Squeeze(OnnxOpConverter):
"""Converts an onnx Squeeze node into an equivalent Relax expression."""
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
data = inputs[0]
axis = get_constant(inputs[1], params)
if isinstance(axis, relax.Constant):
axis = tuple([int(x) for x in axis.data.numpy()])
# If data is constant, perform computation directly.
if isinstance(data, relax.Constant):
if isinstance(axis, tuple | type(None)):
out_data = _np.squeeze(data.data.numpy(), axis)
else:
raise NotImplementedError("Squeeze with symbolic axes not supported")
return relax.const(out_data, data.ty.dtype)
if isinstance(data, relax.ShapeExpr):
shape_tensor_ndim = 1
if axis is None:
if len(data) == 1:
return relax.prim_value(data[0])
return data
normalized_axes = _normalize_constant_axes(list(axis), shape_tensor_ndim, "Squeeze")
if normalized_axes == [0] and len(data) == 1:
return relax.prim_value(data[0])
raise NotImplementedError(
"Squeeze on symbolic shape tensors only supports removing the sole axis."
)
if axis is None:
return relax.op.squeeze(data)
if isinstance(axis, tuple):
return relax.op.squeeze(data, list(axis))
data_ndim = _get_known_tensor_rank(data)
if data_ndim is None:
raise ValueError("Squeeze with dynamic axes requires a statically known input rank.")
axes_len = _get_known_tensor_length(axis)
if axes_len is None:
raise ValueError("Squeeze requires a statically known axes length.")
data_shape = bb.normalize(relax.op.shape_of(data))
data_shape_tensor = bb.normalize(relax.op.shape_to_tensor(data_shape))
output_shape_tensor = _build_squeezed_shape_tensor(bb, data_shape_tensor, axis, data_ndim)
output_shape = _tensor_to_shape_expr(
bb, output_shape_tensor, data_ndim - axes_len, "squeeze_dim"
)
return relax.op.reshape(data, output_shape)
class Constant(OnnxOpConverter):
"""Converts an onnx Constant node into an equivalent Relax expression."""
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
if "value" not in attr:
raise ValueError("no value in Constant")
value = attr.pop("value")
# Constants may rarely have string types. These are likely exported
# from other frameworks and not actually used in TVM. We'll just use
# a zero valued constant for compatibility.
if isinstance(value, bytes):
np_value = _np.asarray([0]).astype("int64")
else:
np_value = get_numpy(value)
dtype = np_value.dtype.name
value = relax.const(np_value, dtype)
return value
class ConstantOfShape(OnnxOpConverter):
"""Converts an onnx ConstantOfShape node into an equivalent Relax expression."""
@classmethod
def _impl_v9(cls, bb, inputs, attr, params):
shape = inputs[0]
# ONNX spec: `value` is optional and defaults to a zero float32 scalar.
# `get_numpy` requires a TensorProto, so dispatch on presence first.
attr_value = attr.get("value")
value = get_numpy(attr_value) if attr_value is not None else 0
if isinstance(value, _np.ndarray):
dtype = str(value.dtype)
else:
dtype = "float32"
# If shape is a constant, treat it as a ShapeExpr.
if isinstance(shape, relax.Constant):
shape = relax.ShapeExpr(list(shape.data.numpy()))
# Special case where requested shape are constant
if len(shape) == 1 and all([isinstance(x, tirx.IntImm) for x in shape]):
shape = [int(x) for x in shape]
return relax.const(_np.full(shape, value, dtype), dtype)
# Convert to shape expression from tensor if needed.
if not isinstance(shape, relax.ShapeExpr):
shape = relax.op.tensor_to_shape(shape)
return relax.op.broadcast_to(relax.const(value, dtype), shape)
class Sin(OnnxOpConverter):
"""Converts an onnx Sin node into an equivalent Relax expression."""
@classmethod
def _impl_v7(cls, bb, inputs, attr, params):
return relax.op.sin(inputs[0])
class Sinh(OnnxOpConverter):
"""Converts an onnx Sinh node into an equivalent Relax expression."""
@classmethod
def _impl_v9(cls, bb, inputs, attr, params):
return relax.op.sinh(inputs[0])
class Cos(OnnxOpConverter):
"""Converts an onnx Cos node into an equivalent Relax expression."""
@classmethod
def _impl_v7(cls, bb, inputs, attr, params):
return relax.op.cos(inputs[0])
class Cosh(OnnxOpConverter):
"""Converts an onnx Cosh node into an equivalent Relax expression."""
@classmethod
def _impl_v9(cls, bb, inputs, attr, params):
return relax.op.cosh(inputs[0])
class Tan(OnnxOpConverter):
"""Converts an onnx Tan node into an equivalent Relax expression."""
@classmethod
def _impl_v7(cls, bb, inputs, attr, params):
return relax.op.tan(inputs[0])
class Tanh(OnnxOpConverter):
"""Converts an onnx Tanh node into an equivalent Relax expression."""
@classmethod
def _impl_v7(cls, bb, inputs, attr, params):
return relax.op.tanh(inputs[0])
class Acos(OnnxOpConverter):
"""Converts an onnx Acos node into an equivalent Relax expression."""
@classmethod
def _impl_v7(cls, bb, inputs, attr, params):
return relax.op.acos(inputs[0])
class Acosh(OnnxOpConverter):
"""Converts an onnx Acosh node into an equivalent Relax expression."""
@classmethod
def _impl_v9(cls, bb, inputs, attr, params):
return relax.op.acosh(inputs[0])
class Asin(OnnxOpConverter):
"""Converts an onnx Asin node into an equivalent Relax expression."""
@classmethod
def _impl_v7(cls, bb, inputs, attr, params):
return relax.op.asin(inputs[0])
class Asinh(OnnxOpConverter):
"""Converts an onnx Asinh node into an equivalent Relax expression."""
@classmethod
def _impl_v9(cls, bb, inputs, attr, params):
return relax.op.asinh(inputs[0])
class Atan(OnnxOpConverter):
"""Converts an onnx Atan node into an equivalent Relax expression."""
@classmethod
def _impl_v7(cls, bb, inputs, attr, params):
return relax.op.atan(inputs[0])
class Atanh(OnnxOpConverter):
"""Converts an onnx Atanh node into an equivalent Relax expression."""
@classmethod
def _impl_v9(cls, bb, inputs, attr, params):
return relax.op.atanh(inputs[0])
class Neg(OnnxOpConverter):
"""Converts an onnx Neg node into an equivalent Relax expression."""
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
if isinstance(inputs[0], relax.Constant):
data_np = inputs[0].data.numpy()
return relax.const(_np.negative(data_np), inputs[0].ty.dtype)
if tvm.ir.is_prim_expr(inputs[0]):
return -inputs[0]
return relax.op.negative(inputs[0])
class Abs(OnnxOpConverter):
"""Converts an onnx Abs node into an equivalent Relax expression."""
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
if isinstance(inputs[0], relax.Constant):
output = _np.abs(inputs[0].data.numpy())
return relax.const(output, output.dtype)
return relax.op.abs(inputs[0])
class Reciprocal(OnnxOpConverter):
"""Converts an onnx Reciprocal node into an equivalent Relax expression."""
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
input_dtype = inputs[0].ty.dtype
return relax.op.divide(relax.const(1, dtype=input_dtype), inputs[0])
class Floor(OnnxOpConverter):
"""Converts an onnx Floor node into an equivalent Relax expression."""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
return relax.op.floor(inputs[0])
class Ceil(OnnxOpConverter):
"""Converts an onnx Ceil node into an equivalent Relax expression."""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
return relax.op.ceil(inputs[0])
class Round(OnnxOpConverter):
"""Converts an onnx Round node into an equivalent Relax expression."""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
return relax.op.round(inputs[0])
class IsInf(OnnxOpConverter):
"""Converts an onnx IsInf node into an equivalent Relax expression."""
@classmethod
def _impl_v10(cls, bb, inputs, attr, params):
return relax.op.isinf(inputs[0])
class IsNaN(OnnxOpConverter):
"""Converts an onnx IsNaN node into an equivalent Relax expression."""
@classmethod
def _impl_v9(cls, bb, inputs, attr, params):
return relax.op.isnan(inputs[0])
class Sqrt(OnnxOpConverter):
"""Converts an onnx Sqrt node into an equivalent Relax expression."""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
return relax.op.sqrt(inputs[0])
def compute_broadcast_shape(shape_a, shape_b):
"""Compute target shape for Multidirectional Broadcasting"""
rank = max(len(shape_a), len(shape_b))
a = (1,) * (rank - len(shape_a)) + tuple(shape_a)
b = (1,) * (rank - len(shape_b)) + tuple(shape_b)
target = []
for ai, bi in zip(a, b):
if ai == bi or ai == 1 or bi == 1:
target.append(max(ai, bi))
else:
raise ValueError(f"Cannot broadcast {ai} and {bi}")
return tuple(target)
class MultiInputBase(OnnxOpConverter):
"""Converts an onnx MultiInputBase node into an equivalent Relax expression."""
numpy_op: Callable = None
relax_op: Callable = None
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
if cls.numpy_op is None or cls.relax_op is None:
raise NotImplementedError("numpy_op and relax_op must be defined for MultiInputBase")
if all([isinstance(inp, relax.Constant) for inp in inputs]):
np_inputs = [inp.data.numpy() for inp in inputs]
output = cls.numpy_op(*np_inputs) # pylint: disable=not-callable
return relax.const(output, output.dtype)
input_shapes = [inp.ty.shape for inp in inputs]
target_shape = functools.reduce(compute_broadcast_shape, input_shapes)
# broadcast_to, stack them, then perform minimum over the new axis.
inputs = [bb.normalize(relax.op.broadcast_to(i, target_shape)) for i in inputs]
stacked_tensor = bb.normalize(relax.op.stack(inputs, axis=0))
return cls.relax_op(stacked_tensor, axis=0) # pylint: disable=not-callable
class Min(MultiInputBase):
"""Converts an onnx Min node into an equivalent Relax expression."""
numpy_op = _np.min
relax_op = relax.op.min
class Max(MultiInputBase):
"""Converts an onnx Max node into an equivalent Relax expression."""
numpy_op = _np.max
relax_op = relax.op.max
class Mean(MultiInputBase):
"""Converts an onnx Mean node into an equivalent Relax expression."""
numpy_op = _np.mean
relax_op = relax.op.mean
class Sum(MultiInputBase):
"""Converts an onnx Sum node into an equivalent Relax expression."""
numpy_op = _np.sum
relax_op = relax.op.sum
class Log(OnnxOpConverter):
"""Converts an onnx Log node into an equivalent Relax expression."""
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
if isinstance(inputs[0], relax.Constant):
return relax.const(_np.log(inputs[0].data.numpy()), inputs[0].ty.dtype)
return relax.op.log(inputs[0])
class Exp(OnnxOpConverter):
"""Converts an onnx Exp node into an equivalent Relax expression."""
@classmethod
def _check_type(cls, dtype, valid_types):
assert dtype in valid_types, f"Types {valid_types} are supported only, but {dtype} is given"
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
data = inputs[0]
valid_types = ["float", "float32", "double", "float64", "float16"]
cls._check_type(data.ty.dtype, valid_types)
return relax.op.exp(data)
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
data = inputs[0]
valid_types = ["float", "float32", "double", "float64", "float16", "bfloat16"]
cls._check_type(data.ty.dtype, valid_types)
return relax.op.exp(data)
class Softplus(OnnxOpConverter):
"""Converts an onnx Softplus node into an equivalent Relax expression."""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
dtype = inputs[0].ty.dtype
threshold = 10.0 if dtype == "float16" else 20.0
return relax.op.nn.softplus(inputs[0], threshold=threshold)
class Softsign(OnnxOpConverter):
"""Converts an onnx Softsign node into an equivalent Relax expression."""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
dtype = inputs[0].ty.dtype
return inputs[0] / (relax.op.abs(inputs[0]) + relax.const(1, dtype=dtype))
class Split(OnnxOpConverter):
"""Converts an onnx Split node into an equivalent Relax expression."""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
splits = attr.get("split", None)
if splits is not None and len(splits) > 1:
indices = []
index = 0
for i in splits[:-1]:
index += i
indices.append(index)
# When splits isnt specified divide evenly over axis.
else:
indices = attr["tvm_custom"]["num_outputs"]
return relax.op.split(inputs[0], indices, attr.get("axis", 0))
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
splits = inputs[1]
splits_rank = None
if splits is not None:
splits_rank = splits.ty.ndim
if splits is not None and splits_rank > 0:
if isinstance(splits, relax.Constant):
splits = splits.data.numpy()
indices = []
index = 0
for i in splits[:-1]:
index += i
indices.append(index.item())
else:
raise ValueError("Dynamic Split not yet supported")
# When splits isnt specified divide evenly over axis.
else:
indices = attr["tvm_custom"]["num_outputs"]
return relax.op.split(inputs[0], indices, attr.get("axis", 0))
def get_prim_value_list(values):
new_values = []
for v in list(values):
if tvm.ir.is_prim_expr(v):
new_values.append(relax.prim_value(v))
else:
new_values.append(v)
return new_values
def _get_known_tensor_rank(expr: relax.Expr) -> int | None:
"""Return the statically known rank of an expression when available."""
if isinstance(expr, relax.Constant):
return len(expr.data.numpy().shape)
if isinstance(expr, relax.ShapeExpr):
return 1
if tvm.ir.is_prim_expr(expr):
return 0
ty = expr.ty
if isinstance(ty, relax.TensorType):
return None if ty.ndim == -1 else ty.ndim
return None
def _get_known_tensor_length(expr: relax.Expr | None) -> int | None:
"""Return the statically known length of a 1-D tensor-like expression."""
if expr is None:
return None
if isinstance(expr, relax.Constant):
np_value = expr.data.numpy()
if np_value.ndim != 1:
raise ValueError(f"Expected a 1-D tensor, but got ndim={np_value.ndim}.")
return int(np_value.shape[0])
if isinstance(expr, relax.ShapeExpr):
return len(expr.values)
if tvm.ir.is_prim_expr(expr):
return 1
ty = expr.ty
if not isinstance(ty, relax.TensorType):
return None
if ty.ndim == -1:
return None
if ty.ndim != 1:
raise ValueError(f"Expected a 1-D tensor, but got ndim={ty.ndim}.")
if isinstance(ty.shape, relax.ShapeExpr):
dim = ty.shape.values[0]
if isinstance(dim, tirx.IntImm):
return int(dim.value)
if isinstance(dim, int):
return dim
return None
def _normalize_constant_axes(axes: list[int], rank: int, op_name: str) -> list[int]:
"""Normalize a list of constant axes and validate their uniqueness."""
normalized_axes = []
for axis in axes:
original_axis = axis
if axis < 0:
axis += rank
if axis < 0 or axis >= rank:
raise ValueError(f"{op_name} axis {original_axis} is out of range for rank {rank}.")
normalized_axes.append(axis)
if len(normalized_axes) != len(set(normalized_axes)):
raise ValueError(f"{op_name} axes must be unique.")
return normalized_axes
def _as_int64_tensor(bb: relax.BlockBuilder, expr: relax.Expr) -> relax.Expr:
"""Convert a tensor-like expression to an int64 tensor expression."""
if isinstance(expr, relax.ShapeExpr):
return bb.normalize(relax.op.shape_to_tensor(expr))
if tvm.ir.is_prim_expr(expr):
return bb.normalize(relax.op.full((1,), expr, dtype="int64"))
if isinstance(expr, relax.Constant):
if expr.ty.dtype == "int64":
return expr
return bb.normalize(relax.op.astype(expr, "int64"))
if isinstance(expr.ty, relax.TensorType) and expr.ty.dtype != "int64":
return bb.normalize(relax.op.astype(expr, "int64"))
return expr
def _tensor_to_shape_expr(
bb: relax.BlockBuilder, shape_tensor: relax.Expr, shape_ndim: int, prefix: str
) -> relax.ShapeExpr:
"""Convert a statically sized int64 tensor into a ShapeExpr."""
shape_tensor = bb.match_cast(shape_tensor, relax.TensorType([shape_ndim], "int64"))
shape_dataflow_var = bb.emit(relax.op.tensor_to_shape(shape_tensor))
shape_vars = [tirx.Var(f"{prefix}_{i}", "int64") for i in range(shape_ndim)]
bb.match_cast(shape_dataflow_var, relax.ShapeType(shape_vars))
return relax.ShapeExpr(shape_vars)
def _build_unsqueezed_shape_tensor(
bb: relax.BlockBuilder, data_shape_tensor: relax.Expr, axes: relax.Expr, data_ndim: int
) -> relax.Expr:
"""Build the output shape tensor for Unsqueeze with runtime axes."""
axes = _as_int64_tensor(bb, axes)
axes_len = _get_known_tensor_length(axes)
if axes_len is None:
raise ValueError("Unsqueeze requires a statically known axes length.")
output_ndim = data_ndim + axes_len
axes = bb.normalize(
relax.op.where(
relax.op.less(axes, relax.const(0, "int64")),
relax.op.add(axes, relax.const(output_ndim, "int64")),
axes,
)
)
positions = relax.op.arange(output_ndim, dtype="int64")
positions = bb.normalize(relax.op.expand_dims(positions, axis=1))
axes = bb.normalize(relax.op.expand_dims(axes, axis=0))
insert_mask = bb.normalize(
relax.op.sum(relax.op.astype(relax.op.equal(positions, axes), "int64"), axis=1)
)
keep_mask = bb.normalize(relax.op.subtract(relax.const(1, "int64"), insert_mask))
input_indices = bb.normalize(
relax.op.subtract(relax.op.cumsum(keep_mask, axis=0), relax.const(1, "int64"))
)
safe_indices = bb.normalize(
relax.op.where(
relax.op.less(input_indices, relax.const(0, "int64")),
relax.const(0, "int64"),
input_indices,
)
)
kept_dims = bb.normalize(relax.op.take(data_shape_tensor, safe_indices, axis=0))
return bb.normalize(
relax.op.where(
relax.op.greater(insert_mask, relax.const(0, "int64")),
relax.const(1, "int64"),
kept_dims,
)
)
def _build_squeezed_shape_tensor(
bb: relax.BlockBuilder, data_shape_tensor: relax.Expr, axes: relax.Expr, data_ndim: int
) -> relax.Expr:
"""Build the output shape tensor for Squeeze with runtime axes."""
axes = _as_int64_tensor(bb, axes)
axes = bb.normalize(
relax.op.where(
relax.op.less(axes, relax.const(0, "int64")),
relax.op.add(axes, relax.const(data_ndim, "int64")),
axes,
)
)
positions = relax.op.arange(data_ndim, dtype="int64")
positions = bb.normalize(relax.op.expand_dims(positions, axis=1))
axes = bb.normalize(relax.op.expand_dims(axes, axis=0))
remove_mask = bb.normalize(
relax.op.sum(relax.op.astype(relax.op.equal(positions, axes), "int64"), axis=1)
)
keep_mask = bb.normalize(relax.op.equal(remove_mask, relax.const(0, "int64")))
keep_indices = bb.normalize(relax.op.nonzero(keep_mask))
num_keep_dims = tirx.Var("squeeze_num_keep_dims", "int64")
keep_indices = bb.match_cast(keep_indices, relax.TensorType([1, num_keep_dims], "int64"))
keep_indices = bb.normalize(relax.op.reshape(keep_indices, [-1]))
return bb.normalize(relax.op.take(data_shape_tensor, keep_indices, axis=0))
class Slice(OnnxOpConverter):
"""Converts an onnx Slice node into an equivalent Relax expression."""
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
data = inputs[0]
starts = get_constant(inputs[1], params)
ends = get_constant(inputs[2], params)
axes = get_constant(inputs[3], params)
steps = get_constant(inputs[4], params)
all_constant_params = all(
isinstance(param, relax.Constant | relax.ShapeExpr)
or tvm.ir.is_prim_expr(param)
or param is None
for param in [starts, ends, axes, steps]
)
if all_constant_params:
starts = get_prim_expr_list(starts)
ends = get_prim_expr_list(ends)
if len(starts) != len(ends):
raise ValueError(
f"Slice expects starts and ends to have the same length, but got "
f"{len(starts)} and {len(ends)}."
)
if axes is not None:
axes = get_prim_expr_list(axes)
if len(axes) != len(starts):
raise ValueError(
f"Slice expects axes and starts to have the same length, but got "
f"{len(axes)} and {len(starts)}."
)
else:
axes = list(range(len(starts)))
data_ndim = _get_known_tensor_rank(data)
if data_ndim is None:
raise ValueError("Slice requires a statically known input rank.")
axes = _normalize_constant_axes(list(axes), data_ndim, "Slice")
if steps is not None:
steps = get_prim_expr_list(steps)
if len(steps) != len(starts):
raise ValueError(
f"Slice expects steps and starts to have the same length, but got "
f"{len(steps)} and {len(starts)}."
)
else:
steps = [1] * len(axes)
if any(
(isinstance(step, int) and step == 0)
or (isinstance(step, tirx.IntImm) and int(step) == 0)
for step in steps
):
raise ValueError("Slice step values must be non-zero.")
if isinstance(data, relax.ShapeExpr):
shape_data = list(data)
assert all(len(i) == 1 for i in [starts, ends, steps])
sliced_values = shape_data[starts[0] : ends[0] : steps[0]]
if all([isinstance(val, tirx.IntImm | int) for val in sliced_values]):
return relax.const([x.value for x in sliced_values], "int64")
return relax.ShapeExpr(sliced_values)
assume_inbound = not all(
[isinstance(param, tirx.IntImm | int) for param in [*starts, *ends, *steps]]
)
starts = get_prim_value_list(starts)
ends = get_prim_value_list(ends)
steps = get_prim_value_list(steps)
return relax.op.strided_slice(
data, axes, starts, ends, steps, assume_inbound=assume_inbound
)
data_ndim = _get_known_tensor_rank(data)
if data_ndim is None:
raise ValueError(
"Slice with dynamic parameters requires a statically known input rank."
)
if isinstance(data, relax.ShapeExpr):
raise ValueError("Slice with dynamic parameters does not support ShapeExpr input.")
data_expr = data
starts_tensor = _as_int64_tensor(bb, starts)
ends_tensor = _as_int64_tensor(bb, ends)
axes_len = _get_known_tensor_length(starts_tensor)
if axes_len is None:
raise ValueError("Slice requires a statically known starts length.")
ends_len = _get_known_tensor_length(ends_tensor)
if ends_len is None:
raise ValueError("Slice requires a statically known ends length.")
if ends_len != axes_len:
raise ValueError(
f"Slice expects starts and ends to have the same length, but got "
f"{axes_len} and {ends_len}."
)
if axes is None:
axes_tensor = relax.op.arange(axes_len, dtype="int64")
else:
axes_tensor = _as_int64_tensor(bb, axes)
axes_tensor_len = _get_known_tensor_length(axes_tensor)
if axes_tensor_len is None:
raise ValueError("Slice requires a statically known axes length.")
if axes_tensor_len != axes_len:
raise ValueError(
f"Slice expects axes and starts to have the same length, but got "
f"{axes_tensor_len} and {axes_len}."
)
if steps is None:
steps_tensor = relax.const(_np.ones((axes_len,), dtype="int64"), "int64")
else:
steps_tensor = _as_int64_tensor(bb, steps)
steps_len = _get_known_tensor_length(steps_tensor)
if steps_len is None:
raise ValueError("Slice requires a statically known steps length.")
if steps_len != axes_len:
raise ValueError(
f"Slice expects steps and starts to have the same length, but got "
f"{steps_len} and {axes_len}."
)
if isinstance(steps_tensor, relax.Constant) and _np.any(steps_tensor.data.numpy() == 0):
raise ValueError("Slice step values must be non-zero.")
axes_tensor = bb.normalize(
relax.op.where(
relax.op.less(axes_tensor, relax.const(0, "int64")),
relax.op.add(axes_tensor, relax.const(data_ndim, "int64")),
axes_tensor,
)
)
data_shape = bb.normalize(relax.op.shape_of(data_expr))
data_shape_tensor = bb.normalize(relax.op.shape_to_tensor(data_shape))
full_starts = relax.const(_np.zeros((data_ndim,), dtype="int64"), "int64")
full_steps = relax.const(_np.ones((data_ndim,), dtype="int64"), "int64")
full_starts = bb.normalize(
relax.op.scatter_elements(full_starts, axes_tensor, starts_tensor)
)
full_ends = bb.normalize(
relax.op.scatter_elements(data_shape_tensor, axes_tensor, ends_tensor)
)
full_steps = bb.normalize(relax.op.scatter_elements(full_steps, axes_tensor, steps_tensor))
return relax.op.dynamic_strided_slice(data_expr, full_starts, full_ends, full_steps)
class Pad(OnnxOpConverter):
"""Converts an onnx Pad node into an equivalent Relax expression."""
@classmethod
def _impl_v2(cls, bb, inputs, attr, params):
pads = attr.get("pads")
pads = relax.const(_np.array(pads), inputs[0].ty.shape[0].ty)
constant_value = attr.get("value")
if constant_value is None:
constant_value = 0.0
if isinstance(pads, relax.Constant):
pad_before, pad_after = _np.split(pads.data.numpy(), 2)
pad_before = _np.ndarray.tolist(pad_before)
pad_after = _np.ndarray.tolist(pad_after)
else:
raise ValueError("Dynamic pads are not supported yet.")
pad_mode = attr.get("mode", b"constant").decode("utf-8")
if pad_mode not in ["constant", "edge", "reflect"]:
raise tvm.error.OpAttributeInvalid(
"Value " + pad_mode + ' in attribute "mode" is invalid for operator Pad.'
)
if pad_mode == "constant":
return bb.emit_te(topi.nn.pad, inputs[0], pad_before, pad_after, constant_value)
elif pad_mode == "reflect":
return bb.emit_te(topi.nn.mirror_pad, inputs[0], pad_before, pad_after, "REFLECT")
else:
# edge mode - replicate border values
return bb.emit_te(topi.nn.replicate_pad, inputs[0], pad_before, pad_after)
@classmethod
def _impl_v11(cls, bb, inputs, attr, params):
pads = get_constant(inputs[1], params)
constant_value = get_constant(inputs[2], params)
if constant_value is not None:
constant_value = constant_value.data.numpy().item()
else:
constant_value = 0.0
if isinstance(pads, relax.Constant):
pad_before, pad_after = _np.split(pads.data.numpy(), 2)
pad_before = _np.ndarray.tolist(pad_before)
pad_after = _np.ndarray.tolist(pad_after)
else:
raise ValueError("Dynamic pads are not supported yet.")
pad_mode = attr.get("mode", b"constant").decode("utf-8")
if pad_mode not in ["constant", "edge", "reflect"]:
raise tvm.error.OpAttributeInvalid(
"Value " + pad_mode + ' in attribute "mode" is invalid for operator Pad.'
)
if pad_mode == "constant":
return bb.emit_te(topi.nn.pad, inputs[0], pad_before, pad_after, constant_value)
elif pad_mode == "reflect":
return bb.emit_te(topi.nn.mirror_pad, inputs[0], pad_before, pad_after, "REFLECT")
else:
# edge mode - replicate border values
return bb.emit_te(topi.nn.replicate_pad, inputs[0], pad_before, pad_after)
@classmethod
def _impl_v19(cls, bb, inputs, attr, params):
pads = get_constant(inputs[1], params)
constant_value = get_constant(inputs[2], params)
if constant_value is not None:
constant_value = constant_value.data.numpy().item()
else:
constant_value = 0.0
if isinstance(pads, relax.Constant):
pad_before, pad_after = _np.split(pads.data.numpy(), 2)
pad_before = _np.ndarray.tolist(pad_before)
pad_after = _np.ndarray.tolist(pad_after)
else:
raise ValueError("Dynamic pads are not supported yet.")
axes_input = inputs[3] if len(inputs) > 3 else None
if axes_input is not None:
axes_const = get_constant(axes_input, params)
if not isinstance(axes_const, relax.Constant):
raise ValueError("Dynamic axes are not supported for Pad yet.")
axes = axes_const.data.numpy().tolist()
if len(pad_before) != len(axes):
raise ValueError(
f"Pad expects pads length 2 * len(axes), got "
f"{len(pad_before) + len(pad_after)} pads and {len(axes)} axes."
)
rank = _get_known_tensor_rank(inputs[0])
if rank is None:
raise ValueError("Pad with axes requires a statically known input rank.")
axes = _normalize_constant_axes([int(a) for a in axes], rank, "Pad")
full_before = [0] * rank
full_after = [0] * rank
for i, ax in enumerate(axes):
full_before[ax] = pad_before[i]
full_after[ax] = pad_after[i]
pad_before, pad_after = full_before, full_after
pad_mode = attr.get("mode", b"constant").decode("utf-8")
if pad_mode not in ["constant", "edge", "reflect", "wrap"]:
raise tvm.error.OpAttributeInvalid(
"Value " + pad_mode + ' in attribute "mode" is invalid for operator Pad.'
)
if pad_mode == "constant":
return bb.emit_te(topi.nn.pad, inputs[0], pad_before, pad_after, constant_value)
elif pad_mode == "reflect":
return bb.emit_te(topi.nn.mirror_pad, inputs[0], pad_before, pad_after, "REFLECT")
elif pad_mode == "wrap":
return bb.emit_te(topi.nn.circular_pad, inputs[0], pad_before, pad_after)
else:
# edge mode - replicate border values
return bb.emit_te(topi.nn.replicate_pad, inputs[0], pad_before, pad_after)
class Tile(OnnxOpConverter):
"""Converts an onnx Tile node into an equivalent Relax expression."""
@staticmethod
def _tensor_length(expr):
shape = expr.ty.shape
if not isinstance(shape, relax.ShapeExpr):
return None
length = shape.values[0]
if not isinstance(length, tirx.IntImm):
return None
return length.value
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
reps = get_constant(inputs[1], params)
if isinstance(reps, relax.Constant):
reps = reps.data.numpy().tolist()
return bb.emit_te(topi.tile, inputs[0], reps)
data = inputs[0]
data_ndim = data.ty.ndim
reps_len = cls._tensor_length(reps)
if data_ndim == -1 or reps_len is None:
raise ValueError("Dynamic Tile requires known input rank and repeats length.")
if reps.ty.dtype != "int64":
reps = bb.normalize(relax.op.astype(reps, "int64"))
data_shape = bb.normalize(relax.op.shape_of(data))
data_shape_tensor = bb.normalize(relax.op.shape_to_tensor(data_shape))
output_shape_tensor = reps
if data_ndim > reps_len:
reps_prefix = relax.const(_np.ones((data_ndim - reps_len,), dtype="int64"), "int64")
output_shape_tensor = bb.normalize(
relax.op.concat([reps_prefix, output_shape_tensor], axis=0)
)
elif reps_len > data_ndim:
data_prefix = relax.const(_np.ones((reps_len - data_ndim,), dtype="int64"), "int64")
data_shape_tensor = bb.normalize(
relax.op.concat([data_prefix, data_shape_tensor], axis=0)
)
output_shape_tensor = bb.normalize(
relax.op.multiply(output_shape_tensor, data_shape_tensor)
)
output_shape = bb.normalize(relax.op.tensor_to_shape(output_shape_tensor))
output_shape_vars = [
tirx.Var(f"tile_dim_{i}", "int64") for i in range(max(data_ndim, reps_len))
]
bb.match_cast(output_shape, relax.ShapeType(output_shape_vars))
return bb.emit_te(topi.dyn_tile, data, output_shape_vars, reps_len)
class Expand(OnnxOpConverter):
"""Converts an onnx Expand node into an equivalent Relax expression."""
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
data = inputs[0]
shape = inputs[1]
if isinstance(shape, relax.ShapeExpr):
data_shape = list(data.ty.shape)
target_shape = list(shape.values)
original_data_shape = [
dim.value if hasattr(dim, "value") else str(dim) for dim in data_shape
]
original_target_shape = [
dim.value if hasattr(dim, "value") else str(dim) for dim in target_shape
]
data_shape = [1] * (len(target_shape) - len(data_shape)) + data_shape
assert len(data_shape) == len(target_shape)
# Apply ONNX v13 Expand broadcasting rules
for i, s in enumerate(target_shape):
if isinstance(s, tvm.tirx.IntImm):
if s.value == -1:
# -1 means preserve the input dimension
target_shape[i] = data_shape[i]
elif isinstance(data_shape[i], tvm.tirx.IntImm) and data_shape[i].value == 1:
# Input dimension is 1, can broadcast to any target dimension >= 1
if s.value < 1:
raise ValueError(
f"ONNX Expand: Invalid target dimension {s.value} "
f"at possition {i}. Target dimensions must be >= 1."
)
elif (
isinstance(data_shape[i], tvm.tirx.IntImm)
and s.value == data_shape[i].value
):
# Dimensions match, no change needed
pass
elif s.value == 1:
# Target dimension is 1 but input dimension is not 1
# This would "squeeze" the dimension - preserve input for safety
target_shape[i] = data_shape[i]
else:
if isinstance(data_shape[i], tvm.tirx.IntImm):
raise ValueError(
f"ONNX Expand: Cannot broadcast input shape {original_data_shape} "
f"to target shape {original_target_shape}. "
f"At dimension {i}: input size {data_shape[i].value} is "
f"incompatible with target size {s.value}. "
f"ONNX broadcasting requires corresponding dimensions to have "
f"the same value or one of them to be 1."
)
# For dynamic shapes, let broadcast_to handle it
if target_shape == data_shape:
return data
return relax.op.broadcast_to(data, relax.ShapeExpr(target_shape))
# If possible, directly expand to constant shape.
if isinstance(shape, relax.Constant):
new_shape = shape.data.numpy().tolist()
# ONNX Expand operator requires preserving target rank and broadcasting
# according to standard rules. Dimensions are right-aligned.
data_shape = [dim.value for dim in data.ty.shape]
original_data_shape = data_shape.copy()
original_new_shape = new_shape.copy()
# Right-align the shapes
if len(new_shape) > len(data_shape):
data_shape = [1] * (len(new_shape) - len(data_shape)) + data_shape
else:
new_shape = [1] * (len(data_shape) - len(new_shape)) + new_shape
# Fix small target shapes - if target dim is smaller than input dim
# use the input dim (ONNX-specific behavior).
for i in range(len(new_shape)):
if new_shape[i] == -1:
# -1 means preserve the input dimension
new_shape[i] = data_shape[i]
elif data_shape[i] == 1:
# Input dimension is 1, can broadcast to any target dimension >= 1
if new_shape[i] < 1:
raise ValueError(
f"ONNX Expand: Invalid target dimension {new_shape[i]} "
f"at possition {i}. Target dimensions must be >= 1."
)
elif new_shape[i] == data_shape[i]:
# Dimensions match, no change needed
pass
elif new_shape[i] == 1:
# Target dimension is 1 but input dimension is not 1
# This would "squeeze" the dimension - preserve input for safety
new_shape[i] = data_shape[i]
else:
raise ValueError(
f"ONNX Expand: Cannot broadcast input shape {original_data_shape} "
f"to target shape {original_new_shape}. "
f"At dimension {i}: input size {data_shape[i]} is incompatible "
f"with target size {new_shape[i]}. "
f"ONNX broadcasting requires corresponding dimensions to have the same "
f"value or one of them to be 1."
)
return relax.op.broadcast_to(data, relax.ShapeExpr(new_shape))
# Otherwise handle dynamic shapes.
shape_ndim = next(dim.value for dim in shape.ty.shape.values)
shape_dataflow_var = bb.emit(
relax.Call(
relax.ExternFunc("vm.builtin.tensor_to_shape"),
[shape],
ty_args=[relax.ShapeType(ndim=shape_ndim)],
)
)
shape_vars = []
for i in range(shape_ndim):
shape_vars.append(tvm.tirx.Var(f"x_{i}", "int64"))
bb.match_cast(shape_dataflow_var, relax.ShapeType(shape_vars))
# Applying broadcasting rules for dynamic shapes
data_shape = list(data.ty.shape)
data_ndim = len(data_shape)
target_ndim = shape_ndim
padded_data = data
if target_ndim > data_ndim:
padded_data_shape = [tirx.IntImm("int64", 1)] * (target_ndim - data_ndim) + data_shape
padded_data = bb.normalize(relax.op.reshape(data, relax.ShapeExpr(padded_data_shape)))
return bb.normalize(relax.op.broadcast_to(padded_data, relax.ShapeExpr(shape_vars)))
class Attention(OnnxOpConverter):
"""Converts an onnx.microsoft Attention node into an equivalent Relax expression."""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
num_heads = attr["num_heads"]
assert "do_rotary" not in attr, "rotary position embedding is not currently supported"
assert "past_present_share_buffer" not in attr, (
"past state for key and value is not currently supported"
)
assert "scale" not in attr, "custom scale is not currently supported"
assert "unidirectional" not in attr, "unidirectional attention is not currently supported"
if "mask_filter_value" in attr:
mask_filter_value = attr["mask_filter_value"]
else:
mask_filter_value = -10000.0
# (batch_size, sequence_length, input_hidden_size)
input_emb = bb.normalize(inputs[0])
# (input_hidden_size, hidden_size + hidden_size + v_hidden_size)
weight = bb.normalize(inputs[1])
def optional_input(k: int):
if inputs[k] is not None:
return bb.normalize(inputs[k])
else:
return None
# (hidden_size + hidden_size + v_hidden_size)
bias = optional_input(2)
# 1. ( batch_size, 1, max_seq_len, max_seq_len,)
# 2. ( batch_size, total_seq_len,)
# 3. ( batch_size, seq_len, total_seq_len,)
# 4. ( batch_size,)
# 5. (2 * batch_size,)
# For now, we only support case 2 & 3.
mask_index = optional_input(3)
# (2, batch_size, num_heads, past_sequence_length, head_size)
assert inputs[4] is None, "past state for key and value is not currently supported"
# (batch_size, num_heads, sequence_length, total_sequence_length)
qk_bias = optional_input(5)
assert inputs[6] is None, "past_sequence_length is not currently supported"
(batch_size, seq_len, input_hidden_size) = [val.value for val in input_emb.ty.shape.values]
weight_shape = [val.value for val in weight.ty.shape.values]
assert weight_shape[0] == input_hidden_size, (
"input and weight should share the same input hiden size"
)
if "qkv_hidden_sizes" in attr:
assert attr["qkv_hidden_sizes"][0] == attr["qkv_hidden_sizes"][1], (
"Q and K should share the same hidden sizes"
)
hidden_size, _, hidden_size_v = attr["qkv_hidden_sizes"]
else:
hidden_size = hidden_size_v = weight_shape[1] // 3
assert hidden_size % num_heads == 0, (
"hidden size should be divisible by number of attention heads"
)
head_size = hidden_size // num_heads
head_size_v = hidden_size_v // num_heads
if mask_index is not None:
mask_index_shape = [val.value for val in mask_index.ty.shape.values]
assert mask_index_shape in (
[batch_size, seq_len],
[
batch_size,
seq_len,
seq_len,
],
), """mask index should be in shape of (batch_size, seq_len),
or (batch_size, seq_len, seq_len)"""
mask_bias = relax.op.subtract(relax.const(1, dtype=mask_index.ty.dtype), mask_index)
mask_bias = relax.op.astype(mask_bias, dtype=input_emb.ty.dtype.dtype)
mask_bias = bb.normalize(
relax.op.multiply(
mask_bias,
relax.const(mask_filter_value, dtype=input_emb.ty.dtype),
)
)
if qk_bias is None:
qk_bias = mask_bias
else:
if len(mask_index_shape) == 2:
mask_bias = bb.normalize(
relax.op.reshape(mask_bias, [batch_size, 1, 1, seq_len])
)
elif len(mask_index_shape) == 3:
mask_bias = bb.normalize(
relax.op.reshape(mask_bias, [batch_size, 1, seq_len, seq_len])
)
qk_bias = bb.normalize(relax.op.add(qk_bias, mask_bias))
QKV = relax.op.matmul(input_emb, weight)
if bias:
bias_shape = [val.value for val in bias.ty.shape.values]
assert bias_shape[0] == weight_shape[1], (
"bias and weight should share the same hidden size sum"
)
QKV = relax.op.add(QKV, bias)
QKV = relax.op.split(QKV, [hidden_size, hidden_size * 2], 2)
Q, K, V = QKV[0], QKV[1], QKV[2]
Q = bb.normalize(relax.op.reshape(Q, (batch_size, seq_len, num_heads, head_size)))
K = bb.normalize(relax.op.reshape(K, (batch_size, seq_len, num_heads, head_size)))
V = bb.normalize(relax.op.reshape(V, (batch_size, seq_len, num_heads, head_size_v)))
output = relax.op.nn.attention(Q, K, V, qk_bias)
output = bb.normalize(
relax.op.reshape(output, (batch_size, seq_len, num_heads * head_size_v))
)
# add placeholder for optional present state supported in the future
placeholder = relax.const(0, dtype="float32")
return relax.Tuple([output, placeholder])
class Identity(OnnxOpConverter):
"""Converts an onnx Identity node into an equivalent Relax expression."""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
return inputs[0]
class Dropout(OnnxOpConverter):
"""Converts an onnx Dropout node into an equivalent Relax expression."""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
ratio = float(attr.get("ratio", 0.5))
return relax.op.nn.dropout(inputs[0], ratio)
@classmethod
def _impl_v12(cls, bb, inputs, attr, params):
# Since opset 12 ratio is the optional second input rather than an attribute.
ratio = 0.5
if len(inputs) >= 2 and inputs[1] is not None:
const = get_constant(inputs[1], params)
if isinstance(const, relax.Constant):
ratio = float(const.data.numpy())
return relax.op.nn.dropout(inputs[0], ratio)
def _onnx_resize_spatial_roi_vector(roi_full: relax.Expr, rank: int) -> relax.Expr:
"""Map ONNX ROI [starts..., ends...] to TOPI spatial ROI (drop N/C axes)."""
return relax.op.concat(
[
relax.op.strided_slice(roi_full, axes=[0], begin=[2], end=[rank]),
relax.op.strided_slice(roi_full, axes=[0], begin=[rank + 2], end=[2 * rank]),
],
axis=0,
)
def _topi_resize3d_roi_from_onnx_ncdhw_spatial(roi_spatial: list[float]) -> list[float]:
"""Reorder spatial ROI for NCDHW ONNX layout to TOPI resize3d convention.
ONNX spatial slice after dropping N/C is ordered (D, H, W) for starts then ends.
TOPI ``resize3d`` with layout NCDHW expects
``(start_w, start_h, start_d, end_w, end_h, end_d)`` (see topi/image/resize.py).
"""
if len(roi_spatial) != 6:
return roi_spatial
d0, h0, w0, d1, h1, w1 = roi_spatial
return [w0, h0, d0, w1, h1, d1]
def _emit_resize_topi_dynamic_roi(
bb: relax.BlockBuilder,
data: relax.Expr,
roi_spatial_vec: relax.Expr,
sizes_spatial: list,
rank: int,
topi_mode: str,
coord_mode: str,
rounding_method: str,
cubic_coeff_a: float,
exclude_outside: int,
extrapolation_value: float,
) -> relax.Expr:
"""Lower Resize with runtime ROI via TOPI, which supports Expr ROI."""
if rank == 3:
def resize1d_dyn(d, r, s0):
return topi.image.resize1d(
d,
(r[0], r[1]),
[s0],
"NCW",
topi_mode,
coord_mode,
rounding_method,
cubic_coeff_a,
exclude_outside,
extrapolation_value,
)
return bb.emit_te(resize1d_dyn, data, roi_spatial_vec, sizes_spatial[0])
if rank == 4:
def resize2d_dyn(d, r, s0, s1):
return topi.image.resize2d(
d,
(r[0], r[1], r[2], r[3]),
(s0, s1),
layout="NCHW",
method=topi_mode,
coordinate_transformation_mode=coord_mode,
rounding_method=rounding_method,
bicubic_alpha=cubic_coeff_a,
bicubic_exclude=exclude_outside,
extrapolation_value=extrapolation_value,
)
return bb.emit_te(resize2d_dyn, data, roi_spatial_vec, sizes_spatial[0], sizes_spatial[1])
def resize3d_dyn(d, r, s0, s1, s2):
# r is ONNX order (D,H,W) x2; TOPI expects (W,H,D) x2.
return topi.image.resize3d(
d,
(r[2], r[1], r[0], r[5], r[4], r[3]),
(s0, s1, s2),
layout="NCDHW",
method=topi_mode,
coordinate_transformation_mode=coord_mode,
rounding_method=rounding_method,
bicubic_alpha=cubic_coeff_a,
bicubic_exclude=exclude_outside,
extrapolation_value=extrapolation_value,
)
return bb.emit_te(
resize3d_dyn,
data,
roi_spatial_vec,
sizes_spatial[0],
sizes_spatial[1],
sizes_spatial[2],
)
class Resize(OnnxOpConverter):
"""Converts an onnx Resize node into an equivalent Relax expression."""
@classmethod
def _impl_v18(cls, bb, inputs, attr, params):
# Extract the many attributes of resize.
coord_mode = attr.get("coordinate_transformation_mode", b"half_pixel").decode("ascii")
cubic_coeff_a = attr.get("cubic_coeff_a", -0.75)
exclude_outside = attr.get("exclude_outside", 0)
extrapolation_value = attr.get("extrapolation_value", 0.0)
mode = attr.get("mode", b"nearest").decode("ascii")
rounding_method = attr.get("nearest_mode", b"round_prefer_floor").decode("ascii")
# Adapt attributes to fit TVM definition.
if mode == "nearest":
relax_mode = "nearest_neighbor"
else:
relax_mode = mode
topi_mode = relax_mode
# Unpack inputs.
x = inputs[0]
roi = get_constant(inputs[1], params) if len(inputs) > 1 and inputs[1] is not None else None
scales = get_constant(inputs[2], params) if len(inputs) > 2 else None
sizes = get_constant(inputs[3], params) if len(inputs) > 3 else None
ndims = len(x.ty.shape)
assert ndims in (3, 4, 5), "Only resize1d/resize2d/resize3d are supported."
assert scales is None or sizes is None, (
"Only one of scales and sizes can be provided in Resize."
)
# ROI can be a static list (for relax.image.resize*) or dynamic tensor (TOPI path).
roi_static: list[float] | None = None
roi_dynamic_vec: relax.Expr | None = None
if roi is not None:
if isinstance(roi, relax.Constant):
roi_np = roi.data.numpy().tolist()
if len(roi_np) == 2 * ndims:
roi_static = roi_np[2:ndims] + roi_np[ndims + 2 : 2 * ndims]
elif len(roi_np) == 0:
roi_static = [0.0] * (2 * (ndims - 2))
elif len(roi_np) == 2 * (ndims - 2):
# Some exporters already provide spatial-only ROI.
roi_static = roi_np
else:
roi_static = roi_np
else:
roi_dynamic_vec = bb.normalize(_onnx_resize_spatial_roi_vector(roi, ndims))
else:
roi_static = [0.0] * (2 * (ndims - 2))
use_dynamic_roi = roi_dynamic_vec is not None
# Convert scales to sizes if needed.
if scales is not None:
if isinstance(scales, relax.Constant):
scales = scales.data.numpy()
elif isinstance(scales, relax.expr.ShapeExpr):
scales = [int(val.value) for val in scales.values]
else:
raise ValueError(f"Type {type(scales)} for scale is currently unsupported.")
sizes = []
for i, dim in enumerate(x.ty.shape):
sizes.append((scales[i] * dim).astype("int64"))
sizes = sizes[2:]
else:
if isinstance(sizes, relax.Constant):
sizes = sizes.data.numpy().astype("int64").tolist()[2:]
elif isinstance(sizes, relax.expr.ShapeExpr):
sizes = [int(val.value) for val in sizes.values][2:]
else:
raise ValueError(f"Type {type(sizes)} for size is currently unsupported.")
if use_dynamic_roi:
return _emit_resize_topi_dynamic_roi(
bb,
x,
roi_dynamic_vec,
sizes,
ndims,
topi_mode,
coord_mode,
rounding_method,
cubic_coeff_a,
exclude_outside,
extrapolation_value,
)
if ndims == 3:
return bb.emit_te(
topi.image.resize1d,
x,
roi_static,
sizes,
"NCW",
topi_mode,
coord_mode,
rounding_method,
cubic_coeff_a,
exclude_outside,
extrapolation_value,
)
elif ndims == 4:
return relax.op.image.resize2d(
x,
size=relax.ShapeExpr(sizes),
roi=roi_static,
layout="NCHW",
method=relax_mode,
coordinate_transformation_mode=coord_mode,
rounding_method=rounding_method,
cubic_alpha=cubic_coeff_a,
cubic_exclude=exclude_outside,
extrapolation_value=extrapolation_value,
)
else: # ndims == 5
roi3d = _topi_resize3d_roi_from_onnx_ncdhw_spatial(roi_static)
return relax.op.image.resize3d(
x,
size=relax.ShapeExpr(sizes),
roi=roi3d,
layout="NCDHW",
method=relax_mode,
coordinate_transformation_mode=coord_mode,
rounding_method=rounding_method,
cubic_alpha=cubic_coeff_a,
cubic_exclude=exclude_outside,
extrapolation_value=extrapolation_value,
)
class AffineGrid(OnnxOpConverter):
"""Converts an onnx AffineGrid node into an equivalent Relax expression."""
@classmethod
def _impl_v20(cls, bb, inputs, attr, params):
theta = inputs[0] # [N, 2, 3] for 2D
size = get_constant(inputs[1], params) # [N, C, H, W] for 2D
align_corners = bool(attr.get("align_corners", 0))
# Extract size values
if isinstance(size, relax.Constant):
size_vals = size.data.numpy().astype("int64").tolist()
elif isinstance(size, relax.expr.ShapeExpr):
size_vals = [int(v.value) for v in size.values]
else:
raise NotImplementedError(f"Dynamic size of type {type(size)} is not supported")
if len(size_vals) not in (4, 5):
raise ValueError("AffineGrid expects size to be [N,C,H,W] (2D) or [N,C,D,H,W] (3D)")
# relax affine_grid outputs [N, spatial, *spatial_dims]; move the coord axis
# last to match the ONNX convention [N, *spatial_dims, spatial].
grid = bb.emit(relax.op.image.affine_grid(theta, tuple(size_vals[2:]), align_corners))
axes = [0, *range(2, len(size_vals)), 1]
return bb.emit(relax.op.permute_dims(grid, axes=axes))
class Einsum(OnnxOpConverter):
"""Converts an onnx Einsum node into an equivalent Relax expression."""
@classmethod
def _impl_v12(cls, bb, inputs, attr, params):
equation = attr["equation"].decode("utf-8")
return bb.emit_te(topi.einsum, equation, *inputs)
class RoiAlign(OnnxOpConverter):
"""Converts an onnx RoiAlign node into an equivalent Relax expression."""
@classmethod
def _impl(cls, bb, inputs, attr, params, default_coordinate_transformation_mode):
if len(inputs) != 3:
raise ValueError("RoiAlign expects exactly 3 inputs")
data = inputs[0]
rois = inputs[1]
batch_indices = inputs[2]
rois_dtype = rois.ty.dtype
mode = attr.get("mode", b"avg")
if isinstance(mode, bytes):
mode = mode.decode("ascii")
if mode not in ("avg", "max"):
raise NotImplementedError("RoiAlign in Relax only supports avg and max modes")
output_height = attr.get("output_height", 1)
output_width = attr.get("output_width", 1)
sampling_ratio = attr.get("sampling_ratio", 0)
spatial_scale = attr.get("spatial_scale", 1.0)
coordinate_transformation_mode = attr.get(
"coordinate_transformation_mode", default_coordinate_transformation_mode
)
if isinstance(coordinate_transformation_mode, bytes):
coordinate_transformation_mode = coordinate_transformation_mode.decode("ascii")
if coordinate_transformation_mode == "half_pixel":
offset = relax.const([-0.5, -0.5, -0.5, -0.5], rois_dtype)
rois = relax.op.add(rois, offset)
aligned = True
elif coordinate_transformation_mode != "output_half_pixel":
raise NotImplementedError(
"RoiAlign only supports coordinate_transformation_mode "
"'half_pixel' and 'output_half_pixel'"
)
else:
aligned = False
batch_indices = relax.op.expand_dims(batch_indices, axis=1)
batch_indices = relax.op.astype(batch_indices, rois_dtype)
rois = relax.op.concat([batch_indices, rois], axis=1)
return relax.op.vision.roi_align(
data,
rois,
pooled_size=(output_height, output_width),
spatial_scale=spatial_scale,
sample_ratio=sampling_ratio,
aligned=aligned,
layout="NCHW",
mode=mode,
)
@classmethod
def _impl_v10(cls, bb, inputs, attr, params):
return cls._impl(bb, inputs, attr, params, b"output_half_pixel")
@classmethod
def _impl_v16(cls, bb, inputs, attr, params):
return cls._impl(bb, inputs, attr, params, b"half_pixel")
class MaxRoiPool(OnnxOpConverter):
"""Converts an onnx MaxRoiPool node into an equivalent Relax expression."""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
if len(inputs) != 2:
raise ValueError("MaxRoiPool expects exactly 2 inputs")
pooled_shape = attr.get("pooled_shape")
if pooled_shape is None:
raise ValueError("MaxRoiPool requires pooled_shape attribute")
spatial_scale = attr.get("spatial_scale", 1.0)
return relax.op.vision.roi_pool(
inputs[0],
inputs[1],
pooled_size=tuple(pooled_shape),
spatial_scale=spatial_scale,
layout="NCHW",
)
class Range(OnnxOpConverter):
"""Converts an onnx Range node into an equivalent Relax expression."""
@classmethod
def _impl_v12(cls, bb, inputs, attr, params):
start = get_constant(inputs[0], params)
limit = get_constant(inputs[1], params)
delta = get_constant(inputs[2], params)
out_dtype = start.ty.dtype
if isinstance(start, relax.Constant):
start = start.data.numpy().tolist()
if isinstance(limit, relax.Constant):
limit = limit.data.numpy().tolist()
assert isinstance(delta, relax.Constant), "Constant delta required for Range."
step = delta.data.numpy().tolist()
# If all inputs are constant, compute directly.
if isinstance(start, int) and isinstance(limit, int):
out_range = _np.arange(start=start, stop=limit, step=step)
return relax.const(out_range, out_dtype)
# Otherwise compute in graph.
return relax.op.arange(start, limit, step, out_dtype)
class InstanceNormalization(OnnxOpConverter):
"""Converts an onnx InstanceNormalization node into an equivalent Relax expression."""
@classmethod
def _impl_v6(cls, bb, inputs, attr, params):
data = inputs[0]
scale = inputs[1]
B = inputs[2]
epsilon = attr.get("epsilon", 1e-05)
epsilon = relax.const(epsilon, dtype=data.ty.dtype)
ndim = len(data.ty.shape)
redux_axes = list(range(2, ndim))
mean = relax.op.mean(data, axis=redux_axes, keepdims=True)
var = relax.op.variance(data, axis=redux_axes, keepdims=True)
sqrt = relax.op.sqrt(relax.op.add(var, epsilon))
out = relax.op.divide(relax.op.subtract(data, mean), sqrt)
broadcast_shape = [-1] + [
1,
] * (ndim - 2)
if scale is not None:
scale = relax.op.reshape(scale, broadcast_shape)
out = relax.op.multiply(out, scale)
if B is not None:
B = relax.op.reshape(B, broadcast_shape)
out = relax.op.add(out, B)
return out
class BatchNormalization(OnnxOpConverter):
"""Converts an onnx BatchNormalization node into an equivalent Relax expression."""
@classmethod
def _impl_v15(cls, bb, inputs, attr, params):
# Unpack inputs
data = inputs[0]
scale = inputs[1]
bias = inputs[2]
mean = inputs[3]
var = inputs[4]
epsilon = attr.get("epsilon", 1e-05)
momentum = attr.get("momentum", 0.9)
training_mode = attr.get("training_mode", 0)
data_dtype = data.ty.dtype
scale_dtype = scale.ty.dtype
bias_dtype = bias.ty.dtype
mean_dtype = mean.ty.dtype
var_dtype = var.ty.dtype
if scale_dtype != bias_dtype:
raise ValueError(
"ONNX BatchNormalization requires scale and bias to have the same "
f"dtype, but received {scale_dtype} and {bias_dtype}."
)
if mean_dtype != var_dtype:
raise ValueError(
"ONNX BatchNormalization requires mean and var to have the same "
f"dtype, but received {mean_dtype} and {var_dtype}."
)
if data_dtype == scale_dtype == mean_dtype:
compute_dtype = data_dtype
elif (
data_dtype == "float16"
and scale_dtype in ("float16", "float32")
and mean_dtype in ("float16", "float32")
):
compute_dtype = "float32"
else:
raise NotImplementedError(
"ONNX BatchNormalization with mixed input dtypes is currently "
"supported only for float16 data with float16/float32 parameters "
"and statistics, but received "
f"data={data_dtype}, scale/bias={scale_dtype}, mean/var={mean_dtype}."
)
# ONNX requires float computation for float16 training statistics to avoid overflow.
if training_mode and data_dtype == "float16":
compute_dtype = "float32"
def cast_for_compute(expr, source_dtype):
if source_dtype == compute_dtype:
return expr
return relax.op.astype(expr, compute_dtype)
output = relax.op.nn.batch_norm(
cast_for_compute(data, data_dtype),
gamma=cast_for_compute(scale, scale_dtype),
beta=cast_for_compute(bias, bias_dtype),
moving_mean=cast_for_compute(mean, mean_dtype),
moving_var=cast_for_compute(var, var_dtype),
axis=1,
epsilon=epsilon,
momentum=momentum,
training=bool(training_mode),
)
y = relax.TupleGetItem(output, 0)
running_mean = relax.TupleGetItem(output, 1)
running_var = relax.TupleGetItem(output, 2)
if compute_dtype != data_dtype:
y = relax.op.astype(y, data_dtype)
if compute_dtype != mean_dtype:
running_mean = relax.op.astype(running_mean, mean_dtype)
if compute_dtype != var_dtype:
running_var = relax.op.astype(running_var, var_dtype)
return relax.Tuple([y, running_mean, running_var])
class MeanVarianceNormalization(OnnxOpConverter):
"""Converts an onnx MeanVarianceNormalization node into an equivalent Relax expression."""
@classmethod
def _impl_v9(cls, bb, inputs, attr, params):
data = inputs[0]
axis = attr.get("axes", (0, 2, 3))
data_mean = relax.op.mean(data, axis=axis, keepdims=True)
data_mean_squared = relax.op.power(data_mean, relax.const(2, dtype="float32"))
data_squared = relax.op.power(data, relax.const(2, dtype="float32"))
data_squared_mean = relax.op.mean(data_squared, axis=axis, keepdims=True)
return (data - data_mean) / relax.op.sqrt(data_squared_mean - data_mean_squared)
class LocalResponseNormalization(OnnxOpConverter):
"""Converts an onnx LocalResponseNormalization node into an equivalent Relax expression."""
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
data = inputs[0]
size = attr["size"]
alpha = attr.get("alpha", 0.0001)
beta = attr.get("beta", 0.75)
bias = attr.get("bias", 1.0)
if hasattr(data.ty, "ndim"):
ndim = data.ty.ndim
else:
ndim = len(data.ty.shape)
if ndim not in [3, 4]:
raise ValueError(f"LRN only supports 3D or 4D input, got {ndim}D.")
data_squared = relax.op.multiply(data, data)
data_expanded = relax.op.expand_dims(data_squared, axis=1)
pad_len = size // 2
if ndim == 3:
pool_padding = [pad_len, 0, pad_len, 0]
pool_op = relax.op.nn.avg_pool2d
pool_size = (size, 1)
layout = "NCHW"
strides = (1, 1)
else:
pool_padding = [pad_len, 0, 0, pad_len, 0, 0]
pool_op = relax.op.nn.avg_pool3d
pool_size = (size, 1, 1)
layout = "NCDHW"
strides = (1, 1, 1)
data_avgpool = pool_op(
data_expanded,
pool_size=pool_size,
strides=strides,
padding=pool_padding,
layout=layout,
ceil_mode=False,
count_include_pad=True,
)
data_squeezed = relax.op.squeeze(data_avgpool, axis=1)
const_alpha = relax.const(alpha, dtype="float32")
const_bias = relax.const(bias, dtype="float32")
const_beta = relax.const(beta, dtype="float32")
scale = relax.op.multiply(data_squeezed, const_alpha)
scale = relax.op.add(scale, const_bias)
denominator = relax.op.power(scale, const_beta)
return relax.op.divide(data, denominator)
class Pool(OnnxOpConverter):
"""A helper class for pool op converters."""
name = ""
@classmethod
def get_pad_pair(cls, input1d, kernel1d, stride1d, mode):
"""infer pad size"""
if input1d % stride1d == 0:
pad = max(kernel1d - stride1d, 0)
else:
pad = max(kernel1d - (input1d % stride1d), 0)
pad_before = pad // 2
pad_after = pad - pad_before
if "LOWER" in mode:
return [pad_after, pad_before]
return [pad_before, pad_after]
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
# Unpack inputs and attributes.
data = inputs[0]
input_shape = data.ty.shape
ndim = len(input_shape)
auto_pad = attr.get("auto_pad", b"NOTSET").decode("utf-8")
ceil_mode = attr.get("ceil_mode", 0)
dilations = attr.get("dilations", [1] * (ndim - 2))
kernel_shape = attr.get("kernel_shape")
pads = attr.get("pads", 0)
strides = attr.get("strides", [1] * (ndim - 2))
count_include_pad = attr.get("count_include_pad", False)
assert len(kernel_shape) in [1, 2, 3], "Currently only 1D/2D/3D/ pooling is supported."
assert auto_pad in [
"NOTSET",
"SAME_UPPER",
"SAME_LOWER",
"VALID",
], f"Value {auto_pad} in attribute auto_pad is invalid."
if auto_pad in ("SAME_UPPER", "SAME_LOWER"):
pads = []
if cls.name == "avg_pool":
for axis in range(len(input_shape) - 2):
axis_shape = int(input_shape[2 + axis])
stride = strides[axis]
kernel = kernel_shape[axis]
pad = cls.get_pad_pair(axis_shape, kernel, stride, auto_pad)
pads.append(pad)
else:
input_spatial_shape = cls._get_input_spatial_shape(data)
output_spatial_shape = [0 for _ in input_spatial_shape]
for i, _ in enumerate(input_spatial_shape):
if auto_pad == "SAME_UPPER":
output_spatial_shape[i] = int(_np.ceil(input_spatial_shape[i] / strides[i]))
else:
output_spatial_shape[i] = int(
_np.floor(input_spatial_shape[i] / strides[i])
)
pad_i = (
(output_spatial_shape[i] - 1) * strides[i]
+ ((kernel_shape[i] - 1) * dilations[i] + 1)
- input_spatial_shape[i]
)
if auto_pad == "SAME_UPPER":
pads.append([pad_i // 2, pad_i - pad_i // 2])
else:
pads.append([pad_i - pad_i // 2, pad_i // 2])
pads = tuple([val for pair in zip(*pads) for val in pair])
op = getattr(relax.op.nn, cls.name + str(len(kernel_shape)) + "d")
return op(data, kernel_shape, strides, pads, dilations, ceil_mode, count_include_pad)
@classmethod
def _get_input_spatial_shape(cls, tensor):
# shape is (N x C x D1 x D2 ... Dn)
return _np.array([int(d) for d in tensor.ty.shape], dtype="int64")[2:]
class MaxPool(Pool):
"""Converts an onnx MaxPool node into an equivalent Relax expression."""
name = "max_pool"
class AveragePool(Pool):
"""Converts an onnx MaxPool node into an equivalent Relax expression."""
name = "avg_pool"
class LpPool(OnnxOpConverter):
"""Converts an onnx LpPool node into an equivalent Relax expression."""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
dtype = inputs[0].ty.dtype
p = attr.get("p", 2.0)
reci_p = relax.const(1.0 / p, dtype=dtype)
# emit for get ty
data = bb.emit(relax.op.power(inputs[0], relax.const(p, dtype=dtype)))
attr.update({"count_include_pad": True})
avg_pool = AveragePool._impl_v1(bb, [data], attr, params)
kernels = attr["kernel_shape"]
out = avg_pool * relax.const(_np.prod(kernels).astype(dtype))
return relax.op.power(out, reci_p)
class GlobalAveragePool(OnnxOpConverter):
"""Converts an onnx GlobalAveragePool node into an equivalent Relax expression."""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
rank = len(inputs[0].ty.shape)
axes = list(range(2, rank))
return relax.op.mean(inputs[0], axis=axes, keepdims=True)
class GlobalMaxPool(OnnxOpConverter):
"""Converts an onnx GlobalMaxPool node into an equivalent Relax expression."""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
rank = len(inputs[0].ty.shape)
axes = list(range(2, rank))
return relax.op.max(inputs[0], axis=axes, keepdims=True)
class GlobalLpPool(OnnxOpConverter):
"""Converts an onnx GlobalLpPool node into an equivalent Relax expression."""
@classmethod
def _impl_v2(cls, bb, inputs, attr, params):
p = attr.get("p", 2.0)
dtype = inputs[0].ty.dtype
rank = len(inputs[0].ty.shape)
axes = list(range(2, rank))
x_abs = relax.op.abs(inputs[0])
x_p = relax.op.power(x_abs, relax.const(p, dtype=dtype))
x_sum = relax.op.sum(x_p, axes, keepdims=True)
return relax.op.power(x_sum, relax.const(1.0 / p, dtype=dtype))
class MaxUnpool(OnnxOpConverter):
"""Converts an onnx MaxUnpool node into an equivalent Relax expression."""
@classmethod
def _impl_v9(cls, bb, inputs, attr, params):
data = inputs[0]
indices = inputs[1]
output_shape = inputs[2]
kernel_shape = attr.get("kernel_shape")
pads = attr.get("pads", [0] * len(kernel_shape) * 2)
strides = attr.get("strides", [1] * len(kernel_shape))
multiplier = _np.concatenate([[1, 1], list(strides)])
shape = [v.value for v in data.ty.shape]
total_output_shape = multiplier * shape
# Add extra dimensions from kernel size and stride mismatch
total_output_shape += _np.concatenate([[0, 0], list(kernel_shape)], axis=0)
total_output_shape -= _np.concatenate([[0, 0], list(strides)], axis=0)
if output_shape is not None:
total_output_shape = output_shape
elif pads is not None:
# Get pads in the proper format
pads = _np.concatenate([[0, 0, 0, 0], list(pads)], axis=0)
pads = _np.reshape(pads, [-1, 2])
# Compute the total padding per axis.
total_pad = _np.sum(pads, axis=-1)
# Reversing maxpool means that padding actually makes our output smaller.
total_output_shape = total_output_shape - total_pad
# Create a tensor of zeros then scatter our data through it.
relax_shape = relax.ShapeExpr(total_output_shape.tolist())
zeros_tensor = bb.emit(relax.op.zeros(relax_shape, data.ty.dtype.dtype))
# We need to flatten all our tensors before scattering.
flat_tensor = relax.op.scatter_elements(
relax.op.reshape(zeros_tensor, [-1]),
relax.op.reshape(indices, [-1]),
relax.op.reshape(data, [-1]),
axis=0,
)
# Reshape our flattened data back to normal.
output = relax.op.reshape(flat_tensor, relax_shape)
return output
class Flatten(OnnxOpConverter):
"""Converts an onnx Flatten node into an equivalent Relax expression."""
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
axis = attr.get("axis", 1)
data_shape = list(inputs[0].ty.shape)
if axis == 0:
new_shape = (1, -1)
else:
shape_flags = [isinstance(x, tvm.script.tirx.IntImm) for x in data_shape[0:axis]]
if all(shape_flags):
data_shape = [x.value for x in data_shape[0:axis]]
new_shape = (_np.prod(data_shape).astype("int64"), -1)
else:
batch_size = 1
for el in data_shape[0:axis]:
batch_size = batch_size * el
new_shape = (batch_size, -1)
return relax.op.reshape(inputs[0], new_shape)
class LayerNormalization(OnnxOpConverter):
"""Converts an onnx LayerNormalization node into an equivalent Relax expression."""
@classmethod
def _impl_v17(cls, bb, inputs, attr, params):
data = inputs[0]
scale = inputs[1]
bias = inputs[2]
axis = attr.get("axis", -1)
epsilon = attr.get("epsilon", 1e-05)
gamma_shape = get_const_tuple(scale.ty.shape)
if bias is None:
bias = relax.const(_np.zeros(gamma_shape), dtype=scale.ty.dtype)
else:
beta_shape = get_const_tuple(bias.ty.shape)
if gamma_shape != beta_shape:
raise ValueError("gamma and beta shapes do not match")
axis = list(axis) if isinstance(axis, list | tuple) else [axis]
if len(axis) < len(gamma_shape):
axis.extend(range(axis[-1] + 1, axis[-1] + 1 + len(gamma_shape) - len(axis)))
output = relax.op.nn.layer_norm(data, scale, bias, axis, epsilon)
# Onnx layernorm has 3 outputs but only the first is used.
# We construct two empty constants for this.
placeholder = relax.const(0, dtype="float32")
return relax.Tuple([output, placeholder, placeholder])
class RMSNormalization(OnnxOpConverter):
"""Converts an onnx RMSNormalization node into an equivalent Relax expression."""
@classmethod
def _impl_v23(cls, bb, inputs, attr, params):
data = inputs[0]
scale = inputs[1]
axis = attr.get("axis", -1)
epsilon = attr.get("epsilon", 1e-05)
stash_type = attr.get("stash_type", 1)
# Determine normalization axes: from `axis` to the last dimension
ndim = _get_known_tensor_rank(data)
if ndim is None:
raise ValueError("RMSNormalization requires a statically known input rank.")
axis = _normalize_constant_axes([axis], ndim, "RMSNormalization")[0]
axes = list(range(axis, ndim))
# If stash_type requires float32 computation and input is not float32, cast
input_dtype = data.ty.dtype.dtype
if stash_type == 1 and input_dtype != "float32":
data_compute = relax.op.astype(data, "float32")
scale_compute = relax.op.astype(scale, "float32")
else:
data_compute = data
scale_compute = scale
output = relax.op.nn.rms_norm(data_compute, scale_compute, axes, epsilon)
# Cast back to original dtype if needed
if stash_type == 1 and input_dtype != "float32":
output = relax.op.astype(output, input_dtype)
return output
class GroupNormalization(OnnxOpConverter):
"""Converts an onnx GroupNormalization node into an equivalent Relax expression"""
@classmethod
def _impl_v18(cls, bb, inputs, attr, params):
data = inputs[0]
scale = inputs[1]
bias = inputs[2]
num_groups = attr["num_groups"]
epsilon = attr.get("epsilon", 1e-05)
ndim = _get_known_tensor_rank(data)
if ndim is None:
raise ValueError("GroupNormalization requires a statically known input rank.")
ty = data.ty
if not isinstance(ty, relax.TensorType) or len(ty.shape) < 2:
raise ValueError(
"GroupNormalization-18 requires a statically typed input with rank >= 2."
)
input_dtype = ty.dtype
if input_dtype != "float32":
raise ValueError("GroupNormalization-18 currently only supports float32 inputs.")
if num_groups <= 0:
raise ValueError(
f"GroupNormalization requires num_groups to be positive, got {num_groups}."
)
channel_dim = ty.shape[1]
if not isinstance(channel_dim, tirx.IntImm):
raise ValueError(
"GroupNormalization-18 requires a statically known channel count "
"to expand per-group scale/bias to per-channel."
)
channels = int(channel_dim)
if channels % num_groups != 0:
raise ValueError(
f"GroupNormalization requires num_groups to divide channel count, "
f"but got C={channels} and num_groups={num_groups}."
)
channels_per_group = channels // num_groups
scale = relax.op.reshape(scale, [num_groups, 1])
scale = relax.op.broadcast_to(scale, [num_groups, channels_per_group])
scale = relax.op.reshape(scale, [channels])
bias = relax.op.reshape(bias, [num_groups, 1])
bias = relax.op.broadcast_to(bias, [num_groups, channels_per_group])
bias = relax.op.reshape(bias, [channels])
axes = list(range(2, ndim))
return relax.op.nn.group_norm(
data, scale, bias, num_groups, channel_axis=1, axes=axes, epsilon=epsilon
)
@classmethod
def _impl_v21(cls, bb, inputs, attr, params):
data = inputs[0]
scale = inputs[1]
bias = inputs[2]
num_groups = attr["num_groups"]
epsilon = attr.get("epsilon", 1e-05)
stash_type = attr.get("stash_type", 1)
if stash_type != 1:
raise ValueError(
f"GroupNormalization currently only supports stash_type=1 (FLOAT), "
f"but got stash_type={stash_type}."
)
ndim = _get_known_tensor_rank(data)
if ndim is None:
raise ValueError("GroupNormalization requires a statically known input rank.")
ty = data.ty
if not isinstance(ty, relax.TensorType) or len(ty.shape) < 2:
raise ValueError("GroupNormalization requires a statically typed input with rank >= 2.")
if num_groups <= 0:
raise ValueError(
f"GroupNormalization requires num_groups to be positive, got {num_groups}."
)
channel_dim = ty.shape[1]
if isinstance(channel_dim, tirx.IntImm):
channels = int(channel_dim)
if channels % num_groups != 0:
raise ValueError(
f"GroupNormalization requires num_groups to divide channel count, "
f"but got C={channels} and num_groups={num_groups}."
)
axes = list(range(2, ndim))
input_dtype = ty.dtype
orig_scale = scale
orig_bias = bias
if input_dtype != "float32":
data = relax.op.astype(data, "float32")
scale = relax.op.astype(scale, "float32")
bias = relax.op.astype(bias, "float32")
norm_scale = relax.op.ones_like(scale)
norm_bias = relax.op.zeros_like(bias)
output = relax.op.nn.group_norm(
data,
norm_scale,
norm_bias,
num_groups,
channel_axis=1,
axes=axes,
epsilon=epsilon,
center=False,
scale=False,
)
if input_dtype != "float32":
output = relax.op.astype(output, input_dtype)
affine_shape = [channel_dim] + [1] * (ndim - 2)
orig_scale = relax.op.reshape(orig_scale, affine_shape)
orig_bias = relax.op.reshape(orig_bias, affine_shape)
output = relax.op.multiply(output, orig_scale)
output = relax.op.add(output, orig_bias)
return output
class ReduceMax(OnnxOpConverter):
"""Converts an onnx ReduceMax node into an equivalent Relax expression."""
@classmethod
def _impl_v11(cls, bb, inputs, attr, params):
data = inputs[0]
axes = attr.get("axes", None)
keepdims = attr.get("keepdims", 1)
return relax.op.max(data, axes, keepdims)
@classmethod
def _impl_v18(cls, bb, inputs, attr, params):
data = inputs[0]
keepdims = attr.get("keepdims", 1)
noop_with_empty_axes = attr.get("noop_with_empty_axes", 0)
# Optional axes input
axes = None
if len(inputs) > 1 and inputs[1] is not None:
axes_const = get_constant(inputs[1], params)
assert isinstance(axes_const, relax.Constant), "Only constant axes currently supported"
axes = axes_const.data.numpy().tolist()
# If axes is empty and noop_with_empty_axes is False, reduce all dims
if not axes and not noop_with_empty_axes:
return relax.op.max(data, None, keepdims)
# If axes is empty and noop_with_empty_axes is True, return input unchanged
elif not axes and noop_with_empty_axes:
return data
# Otherwise reduce over specified axes
else:
return relax.op.max(data, axes, keepdims)
class ReduceMin(OnnxOpConverter):
"""Converts an onnx ReduceMin node into an equivalent Relax expression."""
@classmethod
def _impl_v11(cls, bb, inputs, attr, params):
data = inputs[0]
axes = attr.get("axes", None)
keepdims = attr.get("keepdims", 1)
return relax.op.min(data, axes, keepdims)
@classmethod
def _impl_v18(cls, bb, inputs, attr, params):
data = inputs[0]
keepdims = attr.get("keepdims", 1)
noop_with_empty_axes = attr.get("noop_with_empty_axes", 0)
# Optional axes input
axes = None
if len(inputs) > 1 and inputs[1] is not None:
axes_const = get_constant(inputs[1], params)
assert isinstance(axes_const, relax.Constant), "Only constant axes currently supported"
axes = axes_const.data.numpy().tolist()
# If axes is empty and noop_with_empty_axes is False, reduce all dims
if not axes and not noop_with_empty_axes:
return relax.op.min(data, None, keepdims)
# If axes is empty and noop_with_empty_axes is True, return input unchanged
elif not axes and noop_with_empty_axes:
return data
# Otherwise reduce over specified axes
else:
return relax.op.min(data, axes, keepdims)
class ReduceSum(OnnxOpConverter):
"""Converts an onnx ReduceSum node into an equivalent Relax expression."""
@classmethod
def _impl_v11(cls, bb, inputs, attr, params):
data = inputs[0]
axes = attr.get("axes", None)
keepdims = attr.get("keepdims", 1)
return relax.op.sum(data, axes, keepdims)
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
data = inputs[0]
keepdims = attr.get("keepdims", 1)
noop_with_empty_axes = attr.get("noop_with_empty_axes", 0)
# Optional axes input
axes = None
if len(inputs) > 1 and inputs[1] is not None:
axes_const = get_constant(inputs[1], params)
assert isinstance(axes_const, relax.Constant), "Only constant axes currently supported"
axes = axes_const.data.numpy().tolist()
# If axes is empty and noop_with_empty_axes is 0, reduce all dimensions
if not axes and not noop_with_empty_axes:
return relax.op.sum(data, None, keepdims)
# If axes is empty and noop_with_empty_axes is 1, return the input data unchanged.
elif not axes and noop_with_empty_axes:
return data
# If axes is provided, reduce over the specified axes
else:
return relax.op.sum(data, axes, keepdims)
class ReduceMean(OnnxOpConverter):
"""Converts an onnx ReduceMean node into an equivalent Relax expression."""
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
data = inputs[0]
axes = attr.get("axes", None)
keepdims = attr.get("keepdims", 1)
return relax.op.mean(data, axes, keepdims)
@classmethod
def _impl_v18(cls, bb, inputs, attr, params):
data = inputs[0]
keepdims = attr.get("keepdims", 1)
noop_with_empty_axes = attr.get("noop_with_empty_axes", 0)
# Optional axes input
axes = None
if len(inputs) > 1 and inputs[1] is not None:
axes_const = get_constant(inputs[1], params)
assert isinstance(axes_const, relax.Constant), "Only constant axes currently supported"
axes = axes_const.data.numpy().tolist()
# If axes is empty and noop_with_empty_axes is 0, reduce all dimensions
if not axes and not noop_with_empty_axes:
return relax.op.mean(data, None, keepdims)
# If axes is empty and noop_with_empty_axes is 1, return the input data unchanged.
elif not axes and noop_with_empty_axes:
return data
# If axes is provided, reduce over the specified axes
else:
return relax.op.mean(data, axes, keepdims)
class ReduceProd(OnnxOpConverter):
"""Converts an onnx ReduceProd node into an equivalent Relax expression."""
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
data = inputs[0]
axes = attr.get("axes", None)
keepdims = attr.get("keepdims", 1)
return relax.op.prod(data, axes, keepdims)
@classmethod
def _impl_v18(cls, bb, inputs, attr, params):
data = inputs[0]
keepdims = attr.get("keepdims", 1)
noop_with_empty_axes = attr.get("noop_with_empty_axes", 0)
# Optional axes input
axes = None
if len(inputs) > 1 and inputs[1] is not None:
axes_const = get_constant(inputs[1], params)
assert isinstance(axes_const, relax.Constant), "Only constant axes currently supported"
axes = axes_const.data.numpy().tolist()
# If axes is empty and noop_with_empty_axes is 0, reduce all dimensions
if not axes and not noop_with_empty_axes:
return relax.op.prod(data, None, keepdims)
# If axes is empty and noop_with_empty_axes is 1, return the input data unchanged.
elif not axes and noop_with_empty_axes:
return data
# If axes is provided, reduce over the specified axes
else:
return relax.op.prod(data, axes, keepdims)
class ReduceLogSumExp(OnnxOpConverter):
"""Converts an onnx ReduceLogSumExp node into an equivalent Relax expression."""
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
x = inputs[0]
axes = attr.get("axes", None)
keepdims = attr.get("keepdims", 1)
max_x = relax.op.max(x, axes, True)
exp_x = relax.op.exp(relax.op.subtract(x, max_x))
sum_x = relax.op.sum(exp_x, axes, True)
out_x = relax.op.add(relax.op.log(sum_x), max_x)
if not keepdims:
out_x = relax.op.squeeze(out_x, axes)
return out_x
@classmethod
def _impl_v18(cls, bb, inputs, attr, params):
x = inputs[0]
keepdims = attr.get("keepdims", 1)
noop_with_empty_axes = attr.get("noop_with_empty_axes", 0)
# Optional axes input (second input)
axes = None
if len(inputs) > 1 and inputs[1] is not None:
axes_const = get_constant(inputs[1], params)
assert isinstance(axes_const, relax.Constant), "Only constant axes currently supported"
axes = axes_const.data.numpy().tolist()
# Calculate LogSumExp
log_sum_exp = lambda axes: (
max_x := relax.op.max(x, axes, True),
exp_x := relax.op.exp(relax.op.subtract(x, max_x)),
sum_x := relax.op.sum(exp_x, axes, True),
out_x := relax.op.add(relax.op.log(sum_x), max_x),
relax.op.squeeze(out_x, axes) if not keepdims else out_x,
)[-1]
# If axes is empty and noop_with_empty_axes is 0, reduce all dimensions
if not axes and not noop_with_empty_axes:
return log_sum_exp(None)
# If axes is empty and noop_with_empty_axes is 1, return the input data unchanged.
elif not axes and noop_with_empty_axes:
return x
# If axes is provided, reduce over the specified axes
else:
return log_sum_exp(axes)
class ReduceLogSum(OnnxOpConverter):
"""Converts an onnx ReduceLogSum node into an equivalent Relax expression."""
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
data = inputs[0]
axes = attr.get("axes", None)
keepdims = attr.get("keepdims", 1)
return relax.op.log(relax.op.sum(data, axes, keepdims))
@classmethod
def _impl_v18(cls, bb, inputs, attr, params):
data = inputs[0]
keepdims = attr.get("keepdims", 1)
noop_with_empty_axes = attr.get("noop_with_empty_axes", 0)
# Optional axes input
axes = None
if len(inputs) > 1 and inputs[1] is not None:
axes_const = get_constant(inputs[1], params)
assert isinstance(axes_const, relax.Constant), "Only constant axes currently supported"
axes = axes_const.data.numpy().tolist()
# If axes is empty and noop_with_empty_axes is 0, reduce all dimensions
if not axes and not noop_with_empty_axes:
return relax.op.log(relax.op.sum(data, None, keepdims))
# If axes is empty and noop_with_empty_axes is 1, return the input data unchanged.
elif not axes and noop_with_empty_axes:
return data
# If axes is provided, reduce over the specified axes
else:
return relax.op.log(relax.op.sum(data, axes, keepdims))
class ReduceSumSquare(OnnxOpConverter):
"""Converts an onnx ReduceSumSquare node into an equivalent Relax expression."""
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
data = inputs[0]
axes = attr.get("axes", None)
keepdims = attr.get("keepdims", 1)
return relax.op.sum(relax.op.multiply(data, data), axes, keepdims)
@classmethod
def _impl_v18(cls, bb, inputs, attr, params):
data = inputs[0]
keepdims = attr.get("keepdims", 1)
noop_with_empty_axes = attr.get("noop_with_empty_axes", 0)
# Optional axes input
axes = None
if len(inputs) > 1 and inputs[1] is not None:
axes_const = get_constant(inputs[1], params)
assert isinstance(axes_const, relax.Constant), "Only constant axes currently supported"
axes = axes_const.data.numpy().tolist()
# If axes is empty and noop_with_empty_axes is 0, reduce all dimensions
if not axes and not noop_with_empty_axes:
return relax.op.sum(relax.op.multiply(data, data), None, keepdims)
# If axes is empty and noop_with_empty_axes is 1, return the input data unchanged.
elif not axes and noop_with_empty_axes:
return data
# If axes is provided, reduce over the specified axes
else:
return relax.op.sum(relax.op.multiply(data, data), axes, keepdims)
class ReduceL1(OnnxOpConverter):
"""Converts an onnx ReduceL1 node into an equivalent Relax expression."""
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
data = inputs[0]
axes = attr.get("axes", None)
keepdims = attr.get("keepdims", 1)
return relax.op.sum(relax.op.abs(data), axes, keepdims)
@classmethod
def _impl_v18(cls, bb, inputs, attr, params):
data = inputs[0]
keepdims = attr.get("keepdims", 1)
noop_with_empty_axes = attr.get("noop_with_empty_axes", 0)
# Optional axes input
axes = None
if len(inputs) > 1 and inputs[1] is not None:
axes_const = get_constant(inputs[1], params)
assert isinstance(axes_const, relax.Constant), "Only constant axes currently supported"
axes = axes_const.data.numpy().tolist()
# If axes is empty and noop_with_empty_axes is 0, reduce all dimensions
if not axes and not noop_with_empty_axes:
return relax.op.sum(relax.op.abs(data), None, keepdims)
# If axes is empty and noop_with_empty_axes is 1, return the input data unchanged.
elif not axes and noop_with_empty_axes:
return data
# If axes is provided, reduce over the specified axes
else:
return relax.op.sum(relax.op.abs(data), axes, keepdims)
class ReduceL2(OnnxOpConverter):
"""Converts an onnx ReduceL2 node into an equivalent Relax expression."""
@classmethod
def _impl_v13(cls, bb, inputs, attr, params):
data = inputs[0]
axes = attr.get("axes", None)
keepdims = attr.get("keepdims", 1)
return relax.op.sqrt(relax.op.sum(relax.op.multiply(data, data), axes, keepdims))
@classmethod
def _impl_v18(cls, bb, inputs, attr, params):
data = inputs[0]
keepdims = attr.get("keepdims", 1)
noop_with_empty_axes = attr.get("noop_with_empty_axes", 0)
# Optional axes input
axes = None
if len(inputs) > 1 and inputs[1] is not None:
axes_const = get_constant(inputs[1], params)
assert isinstance(axes_const, relax.Constant), "Only constant axes currently supported"
axes = axes_const.data.numpy().tolist()
# If axes is empty and noop_with_empty_axes is 0, reduce all dimensions
if not axes and not noop_with_empty_axes:
return relax.op.sqrt(relax.op.sum(relax.op.multiply(data, data), None, keepdims))
# If axes is empty and noop_with_empty_axes is 1, return the input data unchanged.
elif not axes and noop_with_empty_axes:
return data
# If axes is provided, reduce over the specified axes
else:
return relax.op.sqrt(relax.op.sum(relax.op.multiply(data, data), axes, keepdims))
def _argreduce_select_last_index(bb, data, axis, keepdims, op):
"""Helper for ArgMax/ArgMin with select_last_index=1.
Reverses the tensor along the reduction axis, runs the reduction op,
then remaps the index back: last_idx = (axis_size - 1) - flipped_idx.
Handles both static and dynamic axis sizes.
"""
data_flipped = relax.op.flip(data, axis=axis)
flipped_idx = bb.normalize(op(data_flipped, axis, keepdims))
axis_size = data.ty.shape[axis]
if isinstance(axis_size, tirx.IntImm):
offset = relax.const(int(axis_size) - 1, "int64")
else:
# dynamic: get axis size at runtime and subtract 1
shape_tensor = bb.normalize(relax.op.shape_to_tensor(bb.normalize(relax.op.shape_of(data))))
offset = bb.normalize(
relax.op.subtract(
bb.normalize(relax.op.take(shape_tensor, relax.const(axis, "int64"), axis=0)),
relax.const(1, "int64"),
)
)
return relax.op.subtract(offset, flipped_idx)
class ArgMax(OnnxOpConverter):
"""Converts an onnx ArgMax node into an equivalent Relax expression."""
@classmethod
def _check_attrs(cls, data, attr, shift_axis=True):
dims_num = len(data.ty.shape)
axis = attr.get("axis", 0)
if shift_axis and axis < 0:
axis += dims_num
assert 0 <= axis < dims_num, "Axis is out of bounds"
keepdims = attr.get("keepdims", True)
return axis, keepdims
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
data = inputs[0]
axis, keepdims = cls._check_attrs(data, attr, False)
return relax.op.argmax(data, axis, keepdims)
@classmethod
def _impl_v11(cls, bb, inputs, attr, params):
data = inputs[0]
axis, keepdims = cls._check_attrs(data, attr)
return relax.op.argmax(data, axis, keepdims)
@classmethod
def _impl_v12(cls, bb, inputs, attr, params):
data = inputs[0]
axis, keepdims = cls._check_attrs(data, attr)
select_last_index = attr.get("select_last_index", False)
if select_last_index:
return _argreduce_select_last_index(bb, data, axis, keepdims, relax.op.argmax)
return relax.op.argmax(data, axis, keepdims)
class ArgMin(OnnxOpConverter):
"""Converts an onnx ArgMin node into an equivalent Relax expression."""
@classmethod
def _check_attrs(cls, data, attr, shift_axis=True):
dims_num = len(data.ty.shape)
axis = attr.get("axis", 0)
if shift_axis and axis < 0:
axis += dims_num
assert 0 <= axis < dims_num, "Axis is out of bounds"
keepdims = attr.get("keepdims", True)
return axis, keepdims
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
data = inputs[0]
axis, keepdims = cls._check_attrs(data, attr, False)
return relax.op.argmin(data, axis, keepdims)
@classmethod
def _impl_v11(cls, bb, inputs, attr, params):
data = inputs[0]
axis, keepdims = cls._check_attrs(data, attr)
return relax.op.argmin(data, axis, keepdims)
@classmethod
def _impl_v12(cls, bb, inputs, attr, params):
data = inputs[0]
axis, keepdims = cls._check_attrs(data, attr)
select_last_index = attr.get("select_last_index", False)
if select_last_index:
return _argreduce_select_last_index(bb, data, axis, keepdims, relax.op.argmin)
return relax.op.argmin(data, axis, keepdims)
class TopK(OnnxOpConverter):
"""Converts an onnx TopK node into an equivalent Relax expression."""
@classmethod
def _impl_v11(cls, bb, inputs, attr, params):
data = inputs[0]
k = get_constant(inputs[1], params)
if not isinstance(k, relax.Constant):
raise ValueError("TopK k must be a constant")
k = int(k.data.numpy().item())
axis = attr.get("axis", -1)
largest = attr.get("largest", 1)
sorted = attr.get("sorted", 1)
if sorted != 1:
raise ValueError("TopK sorted must be 1 for Relax frontend")
return relax.op.topk(data, k, axis, ret_type="both", largest=largest, dtype="int64")
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
data = inputs[0]
k = attr.get("k", 1)
axis = attr.get("axis", -1)
return relax.op.topk(data, k, axis, ret_type="both", dtype="int64")
class SkipLayerNormalization(OnnxOpConverter):
"""Converts a microsoft contrib SkipLayerNormalization node into a Relax expression."""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
data = inputs[0]
skip = inputs[1]
gamma = inputs[2]
beta = inputs[3]
bias = inputs[4]
assert beta is not None and bias is not None, (
"SkipLayerNormalization import currently only supports required beta and bias"
)
epsilon = attr.get("epsilon", 1e-12)
data = relax.op.add(data, skip)
if bias is not None:
data = relax.op.add(data, bias)
output = relax.op.nn.layer_norm(data, gamma, beta, axes=-1, epsilon=epsilon)
# Expects three outputs though only the first is used. Construct a placeholder for others.
placeholder = relax.const(0, dtype="float32")
return relax.Tuple([output, placeholder, placeholder])
class EmbedLayerNormalization(OnnxOpConverter):
"""Converts a microsoft contrib EmbedLayerNormalization node into a Relax expression."""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
input_ids = inputs[0]
segment_ids = inputs[1]
word_emb = inputs[2]
pos_emb = inputs[3]
segment_emb = inputs[4]
gamma = inputs[5]
beta = inputs[6]
mask = inputs[7]
pos_ids = inputs[8]
epsilon = attr.get("epsilon", 1e-12)
(batch_size, seq_len) = [dim.value for dim in input_ids.ty.shape]
if segment_ids:
assert segment_emb
if pos_ids is None:
pos_ids = relax.const([list(range(seq_len))] * batch_size, dtype="int64")
word_vec = relax.op.take(word_emb, input_ids, axis=0)
if segment_ids:
segment_vec = relax.op.take(segment_emb, segment_ids, axis=0)
pos_vec = relax.op.take(pos_emb, pos_ids, axis=0)
vec_sum = relax.op.add(word_vec, pos_vec)
if segment_ids:
vec_sum = relax.op.add(vec_sum, segment_vec)
ln = relax.op.nn.layer_norm(vec_sum, gamma, beta, axes=-1, epsilon=epsilon)
mask_index = relax.const(_np.zeros((batch_size,), dtype="int64"))
if mask:
# Caculate number of words per sentence.
mask_index = relax.op.sum(mask, axis=1)
return relax.Tuple([ln, mask_index])
class OneHot(OnnxOpConverter):
"""Converts an onnx OneHot node into an equivalent Relax expression."""
@classmethod
def _impl_v11(cls, bb, inputs, attr, params):
indices = inputs[0]
depth = get_constant(inputs[1], params)
values = get_constant(inputs[2], params)
axis = attr.get("axis", -1)
assert isinstance(depth, relax.Constant), "Only constant depth currently supported."
depth = depth.data.numpy().tolist()
assert isinstance(values, relax.Constant), "Only constant values currently supported."
values = values.data.numpy().tolist()
off_value, on_value = values
off_value, on_value = (
relax.prim_value(off_value),
relax.prim_value(on_value),
)
return relax.op.one_hot(indices, on_value, off_value, depth, axis)
class Unique(OnnxOpConverter):
"""Converts an onnx Unique node into an equivalent Relax expression."""
@classmethod
def _impl_v11(cls, bb, inputs, attr, params):
data = inputs[0]
axis = attr.get("axis", None)
sorted_flag = bool(attr.get("sorted", 1))
num_outputs = attr["tvm_custom"]["num_outputs"]
return_index = num_outputs > 1
return_inverse = num_outputs > 2
return_counts = num_outputs > 3
unique = relax.op.unique(
data,
sorted=sorted_flag,
return_index=return_index,
return_inverse=return_inverse,
return_counts=return_counts,
axis=axis,
)
unique_numbers = tirx.Var("unique_numbers", "int64")
input_shape = data.ty.shape
dtype = data.ty.dtype
if axis is None:
output_shape = (unique_numbers,)
else:
axis = axis if axis >= 0 else len(input_shape) + axis
if axis < 0 or axis >= len(input_shape):
raise ValueError(f"Axis {axis} is out of bounds")
output_shape = [
input_shape[i] if i != axis else unique_numbers for i in range(len(input_shape))
]
if num_outputs == 1:
return bb.match_cast(unique, relax.TensorType(output_shape, dtype))
outputs = [bb.match_cast(unique[0], relax.TensorType(output_shape, dtype))]
tuple_idx = 1 # Track which index in the tuple we're at
if return_index:
index_shape = (unique_numbers,)
index_ty = relax.TensorType(index_shape, "int64")
outputs.append(bb.match_cast(unique[tuple_idx], index_ty))
tuple_idx += 1
if return_inverse:
# ONNX spec: inverse_indices is always 1D
# When axis is None: shape is [X.size]
# When axis is specified: shape is [X.shape[axis]]
inverse_shape = (tirx.Var("inverse_numbers", "int64"),)
inverse_ty = relax.TensorType(inverse_shape, "int64")
outputs.append(bb.match_cast(unique[tuple_idx], inverse_ty))
tuple_idx += 1
if return_counts:
count_shape = (unique_numbers,)
count_ty = relax.TensorType(count_shape, "int64")
outputs.append(bb.match_cast(unique[tuple_idx], count_ty))
return relax.Tuple(outputs)
class NonZero(OnnxOpConverter):
"""Converts an onnx NonZero node into an equivalent Relax expression."""
@classmethod
def _impl_v9(cls, bb, inputs, attr, params):
ndim = inputs[0].ty.ndim
ndim = 1 if ndim == 0 else ndim
nonzero_numbers = tirx.Var("nonzero_numbers", "int64")
return bb.match_cast(
relax.op.nonzero(inputs[0]), relax.TensorType((ndim, nonzero_numbers), "int64")
)
class Upsample(OnnxOpConverter):
"""Operator converter for Upsample (nearest mode)."""
@classmethod
def _impl_v9(cls, bb, inputs, attr, params):
scales = attr.get("scales")
assert len(scales) == 4
assert scales[0] == scales[1] == 1
inp_shape = [int(x) for x in inputs[0].ty.shape]
assert len(inp_shape) == 4
out_shape2d = [int(dim * scale) for dim, scale in zip(inp_shape[2:], scales[2:])]
mode = attr.get("mode", b"nearest").decode("ascii")
if mode == "nearest":
mode = "nearest_neighbor"
msg = f'Value {mode} in attribute "mode" of operator Upsample is not valid.'
assert mode in ("linear", "nearest_neighbor", "cubic"), msg
return relax.op.image.resize2d(
data=inputs[0],
roi=None,
size=relax.ShapeExpr(out_shape2d), # (H, W)
layout="NCHW",
method=mode,
coordinate_transformation_mode="asymmetric", # Align with Upsample
)
class HardSigmoid(OnnxOpConverter):
"""Converts an onnx HardSigmoid node into an equivalent Relax expression."""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
x = inputs[0]
dtype = x.ty.dtype
alpha = float(attr.get("alpha", 0.2))
alpha = relax.const(alpha, dtype=dtype)
beta = float(attr.get("beta", 0.5))
beta = relax.const(beta, dtype=dtype)
return relax.op.clip(relax.op.add(relax.op.multiply(alpha, x), beta), 0, 1)
class HardSwish(OnnxOpConverter):
"""Converts an onnx HardSwish node into an equivalent Relax expression."""
@classmethod
def _impl_v14(cls, bb, inputs, attr, params):
x = inputs[0]
dtype = x.ty.dtype
return relax.op.multiply(
x,
relax.op.divide(
relax.op.clip(relax.op.add(x, relax.const(3, dtype)), 0, 6),
relax.expr.const(6, dtype),
),
)
class Sign(OnnxOpConverter):
"""Converts an onnx Sign node into an equivalent Relax expression."""
@classmethod
def _impl_v9(cls, bb, inputs, attr, params):
return relax.op.sign(inputs[0])
class Not(OnnxOpConverter):
"""Converts an onnx Not node into an equivalent Relax expression."""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
return relax.op.logical_not(inputs[0])
class DepthToSpace(OnnxOpConverter):
"""Converts an onnx DepthToSpace node into an equivalent Relax expression."""
@classmethod
def _impl_v11(cls, bb, inputs, attr, params):
block_size = int(attr["blocksize"])
mode = attr.get("mode", b"DCR").decode("utf-8")
b, c, h, w = inputs[0].ty.shape
if mode == "DCR":
x = relax.op.reshape(inputs[0], (b, block_size, block_size, c // (block_size**2), h, w))
x = relax.op.permute_dims(x, [0, 3, 4, 1, 5, 2])
return relax.op.reshape(x, (b, c // (block_size**2), h * block_size, w * block_size))
elif mode == "CRD":
x = relax.op.reshape(inputs[0], (b, c // (block_size**2), block_size, block_size, h, w))
x = relax.op.permute_dims(x, [0, 1, 4, 2, 5, 3])
return relax.op.reshape(x, (b, c // (block_size**2), h * block_size, w * block_size))
else:
raise ValueError(f"Unsupported mode: {mode}, expected DCR or CRD")
class SpaceToDepth(OnnxOpConverter):
"""Converts an onnx SpaceToDepth node into an equivalent Relax expression."""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
block_size = int(attr["blocksize"])
b, c, h, w = inputs[0].ty.shape
x = relax.op.reshape(
inputs[0], (b, c, h // block_size, block_size, w // block_size, block_size)
)
x = relax.op.permute_dims(x, [0, 3, 5, 1, 2, 4])
return relax.op.reshape(
x, (b, c * block_size * block_size, h // block_size, w // block_size)
)
class Optional_(OnnxOpConverter):
"""Converts an ONNX Optional node into an erased or empty Optional representation."""
@classmethod
def _impl_v15(cls, bb, inputs, attr, params):
if len(inputs) > 1:
raise ValueError(f"Optional accepts at most one input, but got {len(inputs)}")
if len(inputs) == 0 or inputs[0] is None:
if "type" not in attr:
raise ValueError("Optional without an input must specify the type attribute.")
return _EmptyOptional(attr["type"])
return inputs[0]
_impl_v18 = _impl_v15
class OptionalHasElement(OnnxOpConverter):
"""Converts an ONNX OptionalHasElement node into a boolean constant."""
@classmethod
def _impl_v15(cls, bb, inputs, attr, params):
if len(inputs) != 1:
raise ValueError(f"OptionalHasElement expects one input, but got {len(inputs)}")
if inputs[0] is None or _is_empty_optional(inputs[0]):
return relax.const(False, dtype="bool")
return relax.const(True, dtype="bool")
_impl_v18 = _impl_v15
class OptionalGetElement(OnnxOpConverter):
"""Converts an ONNX OptionalGetElement node by unwrapping a non-empty Optional."""
@classmethod
def _impl_v15(cls, bb, inputs, attr, params):
if len(inputs) != 1:
raise ValueError(f"OptionalGetElement expects one input, but got {len(inputs)}")
if inputs[0] is None or _is_empty_optional(inputs[0]):
raise ValueError("OptionalGetElement cannot access an empty optional.")
return inputs[0]
_impl_v18 = _impl_v15
class SequenceConstruct(OnnxOpConverter):
"""Operator converter for sequence construction op."""
@classmethod
def _impl_v11(cls, bb, inputs, attr, params):
# Construct a tuple from input tensors.
return relax.Tuple(inputs)
class SequenceEmpty(OnnxOpConverter):
"""Operator converter for sequence empty op."""
@classmethod
def _impl_v11(cls, bb, inputs, attr, params):
# Construct an empty tuple.
return relax.Tuple([])
class SequenceErase(OnnxOpConverter):
"""Operator converter for sequence erase op."""
@classmethod
def _impl_v11(cls, bb, inputs, attr, params):
# Erase tensor from sequence on specified position
input_sequence = inputs[0]
if len(inputs) == 2:
position = inputs[1]
# Non constant position is not supported.
if isinstance(position, relax.Constant):
position = int(position.data.numpy())
else:
raise NotImplementedError("Position must be a constant.")
else:
position = -1
seq_len = len(input_sequence)
if not -seq_len <= position < seq_len:
raise ValueError(
f"Position is out of bounds, expected [-{seq_len}, {seq_len}), got {position}"
)
if position < 0:
position = seq_len + position
seq_list = list(input_sequence)
items = [t for i, t in enumerate(seq_list) if i != position]
return relax.Tuple(items)
class SequenceInsert(OnnxOpConverter):
"""Operator converter for sequence insert op."""
@classmethod
def _impl_v11(cls, bb, inputs, attr, params):
# Insert a new tensor into a tuple of tensors.
input_sequence = inputs[0]
tensor_to_insert = inputs[1]
if len(inputs) == 3:
position = inputs[2]
# Non constant position is not supported.
if isinstance(position, relax.Constant):
position = int(position.data.numpy())
else:
raise NotImplementedError("Position must be a constant.")
else:
position = -1
seq_len = len(input_sequence)
if position < 0:
position = seq_len + position + 1
# Upper bound is inclusive: position == seq_len appends at the end.
if not 0 <= position <= seq_len:
raise ValueError(
f"SequenceInsert position out of bounds for length {seq_len}, got {position}"
)
tensor_list = list(input_sequence)
tensor_list.insert(position, tensor_to_insert)
return relax.Tuple(tensor_list)
class SequenceLength(OnnxOpConverter):
"""Operator converter for sequence length op."""
@classmethod
def _impl_v11(cls, bb, inputs, attr, params):
# Get length of input sequence
return relax.const(len(inputs[0]), dtype="int64")
class ConcatFromSequence(OnnxOpConverter):
"""Operator converter for sequence concatenation op."""
@classmethod
def _impl_v11(cls, bb, inputs, attr, params):
axis = attr.get("axis", 0)
new_axis = attr.get("new_axis", 0)
if new_axis not in (0, 1):
raise ValueError(f"ConcatFromSequence only supports new_axis in (0, 1), got {new_axis}")
tensors = list(inputs[0])
if new_axis == 1:
tensors = [relax.op.expand_dims(t, axis=axis) for t in tensors]
return relax.op.concat(tensors, axis=axis)
class SplitToSequence(OnnxOpConverter):
"""Operator converter for split to sequence op."""
@classmethod
def _impl_v11(cls, bb, inputs, attr, params):
axis = attr.get("axis", 0)
keepdims = attr.get("keepdims", 1)
input_tensor = inputs[0]
input_shape = input_tensor.ty.shape
if len(inputs) == 1:
split = _np.array(1)
else:
split = inputs[1]
if not isinstance(split, relax.Constant):
raise ValueError("Only constant split supported for SplitToSequence")
split = split.data.numpy()
if len(split.shape) == 1 and split.shape[0] > 1:
split = _np.cumsum(split)
split = list(split[:-1])
else:
chunk_size = int(split)
dim_size = input_shape[axis]
if isinstance(dim_size, int | tirx.IntImm):
dim_size_int = int(dim_size)
split = math.ceil(dim_size_int / chunk_size)
else:
raise NotImplementedError(
"SplitToSequence with dynamic dim size and scalar split is not supported."
)
output = relax.op.split(input_tensor, split, axis=axis)
# keepdims=0 applies when split is a scalar (whether provided or defaulted to 1)
# Per ONNX spec: "If input 'split' is specified, this attribute is ignored."
if not keepdims and len(inputs) == 1:
output = bb.emit(output)
n = len(output.ty.fields)
squeezed = [
relax.op.squeeze(bb.emit(relax.TupleGetItem(output, i)), axis=[axis])
for i in range(n)
]
return relax.Tuple(squeezed)
return output
class SequenceAt(OnnxOpConverter):
"""Operator converter for sequence at op."""
@classmethod
def _impl_v11(cls, bb, inputs, attr, params):
input_sequence = inputs[0]
position = inputs[1]
assert isinstance(position, relax.Constant), (
"Only constant position supported for SequenceAt"
)
position = int(position.data.numpy())
return input_sequence[position]
class NonMaxSuppression(OnnxOpConverter):
"""Converts an onnx NonMaxSuppression node into an equivalent Relax expression."""
@classmethod
def _impl_v10(cls, bb, inputs, attr, params):
"""
NonMaxSuppression performs non-maximum suppression (NMS) on all classes.
Inputs:
- boxes: (N, 4) tensor of bounding boxes in format [x1, y1, x2, y2]
- scores: (N, C) tensor of scores for each box and class
- max_output_boxes_per_class: maximum number of boxes to keep per class
- iou_threshold: IoU threshold for NMS
- score_threshold: score threshold for filtering
Outputs:
- selected_indices: (M, 3) tensor with [batch_idx, class_idx, box_idx]
"""
boxes = inputs[0]
scores = inputs[1]
max_output_boxes_per_class = inputs[2] if len(inputs) > 2 else None
iou_threshold = inputs[3] if len(inputs) > 3 else None
score_threshold = inputs[4] if len(inputs) > 4 else None
center_point_box = attr.get("center_point_box", 0)
if max_output_boxes_per_class is not None and isinstance(
max_output_boxes_per_class, relax.Constant
):
max_output_boxes_per_class = int(max_output_boxes_per_class.data.numpy().item())
elif max_output_boxes_per_class is not None and isinstance(
max_output_boxes_per_class, relax.Var
):
var_name = max_output_boxes_per_class.name_hint
if var_name in params[1]:
_, param_value = params[1][var_name]
max_output_boxes_per_class = int(param_value.numpy().item())
else:
max_output_boxes_per_class = 0 # Default value
else:
max_output_boxes_per_class = 0 # Default value
if iou_threshold is not None and isinstance(iou_threshold, relax.Constant):
iou_threshold = float(iou_threshold.data.numpy().item())
elif iou_threshold is not None and isinstance(iou_threshold, relax.Var):
var_name = iou_threshold.name_hint
if var_name in params[1]:
_, param_value = params[1][var_name]
iou_threshold = float(param_value.numpy().item())
else:
iou_threshold = 0.5 # Default value
else:
iou_threshold = 0.5 # Default value
if score_threshold is not None and isinstance(score_threshold, relax.Constant):
score_threshold = float(score_threshold.data.numpy().item())
elif score_threshold is not None and isinstance(score_threshold, relax.Var):
var_name = score_threshold.name_hint
if var_name in params[1]:
_, param_value = params[1][var_name]
score_threshold = float(param_value.numpy().item())
else:
score_threshold = 0.0 # Default value
else:
score_threshold = 0.0 # Default value
if center_point_box != 0:
split_result = relax.op.split(boxes, 4, axis=2)
xc = split_result[0]
yc = split_result[1]
w = split_result[2]
h = split_result[3]
half_w = w / relax.const(2.0, boxes.ty.dtype)
half_h = h / relax.const(2.0, boxes.ty.dtype)
x1 = xc - half_w
x2 = xc + half_w
y1 = yc - half_h
y2 = yc + half_h
boxes = relax.op.concat([y1, x1, y2, x2], axis=2)
nms_out = bb.normalize(
relax.op.vision.all_class_non_max_suppression(
boxes,
scores,
relax.const(max_output_boxes_per_class, dtype="int64"),
relax.const(iou_threshold, dtype="float32"),
relax.const(score_threshold, dtype="float32"),
output_format="onnx",
)
)
selected_indices = bb.emit(relax.TupleGetItem(nms_out, 0))
return selected_indices
class AllClassNMS(OnnxOpConverter):
"""Converts an onnx AllClassNMS node into an equivalent Relax expression."""
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
"""
AllClassNMS performs non-maximum suppression (NMS) on all classes.
Inputs:
- boxes: (N, 4) tensor of bounding boxes in format [x1, y1, x2, y2]
- scores: (N, C) tensor of scores for each box and class
- max_output_boxes_per_class: maximum number of boxes to keep per class
- iou_threshold: IoU threshold for NMS
- score_threshold: score threshold for filtering
Outputs:
- selected_indices: (M, 3) tensor with [batch_idx, class_idx, box_idx]
"""
boxes = inputs[0]
scores = inputs[1]
max_output_boxes_per_class = inputs[2] if len(inputs) > 2 else None
iou_threshold = inputs[3] if len(inputs) > 3 else None
score_threshold = inputs[4] if len(inputs) > 4 else None
center_point_box = attr.get("center_point_box", 0)
if max_output_boxes_per_class is not None and isinstance(
max_output_boxes_per_class, relax.Constant
):
max_output_boxes_per_class = int(max_output_boxes_per_class.data.numpy().item())
elif max_output_boxes_per_class is not None and isinstance(
max_output_boxes_per_class, relax.Var
):
var_name = max_output_boxes_per_class.name_hint
if var_name in params[1]:
_, param_value = params[1][var_name]
max_output_boxes_per_class = int(param_value.numpy().item())
else:
max_output_boxes_per_class = 0 # Default value
else:
max_output_boxes_per_class = 0 # Default value
if iou_threshold is not None and isinstance(iou_threshold, relax.Constant):
iou_threshold = float(iou_threshold.data.numpy().item())
elif iou_threshold is not None and isinstance(iou_threshold, relax.Var):
var_name = iou_threshold.name_hint
if var_name in params[1]:
_, param_value = params[1][var_name]
iou_threshold = float(param_value.numpy().item())
else:
iou_threshold = 0.5 # Default value
else:
iou_threshold = 0.5 # Default value
if score_threshold is not None and isinstance(score_threshold, relax.Constant):
score_threshold = float(score_threshold.data.numpy().item())
elif score_threshold is not None and isinstance(score_threshold, relax.Var):
var_name = score_threshold.name_hint
if var_name in params[1]:
_, param_value = params[1][var_name]
score_threshold = float(param_value.numpy().item())
else:
score_threshold = 0.0 # Default value
else:
score_threshold = 0.0 # Default value
if center_point_box != 0:
split_result = relax.op.split(boxes, 4, axis=2)
xc = split_result[0]
yc = split_result[1]
w = split_result[2]
h = split_result[3]
half_w = w / relax.const(2.0, boxes.ty.dtype)
half_h = h / relax.const(2.0, boxes.ty.dtype)
x1 = xc - half_w
x2 = xc + half_w
y1 = yc - half_h
y2 = yc + half_h
boxes = relax.op.concat([y1, x1, y2, x2], axis=2)
nms_out = bb.normalize(
relax.op.vision.all_class_non_max_suppression(
boxes,
scores,
relax.const(max_output_boxes_per_class, dtype="int64"),
relax.const(iou_threshold, dtype="float32"),
relax.const(score_threshold, dtype="float32"),
output_format="onnx",
)
)
return nms_out
class GridSample(OnnxOpConverter):
"""Converts an onnx GridSample node into an equivalent Relax expression."""
@classmethod
def _impl_v16(cls, bb, inputs, attr, params):
data = inputs[0]
grid = inputs[1]
method = attr.get("mode", b"bilinear")
if isinstance(method, bytes):
method = method.decode("ascii")
# Translate ONNX mode names to TVM method names
if method == "linear":
method = "bilinear"
elif method == "cubic":
method = "bicubic"
padding_mode = attr.get("padding_mode", b"zeros")
if isinstance(padding_mode, bytes):
padding_mode = padding_mode.decode("ascii")
align_corners = bool(attr.get("align_corners", 0))
if hasattr(data.ty, "ndim"):
ndim = data.ty.ndim
else:
ndim = len(data.ty.shape)
if ndim == 5 and method == "bicubic":
raise NotImplementedError(
"5D (volumetric) GridSample with mode='cubic' is not supported "
"(TOPI 3D grid_sample supports only bilinear and nearest)."
)
if ndim == 4:
# ONNX grid shape: [N, H_out, W_out, 2]
# TVM grid shape: [N, 2, H_out, W_out]
grid = relax.op.permute_dims(grid, [0, 3, 1, 2])
layout = "NCHW"
elif ndim == 5:
# ONNX grid shape: [N, D_out, H_out, W_out, 3]
# TVM grid shape: [N, 3, D_out, H_out, W_out]
grid = relax.op.permute_dims(grid, [0, 4, 1, 2, 3])
layout = "NCDHW"
else:
raise NotImplementedError(f"GridSample only supports 4D or 5D input, got {ndim}D.")
return relax.op.image.grid_sample(
data,
grid,
method=method,
layout=layout,
padding_mode=padding_mode,
align_corners=align_corners,
)
class MatMulInteger(OnnxOpConverter):
"""
Converts ONNX MatMulInteger (INT8/UINT8 quantized matrix multiply).
Computes: output = (A - a_zero_point) * (B - b_zero_point)
in int32 accumulation, per ONNX spec v10.
Zero-point shapes per spec:
a_zero_point: scalar | [M] (per-row) | [D1, D2, M, 1] (N-D per-row)
b_zero_point: scalar | [N] (per-col) | [D1, D2, 1, N] (N-D per-col)
"""
@classmethod
def _impl_v10(cls, bb, inputs, attr, params):
a = inputs[0]
b = inputs[1]
# Optional zero points with default of None (treated as 0)
a_zero_point = inputs[2] if len(inputs) > 2 and inputs[2] is not None else None
b_zero_point = inputs[3] if len(inputs) > 3 and inputs[3] is not None else None
# Widen to int32 before any arithmetic to prevent overflow
a = relax.op.astype(a, "int32")
b = relax.op.astype(b, "int32")
if a_zero_point is not None:
a_zp = relax.op.astype(
a_zero_point, "int32"
) # Ensure zero point is int32 for subtraction
a_zp = bb.normalize(a_zp) # Normalize the expr so ty gets populated
a_zp_ndim = len(a_zp.ty.shape)
# Per-row case: [M] -> [M, 1] so it broadcasts over [M, K] row-wise
# N-D case: spec says shape is [D1, D2, M, 1], which already broadcasts correctly (no need to reshape)
if a_zp_ndim == 1:
a_zp = relax.op.expand_dims(a_zp, axis=-1)
a = relax.op.subtract(a, a_zp)
if b_zero_point is not None:
b_zp = relax.op.astype(b_zero_point, "int32")
b_zp = bb.normalize(b_zp)
b_zp_ndim = len(b_zp.ty.shape)
# Per-col case: [N] -> [1, N] so it broadcasts over [K, N] column-wise
# N-D case: [D1, D2, 1, N] already broadcasts correctly
if b_zp_ndim == 1:
b_zp = relax.op.expand_dims(b_zp, axis=0)
b = relax.op.subtract(b, b_zp)
return relax.op.matmul(a, b, out_dtype="int32") # Output is int32 per ONNX spec
def _get_convert_map():
return {
# defs/experimental
"Optional": Optional_,
"OptionalHasElement": OptionalHasElement,
"OptionalGetElement": OptionalGetElement,
# Binary operators
"Add": Add,
"Sub": Sub,
"Mul": Mul,
"Div": Div,
"Mod": Mod,
"Less": Less,
"LessOrEqual": LessOrEqual,
"Greater": Greater,
"GreaterOrEqual": GreaterOrEqual,
"Equal": Equal,
"BitwiseAnd": BitwiseAnd,
"BitwiseOr": BitwiseOr,
"BitwiseXor": BitwiseXor,
"BitShift": BitShift,
"And": And,
"Or": Or,
"Xor": Xor,
"Not": Not,
# Unary operators
"BitwiseNot": BitwiseNot,
"Log": Log,
"Exp": Exp,
"Acos": Acos,
"Acosh": Acosh,
"Asin": Asin,
"Asinh": Asinh,
"Atan": Atan,
"Atanh": Atanh,
"Cos": Cos,
"Cosh": Cosh,
"Sin": Sin,
"Sinh": Sinh,
"Tan": Tan,
"Tanh": Tanh,
"Neg": Neg,
"Abs": Abs,
"Reciprocal": Reciprocal,
"Floor": Floor,
"Ceil": Ceil,
"Round": Round,
"IsInf": IsInf,
"IsNaN": IsNaN,
"Sqrt": Sqrt,
"Relu": Relu,
"Selu": Selu,
"Mish": Mish,
"Trilu": Trilu,
"PRelu": PRelu,
"LeakyRelu": LeakyRelu,
"ThresholdedRelu": ThresholdedRelu,
"Elu": Elu,
"Gelu": Gelu,
"FastGelu": FastGelu,
"BiasGelu": BiasGelu,
"HardSigmoid": HardSigmoid,
"HardSwish": HardSwish,
"Sign": Sign,
"Softplus": Softplus,
"Softsign": Softsign,
"Shrink": Shrink,
"Erf": Erf,
"Sum": Sum,
"Min": Min,
"Max": Max,
"Mean": Mean,
"Cast": Cast,
"Gemm": Gemm,
"MatMul": MatMul,
"MatMulInteger": MatMulInteger,
"MatMulInteger16": MatMulInteger16,
"Reshape": Reshape,
"Sigmoid": Sigmoid,
"Softmax": Softmax,
"LogSoftmax": LogSoftmax,
"Hardmax": Hardmax,
"Transpose": Transpose,
"Unsqueeze": Unsqueeze,
"Where": Where,
"Concat": Concat,
"Clip": Clip,
"Shape": Shape,
"Pow": Pow,
"CumSum": CumSum,
"Squeeze": Squeeze,
"Constant": Constant,
"Gather": Gather,
"GatherElements": GatherElements,
"GatherND": GatherND,
"Scatter": Scatter,
"ScatterElements": ScatterElements,
"ScatterND": ScatterND,
"Compress": Compress,
"Size": Size,
"EyeLike": EyeLike,
# Normalization
"BatchNormalization": BatchNormalization,
"LayerNormalization": LayerNormalization,
"RMSNormalization": RMSNormalization,
"GroupNormalization": GroupNormalization,
"SkipLayerNormalization": SkipLayerNormalization,
"EmbedLayerNormalization": EmbedLayerNormalization,
"InstanceNormalization": InstanceNormalization,
"MeanVarianceNormalization": MeanVarianceNormalization,
"LRN": LocalResponseNormalization,
# defs/reduction
"ReduceMax": ReduceMax,
"ReduceMin": ReduceMin,
"ReduceSum": ReduceSum,
"ReduceMean": ReduceMean,
"ReduceProd": ReduceProd,
"ReduceLogSumExp": ReduceLogSumExp,
"ReduceLogSum": ReduceLogSum,
"ReduceSumSquare": ReduceSumSquare,
"ReduceL1": ReduceL1,
"ReduceL2": ReduceL2,
"ArgMax": ArgMax,
"ArgMin": ArgMin,
"TopK": TopK,
"Expand": Expand,
"ConstantOfShape": ConstantOfShape,
"Slice": Slice,
"Attention": Attention,
"Pad": Pad,
"Split": Split,
"Tile": Tile,
"AveragePool": AveragePool,
"MaxPool": MaxPool,
"LpPool": LpPool,
"GlobalAveragePool": GlobalAveragePool,
"GlobalMaxPool": GlobalMaxPool,
"GlobalLpPool": GlobalLpPool,
"MaxUnpool": MaxUnpool,
"Conv": Conv,
"ConvTranspose": ConvTranspose,
"Flatten": Flatten,
"Identity": Identity,
"Dropout": Dropout,
"Resize": Resize,
"Einsum": Einsum,
"Range": Range,
"OneHot": OneHot,
"Unique": Unique,
"NonZero": NonZero,
"MaxRoiPool": MaxRoiPool,
"RoiAlign": RoiAlign,
"NonMaxSuppression": NonMaxSuppression,
"AllClassNMS": AllClassNMS,
"GridSample": GridSample,
"AffineGrid": AffineGrid,
"Upsample": Upsample,
# others
"DepthToSpace": DepthToSpace,
"SpaceToDepth": SpaceToDepth,
# Sequence operators
"SequenceConstruct": SequenceConstruct,
"SequenceEmpty": SequenceEmpty,
"SequenceErase": SequenceErase,
"SequenceInsert": SequenceInsert,
"SequenceLength": SequenceLength,
"ConcatFromSequence": ConcatFromSequence,
"SplitToSequence": SplitToSequence,
"SequenceAt": SequenceAt,
# Quantization
"QuantizeLinear": QuantizeLinear,
"DequantizeLinear": DequantizeLinear,
"DynamicQuantizeLinear": DynamicQuantizeLinear,
}
class ONNXGraphImporter:
"""A helper class for handling Relax expression copying from pb2.GraphProto.
Definition: https://github.com/onnx/onnx/blob/main/onnx/onnx.proto
Parameters
----------
shape_dict : dict of str to tuple, optional
The input shape to the graph
dtype_dict : str or dict of str to str
The input types to the graph
keep_params_in_input : bool
If True, parameters will be treated as input variables. If false,
parameters are treated as constant and folded directly into the graph.
sanitize : bool
Whether to sanitize the input names to be valid Relax identifiers.
"""
current = None
def __init__(
self,
shape_dict: dict[str, list],
dtype_dict: str | dict[str, str],
keep_params_in_input: bool = False,
sanitize: bool = True,
):
self._nodes: dict[str, relax.Expr] = {}
self._inputs: dict[str, relax.Var] = {}
self._num_input: int = 0
self._shape = shape_dict.copy() if shape_dict else {}
self._input_names: list[str] = []
self._dtype = dtype_dict
self.opset: int = None
self._name_supply = UniqueNameSupply()
self._keep_params_in_input = keep_params_in_input
self._sanitize: bool = sanitize
self.bb: relax.BlockBuilder = relax.BlockBuilder() # pylint: disable=invalid-name
self._params = {}
def from_onnx(self, graph: onnx.onnx_ml_pb2.ModelProto, opset: int) -> IRModule:
"""Construct Relax expressions from the ONNX graph.
Onnx graph is a python protobuf object.
Parameters
----------
graph : onnx protobuf object
The loaded onnx graph
opset : opset version
Returns
-------
mod : tvm.IRModule
The returned relax module
"""
has_if = any(node.op_type == "If" for node in graph.node)
self.opset = opset
self._parse_graph_initializers(graph)
self._parse_graph_input(graph)
self._check_for_unsupported_ops(graph)
func_attrs = {"num_input": self._num_input}
input_list = [value for value in self._inputs.values() if isinstance(value, relax.Var)]
if self._keep_params_in_input and self._params:
param_var_list, param_value_list = map(list, zip(*self._params.values()))
input_list = input_list + param_var_list
func_attrs["params"] = param_value_list
# Enter the function with its parameters already known. This lets
# BlockBuilder derive non-negative constraints from shape positions
# before constructing and simplifying the body.
with self.bb.function("main", params=input_list):
with contextlib.ExitStack() as stack:
if not has_if:
stack.enter_context(self.bb.dataflow())
self._construct_nodes(graph)
# now return the outputs
output_names = [self._parse_value_proto(output) for output in graph.output]
outputs = []
for output_name in output_names:
output_value = self._nodes[output_name]
if _is_empty_optional(output_value):
raise ValueError(
"ONNX graph output "
f"{output_name} is an empty optional. Empty optional graph outputs "
"are not supported by the Relax ONNX frontend."
)
outputs.append(output_value)
outputs = outputs[0] if len(outputs) == 1 else relax.Tuple(outputs)
if has_if:
output_var = outputs
else:
output_var = self.bb.emit_output(outputs)
# ExitStack closes here — dataflow block is now closed
self.bb.emit_func_output(output_var)
relax_mod = self.bb.get()
relax_mod["main"] = relax_mod["main"].with_attrs(func_attrs)
return relax_mod
def _parse_graph_initializers(self, graph: onnx.onnx_ml_pb2.GraphProto):
"""Parse network inputs to relax, aka parameters."""
for init_tensor in graph.initializer:
# There are two cases for handling parameters, they are either
# treated as variables or constants.
if not init_tensor.name.strip():
raise ValueError("Tensor's name is required.")
array = self._parse_array(init_tensor)
# Create variables for constants.
if self._keep_params_in_input:
# Pytorch sometimes inserts silly weight prefix. Remove it.
var_name = init_tensor.name.strip("onnx::")
init_var = self._new_var(var_name, shape=array.shape, dtype=array.dtype)
self._nodes[init_tensor.name] = init_var
# We need to keep track of both the real value and variable for this variable.
self._params[var_name] = (init_var, array)
# Otherwise we can use the weight as a constant.
else:
self._nodes[init_tensor.name] = relax.const(array)
def _sanitize_name(self, name: str) -> str:
"""Sanitize a name to make it a valid identifier.
If the name is None, returns a string input_0, input_1, etc.
If the input is an empty string, returns empty_0, empty_1, etc.
If the input is a string that does not start with a letter or underscore,
returns input_<name>. Otherwise, returns an unique input name.
Parameters
----------
name : str
The name to sanitize
Returns
-------
new_name : str
"""
if name == "":
return self._name_supply.fresh_name("empty_")
new_name = name.replace(".", "_")
if not new_name[0].isalpha() and new_name[0] != "_":
new_name = str(self._name_supply.fresh_name("input_" + new_name))
else:
new_name = str(self._name_supply.fresh_name(new_name))
if new_name != name:
warnings.warn(f"Renaming name {name} to {new_name}")
return new_name
def _new_var(self, var_name: str, shape: list, dtype: str = "float32"):
"""Creates a new Relax variable."""
return relax.Var(name_hint=var_name, ty=relax.TensorType(shape=shape, dtype=dtype))
def _parse_graph_input(self, graph: onnx.onnx_ml_pb2.GraphProto):
"""Parse model inputs to Relax parameters."""
value_dict = {}
for i in graph.input:
# from onnx v0.2, GraphProto.input has type ValueInfoProto,
# and the name is 'i.name'
i_name, i_shape, d_type, i_shape_name, value_dict = get_info(i, value_dict)
if i_name not in self._nodes:
self._num_input += 1
self._input_names.append(i_name)
if i_name in self._shape:
i_shape = self._shape[i_name]
else:
if "?" in str(i_shape):
warning_msg = (
f"Input {i_name} has unknown dimension shapes: {i_shape_name!s}. "
"Specifying static values may improve performance"
)
warnings.warn(warning_msg)
if isinstance(self._dtype, dict):
dtype = self._dtype[i_name] if i_name in self._dtype else d_type
else:
dtype = d_type
var_name = self._sanitize_name(i_name) if self._sanitize else i_name
self._nodes[i_name] = self._new_var(var_name, shape=i_shape, dtype=dtype)
self._inputs[i_name] = self._nodes[i_name]
def _check_for_unsupported_ops(self, graph: onnx.onnx_ml_pb2.GraphProto):
convert_map = _get_convert_map()
# Ops handled directly in _construct_nodes rather than via the converter map.
directly_handled_ops = {"If"}
unsupported_ops = set()
for node in graph.node:
op_name = node.op_type
if (
op_name not in convert_map
and op_name not in directly_handled_ops
and op_name != "Constant"
):
unsupported_ops.add(op_name)
if unsupported_ops:
msg = "The following operators are not supported for frontend ONNX: "
msg += ", ".join(unsupported_ops)
raise tvm.error.OpNotImplemented(msg)
def _construct_nodes(self, graph: onnx.onnx_ml_pb2.GraphProto):
"""Nodes are stored as directed acyclic graph."""
for node in graph.node:
op_name = node.op_type
attr = self._parse_attr(node.attribute)
# Create and populate input list.
inputs = onnx_input()
for i in node.input:
if i != "":
inputs.append(self._nodes[i])
else:
inputs.append(None)
i_name = self._parse_value_proto(node)
outputs = node.output
attr["tvm_custom"] = {}
attr["tvm_custom"]["name"] = i_name
attr["tvm_custom"]["num_outputs"] = len(outputs)
if op_name == "If":
cond = inputs[0]
then_expr = self._convert_subgraph(self.bb, attr["then_branch"])
else_expr = self._convert_subgraph(self.bb, attr["else_branch"])
then_seq = relax.SeqExpr(blocks=[], body=then_expr)
else_seq = relax.SeqExpr(blocks=[], body=else_expr)
if_result = self.bb.emit(relax.If(cond, then_seq, else_seq))
if len(outputs) == 1:
self._nodes[outputs[0]] = if_result
else:
for i, k in enumerate(outputs):
self._nodes[k] = self.bb.emit(relax.TupleGetItem(if_result, i))
continue
# Perform special handling for shape expressions. If an input is a
# shape expr, make sure the current op can handle it, otherwise
# convert it to a tensor.
shape_compatible_ops = [
"Reshape",
"Resize",
"ConstantOfShape",
"Gather",
"Slice",
"Shape",
"Expand",
"Concat",
"Equal",
"Where",
"Cast",
"Squeeze",
]
return_tuple_ops = [
"Optional",
"OptionalGetElement",
"SequenceConstruct",
"SequenceEmpty",
"SequenceErase",
"SequenceInsert",
"ConcatFromSequence",
"SplitToSequence",
]
for i, inp in enumerate(inputs):
if (
inp is not None
and isinstance(inp, relax.Expr)
and isinstance(inp.ty, relax.ShapeType)
and op_name not in shape_compatible_ops
):
raise ValueError(f"Node {node.name} cannot handle ShapeExpr inputs.")
try:
op = self._convert_operator(op_name, inputs, attr, self.opset)
# Create type information for the new operator.
if isinstance(op, relax.Expr):
op = self.bb.normalize(op)
except Exception as err: # pylint: disable=broad-exception-caught
print(f"Error converting operator {op_name}, with inputs: {inputs}")
raise err
if op_name in return_tuple_ops:
outputs_num = 1
elif not isinstance(op, relax.Tuple):
if isinstance(op.ty, relax.TupleType):
# This is a var bound to a tuple. We need to unpack it and create
# a new tuple.
tuple_items = []
for i in range(len(op.ty.fields)):
tuple_items.append(self.bb.emit(relax.TupleGetItem(op, i)))
op = relax.Tuple(tuple_items)
outputs_num = len(tuple_items)
else:
outputs_num = 1
else:
outputs_num = len(op)
assert len(outputs) <= outputs_num, (
f"Missing outputs during conversion. Expected {len(outputs)} but Got {outputs_num} in {op_name}."
)
if outputs_num == 1:
self._nodes[outputs[0]] = op
else:
for i, k in enumerate(outputs):
self._nodes[k] = op[i]
def _parse_value_proto(self, value_proto: onnx.onnx_ml_pb2.GraphProto):
"""Parse ValueProto or raw str."""
try:
name = value_proto.name
except AttributeError:
name = value_proto
return name
def _parse_array(self, tensor_proto: onnx.onnx_ml_pb2.TensorProto) -> tvm.runtime.tensor:
np_array = get_numpy(tensor_proto).reshape(tuple(tensor_proto.dims))
return tvm.runtime.tensor(np_array)
def _parse_attr(self, attr_proto: onnx.onnx_ml_pb2.AttributeProto) -> dict[str, Any]:
"""Convert a list of AttributeProto to a dict, with names as keys."""
attrs = {}
for a in attr_proto:
for f in ["f", "i", "s", "g"]:
if a.HasField(f):
attrs[a.name] = getattr(a, f)
for f in ["floats", "ints", "strings"]:
if list(getattr(a, f)):
assert a.name not in attrs, "Only one type of attr is allowed"
attrs[a.name] = tuple(getattr(a, f))
for f in ["t", "tp"]:
if hasattr(a, f) and a.HasField(f):
attrs[a.name] = getattr(a, f)
for f in ["tensors", "type_protos"]:
if hasattr(a, f) and list(getattr(a, f)):
assert a.name not in attrs, "Only one type of attr is allowed"
attrs[a.name] = tuple(getattr(a, f))
for f in ["graphs"]:
if list(getattr(a, f)):
assert a.name not in attrs, "Only one type of attr is allowed"
attrs[a.name] = tuple(getattr(a, f))
if a.name not in attrs:
raise ValueError(f"Cannot parse attribute: \n{a}\n.")
return attrs
def _convert_operator(
self,
op_name: str,
inputs: list[relax.Expr],
attrs: dict,
opset: int,
) -> relax.Expr:
"""Convert ONNX operator into a Relax 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.relax.function.Function
List of inputs.
attrs : dict
Dict of operator attributes
opset : int
Opset version
Returns
-------
sym : tvm.relax.function.Function
Converted relax function
"""
convert_map = _get_convert_map()
if op_name in convert_map:
convert_class = convert_map[op_name]
op_function = convert_class.get_converter(opset)
sym = op_function(self.bb, inputs, attrs, [self._nodes, self._params])
else:
raise NotImplementedError(f"Operator {op_name} not implemented.")
return sym
def _convert_subgraph(self, bb, graph):
"""
Walk an ONNX GraphProto (a branch body) and return a Relax SeqExpr.
Outer-scope nodes are visible because we copy self._nodes into the
local lookup table before processing.
"""
outer_nodes = dict(self._nodes)
try:
for init_tensor in graph.initializer:
array = self._parse_array(init_tensor)
self._nodes[init_tensor.name] = relax.const(array)
for node in graph.node:
op_name = node.op_type
attr = self._parse_attr(node.attribute)
inputs = onnx_input()
for i in node.input:
if i != "":
inputs.append(self._nodes.get(i, outer_nodes.get(i)))
else:
inputs.append(None)
attr["tvm_custom"] = {}
attr["tvm_custom"]["name"] = node.name
attr["tvm_custom"]["num_outputs"] = len(node.output)
# Handle nested If recursively.
if op_name == "If":
cond = inputs[0]
then_expr = self._convert_subgraph(bb, attr["then_branch"])
else_expr = self._convert_subgraph(bb, attr["else_branch"])
then_seq = relax.SeqExpr(blocks=[], body=then_expr)
else_seq = relax.SeqExpr(blocks=[], body=else_expr)
op = bb.emit(relax.If(cond, then_seq, else_seq))
outputs = node.output
if len(outputs) == 1:
self._nodes[outputs[0]] = op
else:
for i, k in enumerate(outputs):
self._nodes[k] = bb.emit(relax.TupleGetItem(op, i))
continue
op = self._convert_operator(op_name, inputs, attr, self.opset)
try:
_ = op.ty
has_ty = True
except tvm.error.InternalError:
has_ty = False
if not has_ty:
op = bb.normalize(op)
if not isinstance(op, relax.Tuple):
if isinstance(op.ty, relax.TupleType):
tuple_items = [relax.TupleGetItem(op, i) for i in range(len(op.ty.fields))]
op = relax.Tuple(tuple_items)
outputs = node.output
if len(outputs) == 1:
self._nodes[outputs[0]] = op
else:
for i, k in enumerate(outputs):
self._nodes[k] = op[i]
branch_outputs = [self._nodes[o.name] for o in graph.output]
result = branch_outputs[0] if len(branch_outputs) == 1 else relax.Tuple(branch_outputs)
self._nodes = outer_nodes
return result
finally:
self._nodes = outer_nodes
def from_onnx(
model: onnx.onnx_ml_pb2.GraphProto,
shape_dict: dict[str, list] | None = None,
dtype_dict: str | dict[str, str] | None = "float32",
opset: int | None = None,
keep_params_in_input: bool = False,
sanitize_input_names: bool = True,
) -> IRModule:
"""Convert a ONNX model into an equivalent Relax Function.
ONNX graphs are represented as Python Protobuf objects.
The current implementation assumes that the input model is after ONNX v1.1.0.
Parameters
----------
model : protobuf object
ONNX ModelProto after ONNX v1.1.0
shape_dict : dict of str to tuple, optional
The input shape to the graph
dtype_dict : str or dict of str to str, optional
The input types to the graph
opset : int, optional
Override to autodetected opset.
This can be helpful for some testing.
keep_params_in_input : bool
If True, parameters will be treated as input variables. If false,
parameters are treated as constant and folded directly into the graph.
sanitize_input_names : bool, optional
Whether to sanitize the input names to ensure they are valid Relax identifiers.
Returns
-------
mod : tvm.IRModule
The relax module for compilation
"""
# Error if the model version is below 1.1.0
if model.ir_version < 3:
raise ValueError(
f"Model IR version {model.ir_version} not supported. Must be at least after 1.1.0."
)
try:
import onnx # pylint: disable=import-outside-toplevel, redefined-outer-name
if hasattr(onnx.checker, "check_model"):
# try use onnx's own model checker before converting any model
try:
onnx.checker.check_model(model)
except Exception as exception: # pylint: disable=c-extension-no-member, broad-except
# the checker is a bit violent about errors, so simply print warnings here
warnings.warn(str(exception))
except ImportError as error:
raise ImportError(f"Unable to import onnx which is required {error}")
g = ONNXGraphImporter(
shape_dict,
dtype_dict,
keep_params_in_input=keep_params_in_input,
sanitize=sanitize_input_names,
)
graph = model.graph
try:
opset_in_model = 1
if model.opset_import:
# TODO: for now we only really support ai.onnx op set
# TODO: handle other namespaces well see https://github.com/apache/tvm/issues/10950
for opset_identifier in model.opset_import:
# As per https://github.com/onnx/onnx/blob/main/docs/IR.md
# All operator sets except the default one must specify the operator version
if str(opset_identifier.domain) in ["ai.onnx", ""]:
opset_in_model = opset_identifier.version
break
except AttributeError:
opset_in_model = 1
if opset is None:
opset = opset_in_model
elif opset < opset_in_model:
warnings.warn(
""
f"You are overwritting original opset ver = {opset_in_model} by lower ver = {opset}. "
f"That might cause model conversion errors."
)
# Use the graph proto as a scope so that ops can access other nodes if needed.
return g.from_onnx(graph, opset)