Skip to content

[Fix][Relax][Frontend][ONNX] Fix Softplus accuracy loss from hardcoded threshold - #20212

Merged
tlopex merged 2 commits into
apache:mainfrom
siyiweigeHEW:fix/relax-onnx-softplus-stable
Aug 28, 2026
Merged

tlopex merged 2 commits into
apache:mainfrom
siyiweigeHEW:fix/relax-onnx-softplus-stable

Conversation

@siyiweigeHEW

Copy link
Copy Markdown
Contributor

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/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:

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.

…eshold

The ONNX Softplus converter passed a hardcoded threshold (10.0 for
float16, 20.0 otherwise) to relax.op.nn.softplus, whose topi lowering
clamps the result to the linear function x once beta*x exceeds the
threshold. The ONNX spec defines y = log(exp(x) + 1) with no threshold,
so in float64 the clamp dropped the log(1 + exp(-x)) term and lost about
2e-9 at x = 20.1.

Lower ONNX Softplus as the numerically stable form
max(x, 0) + log(1 + exp(-|x|)) instead. It equals log(exp(x) + 1) for
every finite x, matches onnxruntime on float32/float16 (both return a
finite value near overflow where the naive formula overflows to inf),
and keeps float64 exact across the whole representable range.

Also add test_softplus_large_values (float32 up to x = 80, matches
onnxruntime) and test_softplus_float64_accuracy (float64 against the
mathematically exact value; failed before this fix).

@tlopex tlopex left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@tlopex
tlopex merged commit 70b2640 into apache:main Aug 28, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants