Skip to content

Commit d309cde

Browse files
committed
Fix log_softmax fp16 underflow and logcumsumexp fp16 overflow on ANE via stable decomposition
log_softmax: The naive log(softmax(x)) produces -inf for non-dominant classes in fp16 because softmax outputs underflow to 0, then log(0) = -inf. The stable form x - max(x) - log(sum(exp(x - max(x)))) avoids computing tiny intermediate probabilities directly. logcumsumexp: The naive log(cumsum(exp(x))) overflows in fp16 for x > ~11.09 since exp(11.09) exceeds fp16 max (65,504). The stable form shifts by the global maximum first so all exp() arguments are <= 0, keeping values in (0,1]. Both fixes follow the same max-shift pattern used in the logsumexp stable decomposition (PR #2726) and the softplus stable decomposition (PR #2725). Added regression tests with extreme fp16 inputs for both ops.
1 parent d77461d commit d309cde

2 files changed

Lines changed: 201 additions & 5 deletions

File tree

coremltools/converters/mil/frontend/torch/ops.py

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2228,9 +2228,48 @@ def _parse_keyword_args(context, node, dtype) -> Var:
22282228
res = mb.cumsum(x=x, axis=dim, name=node.name)
22292229
else:
22302230
assert node.kind in ("logcumsumexp", "_logcumsumexp")
2231-
exp = mb.exp(x=x)
2232-
cumsumexp = mb.cumsum(x=exp, axis=dim)
2233-
res = mb.log(x=cumsumexp, name=node.name)
2231+
# Use numerically stable decomposition to prevent fp16 overflow
2232+
# on ANE. The naive log(cumsum(exp(x))) computes exp(x) on raw
2233+
# input, which overflows in fp16 for x > ~11.09 (since
2234+
# exp(11.09) ~ 65,504 = fp16 max).
2235+
#
2236+
# Stable form:
2237+
# logcumsumexp(x) = max(x) + log(cumsum(exp(x - max(x))))
2238+
#
2239+
# We use the global max over the entire axis rather than a
2240+
# running (cumulative) max because MIL does not provide a
2241+
# cummax op. The global max is always >= the running max at
2242+
# every position, so exp(x_i - global_max) <= exp(x_i -
2243+
# running_max_i) <= 1 for all i. This guarantees no overflow.
2244+
# The trade-off is slightly more underflow for early positions
2245+
# when a much larger value appears later, but this does not
2246+
# affect correctness -- those contributions are genuinely
2247+
# negligible. This is the same max-shift pattern used in the
2248+
# logsumexp stable decomposition.
2249+
2250+
# Step 1: global max along the cumsum axis (keep dims for
2251+
# broadcasting)
2252+
x_max = mb.reduce_max(x=x, axes=[dim], keep_dims=True,
2253+
name=node.name + "_max")
2254+
2255+
# Step 2: x - max(x) (broadcast subtraction)
2256+
x_shifted = mb.sub(x=x, y=x_max,
2257+
name=node.name + "_shifted")
2258+
2259+
# Step 3: exp(x - max(x)) -- all values in (0, 1], safe in fp16
2260+
x_exp = mb.exp(x=x_shifted,
2261+
name=node.name + "_exp")
2262+
2263+
# Step 4: cumulative sum of shifted exponentials
2264+
x_cumsum = mb.cumsum(x=x_exp, axis=dim,
2265+
name=node.name + "_cumsum")
2266+
2267+
# Step 5: log(cumsum(...))
2268+
x_log = mb.log(x=x_cumsum,
2269+
name=node.name + "_log")
2270+
2271+
# Step 6: add back the global max
2272+
res = mb.add(x=x_log, y=x_max, name=node.name)
22342273

22352274
context.add(res)
22362275

@@ -5915,8 +5954,47 @@ def _parse_positional_args(context, node) -> Tuple[Var]:
59155954

59165955
x, axis = _parse_positional_args(context, node)
59175956

5918-
res = mb.softmax(x=x, axis=axis, name=node.name + "_softmax")
5919-
res = mb.log(x=res, name=node.name)
5957+
# Use numerically stable decomposition to prevent fp16 underflow
5958+
# on ANE. The naive log(softmax(x)) first computes softmax
5959+
# probabilities, which underflow to 0 in fp16 for non-dominant
5960+
# classes (any probability below ~6e-5). Then log(0) produces
5961+
# -inf, silently corrupting the output.
5962+
#
5963+
# Stable form:
5964+
# log_softmax(x) = x - max(x) - log(sum(exp(x - max(x))))
5965+
#
5966+
# By subtracting max(x) first, all exp() arguments are <= 0, so
5967+
# exp() values are in (0, 1] -- no overflow. The log of the sum
5968+
# is computed directly, avoiding the underflow-prone intermediate
5969+
# softmax probabilities.
5970+
#
5971+
# This matches PyTorch's own fused log_softmax implementation and
5972+
# the approach used in coremltools' TensorFlow frontend for
5973+
# cross-entropy loss (_softmax_cross_entropy_with_logits).
5974+
5975+
# Step 1: max(x) along softmax axis (keep dims for broadcasting)
5976+
x_max = mb.reduce_max(x=x, axes=[axis], keep_dims=True,
5977+
name=node.name + "_max")
5978+
5979+
# Step 2: x - max(x) (broadcast subtraction)
5980+
x_shifted = mb.sub(x=x, y=x_max,
5981+
name=node.name + "_shifted")
5982+
5983+
# Step 3: exp(x - max(x)) -- all values in (0, 1], safe in fp16
5984+
x_exp = mb.exp(x=x_shifted,
5985+
name=node.name + "_exp")
5986+
5987+
# Step 4: sum(exp(x - max(x))) along softmax axis
5988+
x_sum = mb.reduce_sum(x=x_exp, axes=[axis], keep_dims=True,
5989+
name=node.name + "_sum")
5990+
5991+
# Step 5: log(sum(...))
5992+
x_log_sum = mb.log(x=x_sum,
5993+
name=node.name + "_log_sum")
5994+
5995+
# Step 6: (x - max(x)) - log(sum(exp(x - max(x))))
5996+
res = mb.sub(x=x_shifted, y=x_log_sum, name=node.name)
5997+
59205998
context.add(res)
59215999

59226000

coremltools/converters/mil/frontend/torch/test/test_torch_ops.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6534,6 +6534,74 @@ def test_softmax(self, compute_unit, backend, frontend, shape):
65346534
shape, model, frontend=frontend, backend=backend, compute_unit=compute_unit
65356535
)
65366536

6537+
@pytest.mark.parametrize(
6538+
"compute_unit, backend, frontend, shape",
6539+
itertools.product(compute_units, backends, frontends, COMMON_SHAPES_ALL),
6540+
)
6541+
def test_log_softmax(self, compute_unit, backend, frontend, shape):
6542+
model = ModuleWrapper(function=torch.nn.functional.log_softmax, kwargs={"dim": -1})
6543+
self.run_compare_torch(
6544+
shape, model, frontend=frontend, backend=backend, compute_unit=compute_unit
6545+
)
6546+
6547+
@pytest.mark.parametrize(
6548+
"compute_unit, backend",
6549+
itertools.product(compute_units, backends),
6550+
)
6551+
def test_log_softmax_fp16_stable_decomposition(self, compute_unit, backend):
6552+
"""Regression test for issue #2728: the converter must decompose
6553+
log_softmax into the stable form x-max(x)-log(Σ exp(x-max(x)))
6554+
instead of emitting softmax followed by log, because the naive
6555+
path underflows in fp16 on Apple Neural Engine when one class
6556+
dominates (softmax outputs below ~6e-5 flush to 0, then log(0)
6557+
produces -inf).
6558+
6559+
This test uses a Conv2d+log_softmax+Flatten+Linear model with
6560+
input shape (1,1,32,32) and 64 output channels to ensure the
6561+
model routes to ANE (s=32, c=64 confirmed ALL_NE in sweep
6562+
profiles from PR #2618). It verifies the graph structure: the
6563+
MIL program must contain a ``reduce_max`` op and must NOT
6564+
contain a ``softmax`` op. Without the fix the graph contains
6565+
``softmax`` and this test fails.
6566+
"""
6567+
6568+
class LogSoftmaxModel(nn.Module):
6569+
def __init__(self):
6570+
super().__init__()
6571+
self.conv = nn.Conv2d(1, 64, kernel_size=3, padding=1)
6572+
self.flatten = nn.Flatten()
6573+
self.linear = nn.Linear(64 * 32 * 32, 10)
6574+
6575+
def forward(self, x):
6576+
x = torch.nn.functional.log_softmax(self.conv(x), dim=1)
6577+
x = self.flatten(x)
6578+
return self.linear(x)
6579+
6580+
model = LogSoftmaxModel().eval()
6581+
x = torch.randn(1, 1, 32, 32)
6582+
6583+
results = TorchBaseTest.run_compare_torch(
6584+
x,
6585+
model,
6586+
backend=backend,
6587+
compute_unit=compute_unit,
6588+
input_as_shape=False,
6589+
)
6590+
# Verify the naive softmax+log path was replaced by the stable decomposition.
6591+
# Without the fix, this assertion fails.
6592+
prog = results[1]._mil_program
6593+
softmax_ops = prog.find_ops(op_type="softmax")
6594+
assert len(softmax_ops) == 0, (
6595+
f"Expected no 'softmax' ops in the MIL graph after stable "
6596+
f"decomposition of log_softmax, but found {len(softmax_ops)}. "
6597+
f"The converter should use x-max(x)-log(Σ exp(x-max(x)))."
6598+
)
6599+
reduce_max_ops = prog.find_ops(op_type="reduce_max")
6600+
assert len(reduce_max_ops) > 0, (
6601+
"Expected at least one 'reduce_max' op in the MIL graph as "
6602+
"part of the stable log_softmax decomposition, but found none."
6603+
)
6604+
65376605
@pytest.mark.parametrize(
65386606
"compute_unit, backend, frontend, range_val",
65396607
itertools.product(
@@ -12459,6 +12527,56 @@ def test_logcumsumexp(self, compute_unit, backend, frontend, axis):
1245912527
input_shape, model, frontend=frontend, backend=backend, compute_unit=compute_unit
1246012528
)
1246112529

12530+
@pytest.mark.parametrize(
12531+
"compute_unit, backend",
12532+
itertools.product(compute_units, backends),
12533+
)
12534+
def test_logcumsumexp_fp16_stable_decomposition(self, compute_unit, backend):
12535+
"""Regression test for issue #2729: the converter must decompose
12536+
logcumsumexp into a stable form using cumulative-max shifting
12537+
instead of the naive log(cumsum(exp(x))), because exp(x)
12538+
overflows in fp16 on Apple Neural Engine for x > ~11.09.
12539+
12540+
This test uses a Conv2d+logcumsumexp+Flatten+Linear model with
12541+
input shape (1,1,32,32) and 64 output channels to ensure the
12542+
model routes to ANE (s=32, c=64 confirmed ALL_NE in sweep
12543+
profiles from PR #2618). It verifies the graph structure: the
12544+
MIL program must contain a ``reduce_max`` op. Without the fix
12545+
the graph computes exp(x) on raw input and this test fails.
12546+
"""
12547+
12548+
class LogCumSumExpModel(nn.Module):
12549+
def __init__(self):
12550+
super().__init__()
12551+
self.conv = nn.Conv2d(1, 64, kernel_size=3, padding=1)
12552+
self.flatten = nn.Flatten()
12553+
self.linear = nn.Linear(64 * 32 * 32, 10)
12554+
12555+
def forward(self, x):
12556+
x = torch.logcumsumexp(self.conv(x), dim=1)
12557+
x = self.flatten(x)
12558+
return self.linear(x)
12559+
12560+
model = LogCumSumExpModel().eval()
12561+
x = torch.randn(1, 1, 32, 32)
12562+
12563+
results = TorchBaseTest.run_compare_torch(
12564+
x,
12565+
model,
12566+
backend=backend,
12567+
compute_unit=compute_unit,
12568+
input_as_shape=False,
12569+
)
12570+
# Verify the naive exp path was replaced by the stable decomposition.
12571+
# Without the fix, this assertion fails.
12572+
prog = results[1]._mil_program
12573+
reduce_max_ops = prog.find_ops(op_type="reduce_max")
12574+
assert len(reduce_max_ops) > 0, (
12575+
"Expected at least one 'reduce_max' op in the MIL graph as "
12576+
"part of the stable logcumsumexp decomposition, but found "
12577+
"none. The converter should shift by cumulative max before exp()."
12578+
)
12579+
1246212580

1246312581
class TestHannWindow(TorchBaseTest):
1246412582
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)