Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion coremltools/converters/mil/frontend/tensorflow/ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -2146,7 +2146,14 @@ def Sigmoid(context, node):
@register_tf_op
def Softplus(context, node):
x = context[node.inputs[0]]
x = mb.softplus(x=x, name=node.name)
# Numerically stable softplus: max(x, 0) + log(1 + exp(-|x|)).
# Since -|x| <= 0, exp(-|x|) is always in (0, 1], so no overflow occurs
# in any precision. This avoids fp16 overflow on the Apple Neural Engine
# at x > ~10.4 (see issues #2687, #2747).
abs_x = mb.abs(x=x)
exp_val = mb.exp(x=mb.mul(x=-1.0, y=abs_x))
log_val = mb.log(x=mb.add(x=1.0, y=exp_val))
x = mb.add(x=mb.maximum(x=x, y=0.0), y=log_val, name=node.name)
context.add(node.name, x)


Expand Down
86 changes: 86 additions & 0 deletions coremltools/converters/mil/frontend/tensorflow/test/test_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,92 @@ def build_model(x):
backend=backend,
)

@pytest.mark.parametrize(
"compute_unit, backend",
itertools.product(
[ct.ComputeUnit.CPU_ONLY, ct.ComputeUnit.ALL],
backends,
),
)
def test_softplus_large_values(self, compute_unit, backend):
"""Verify softplus produces correct output for large inputs (#2747)."""
input_shape = (1, 6)

@make_tf_graph([input_shape])
def build_model(x):
return tf.math.softplus(x)

model, inputs, outputs = build_model
input_values = [np.array([[15.0, 20.0, -5.0, 0.0, 1.0, 10.5]], dtype=np.float32)]
input_dict = dict(zip(inputs, input_values))

self.run_compare_tf(
model,
input_dict,
outputs,
compute_unit=compute_unit,
backend=backend,
)

def test_softplus_stable_decomposition(self):
"""Verify softplus is decomposed into overflow-safe ops for fp16 (#2747).

The native ``mb.softplus`` op overflows in fp16 on the Apple Neural Engine
for x > ~10.4 (exp(x) exceeds 65504). The stable decomposition
``max(x, 0) + log(1 + exp(-|x|))`` avoids this because exp(-|x|) is
always in (0, 1].

On CPU the native op produces correct output regardless of this fix
(the CPU runtime computes in fp32 internally even when the model uses
fp16 compute precision — verified empirically). The overflow only
manifests on the ANE, which CI cannot exercise. We therefore:

1. Demonstrate the fp16 overflow mathematically with numpy (the same
arithmetic the ANE uses), proving the naive formula is unsafe.
2. Assert the converter emits the stable decomposition (no native
``softplus`` ops, at least one ``exp`` op), which is the same
approach used in PR #2725 for the PyTorch frontend.
"""
# --- 1. Numpy proof: naive fp16 softplus overflows for x > ~10.4 ---
x_val = np.float16(15.0)
naive = np.log(np.float16(1.0) + np.exp(x_val))
assert not np.isfinite(naive), (
f"Naive fp16 softplus(15) should overflow to inf, got {naive}"
)
stable = np.maximum(x_val, np.float16(0.0)) + np.log(
np.float16(1.0) + np.exp(-np.abs(x_val))
)
assert np.isfinite(stable) and stable == np.float16(15.0), (
f"Stable fp16 softplus(15) should be 15.0, got {stable}"
)

# --- 2. Conversion-only graph assertion ---
input_shape = (1, 6)

@make_tf_graph([input_shape])
def build_model(x):
return tf.math.softplus(x)

model, inputs, outputs = build_model
mlmodel = ct.convert(
model,
inputs=[ct.TensorType(name=inputs[0], shape=input_shape, dtype=np.float32)],
compute_precision=ct.precision.FLOAT16,
convert_to="mlprogram",
)
prog = mlmodel._mil_program
softplus_ops = prog.find_ops(op_type="softplus")
assert len(softplus_ops) == 0, (
f"Expected no native 'softplus' ops after stable decomposition, "
f"but found {len(softplus_ops)}. The converter should replace "
f"softplus with max(x,0)+log(1+exp(-|x|)) for fp16 safety."
)
exp_ops = prog.find_ops(op_type="exp")
assert len(exp_ops) >= 1, (
"Expected at least one 'exp' op from the stable decomposition, "
"but found none."
)

@pytest.mark.parametrize(
"compute_unit, backend, rank_and_axes",
itertools.product(
Expand Down