Repository navigation
[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
Description
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.referenceused 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:
- Do not pass a
thresholdfor the ONNX converter (use a numerically stable form without the hard linear fallback, e.g. computelog(exp(x)+1)stably), or - Make the threshold dtype-aware:
20is too small for float64 (should be ≥ ~37, wherelog(1+exp(-x))drops below float64 epsilon).
Triage
- needs-triage
- bug
- relax
- frontend/onnx
Activity
[Fix][Relax][Frontend][ONNX] Fix Softplus accuracy loss from hardcode…