Skip to content

[Bug][Relax][Frontend][ONNX] Softplus: frontend injects a threshold that does not exist in the ONNX spec — observable accuracy loss in float64 for 20 < x ≲ 30 #20184

Closed

Description

@siyiweigeHEW

Expected behavior

ONNX Softplus is defined (opset 1 through 22, no attributes) as:

Y = log(exp(X) + 1)

It must be evaluated for every finite input. There is no threshold parameter in the ONNX specification. For X = 20.1 in float64, log(exp(X) + 1) is exactly representable and should equal 20.10000000186501.

Actual behavior

The ONNX frontend hardcodes a threshold that the spec does not have, and the lowering silently replaces log(exp(x)+1) with the linear function x for x > threshold:

  • python/tvm/relax/frontend/onnx/onnx_frontend.py:1806-1807:

    class Softplus(OnnxOpConverter):
        @classmethod
        def _impl_v1(cls, bb, inputs, attr, params):
            dtype = inputs[0].struct_info.dtype
            threshold = 10.0 if dtype == "float16" else 20.0
            return relax.op.nn.softplus(inputs[0], threshold=threshold)
  • python/tvm/topi/nn/elemwise.py:94-96:

    return tvm.tir.Select(
        b * value > t, value, (1 / b) * tvm.tir.log(1 + tvm.tir.exp(b * value))
    )

Consequence in float64: for x in roughly (20, 30], TVM returns exactly x, while the ONNX spec value x + log(1 + exp(-x)) is fully representable. x = 20.1 gives an absolute error of 1.87e-9:

x (float64) ONNX spec / onnx.reference TVM abs error
20.0 20.000000002061153 20.000000002061153 0 (below threshold, exact path)
20.1 20.10000000186501 20.1 1.87e-9
25.0 25.000000000013888 25.0 1.39e-11

The threshold of 20 is appropriate for float32 (where log(exp(x)+1) already rounds to x for x ≳ 15 because the +1 is lost), but it is far too small for float64, where the exact value is representable up to x ≈ 709.

Note: in float32/float16 the linear fallback is numerically indistinguishable from log(exp(x)+1) for ordinary inputs (max abs diff = 0 vs onnxruntime and onnx.reference for x ∈ [19, 50]), and near overflow (x > ~88 float32) onnxruntime also returns a finite value, so TVM agrees with onnxruntime there. The clearly observable, spec-relevant deviation is the float64 case above.

Environment

  • OS: Linux
  • TVM: v0.24.dev0 (main branch, commit 262c6d2e0, built 2026-02-11)
  • Python: 3.11
  • onnx: 1.20.1
  • onnxruntime: 1.24.1 (CPU EP does not implement float64 Softplus; onnx.reference used as the float64 oracle)

Steps to reproduce

"""Repro: ONNX Softplus float64 accuracy loss from the injected threshold."""
import numpy as np
import onnxruntime
from onnx import helper, TensorProto
from onnx.reference import ReferenceEvaluator
from tvm.relax.frontend.onnx import from_onnx
from tvm import relax

np.set_printoptions(precision=17)


def build(xs, dtype, opset=13):
    dt = TensorProto.DOUBLE if dtype == "float64" else TensorProto.FLOAT
    node = helper.make_node("Softplus", ["X"], ["Y"])
    g = helper.make_graph(
        [node], "g",
        [helper.make_tensor_value_info("X", dt, list(xs.shape))],
        [helper.make_tensor_value_info("Y", dt, None)], [])
    m = helper.make_model(g, opset_imports=[helper.make_opsetid("", opset)])
    m.ir_version = 8
    return m


def run_tvm(m, shape, feeds):
    mod = from_onnx(m, shape_dict={"X": list(shape)})
    ex = relax.build(mod, target="llvm")
    return relax.VirtualMachine(ex, tvm.cpu())["main"](feeds["X"]).numpy()


# float64: x = 20.1 is just above the hardcoded threshold
m = build(np.array([20.1]), "float64")
feeds = {"X": np.array([20.1], np.float64)}
print("onnx.reference:", ReferenceEvaluator(m).run(None, feeds)[0])  # [20.10000000186501]
print("TVM           :", run_tvm(m, [1], feeds))                      # [20.1]

# float32 near overflow (mechanism only; TVM agrees with onnxruntime)
m2 = build(np.array([100.0]), "float32")
f2 = {"X": np.array([100.0], np.float32)}
print("f32 onnxruntime:", onnxruntime.InferenceSession(m2.SerializeToString()).run(None, f2)[0])  # [100.]
print("f32 onnx.reference:", ReferenceEvaluator(m2).run(None, f2)[0])  # [inf]
print("f32 TVM        :", run_tvm(m2, [1], f2))                        # [100.]

Actual output:

onnx.reference: [20.10000000186501]
TVM           : [20.1]

f32 onnxruntime: [100.]
f32 onnx.reference: [inf]
f32 TVM        : [100.]

Possible fix

The ONNX Softplus has no threshold semantics. Options:

  1. Do not pass a threshold for the ONNX converter (use a numerically stable form without the hard linear fallback, e.g. compute log(exp(x)+1) stably), or
  2. Make the threshold dtype-aware: 20 is too small for float64 (should be ≥ ~37, where log(1+exp(-x)) drops below float64 epsilon).

Triage

  • needs-triage
  • bug
  • relax
  • frontend/onnx

Activity

  1. added
    needs-triagePRs or issues that need to be investigated by maintainers to find the right assignees to address it
    on Aug 25, 2026
  2. added a commit that references this issue on Aug 28, 2026
    70b2640
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-triagePRs or issues that need to be investigated by maintainers to find the right assignees to address ittype: bug

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions