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 70b2640066 [Fix][Relax][Frontend][ONNX] Fix Softplus accuracy loss 
from hardcoded threshold (#20212)
70b2640066 is described below

commit 70b264006695562893afc954e95c0278ce0a835a
Author: HuEnwei <[email protected]>
AuthorDate: Fri Aug 28 16:06:06 2026 +0800

    [Fix][Relax][Frontend][ONNX] Fix Softplus accuracy loss from hardcoded 
threshold (#20212)
    
    Fixes: #20184
    ## Summary
    
    The Relax ONNX frontend `Softplus._impl_v1` passed a hardcoded
    `threshold`
    (`10.0` for float16, `20.0` otherwise) to `relax.op.nn.softplus`. ONNX
    `Softplus` is defined as `y = log(exp(x) + 1)` with **no threshold**
    attribute, but the topi lowering of `relax.op.nn.softplus` clamps the
    result
    to the linear function `x` once `beta * x` exceeds the threshold:
    
    ```python
    # python/tvm/topi/nn/elemwise.py
    return tvm.tir.Select(b * value > t, value, (1 / b) * tvm.tir.log(1 + 
tvm.tir.exp(b * value)))
    ```
    
    ## Root cause
    
    The `threshold` is chosen once for every dtype, and `20` is far too
    small for
    float64: `log(exp(x) + 1) = x + log(1 + exp(-x))` is fully representable
    in
    float64 up to `x ≈ 709`, so clamping at `x = 20` drops the
    `log(1 + exp(-x))` term. Differential test vs `onnx.reference` showed an
    observable absolute error of `1.87e-9` at `x = 20.1`:
    
    | x (float64) | `onnx.reference` | TVM (before) |
    |---|---|---|
    | 20.0 | 20.000000002061153 | 20.000000002061153 |
    | 20.1 | 20.10000000186501 | **20.1** (err 1.87e-9) |
    | 25.0 | 25.000000000013888 | **25.0** (err 1.39e-11) |
    
    (For float32/float16 the clamp is numerically invisible in ordinary
    ranges —
    `log(exp(x)+1)` already rounds to `x` for `x ≳ 15` — and near overflow
    both
    onnxruntime and TVM intentionally return a finite value, so the
    observable,
    spec-relevant defect is the float64 case.)
    
    ## Fix
    
    Lower ONNX `Softplus` as the numerically stable, threshold-free form:
    
    ```python
    x = inputs[0]
    dtype = x.ty.dtype
    return relax.op.add(
        relax.op.maximum(x, relax.const(0, dtype)),
        relax.op.log(
            relax.op.add(
                relax.const(1, dtype),
                relax.op.exp(relax.op.negative(relax.op.abs(x))),
            )
        ),
    )
    ```
    
    `max(x, 0) + log(1 + exp(-|x|))` equals `log(exp(x) + 1)` mathematically
    for
    every finite `x`: it never overflows (both terms are bounded), it
    matches
    onnxruntime on float32/float16 (including near overflow, where the naive
    formula would overflow to `inf`), and it keeps float64 exact across the
    whole
    representable range. The torch frontend keeps using
    `relax.op.nn.softplus`
    with `beta`/`threshold` since PyTorch's `nn.Softplus` genuinely has
    those
    parameters.
    
    ## Validation
    
    Differential test: Relax (build + `VirtualMachine`) vs onnxruntime
    (float32/float16) and the mathematically exact stable form cross-checked
    with
    `onnx.reference` (float64; onnxruntime's CPU EP has no float64
    Softplus), across
    3 dtypes × full value range (incl. the old failure region `x ∈ (20, 30]`
    and
    near-overflow) × multiple shapes and opsets.
    
    | Case | Result |
    |---|---|
    | fp32 regular / threshold-region / near-overflow `[60..100]` | all OK
    (vs onnxrt) |
    | fp64 regular / `x ∈ (19..50]` (old failure region) / `[600..1000]` |
    all OK, max\|diff\|=0 (vs exact) |
    | fp16 regular / threshold-region `[10..12]` | all OK (vs onnxrt) |
    | opset 1 / 13 / 22 (fp32) | all OK |
    | fp64 `x = 20.1 / 25 / 30` (pre-fix failures) | max\|diff\| = 0.00e+00
    |
    
    Total: **19 differential cases, 19 OK, 0 rejected, 0 numeric
    mismatches**.
    In-tree tests `test_softplus_large_values` (float32 up to `x = 80` vs
    onnxruntime) and `test_softplus_float64_accuracy` (float64 vs exact,
    fails
    before this fix) were added; the existing `test_unary[Softplus]` still
    passes.
    
    ## Files changed
    
    - `python/tvm/relax/frontend/onnx/onnx_frontend.py` —
    `Softplus._impl_v1`:
      lower numerically stable `max(x, 0) + log(1 + exp(-|x|))` instead of
      `relax.op.nn.softplus` with a hardcoded `threshold`.
    - `tests/python/relax/test_frontend_onnx.py` — add
      `test_softplus_large_values` and `test_softplus_float64_accuracy`.
    
    ## Base
    
    Based on current upstream `main` (`ad0a225074`, 2026-08-28) using the
    current
    `x.ty.dtype` API (vs the older `struct_info.dtype`). The single commit
    `e5eede1b5d` applies cleanly on `main` with `git am`/`git apply`.
---
 python/tvm/relax/frontend/onnx/onnx_frontend.py | 14 +++++--
 tests/python/relax/test_frontend_onnx.py        | 53 +++++++++++++++++++++++++
 2 files changed, 64 insertions(+), 3 deletions(-)

diff --git a/python/tvm/relax/frontend/onnx/onnx_frontend.py 
b/python/tvm/relax/frontend/onnx/onnx_frontend.py
index 024b2bf2b6..0bbfb4e281 100644
--- a/python/tvm/relax/frontend/onnx/onnx_frontend.py
+++ b/python/tvm/relax/frontend/onnx/onnx_frontend.py
@@ -2662,9 +2662,17 @@ class Softplus(OnnxOpConverter):
 
     @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)
+        x = inputs[0]
+        dtype = x.ty.dtype
+        return relax.op.add(
+            relax.op.maximum(x, relax.const(0, dtype)),
+            relax.op.log(
+                relax.op.add(
+                    relax.const(1, dtype),
+                    relax.op.exp(relax.op.negative(relax.op.abs(x))),
+                )
+            ),
+        )
 
 
 class Softsign(OnnxOpConverter):
diff --git a/tests/python/relax/test_frontend_onnx.py 
b/tests/python/relax/test_frontend_onnx.py
index 91b2e2ded7..984e05aff9 100644
--- a/tests/python/relax/test_frontend_onnx.py
+++ b/tests/python/relax/test_frontend_onnx.py
@@ -3973,6 +3973,59 @@ def test_prelu_multi_axis_slope():
         check_correctness(model, inputs=inputs, opset=16, check_dtypes=True)
 
 
+def test_softplus_large_values():
+    """ONNX Softplus is y = log(exp(x) + 1) for every finite input. The 
frontend
+    must not clamp the output to the linear function x beyond a hardcoded 
threshold
+    (it used to pass threshold=20 to relax.op.nn.softplus). Large float32 
inputs,
+    including values beyond that threshold, must still match onnxruntime."""
+    for shape in [[3, 32, 32], [5]]:
+        graph = helper.make_graph(
+            [helper.make_node("Softplus", ["x"], ["y"])],
+            "softplus_large_values",
+            inputs=[helper.make_tensor_value_info("x", TensorProto.FLOAT, 
shape)],
+            outputs=[helper.make_tensor_value_info("y", TensorProto.FLOAT, 
shape)],
+        )
+        model = helper.make_model(graph, producer_name="softplus_large_values")
+        inputs = {
+            "x": np.linspace(-10.0, 80.0, np.prod(shape), 
dtype="float32").reshape(shape),
+        }
+        check_correctness(model, inputs=inputs, opset=14, rtol=1e-6, atol=1e-6)
+
+
+def test_softplus_float64_accuracy():
+    """float64 Softplus must equal log(exp(x) + 1) across the threshold region.
+    Regression: the frontend used to clamp to the linear function x for x > 20,
+    losing ~1.9e-9 at x = 20.1. onnxruntime's CPU EP has no float64 Softplus, 
so
+    the expected value is computed with the numerically stable form
+    max(x, 0) + log1p(exp(-|x|)), which equals log(exp(x) + 1) 
mathematically."""
+    xs = np.array([19.0, 20.0, 20.1, 21.0, 25.0, 30.0, 40.0], dtype=np.float64)
+    expected = np.maximum(xs, 0.0) + np.log1p(np.exp(-np.abs(xs)))
+
+    graph = helper.make_graph(
+        [helper.make_node("Softplus", ["x"], ["y"])],
+        "softplus_float64",
+        inputs=[helper.make_tensor_value_info("x", TensorProto.DOUBLE, 
[len(xs)])],
+        outputs=[helper.make_tensor_value_info("y", TensorProto.DOUBLE, 
[len(xs)])],
+    )
+    model = helper.make_model(graph, producer_name="softplus_float64")
+    model.opset_import[0].version = 14
+
+    tvm_model = from_onnx(model, opset=14, keep_params_in_input=True)
+    tvm_model = relax.transform.DecomposeOpsForInference()(tvm_model)
+    tvm_model = relax.transform.LegalizeOps()(tvm_model)
+    tvm_model, params = relax.frontend.detach_params(tvm_model)
+
+    with tvm.transform.PassContext(opt_level=3):
+        ex = tvm.compile(tvm_model, target="llvm")
+        vm = relax.VirtualMachine(ex, tvm.cpu())
+
+    vm.set_input("main", xs)
+    vm.invoke_stateful("main")
+    tvm_output = vm.get_outputs("main").numpy()
+
+    np.testing.assert_allclose(tvm_output, expected, rtol=1e-12, atol=1e-12)
+
+
 def test_thresholded_relu():
     model = make_unary_model("ThresholdedRelu", [2, 3])
     tvm_model = from_onnx(model, keep_params_in_input=True)

Reply via email to