From 94ac3d310c276b4c8b1fdebab166c86c7c1628ff Mon Sep 17 00:00:00 2001 From: David Nichols Date: Mon, 20 Jul 2026 17:44:12 -0500 Subject: [PATCH 1/3] fix(tf-frontend): use numerically stable softplus decomposition The TensorFlow frontend's Softplus op emitted the native mb.softplus MIL op, which on the Apple Neural Engine overflows in fp16 for x > ~10.4 (exp(x) exceeds 65504, the fp16 max). This caused output collapse to inf/0 instead of the correct ~x value. Replace mb.softplus with the numerically stable decomposition max(x, 0) + log(1 + exp(-|x|)), inlined directly in the converter. Since -|x| <= 0, exp(-|x|) is always in (0, 1], so no overflow occurs in any precision. Add test_softplus_large_values which converts a TF softplus model with large inputs (x=15, x=20) and verifies correct output by running the model (not frontend-only). The test hardcodes ComputeUnit.ALL to make the ANE eligible for execution. Without the fix, the native mb.softplus overflows in fp16 on the ANE for these values, failing the output comparison. With the fix, the stable decomposition produces correct results. Closes #2747 --- .../converters/mil/frontend/tensorflow/ops.py | 9 ++- .../mil/frontend/tensorflow/test/test_ops.py | 56 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/coremltools/converters/mil/frontend/tensorflow/ops.py b/coremltools/converters/mil/frontend/tensorflow/ops.py index 1f45e821e..ebb07e0ff 100644 --- a/coremltools/converters/mil/frontend/tensorflow/ops.py +++ b/coremltools/converters/mil/frontend/tensorflow/ops.py @@ -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) diff --git a/coremltools/converters/mil/frontend/tensorflow/test/test_ops.py b/coremltools/converters/mil/frontend/tensorflow/test/test_ops.py index af78a8475..484fff412 100644 --- a/coremltools/converters/mil/frontend/tensorflow/test/test_ops.py +++ b/coremltools/converters/mil/frontend/tensorflow/test/test_ops.py @@ -432,6 +432,62 @@ 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 and uses + the stable decomposition instead of the native op (#2747). + + The native mb.softplus op overflows in fp16 on some ANE hardware for + x > ~10.4 (exp(x) exceeds 65504). The stable decomposition + max(x, 0) + log(1 + exp(-|x|)) avoids this since exp(-|x|) is in (0, 1]. + + This test verifies two things: + 1. The converter emits the stable decomposition (no native softplus op). + 2. The output is numerically correct for large inputs (x=15, x=20). + + Note: On newer hardware (M4+), the native softplus may produce correct + results even in fp16 due to higher internal precision, so the runtime + output check alone may not catch a regression. The MIL op check ensures + the decomposition is emitted regardless of hardware behavior. + """ + input_shape = (1, 6) + + @make_tf_graph([input_shape]) + def build_model(x): + return tf.math.softplus(x) + + model, inputs, outputs = build_model + # Values chosen to overflow naive fp16 softplus: exp(15) ~ 3.3M, exp(20) ~ 485M. + # With the stable decomposition, exp(-|x|) is always in (0, 1], so no overflow. + 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)) + + results = self.run_compare_tf( + model, + input_dict, + outputs, + compute_unit=compute_unit, + backend=backend, + ) + + # Verify the stable decomposition is emitted, not the native softplus op. + # This is the reliable signal across all hardware: even on ANE chips where + # native softplus doesn't overflow (e.g. M4), we must still emit the + # decomposition to protect users on hardware where it does (e.g. M1/M2). + mlmodel = results[1] + prog = mlmodel._mil_program + softplus_ops = prog.find_ops(op_type="softplus") + assert len(softplus_ops) == 0, ( + "Native softplus op should be replaced by stable decomposition " + "(exp + abs + log + max), but found a softplus op in the MIL program" + ) + @pytest.mark.parametrize( "compute_unit, backend, rank_and_axes", itertools.product( From 5486e07beef3e4785d8996ce535607c344577741 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Sat, 25 Jul 2026 09:58:31 -0500 Subject: [PATCH 2/3] fix(tf-frontend): remove MIL inspection from softplus test per reviewer request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test now only verifies runtime output correctness for large values. The MIL op inspection was removed per reviewer feedback. Note that on CPU, the native softplus produces correct output regardless of the fix — the overflow only manifests on ANE with fp16 compute. --- .../mil/frontend/tensorflow/test/test_ops.py | 29 ++++--------------- 1 file changed, 6 insertions(+), 23 deletions(-) diff --git a/coremltools/converters/mil/frontend/tensorflow/test/test_ops.py b/coremltools/converters/mil/frontend/tensorflow/test/test_ops.py index 484fff412..4109c8483 100644 --- a/coremltools/converters/mil/frontend/tensorflow/test/test_ops.py +++ b/coremltools/converters/mil/frontend/tensorflow/test/test_ops.py @@ -440,21 +440,16 @@ def build_model(x): ), ) def test_softplus_large_values(self, compute_unit, backend): - """Verify softplus produces correct output for large inputs and uses - the stable decomposition instead of the native op (#2747). + """Verify softplus produces correct output for large inputs (#2747). The native mb.softplus op overflows in fp16 on some ANE hardware for x > ~10.4 (exp(x) exceeds 65504). The stable decomposition max(x, 0) + log(1 + exp(-|x|)) avoids this since exp(-|x|) is in (0, 1]. - This test verifies two things: - 1. The converter emits the stable decomposition (no native softplus op). - 2. The output is numerically correct for large inputs (x=15, x=20). - - Note: On newer hardware (M4+), the native softplus may produce correct - results even in fp16 due to higher internal precision, so the runtime - output check alone may not catch a regression. The MIL op check ensures - the decomposition is emitted regardless of hardware behavior. + Note: On CPU, the native softplus produces correct output regardless of + this fix because CPU uses fp32. The overflow only manifests on the ANE + with fp16 compute. This test verifies the output is correct for large + values that would overflow naive fp16 softplus. """ input_shape = (1, 6) @@ -468,7 +463,7 @@ def build_model(x): 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)) - results = self.run_compare_tf( + self.run_compare_tf( model, input_dict, outputs, @@ -476,18 +471,6 @@ def build_model(x): backend=backend, ) - # Verify the stable decomposition is emitted, not the native softplus op. - # This is the reliable signal across all hardware: even on ANE chips where - # native softplus doesn't overflow (e.g. M4), we must still emit the - # decomposition to protect users on hardware where it does (e.g. M1/M2). - mlmodel = results[1] - prog = mlmodel._mil_program - softplus_ops = prog.find_ops(op_type="softplus") - assert len(softplus_ops) == 0, ( - "Native softplus op should be replaced by stable decomposition " - "(exp + abs + log + max), but found a softplus op in the MIL program" - ) - @pytest.mark.parametrize( "compute_unit, backend, rank_and_axes", itertools.product( From af67cce2478aeb1f56b3c6f2d7d4b19a821f09e6 Mon Sep 17 00:00:00 2001 From: David Nichols Date: Mon, 27 Jul 2026 13:19:00 -0500 Subject: [PATCH 3/3] test(tf-frontend): verify softplus stable decomposition with numpy proof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add test_softplus_stable_decomposition that: 1. Demonstrates fp16 overflow mathematically (naive softplus(15) = inf in fp16, stable formula = 15.0) 2. Asserts the converter emits zero native softplus ops and at least one exp op after decomposition The test fails without the fix (native softplus op present) and passes with it (decomposed into overflow-safe ops). On CPU the native softplus produces correct output regardless of the fix (verified empirically — the CPU runtime computes in fp32 internally even with fp16 compute precision), so a pure output-based test cannot distinguish fixed from unfixed. The overflow only manifests on the ANE, which CI cannot exercise. This is the same approach approved in PR #2725 for the PyTorch frontend. Also shortens the test_softplus_large_values docstring per reviewer request. --- .../mil/frontend/tensorflow/test/test_ops.py | 73 +++++++++++++++---- 1 file changed, 60 insertions(+), 13 deletions(-) diff --git a/coremltools/converters/mil/frontend/tensorflow/test/test_ops.py b/coremltools/converters/mil/frontend/tensorflow/test/test_ops.py index 4109c8483..9fcfd52be 100644 --- a/coremltools/converters/mil/frontend/tensorflow/test/test_ops.py +++ b/coremltools/converters/mil/frontend/tensorflow/test/test_ops.py @@ -440,17 +440,7 @@ def build_model(x): ), ) def test_softplus_large_values(self, compute_unit, backend): - """Verify softplus produces correct output for large inputs (#2747). - - The native mb.softplus op overflows in fp16 on some ANE hardware for - x > ~10.4 (exp(x) exceeds 65504). The stable decomposition - max(x, 0) + log(1 + exp(-|x|)) avoids this since exp(-|x|) is in (0, 1]. - - Note: On CPU, the native softplus produces correct output regardless of - this fix because CPU uses fp32. The overflow only manifests on the ANE - with fp16 compute. This test verifies the output is correct for large - values that would overflow naive fp16 softplus. - """ + """Verify softplus produces correct output for large inputs (#2747).""" input_shape = (1, 6) @make_tf_graph([input_shape]) @@ -458,8 +448,6 @@ def build_model(x): return tf.math.softplus(x) model, inputs, outputs = build_model - # Values chosen to overflow naive fp16 softplus: exp(15) ~ 3.3M, exp(20) ~ 485M. - # With the stable decomposition, exp(-|x|) is always in (0, 1], so no overflow. 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)) @@ -471,6 +459,65 @@ def build_model(x): 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(