Skip to content

Commit f11a5f8

Browse files
authored
Arm backend: Enable quantized softplus lowering through TOSA TABLE (pytorch#23522)
Keep quantized softplus intact through partitioning and lower it to a TOSA TABLE generated from its beta and threshold parameters. This lets Ethos-U85 delegate the Qwen3.5 DeltaNet gate with INT8 or INT16 activations. Cover TOSA INT8, INT16, and FP paths, U55 and U85 FVP runs, and VGF emulation. Map the exported op to its public PyTorch API in the generated support pages. AI-Assisted-by: Codex cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell @rascani Signed-off-by: Xingguo Li <xingguo.li@arm.com>
1 parent 74f957a commit f11a5f8

13 files changed

Lines changed: 199 additions & 22 deletions

File tree

‎backends/arm/_passes/arm_pass.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ def call_operator(self, op, args, kwargs, meta, updated: Optional[bool] = False)
8989
ops_without_quantized_fake_kernel = {
9090
exir_ops.edge.aten.bmm.default,
9191
exir_ops.edge.aten.leaky_relu.default,
92+
exir_ops.edge.aten.softplus.default,
9293
}
9394
if (
9495
op in ops_without_quantized_fake_kernel

‎backends/arm/_passes/insert_table_ops.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ class TableOps:
6666

6767
# Targets that must be treated explicitly
6868
special_table_ops: Set[EdgeOpOverload] = {
69+
exir_ops.edge.aten.softplus.default,
6970
exir_ops.edge.aten.pow.Tensor_Scalar,
7071
exir_ops.edge.aten.gelu.default,
7172
exir_ops.edge.aten.elu.default,
@@ -87,6 +88,26 @@ def __getitem__(self, node: Node):
8788
return self.unary_table_ops[target]
8889
elif target in self.special_table_ops:
8990
match target:
91+
case exir_ops.edge.aten.softplus.default:
92+
beta = cast(
93+
float,
94+
(
95+
node.args[1]
96+
if len(node.args) > 1
97+
else node.kwargs.get("beta", 1)
98+
),
99+
)
100+
threshold = cast(
101+
float,
102+
(
103+
node.args[2]
104+
if len(node.args) > 2
105+
else node.kwargs.get("threshold", 20)
106+
),
107+
)
108+
return lambda x: torch.nn.functional.softplus(
109+
x, beta=beta, threshold=threshold
110+
).flatten()
90111
case exir_ops.edge.aten.pow.Tensor_Scalar:
91112
# Exponent is a constant. Embed it into a lambda.
92113
exp = cast(int, node.args[1])

‎backends/arm/operator_support/tosa_profile_supported_op_lists.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
exir_ops.edge.aten.expm1.default,
5757
exir_ops.edge.aten.log.default,
5858
exir_ops.edge.aten.log1p.default,
59+
exir_ops.edge.aten.softplus.default,
5960
exir_ops.edge.aten.log10.default,
6061
exir_ops.edge.aten.linear.default,
6162
exir_ops.edge.aten.split_with_sizes_copy.default,

‎backends/arm/quantizer/quantization_annotator.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,7 @@ def _get_fixed_qparams_qspec(
556556
torch.ops.aten.pow.Tensor_Scalar,
557557
torch.ops.aten.gelu.default,
558558
torch.ops.aten.silu.default,
559+
torch.ops.aten.softplus.default,
559560
torch.ops.aten.silu_.default,
560561
torch.ops.aten.sinh.default,
561562
torch.ops.aten.atan.default,

‎backends/arm/scripts/docgen/generate_op_support.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,7 @@ def _active_explicit_backend_coverage() -> dict[tuple[str, str], dict[str, set[s
598598
"torch.ops.aten.logical_xor.default": ("torch.logical_xor",),
599599
"torch.ops.aten.logical_not.default": ("torch.logical_not",),
600600
# Linear algebra and neural-network layers.
601+
"torch.ops.aten.softplus.default": ("torch.nn.functional.softplus",),
601602
"torch.ops.aten.linear.default": ("torch.nn.Linear", "torch.nn.functional.linear"),
602603
"torch.ops.aten.mm.default": ("torch.mm",),
603604
"torch.ops.aten.bmm.default": ("torch.bmm",),

‎backends/arm/test/misc/test_partition_decomposed_quantized_ops.py‎

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@
44
# LICENSE file in the root directory of this source tree.
55

66
# Test that tosa_supported_operators reject operators that are not
7-
# quantized properly. This is typically a consequence of a torch op
8-
# such a Softplus that is decompsed into many other ops without
9-
# surrounding q/dq nodes.
7+
# quantized properly after decomposition.
108

119
import torch
1210
from executorch.backends.arm.test import common
@@ -48,11 +46,7 @@
4846

4947

5048
class SoftplusModule(torch.nn.Module):
51-
"""Module containing an addition followed by a Softplus.
52-
53-
Softplus is currently not supported by TosaBackend.
54-
55-
"""
49+
"""Addition followed by Softplus for FP decomposition and INT lowering."""
5650

5751
def __init__(self):
5852
super().__init__()
@@ -85,8 +79,6 @@ def forward(self, x: torch.Tensor):
8579
return x1 + x3
8680

8781

88-
# Softplus is decomposed which messes up the quantization. This test tests that CheckProperQuantization does not
89-
# partition nodes where quantization is not as expected.
9082
@common.parametrize("test_data", test_data)
9183
def test_softplus_tosa_FP(test_data: input_t1):
9284
pipeline = TosaPipelineFP[input_t1](
@@ -106,15 +98,7 @@ def test_softplus_tosa_INT(test_data: input_t1):
10698
SoftplusModule(),
10799
test_data=test_data,
108100
aten_op=softplus_aten_op,
109-
exir_op=softplus_exir_op,
110-
)
111-
pipeline.pop_stage("check_not.exir")
112-
# check that all ops in softplus_exir_op except add are rejected
113-
pipeline.add_stage_after(
114-
"to_edge_transform_and_lower",
115-
pipeline.tester.check,
116-
softplus_exir_op[1:],
117-
suffix="exir_post_partition",
101+
exir_op="executorch_exir_dialects_edge__ops_aten_softplus_default",
118102
)
119103
pipeline.run()
120104

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# Copyright 2026 Arm Limited and/or its affiliates.
2+
#
3+
# This source code is licensed under the BSD-style license found in the
4+
# LICENSE file in the root directory of this source tree.
5+
6+
import torch
7+
import torch.nn.functional as F
8+
from executorch.backends.arm.test import common
9+
from executorch.backends.arm.test.tester.test_pipeline import (
10+
EthosU55PipelineINT,
11+
EthosU85PipelineINT,
12+
TosaPipelineINT,
13+
VgfPipeline,
14+
)
15+
16+
17+
class QwenDeltaGate(torch.nn.Module):
18+
a_log: torch.Tensor
19+
dt_bias: torch.Tensor
20+
21+
def __init__(self) -> None:
22+
super().__init__()
23+
self.register_buffer("a_log", torch.log(torch.tensor([0.5, 1.0, 1.5, 2.0])))
24+
self.register_buffer("dt_bias", torch.tensor([-2.0, -0.5, 0.5, 2.0]))
25+
26+
def forward(self, a: torch.Tensor) -> torch.Tensor:
27+
g = -self.a_log.exp() * F.softplus(a + self.dt_bias)
28+
return g.exp()
29+
30+
31+
@common.XfailIfNoCorstone300
32+
def test_qwen_gate_u55_int():
33+
values = torch.linspace(-4.0, 4.0, 32).reshape(1, 8, 4)
34+
pipeline = EthosU55PipelineINT(
35+
QwenDeltaGate(),
36+
(values,),
37+
"torch.ops.aten.softplus.default",
38+
"executorch_exir_dialects_edge__ops_aten_softplus_default",
39+
run_on_fvp=True,
40+
)
41+
pipeline.run()
42+
43+
44+
@common.XfailIfNoCorstone320
45+
def test_qwen_gate_u85_int8():
46+
values = torch.linspace(-4.0, 4.0, 32).reshape(1, 8, 4)
47+
pipeline = EthosU85PipelineINT(
48+
QwenDeltaGate(),
49+
(values,),
50+
"torch.ops.aten.softplus.default",
51+
"executorch_exir_dialects_edge__ops_aten_softplus_default",
52+
run_on_fvp=True,
53+
)
54+
pipeline.run()
55+
56+
57+
@common.XfailIfNoCorstone320
58+
def test_qwen_gate_u85_int16():
59+
values = torch.linspace(-4.0, 4.0, 32).reshape(1, 8, 4)
60+
pipeline = EthosU85PipelineINT(
61+
QwenDeltaGate(),
62+
(values,),
63+
"torch.ops.aten.softplus.default",
64+
"executorch_exir_dialects_edge__ops_aten_softplus_default",
65+
run_on_fvp=True,
66+
a16w8_quantization=True,
67+
)
68+
pipeline.run()
69+
70+
71+
@common.SkipIfNoModelConverter
72+
def test_qwen_gate_vgf_quant():
73+
values = torch.linspace(-4.0, 4.0, 32).reshape(1, 8, 4)
74+
pipeline = VgfPipeline(
75+
QwenDeltaGate(),
76+
(values,),
77+
"torch.ops.aten.softplus.default",
78+
"executorch_exir_dialects_edge__ops_aten_softplus_default",
79+
run_on_vulkan_runtime=True,
80+
)
81+
pipeline.run()
82+
83+
84+
def test_qwen_gate_tosa_int():
85+
values = torch.linspace(-4.0, 4.0, 32).reshape(1, 8, 4)
86+
pipeline = TosaPipelineINT(
87+
QwenDeltaGate(),
88+
(values,),
89+
"torch.ops.aten.softplus.default",
90+
"executorch_exir_dialects_edge__ops_aten_softplus_default",
91+
)
92+
pipeline.count_tosa_ops({"TABLE": 2})
93+
pipeline.run()
94+
95+
96+
def test_qwen_gate_tosa_a16w8():
97+
values = torch.linspace(-4.0, 4.0, 32).reshape(1, 8, 4)
98+
pipeline = TosaPipelineINT(
99+
QwenDeltaGate(),
100+
(values,),
101+
"torch.ops.aten.softplus.default",
102+
"executorch_exir_dialects_edge__ops_aten_softplus_default",
103+
tosa_extensions=["int16"],
104+
)
105+
pipeline.count_tosa_ops({"TABLE": 2})
106+
pipeline.run()
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# Copyright 2026 Arm Limited and/or its affiliates.
2+
#
3+
# This source code is licensed under the BSD-style license found in the
4+
# LICENSE file in the root directory of this source tree.
5+
6+
import torch
7+
import torch.nn.functional as F
8+
from executorch.backends.arm.test.tester.test_pipeline import (
9+
TosaPipelineFP,
10+
TosaPipelineINT,
11+
)
12+
13+
14+
class SoftplusWithParameters(torch.nn.Module):
15+
def forward(self, x: torch.Tensor) -> torch.Tensor:
16+
return F.softplus(x, beta=2.0, threshold=5.0)
17+
18+
19+
def test_softplus_parameters_tosa_int():
20+
values = torch.tensor([[-10.0, -3.0, -1.0, 0.0, 1.0, 3.0, 10.0]])
21+
pipeline = TosaPipelineINT(
22+
SoftplusWithParameters(),
23+
(values,),
24+
"torch.ops.aten.softplus.default",
25+
"executorch_exir_dialects_edge__ops_aten_softplus_default",
26+
)
27+
pipeline.count_tosa_ops({"TABLE": 1})
28+
pipeline.run()
29+
30+
31+
def test_softplus_parameters_tosa_a16w8():
32+
values = torch.tensor([[-10.0, -3.0, -1.0, 0.0, 1.0, 3.0, 10.0]])
33+
pipeline = TosaPipelineINT(
34+
SoftplusWithParameters(),
35+
(values,),
36+
"torch.ops.aten.softplus.default",
37+
"executorch_exir_dialects_edge__ops_aten_softplus_default",
38+
tosa_extensions=["int16"],
39+
)
40+
pipeline.count_tosa_ops({"TABLE": 1})
41+
pipeline.run()
42+
43+
44+
def test_softplus_parameters_tosa_fp():
45+
values = torch.tensor([[-10.0, -3.0, -1.0, 0.0, 1.0, 3.0, 10.0]])
46+
pipeline = TosaPipelineFP(
47+
SoftplusWithParameters(),
48+
(values,),
49+
"torch.ops.aten.softplus.default",
50+
[],
51+
)
52+
pipeline.run()

‎backends/arm/test/targets.bzl‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ def define_arm_tests():
3232
"ops/test_slice.py",
3333
"ops/test_sigmoid.py",
3434
"ops/test_softmax.py",
35+
"ops/test_softplus.py",
3536
"ops/test_sub.py",
3637
"ops/test_sum.py",
3738
"ops/test_tanh.py",
@@ -51,6 +52,11 @@ def define_arm_tests():
5152
"ops/test_split.py",
5253
]
5354

55+
# Modules
56+
test_files += [
57+
"modules/test_qwen_delta_gate.py",
58+
]
59+
5460
# Export recipes
5561
test_files += [
5662
"recipes/test_arm_recipes.py",

‎backends/arm/tosa/partitioner.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -879,6 +879,7 @@ def ops_to_not_decompose( # noqa: C901
879879
torch.ops.aten.linear.default,
880880
torch.ops.aten.linspace.default,
881881
torch.ops.aten.silu.default,
882+
torch.ops.aten.softplus.default,
882883
torch.ops.aten.pad.default,
883884
}
884885
ops_to_not_decompose_if_fp = {

0 commit comments

Comments
 (0)