This is an automated email from the ASF dual-hosted git repository.
tlopex pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tvm.git
The following commit(s) were added to refs/heads/main by this push:
new 6b7380e6e2 [Relax][Frontend][ONNX] Add GroupNormalization support
(#19907)
6b7380e6e2 is described below
commit 6b7380e6e2d01f295fc330242163f47264ffcc06
Author: Ronald Nap <[email protected]>
AuthorDate: Sat Jul 11 21:24:34 2026 -0700
[Relax][Frontend][ONNX] Add GroupNormalization support (#19907)
## Summary
Adds ONNX frontend support for `GroupNormalization` by mapping it to the
existing `relax.op.nn.group_norm`.
Supports opset 18 per-group scale/bias expansion, opset 21 per-channel
scale/bias, and `stash_type` cast behavior.
## Testing
Includes structural checks for opset 18, opset 21, rank-3 inputs, and
fp16 `stash_type` paths.
---
python/tvm/relax/frontend/onnx/onnx_frontend.py | 134 +++++++++++++
tests/python/relax/test_frontend_onnx.py | 248 ++++++++++++++++++++++++
2 files changed, 382 insertions(+)
diff --git a/python/tvm/relax/frontend/onnx/onnx_frontend.py
b/python/tvm/relax/frontend/onnx/onnx_frontend.py
index da65cc7bee..d340c696b4 100644
--- a/python/tvm/relax/frontend/onnx/onnx_frontend.py
+++ b/python/tvm/relax/frontend/onnx/onnx_frontend.py
@@ -4005,6 +4005,139 @@ class RMSNormalization(OnnxOpConverter):
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."""
@@ -5385,6 +5518,7 @@ def _get_convert_map():
"BatchNormalization": BatchNormalization,
"LayerNormalization": LayerNormalization,
"RMSNormalization": RMSNormalization,
+ "GroupNormalization": GroupNormalization,
"SkipLayerNormalization": SkipLayerNormalization,
"EmbedLayerNormalization": EmbedLayerNormalization,
"InstanceNormalization": InstanceNormalization,
diff --git a/tests/python/relax/test_frontend_onnx.py
b/tests/python/relax/test_frontend_onnx.py
index 35cf699dba..126c909983 100644
--- a/tests/python/relax/test_frontend_onnx.py
+++ b/tests/python/relax/test_frontend_onnx.py
@@ -4367,6 +4367,254 @@ def test_rms_norm():
check_correctness(model, opset=23, rtol=1e-2, atol=1e-2)
+def _make_group_norm_expected_ir(
+ input_shape: list[int],
+ scale_shape: list[int],
+ bias_shape: list[int],
+ num_groups: int,
+ opset: int = 21,
+ dtype: str = "float32",
+ stash_type: int = 1,
+):
+ input_shape = tuple(input_shape)
+ scale_shape = tuple(scale_shape)
+ bias_shape = tuple(bias_shape)
+ axes = list(range(2, len(input_shape)))
+ epsilon = float(np.float32(1e-5))
+ affine_shape = (input_shape[1],) + (1,) * (len(input_shape) - 2)
+
+ if opset == 18:
+ channels = input_shape[1]
+ channels_per_group = channels // num_groups
+
+ @I.ir_module
+ class ExpectedGroupNormOpset18:
+ @R.function
+ def main(
+ input: R.Tensor(input_shape, dtype=dtype),
+ scale: R.Tensor(scale_shape, dtype=dtype),
+ bias: R.Tensor(bias_shape, dtype=dtype),
+ ) -> R.Tensor(input_shape, dtype=dtype):
+ R.func_attr({"num_input": 3})
+ with R.dataflow():
+ lv: R.Tensor((num_groups, 1), dtype=dtype) = R.reshape(
+ scale, R.shape([num_groups, 1])
+ )
+ lv1: R.Tensor((num_groups, channels_per_group),
dtype=dtype) = R.broadcast_to(
+ lv, R.shape([num_groups, channels_per_group])
+ )
+ lv2: R.Tensor((channels,), dtype=dtype) = R.reshape(lv1,
R.shape([channels]))
+ lv3: R.Tensor((num_groups, 1), dtype=dtype) = R.reshape(
+ bias, R.shape([num_groups, 1])
+ )
+ lv4: R.Tensor((num_groups, channels_per_group),
dtype=dtype) = R.broadcast_to(
+ lv3, R.shape([num_groups, channels_per_group])
+ )
+ lv5: R.Tensor((channels,), dtype=dtype) = R.reshape(lv4,
R.shape([channels]))
+ gv: R.Tensor(input_shape, dtype=dtype) = R.nn.group_norm(
+ input,
+ lv2,
+ lv5,
+ num_groups=num_groups,
+ channel_axis=1,
+ axes=axes,
+ epsilon=epsilon,
+ )
+ R.output(gv)
+ return gv
+
+ return ExpectedGroupNormOpset18
+
+ if opset == 21 and stash_type == 1 and dtype != "float32":
+
+ @I.ir_module
+ class ExpectedGroupNormOpset21Stash:
+ @R.function
+ def main(
+ input: R.Tensor(input_shape, dtype=dtype),
+ scale: R.Tensor(scale_shape, dtype=dtype),
+ bias: R.Tensor(bias_shape, dtype=dtype),
+ ) -> R.Tensor(input_shape, dtype=dtype):
+ R.func_attr({"num_input": 3})
+ with R.dataflow():
+ lv: R.Tensor(input_shape, dtype="float32") =
R.astype(input, dtype="float32")
+ lv1: R.Tensor(scale_shape, dtype="float32") =
R.astype(scale, dtype="float32")
+ lv2: R.Tensor(scale_shape, dtype="float32") =
R.ones_like(lv1)
+ lv3: R.Tensor(bias_shape, dtype="float32") =
R.astype(bias, dtype="float32")
+ lv4: R.Tensor(bias_shape, dtype="float32") =
R.zeros_like(lv3)
+ lv5: R.Tensor(input_shape, dtype="float32") =
R.nn.group_norm(
+ lv,
+ lv2,
+ lv4,
+ num_groups=num_groups,
+ channel_axis=1,
+ axes=axes,
+ epsilon=epsilon,
+ center=False,
+ scale=False,
+ )
+ lv6: R.Tensor(input_shape, dtype=dtype) = R.astype(lv5,
dtype=dtype)
+ lv7: R.Tensor(affine_shape, dtype=dtype) = R.reshape(
+ scale, R.shape(affine_shape)
+ )
+ lv8: R.Tensor(input_shape, dtype=dtype) = R.multiply(lv6,
lv7)
+ lv9: R.Tensor(affine_shape, dtype=dtype) = R.reshape(
+ bias, R.shape(affine_shape)
+ )
+ gv: R.Tensor(input_shape, dtype=dtype) = R.add(lv8, lv9)
+ R.output(gv)
+ return gv
+
+ return ExpectedGroupNormOpset21Stash
+
+ if opset == 21:
+
+ @I.ir_module
+ class ExpectedGroupNormOpset21:
+ @R.function
+ def main(
+ input: R.Tensor(input_shape, dtype=dtype),
+ scale: R.Tensor(scale_shape, dtype=dtype),
+ bias: R.Tensor(bias_shape, dtype=dtype),
+ ) -> R.Tensor(input_shape, dtype=dtype):
+ R.func_attr({"num_input": 3})
+ with R.dataflow():
+ lv: R.Tensor(scale_shape, dtype=dtype) = R.ones_like(scale)
+ lv1: R.Tensor(bias_shape, dtype=dtype) = R.zeros_like(bias)
+ lv2: R.Tensor(input_shape, dtype=dtype) = R.nn.group_norm(
+ input,
+ lv,
+ lv1,
+ num_groups=num_groups,
+ channel_axis=1,
+ axes=axes,
+ epsilon=epsilon,
+ center=False,
+ scale=False,
+ )
+ lv3: R.Tensor(affine_shape, dtype=dtype) = R.reshape(
+ scale, R.shape(affine_shape)
+ )
+ lv4: R.Tensor(input_shape, dtype=dtype) = R.multiply(lv2,
lv3)
+ lv5: R.Tensor(affine_shape, dtype=dtype) = R.reshape(
+ bias, R.shape(affine_shape)
+ )
+ gv: R.Tensor(input_shape, dtype=dtype) = R.add(lv4, lv5)
+ R.output(gv)
+ return gv
+
+ return ExpectedGroupNormOpset21
+
+ raise AssertionError(f"No GroupNormalization expected IR for
opset={opset}")
+
+
+def test_group_norm():
+ def verify_group_norm(
+ input_shape: list[int],
+ scale_shape: list[int],
+ bias_shape: list[int],
+ num_groups: int,
+ expected,
+ opset: int = 21,
+ dtype: int = TensorProto.FLOAT,
+ stash_type: int = 1,
+ ):
+ attrs = {"num_groups": num_groups, "epsilon": 1e-5}
+ if opset == 21:
+ attrs["stash_type"] = stash_type
+
+ node = helper.make_node(
+ "GroupNormalization", ["input", "scale", "bias"], ["output"],
**attrs
+ )
+ graph = helper.make_graph(
+ [node],
+ "group_norm_test",
+ inputs=[
+ helper.make_tensor_value_info("input", dtype,
list(input_shape)),
+ helper.make_tensor_value_info("scale", dtype,
list(scale_shape)),
+ helper.make_tensor_value_info("bias", dtype, list(bias_shape)),
+ ],
+ outputs=[helper.make_tensor_value_info("output", dtype,
list(input_shape))],
+ )
+
+ model = helper.make_model(
+ graph,
+ producer_name="group_norm_test",
+ opset_imports=[helper.make_opsetid("", opset)],
+ )
+ tvm_model = from_onnx(model, opset=opset, keep_params_in_input=True)
+ tvm_model["main"] = tvm_model["main"].without_attr("params")
+ expected = tvm.IRModule(expected.functions)
+ for gv in expected.get_global_vars():
+ if gv.name_hint != "main":
+ expected.update_func(gv, tvm_model[gv.name_hint])
+ tvm.ir.assert_structural_equal(tvm_model, expected)
+
+ for input_shape, scale_shape, bias_shape, num_groups, opset, dtype,
dtype_str, stash_type in [
+ ([1, 4, 2, 2], [2], [2], 2, 18, TensorProto.FLOAT, "float32", 1),
+ ([1, 4, 2, 2], [4], [4], 2, 21, TensorProto.FLOAT, "float32", 1),
+ ([1, 4, 8], [4], [4], 2, 21, TensorProto.FLOAT, "float32", 1),
+ ([1, 4, 2, 2], [4], [4], 2, 21, TensorProto.FLOAT16, "float16", 1),
+ ]:
+ verify_group_norm(
+ input_shape,
+ scale_shape,
+ bias_shape,
+ num_groups,
+ _make_group_norm_expected_ir(
+ input_shape,
+ scale_shape,
+ bias_shape,
+ num_groups,
+ opset=opset,
+ dtype=dtype_str,
+ stash_type=stash_type,
+ ),
+ opset=opset,
+ dtype=dtype,
+ stash_type=stash_type,
+ )
+
+ for bad_stash_type in [0, 10, 11, 16]:
+ with pytest.raises(ValueError, match="stash_type=1"):
+ verify_group_norm(
+ [1, 4, 2, 2],
+ [4],
+ [4],
+ 2,
+ _make_group_norm_expected_ir(
+ [1, 4, 2, 2],
+ [4],
+ [4],
+ 2,
+ opset=21,
+ dtype="float16",
+ stash_type=1,
+ ),
+ opset=21,
+ dtype=TensorProto.FLOAT16,
+ stash_type=bad_stash_type,
+ )
+
+ with pytest.raises(ValueError, match="currently only supports float32"):
+ verify_group_norm(
+ [1, 4, 2, 2],
+ [2],
+ [2],
+ 2,
+ _make_group_norm_expected_ir(
+ [1, 4, 2, 2],
+ [2],
+ [2],
+ 2,
+ opset=18,
+ dtype="float16",
+ ),
+ opset=18,
+ dtype=TensorProto.FLOAT16,
+ )
+
+
# TODO Enable dynamism
@pytest.mark.parametrize("dynamic", [False])
def test_skiplayernormalization(dynamic):