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 0943b365ba [Fix][Relax][Frontend][Torch] Fix `torch.round(x, 
decimals)` via `from_exported_program` and negative-decimals rounding (#20239)
0943b365ba is described below

commit 0943b365ba0aba8607d5a4ac8c6e2bb913378bce
Author: HuEnwei <[email protected]>
AuthorDate: Fri Sep 11 03:11:12 2026 +0800

    [Fix][Relax][Frontend][Torch] Fix `torch.round(x, decimals)` via 
`from_exported_program` and negative-decimals rounding (#20239)
    
    Fixes: #20231
    
    ## Summary
    
    `torch.export` lowers `torch.round(x, decimals)` (any explicit
    `decimals`,
    including `decimals=0`) to `aten.round.decimals`, while plain
    `torch.round(x)`
    lowers to `aten.round.default`. The Relax Torch frontend registered only
    `round.default` in the exported-program convert map, so **any** explicit
    `decimals` made `from_exported_program` fail outright with
    `"AssertionError: Unsupported function types ['round.decimals']"`.
    
    In addition, the `decimals != 0` path in `BaseFXGraphImporter._round`
    always
    scaled by `round(x * 10**decimals) / 10**decimals`. For **negative**
    `decimals`
    this multiplies by `0.1 / 0.01 / ...`, which loses the exact power-of-10
    scale
    and, in float64, breaks e.g. `torch.round(torch.tensor(25.0,
    dtype=float64),
    decimals=-1)` (`25 * 0.1 == 2.5000000000000004` rounds up to `30`
    instead of the
    correct `20`).
    
    This PR registers `round.decimals` in the exported-program convert map
    and makes
    the `decimals != 0` scaling use an exact integer power of 10: multiply
    for
    positive decimals, **divide** for negative ones.
    
    > Note: the round-half-to-even (ties-to-even) semantics themselves are
    already
    > provided on latest by upstream #19367 / #19368 (`tir.round` →
    `nearbyint`
    > across all backends); they are **not** changed by this PR. The fixes
    here are
    > the `round.decimals` dispatch gap and the negative-decimals scale
    precision.
    
    ## Root cause
    
    1. **`from_exported_program` rejects `torch.round(x, decimals)`.** In
    `exported_program_translator.py`,
    `ExportedProgramImporter.create_convert_map`
    maps `"round.default": self._round` but no `round.decimals` entry. Since
    `torch.export` always emits `aten.round.decimals` when `decimals` is
    passed
       explicitly — even `decimals=0` — every such call hits the
       `"Unsupported function types ['round.decimals']"` assert in dispatch.
    
    2. **Negative decimals round incorrectly.** `_round` computed
       `scale = relax.const(10**decimals, dtype)` and emitted
    `divide(round(multiply(arg, scale)), scale)` for every non-zero
    `decimals`.
       For `decimals = -1` the scale is `0.1`; multiplying by a non-integer
       power of 10 is inexact in floating point, so
       `torch.round(torch.tensor([25.0], dtype=torch.float64), decimals=-1)`
       produced `30` instead of `20`. (`from_fx` shares the same `_round`.)
    
    ## Fix
    
    - `exported_program_translator.py` — add `"round.decimals":
    self._round,`
      right after `"round.default": self._round,` in
    `ExportedProgramImporter.create_convert_map`, so any explicit-`decimals`
      `torch.round` converts through the existing `_round`.
    - `base_fx_graph_translator.py` — in `BaseFXGraphImporter._round`, keep
    the
      `decimals == 0` fast path, and branch the `decimals != 0` scale:
    - `decimals > 0`: `divide(round(multiply(arg, 10**d)), 10**d)`
    (unchanged).
    - `decimals < 0`: `multiply(round(divide(arg, 10**-d)), 10**-d)` —
    divide by
    the exact integer power of 10 and multiply back, avoiding the inexact
        `× 0.1` path.
    
    ## Validation
    
    ### In-tree regression tests (added)
    
    - `test_round_decimals` in
    `tests/python/relax/test_frontend_from_exported_program.py`
    — runs `verify_model_numerically` (Relax vs PyTorch) for `decimals in
    (0, 1, -1, -2)`
      over a value set that exercises ties-to-even half values
      (`0.5, 1.5, 2.5, 4.5, -0.5, -2.5`) and the negative-decimals path
    (`25.0, 125.0, 165.0` → `20, 120, 160` at `decimals=-1`, and `2.25 →
    2.2`).
      Before the fix, `decimals=0` alone fails to import with
      `"Unsupported function types ['round.decimals']"`.
    - `test_round_decimals` in `tests/python/relax/test_frontend_from_fx.py`
    — same
    values through `from_fx`, asserting TVM output matches `torch.round` for
    the
    same decimals set (this path already dispatched to `_round`, but
    produced the
      wrong negative-decimals result before the fix).
    
    ### Differential test
    
    `verify_patch.py` was run on the locked pre-#19368 build (rounds half
    values
    away from zero). The real fix code is monkey-patched in; the
    ties-to-even inner
    round is reproduced with `te.nearbyint` to stand in for latest
    `relax.op.round`
    semantics (#19368):
    
    | stage | export + fx × decimals {0,1,2,3,-1,-2,-3} | matched | rejected
    | diff elements |
    
    
|-------|--------------------------------------------|---------|----------|---------------|
    | Part 0 — before fix | `export(decimals=-1)` | import fails:
    `AssertionError: Unsupported function types ['round.decimals']` |
    | | `fx(decimals=-1)` | `[30,130,170]` vs torch `[20,120,160]` |
    | Part A — fix, ties-away inner round (locked build) | 14 | 0 | 22 (all
    half-value ties — the #19368 ties-to-even gap, unrelated to this PR) |
    | Part B — fix + ties-to-even inner round (= latest) | 28 | 0 | 0 |
    
    Part B matches PyTorch for **all** 28 combinations — both frontends
    (`from_exported_program`, `from_fx`) × 7 `decimals` ×
    `float32`/`float64` —
    including the previously-failing `round(25, -1) == 20` and
    `round(2.25, 1) == 2.2` cases.
    
    Run:
    
    ```bash
    export PATH=/home/shenqingchao/miniconda3/envs/tvm23/bin:$PATH
    export 
PYTHONPATH=/tmp/tvmffi019:/data/shenqingchao/enwei/familyfuzz/tvm/python
    export TVM_LIBRARY_PATH=/data/shenqingchao/enwei/familyfuzz/tvm/build
    python results/TVM/deepseek-v4-flash/prove_hum/torch_round/verify_patch.py
    ```
    
    ## Files changed
    
    - `python/tvm/relax/frontend/torch/base_fx_graph_translator.py` —
    `_round`:
      negative `decimals` now divide by the exact integer power of 10
    (`round(x / 10^|d|) * 10^|d|`) instead of multiplying by `10**decimals`
      (`× 0.1`).
    - `python/tvm/relax/frontend/torch/exported_program_translator.py` —
    register
      `round.decimals` in the exported-program convert map.
    - `tests/python/relax/test_frontend_from_exported_program.py` — add
      `test_round_decimals`.
    - `tests/python/relax/test_frontend_from_fx.py` — add
    `test_round_decimals`.
---
 .../frontend/torch/base_fx_graph_translator.py     |  57 +++++++++--
 .../frontend/torch/exported_program_translator.py  |   1 +
 .../relax/test_frontend_from_exported_program.py   |  92 ++++++++++++++++++
 tests/python/relax/test_frontend_from_fx.py        | 106 +++++++++++++++++++++
 4 files changed, 250 insertions(+), 6 deletions(-)

diff --git a/python/tvm/relax/frontend/torch/base_fx_graph_translator.py 
b/python/tvm/relax/frontend/torch/base_fx_graph_translator.py
index d42590c256..b0682f1f4c 100644
--- a/python/tvm/relax/frontend/torch/base_fx_graph_translator.py
+++ b/python/tvm/relax/frontend/torch/base_fx_graph_translator.py
@@ -477,12 +477,57 @@ class BaseFXGraphImporter(metaclass=abc.ABCMeta):
         if decimals == 0:
             return self.block_builder.emit(relax.op.round(arg))
 
-        # For decimals != 0, use: round(x * 10^decimals) / 10^decimals
-        dtype = arg.ty.dtype
-        scale = relax.const(10**decimals, dtype)
-        scaled = relax.op.multiply(arg, scale)
-        rounded = relax.op.round(scaled)
-        result = relax.op.divide(rounded, scale)
+        # For decimals != 0, round on the exact power-of-10 scale and scale 
back:
+        # round(x * 10^decimals) / 10^decimals. The scaling must always use an
+        # integer power of 10: multiply for positive decimals, divide for 
negative
+        # ones. Dividing for negative decimals (instead of multiplying by
+        # 10**decimals, i.e. 0.1 / 0.01 / ...) avoids float precision errors 
such as
+        # 25 * 0.1 == 2.5000000000000004 in float64, which would round up to 30
+        # instead of 20 for torch.round(25, -1).
+        #
+        # For float16/bfloat16 inputs the scaling is done in float32 and cast
+        # back, because 10**|decimals| can overflow the input range: 10**4 == 
10000
+        # with 25 * 10000 == 250000 overflows float16 (max 65504) to inf, and 
10**5
+        # already overflows float16 to inf, turning decimals=5 and -5 into NaN.
+        input_dtype = arg.ty.dtype
+        dtype = input_dtype
+        if dtype in ("float16", "bfloat16"):
+            dtype = "float32"
+            arg = self.block_builder.emit(relax.op.astype(arg, dtype))
+
+        # Build the scale 10**|decimals| directly in `dtype` instead of as a 
host
+        # Python int. relax.const(10**n, dtype) first materializes 10**n as an
+        # unbounded int, which is both wasteful for large n and, once n >= 
309, dies
+        # in the int-to-float conversion with "OverflowError: int too large to
+        # convert to float". PyTorch accepts such decimals (e.g.
+        # torch.round(x, decimals=309)) and exports a valid aten.round.decimals
+        # node, so importing these programs must not crash. Computing the 
power as a
+        # float and saturating it to inf once it leaves the finite range of 
`dtype`
+        # is exactly what happens when PyTorch evaluates the same power in the 
input
+        # dtype.
+        scale_exp = abs(decimals)
+        # Largest exponent for which 10**n is still finite in `dtype`.
+        # (float16/bfloat16 are upcast to float32 above, so dtype is float32 or
+        # float64 here.) Note that `dtype` may be a tvm.DataType-like object 
whose
+        # repr is "T.float32" instead of a plain str, so select via == rather 
than
+        # indexing a str-keyed dict.
+        max_scale_exp = 308 if dtype == "float64" else 38
+        if scale_exp > max_scale_exp:
+            scale = relax.const(float("inf"), dtype)
+        else:
+            scale = relax.const(10.0**scale_exp, dtype)
+
+        if decimals > 0:
+            scaled = relax.op.multiply(arg, scale)
+            rounded = relax.op.round(scaled)
+            result = relax.op.divide(rounded, scale)
+        else:
+            scaled = relax.op.divide(arg, scale)
+            rounded = relax.op.round(scaled)
+            result = relax.op.multiply(rounded, scale)
+
+        if input_dtype in ("float16", "bfloat16"):
+            result = relax.op.astype(result, input_dtype)
         return self.block_builder.emit(result)
 
     def _softmax(self, node: fx.Node) -> relax.Var:
diff --git a/python/tvm/relax/frontend/torch/exported_program_translator.py 
b/python/tvm/relax/frontend/torch/exported_program_translator.py
index e6df019c5c..f01831d63c 100644
--- a/python/tvm/relax/frontend/torch/exported_program_translator.py
+++ b/python/tvm/relax/frontend/torch/exported_program_translator.py
@@ -1771,6 +1771,7 @@ class ExportedProgramImporter(BaseFXGraphImporter):
             "relu6.default": self._unary_op(relax.op.nn.relu6),
             "relu6_.default": self._unary_op(relax.op.nn.relu6),
             "round.default": self._round,
+            "round.decimals": self._round,
             "rsqrt.default": self._rsqrt,
             "scalar_tensor.default": self._scalar_tensor,
             "scatter.value": self._scatter_value,
diff --git a/tests/python/relax/test_frontend_from_exported_program.py 
b/tests/python/relax/test_frontend_from_exported_program.py
index 75962826f9..565fd836bd 100644
--- a/tests/python/relax/test_frontend_from_exported_program.py
+++ b/tests/python/relax/test_frontend_from_exported_program.py
@@ -148,6 +148,98 @@ def test_basic_unary_ops(pytorch_op, relax_op):
     verify_model(UnaryOp(), example_args, {}, expected)
 
 
+def test_round_decimals():
+    """torch.round(x, decimals) is exported as aten.round.decimals, which was 
missing
+    from the convert map (only round.default was registered) and made any 
explicit
+    decimals -- including decimals=0 -- fail with
+    "AssertionError: Unsupported function types ['round.decimals']".
+
+    With the decimals overload registered, torch.round(x, decimals) must 
convert and
+    match PyTorch's round-half-to-even results, including negative decimals
+    (round(25, -1) == 20) where the scale-by-0.1 float precision path used to 
be wrong.
+    """
+
+    class RoundDecimalsModel(Module):
+        def __init__(self, decimals):
+            super().__init__()
+            self.decimals = decimals
+
+        def forward(self, input):
+            return torch.round(input, decimals=self.decimals)
+
+    # Half values exercise ties-to-even; 25/125/165 exercise the 
negative-decimals path.
+    x = torch.tensor(
+        [0.5, 1.5, 2.5, 4.5, -0.5, -2.5, 25.0, 125.0, 165.0, 2.25], 
dtype=torch.float32
+    )
+    for decimals in (0, 1, -1, -2):
+        verify_model_numerically(RoundDecimalsModel(decimals).eval(), (x,), 
rtol=1e-6, atol=1e-6)
+
+
+def test_round_decimals_low_precision():
+    """Scaling for low-precision inputs must happen in float32 and be cast 
back.
+
+    10**|decimals| can overflow float16: 10**4 == 10000 with 25 * 10000 == 
250000
+    exceeds float16's max of 65504, so scaling in float16 yields inf, and 10**5
+    already overflows float16 (the scale itself becomes inf), turning 
decimals=5
+    and -5 into NaN. Upcasting the input to float32 keeps the scaling exact; 
the
+    rounded result is cast back to the input dtype.
+    """
+
+    class RoundDecimalsModel(Module):
+        def __init__(self, decimals):
+            super().__init__()
+            self.decimals = decimals
+
+        def forward(self, input):
+            return torch.round(input, decimals=self.decimals)
+
+    x = torch.tensor([0.5, 1.5, 2.5, 2.25, 25.0, 125.0, 165.0, -0.5], 
dtype=torch.float16)
+    # Positive decimals exercise the multiply-by-10**d overflow (4, 5);
+    # negative decimals exercise the 10**|d| scale overflowing float16 (-5).
+    for decimals in (2, 4, 5, -2, -4, -5):
+        verify_model_numerically(RoundDecimalsModel(decimals).eval(), (x,), 
rtol=1e-6, atol=1e-6)
+
+
+def test_round_decimals_large():
+    """A large |decimals| must import and run without OverflowError.
+
+    The scale 10**|decimals| used to be built as an unbounded host Python int
+    before being handed to relax.const, whose int-to-float conversion raises
+    OverflowError ("int too large to convert to float") once |decimals| >= 309
+    (10**309 already exceeds the float64 range). PyTorch accepts such decimals 
and
+    exports a valid aten.round.decimals node, so importing the exported program
+    must not crash on them. The scale is now built directly in the float dtype 
and
+    saturates to inf once it leaves the finite range, matching PyTorch, whose
+    all-NaN result here comes from the same inf scale.
+    """
+
+    class RoundDecimalsModel(Module):
+        def __init__(self, decimals):
+            super().__init__()
+            self.decimals = decimals
+
+        def forward(self, input):
+            return torch.round(input, decimals=self.decimals)
+
+    x = torch.tensor([0.5, 1.5, 25.0, -0.5, 0.0], dtype=torch.float32)
+    for decimals in (309, -309):
+        exported_program = export(RoundDecimalsModel(decimals).eval(), 
args=(x,))
+        mod = from_exported_program(exported_program)  # used to raise 
OverflowError here
+        ex = relax.build(mod, target="llvm")
+        vm = relax.VirtualMachine(ex, tvm.cpu())
+        tvm_out = vm["main"](tvm.runtime.tensor(x.numpy()))
+        got = tvm_out.numpy() if hasattr(tvm_out, "numpy") else 
tvm_out[0].numpy()
+
+        # The scale overflows to inf, and IEEE arithmetic turns every element 
into
+        # NaN in both TVM and PyTorch. Compare the NaN masks and the remaining
+        # (empty here) finite elements separately, since allclose fails on NaN.
+        expected = torch.round(x, decimals=decimals)
+        actual = torch.as_tensor(got)
+        assert torch.equal(torch.isnan(actual), torch.isnan(expected))
+        finite = ~torch.isnan(expected)
+        assert torch.allclose(actual[finite], expected[finite], rtol=1e-6, 
atol=1e-6)
+
+
 operator_bool_unary = [
     (torch.isinf, R.isinf),
     (torch.isnan, R.isnan),
diff --git a/tests/python/relax/test_frontend_from_fx.py 
b/tests/python/relax/test_frontend_from_fx.py
index 7189b3cb24..a0ba7971db 100644
--- a/tests/python/relax/test_frontend_from_fx.py
+++ b/tests/python/relax/test_frontend_from_fx.py
@@ -2556,6 +2556,112 @@ def test_div_mode():
     verify_model(DivFloorModel(), input_info, {}, expected_div_floor)
 
 
+def test_round_decimals():
+    """torch.round(x, decimals) through from_fx must match PyTorch's 
round-half-to-even
+    results, including negative decimals (round(25, -1) == 20). The previous
+    scale-by-10**decimals implementation multiplied by 0.1 for negative 
decimals, which
+    is numerically wrong: 25 * 0.1 == 2.5000000000000004 in float64 rounds up 
to 30.
+    """
+    input_info = [([10], "float32")]
+    x = torch.tensor(
+        [0.5, 1.5, 2.5, 4.5, -0.5, -2.5, 25.0, 125.0, 165.0, 2.25], 
dtype=torch.float32
+    )
+
+    class RoundDecimalsModel(Module):
+        def __init__(self, decimals):
+            super().__init__()
+            self.decimals = decimals
+
+        def forward(self, input):
+            return torch.round(input, decimals=self.decimals)
+
+    for decimals in (0, 1, -1, -2):
+        gm = fx.symbolic_trace(RoundDecimalsModel(decimals).eval())
+        mod = from_fx(gm, input_info)
+        ex = relax.build(mod, target="llvm")
+        vm = relax.VirtualMachine(ex, tvm.cpu())
+        tvm_out = vm["main"](tvm.runtime.tensor(x.numpy()))
+        got = tvm_out.numpy() if hasattr(tvm_out, "numpy") else 
tvm_out[0].numpy()
+        tvm.testing.assert_allclose(
+            got, torch.round(x, decimals=decimals).numpy(), rtol=1e-6, 
atol=1e-6
+        )
+
+
+def test_round_decimals_low_precision():
+    """Scaling for low-precision inputs must happen in float32 and be cast 
back.
+
+    10**|decimals| can overflow float16: 10**4 == 10000 with 25 * 10000 == 
250000
+    exceeds float16's max of 65504, so scaling in float16 yields inf, and 10**5
+    already overflows float16 (the scale itself becomes inf), turning 
decimals=5
+    and -5 into NaN. Upcasting the input to float32 keeps the scaling exact; 
the
+    rounded result is cast back to the input dtype.
+    """
+    input_info = [([8], "float16")]
+    x = torch.tensor([0.5, 1.5, 2.5, 2.25, 25.0, 125.0, 165.0, -0.5], 
dtype=torch.float16)
+
+    class RoundDecimalsModel(Module):
+        def __init__(self, decimals):
+            super().__init__()
+            self.decimals = decimals
+
+        def forward(self, input):
+            return torch.round(input, decimals=self.decimals)
+
+    # Positive decimals exercise the multiply-by-10**d overflow (4, 5);
+    # negative decimals exercise the 10**|d| scale overflowing float16 (-5).
+    for decimals in (2, 4, 5, -2, -4, -5):
+        gm = fx.symbolic_trace(RoundDecimalsModel(decimals).eval())
+        mod = from_fx(gm, input_info)
+        ex = relax.build(mod, target="llvm")
+        vm = relax.VirtualMachine(ex, tvm.cpu())
+        tvm_out = vm["main"](tvm.runtime.tensor(x.numpy()))
+        got = tvm_out.numpy() if hasattr(tvm_out, "numpy") else 
tvm_out[0].numpy()
+        tvm.testing.assert_allclose(
+            got, torch.round(x, decimals=decimals).numpy(), rtol=1e-6, 
atol=1e-6
+        )
+
+
+def test_round_decimals_large():
+    """A large |decimals| must import and run without OverflowError.
+
+    The scale 10**|decimals| used to be built as an unbounded host Python int
+    before being handed to relax.const, whose int-to-float conversion raises
+    OverflowError ("int too large to convert to float") once |decimals| >= 309
+    (10**309 already exceeds the float64 range). PyTorch accepts such decimals 
--
+    torch.round(x, decimals=309) -- and traces a valid round.decimals call, so
+    importing the graph must not crash on them. The scale is now built directly
+    in the float dtype and saturates to inf once it leaves the finite range,
+    matching PyTorch, whose all-NaN result here comes from the same inf scale.
+    """
+    input_info = [([5], "float32")]
+    x = torch.tensor([0.5, 1.5, 25.0, -0.5, 0.0], dtype=torch.float32)
+
+    class RoundDecimalsModel(Module):
+        def __init__(self, decimals):
+            super().__init__()
+            self.decimals = decimals
+
+        def forward(self, input):
+            return torch.round(input, decimals=self.decimals)
+
+    for decimals in (309, -309):
+        gm = fx.symbolic_trace(RoundDecimalsModel(decimals).eval())
+        mod = from_fx(gm, input_info)  # used to raise OverflowError here
+        ex = relax.build(mod, target="llvm")
+        vm = relax.VirtualMachine(ex, tvm.cpu())
+        tvm_out = vm["main"](tvm.runtime.tensor(x.numpy()))
+        got = tvm_out.numpy() if hasattr(tvm_out, "numpy") else 
tvm_out[0].numpy()
+
+        # The scale overflows to inf, and IEEE arithmetic turns every element 
into
+        # NaN in both TVM and PyTorch. Compare the NaN masks and the remaining
+        # (empty here) finite elements separately, since allclose fails on NaN.
+        expected = torch.round(x, decimals=decimals)
+        actual = torch.as_tensor(got)
+        assert torch.equal(torch.isnan(actual), torch.isnan(expected))
+        finite = ~torch.isnan(expected)
+        assert torch.allclose(actual[finite], expected[finite], rtol=1e-6, 
atol=1e-6)
+
+
 def test_size():
     input_info = [([1, 3, 10, 10], "float32")]
 

Reply via email to