From 481f23b6f17bb1d914d083cc991281ca5bc78097 Mon Sep 17 00:00:00 2001 From: Soowon Jeong Date: Tue, 7 Apr 2026 15:38:21 +0900 Subject: [PATCH] [BugFix][ONNX] Fix Round op to use ties-to-even (banker's rounding) The ONNX Round operator specification requires ties-to-even rounding: "For cases where number is exactly halfway between two integers, it rounds to the nearest even integer." Previously, `topi.round()` lowered to `te.round` -> `tir.round` -> `llvm::round`, which uses ties-away-from-zero. This caused TVM to return wrong results for midpoint values (e.g., round(0.5) = 1 instead of 0, round(2.5) = 3 instead of 2). Fix by switching `topi.round()` to `te.nearbyint`, which lowers to `tir.nearbyint` -> `llvm::nearbyint`. The `nearbyint` intrinsic respects the IEEE 754 default rounding mode (ties-to-even), matching the ONNX spec and onnxruntime behavior. Also register `tir.nearbyint` for the WebGPU backend, mapping to WGSL `round()` which is already ties-to-even per the WGSL spec. Add a targeted test with midpoint inputs to prevent regression. Fixes #18590 --- python/tvm/topi/math.py | 7 +++++-- src/target/source/intrin_rule_webgpu.cc | 8 ++++++++ tests/python/relax/test_frontend_onnx.py | 21 +++++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/python/tvm/topi/math.py b/python/tvm/topi/math.py index 146009e3ba12..d3e8991c85c7 100644 --- a/python/tvm/topi/math.py +++ b/python/tvm/topi/math.py @@ -447,7 +447,10 @@ def isinf(x): @tvm.te.tag_scope(tag=tag.ELEMWISE) def round(x): - """Round elements of x to nearest integer. + """Round elements of x to nearest integer using ties-to-even (banker's rounding). + + Ties are broken by rounding to the nearest even integer, matching the ONNX Round + specification and IEEE 754 default rounding mode. Parameters ---------- @@ -459,7 +462,7 @@ def round(x): y : tvm.te.Tensor The result. """ - return te.compute(x.shape, lambda *i: te.round(x(*i))) + return te.compute(x.shape, lambda *i: te.nearbyint(x(*i))) def log(x): diff --git a/src/target/source/intrin_rule_webgpu.cc b/src/target/source/intrin_rule_webgpu.cc index 968df9a579f4..0dab065f2177 100644 --- a/src/target/source/intrin_rule_webgpu.cc +++ b/src/target/source/intrin_rule_webgpu.cc @@ -89,6 +89,14 @@ TVM_REGISTER_OP("tirx.log2") TVM_REGISTER_OP("tirx.pow") .set_attr("webgpu.FLowerIntrinsic", DispatchPureExtern); +struct ReturnRound { + std::string operator()(DataType t, std::string name) const { return "round"; } +}; + +// WGSL round() uses ties-to-even (banker's rounding), matching IEEE 754 and ONNX Round spec. +TVM_REGISTER_OP("tirx.nearbyint") + .set_attr("webgpu.FLowerIntrinsic", DispatchPureExtern); + TVM_REGISTER_OP("tirx.round") .set_attr("webgpu.FLowerIntrinsic", DispatchPureExtern); diff --git a/tests/python/relax/test_frontend_onnx.py b/tests/python/relax/test_frontend_onnx.py index 8111b95c4bfb..183fb701324f 100644 --- a/tests/python/relax/test_frontend_onnx.py +++ b/tests/python/relax/test_frontend_onnx.py @@ -699,6 +699,27 @@ def test_unary(op_name: str): verify_unary(op_name, [8, 8, 8], input_dtype=input_dtype, output_dtype=output_dtype) +def test_round_ties_to_even(): + """ONNX Round must use ties-to-even (banker's rounding), not ties-away-from-zero. + + Per the ONNX spec: "For cases where number is exactly halfway between two + integers, it rounds to the nearest even integer." + https://onnx.ai/onnx/operators/onnx__Round.html + """ + round_node = helper.make_node("Round", ["x"], ["y"]) + graph = helper.make_graph( + [round_node], + "round_ties_to_even_test", + inputs=[helper.make_tensor_value_info("x", TensorProto.FLOAT, [6])], + outputs=[helper.make_tensor_value_info("y", TensorProto.FLOAT, [6])], + ) + model = helper.make_model(graph, producer_name="round_ties_to_even_test") + # Midpoint values: 0.5->0, 1.5->2, 2.5->2, -0.5->0, -1.5->-2, -2.5->-2 (ties-to-even) + # Ties-away would give: 0.5->1, 1.5->2, 2.5->3, -0.5->-1, -1.5->-2, -2.5->-3 + inputs = {"x": np.array([0.5, 1.5, 2.5, -0.5, -1.5, -2.5], dtype="float32")} + check_correctness(model, inputs=inputs, opset=11) + + @pytest.mark.parametrize("from_type", [TensorProto.INT32, TensorProto.FLOAT, TensorProto.FLOAT16]) @pytest.mark.parametrize("to_type", [TensorProto.INT32, TensorProto.FLOAT, TensorProto.FLOAT16]) def test_cast(from_type, to_type):