Skip to content

Commit cfa1768

Browse files
NXP backend: Add support for aten.clamp.default. (#17327)
### Summary This PR adds support for the `aten.clamp.default` operator no NXP backend. ### Test plan Unit-tests provided.
1 parent c7c7c0a commit cfa1768

9 files changed

Lines changed: 309 additions & 1 deletion

File tree

backends/nxp/backend/edge_helper.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from executorch.exir.dialects._ops import ops as exir_ops
1111
from torch.fx import GraphModule, Node
12+
from torch.fx.node import Argument
1213
from torch.nn import Parameter
1314

1415
QUANTIZE_OPERATORS = [
@@ -362,3 +363,7 @@ def node_has_well_defined_shape(node: Node) -> bool:
362363
return False
363364

364365
return all(isinstance(dim, int) and dim > 0 for dim in val.shape)
366+
367+
368+
def try_get_arg(node: Node, idx: int) -> Argument | None:
369+
return node.args[idx] if idx < len(node.args) else None

backends/nxp/backend/edge_program_converter.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
exir_ops.edge.aten.add.Tensor: AddTensorConverter, # noqa F405
3232
exir_ops.edge.aten.avg_pool2d.default: AvgPool2dConverter, # noqa F405
3333
exir_ops.edge.aten.cat.default: CatConverter, # noqa F405
34+
exir_ops.edge.aten.clamp.default: ClampConverter, # noqa F405
3435
exir_ops.edge.aten.clone.default: CloneConverter, # noqa F405
3536
exir_ops.edge.dim_order_ops._clone_dim_order.default: CloneConverter, # noqa F405
3637
exir_ops.edge.aten.constant_pad_nd.default: ConstantPadNDConverter, # noqa F405

backends/nxp/backend/ir/converter/node_converters/ops_converters/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
from executorch.backends.nxp.backend.ir.converter.node_converters.ops_converters.cat_converter import (
1717
CatConverter,
1818
)
19+
from executorch.backends.nxp.backend.ir.converter.node_converters.ops_converters.clamp_converter import (
20+
ClampConverter,
21+
)
1922
from executorch.backends.nxp.backend.ir.converter.node_converters.ops_converters.clone_converter import (
2023
CloneConverter,
2124
)
@@ -88,6 +91,7 @@
8891
"AddTensorConverter",
8992
"AvgPool2dConverter",
9093
"CatConverter",
94+
"ClampConverter",
9195
"CloneConverter",
9296
"ConstantPadNDConverter",
9397
"ConvolutionConverter",
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Copyright 2026 NXP
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+
from executorch.backends.nxp.backend.edge_helper import try_get_arg
7+
from executorch.backends.nxp.backend.ir.converter.node_converter import (
8+
CustomDelegationOptions,
9+
is_not_qdq_node,
10+
NodeConverter,
11+
)
12+
from executorch.backends.nxp.backend.neutron_target_spec import NeutronTargetSpec
13+
from tflite import BuiltinOperator
14+
from torch.fx import Node
15+
from torch.fx.passes.infra.partitioner import Partition
16+
from torch.nn import Parameter
17+
18+
19+
class ClampConverter(NodeConverter):
20+
SUPPORTED_BOUNDS = {
21+
"ReluN1To1": (-1, 1),
22+
"Relu0To1": (0, 1),
23+
"Relu6": (0, 6),
24+
"Relu": (0, None),
25+
}
26+
27+
BOUNDS_TO_NEUTRON_IR_OP = {
28+
(-1, 1): BuiltinOperator.RELU_N1_TO_1,
29+
(0, 1): BuiltinOperator.RELU_0_TO_1,
30+
(0, 6): BuiltinOperator.RELU6,
31+
(0, None): BuiltinOperator.RELU,
32+
}
33+
34+
# noinspection PyShadowingBuiltins
35+
@staticmethod
36+
def _get_clamp_bounds(clamp_node: Node) -> tuple[float | None, float | None]:
37+
"""Extract min and max bounds from `aten.clamp.default` node."""
38+
min = try_get_arg(clamp_node, 1)
39+
max = try_get_arg(clamp_node, 2)
40+
return min, max
41+
42+
@staticmethod
43+
def _is_supported_in_IR(
44+
node: Node,
45+
parameters_mapping: dict[str, Parameter],
46+
custom_delegation_options: CustomDelegationOptions,
47+
) -> bool:
48+
# No NeutronIR-specific restrictions.
49+
return True
50+
51+
@staticmethod
52+
def _is_supported_on_target(
53+
node: Node,
54+
neutron_target_spec: NeutronTargetSpec,
55+
parameters_mapping: dict[str, Parameter],
56+
custom_delegation_options: CustomDelegationOptions,
57+
) -> bool:
58+
bounds = ClampConverter._get_clamp_bounds(node)
59+
60+
# Only some specific bounds are supported on the target hardware.
61+
if bounds not in ClampConverter.SUPPORTED_BOUNDS.values():
62+
return False
63+
64+
return True
65+
66+
@classmethod
67+
def supports_partitioning_result(
68+
cls,
69+
node: Node,
70+
partition_list: list[Partition],
71+
custom_delegation_options: CustomDelegationOptions,
72+
neutron_target_spec: NeutronTargetSpec,
73+
parameters_mapping: dict[str, Parameter],
74+
) -> bool:
75+
bounds = cls._get_clamp_bounds(node)
76+
77+
if bounds in [cls.SUPPORTED_BOUNDS["Relu"], cls.SUPPORTED_BOUNDS["Relu6"]]:
78+
# If this is the only operator in the partition, NeutronConverter will not create a NeutronNode for some
79+
# reason.
80+
clamp_partitions = [p for p in partition_list if node in p.nodes]
81+
if len(clamp_partitions) != 1:
82+
return False # Should never happen
83+
84+
clamp_partition = clamp_partitions[0]
85+
non_q_dq_partition_nodes = list(
86+
filter(is_not_qdq_node, clamp_partition.nodes)
87+
)
88+
if len(non_q_dq_partition_nodes) <= 1:
89+
return False # This would be the only node in the partition, which would cause a crash later on.
90+
91+
return True
92+
93+
def convert(self, node: Node):
94+
"""Convert the `aten.clamp.default` operator to Neutron IR `Relu*` operators.
95+
The schema is:
96+
aten::clamp(
97+
Tensor self,
98+
Scalar? min=None,
99+
Scalar? max=None
100+
) -> Tensor
101+
"""
102+
self.assert_convertible(node)
103+
104+
bounds = self._get_clamp_bounds(node)
105+
106+
t_op = self._create_tflite_op_with_io_tensors(node)
107+
108+
# noinspection PyTypeChecker,PyUnboundLocalVariable
109+
t_op.opcode_index = self.builder.op_code_index_for_op_type(
110+
self.BOUNDS_TO_NEUTRON_IR_OP[bounds]
111+
)
112+
self.builder.append_operators([t_op])

backends/nxp/neutron_partitioner.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ def tag_qdq_clusters(self, nodes: list[torch.fx.Node]):
203203
exir_ops.edge.aten.add.Tensor: AddTensorConverter, # noqa F405
204204
exir_ops.edge.aten.avg_pool2d.default: AvgPool2dConverter, # noqa F405
205205
exir_ops.edge.aten.cat.default: CatConverter, # noqa F405
206+
exir_ops.edge.aten.clamp.default: ClampConverter, # noqa F405
206207
exir_ops.edge.aten.clone.default: CloneConverter, # noqa F405
207208
exir_ops.edge.dim_order_ops._clone_dim_order.default: CloneConverter, # noqa F405
208209
exir_ops.edge.aten.constant_pad_nd.default: ConstantPadNDConverter, # noqa F405

backends/nxp/quantizer/neutron_quantizer.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
AvgPoolPattern,
2121
BatchNormPattern,
2222
CatPattern,
23+
ClampPattern,
2324
Conv1dPattern,
2425
Conv2dPattern,
2526
ConvTranspose2dPattern,
@@ -252,6 +253,7 @@ def __init__(self, neutron_target_spec: NeutronTargetSpec, is_qat: bool = False)
252253
OpQuantizer(AvgPoolPattern(is_qat=is_qat), static_qconfig),
253254
OpQuantizer(BatchNormPattern(is_qat=is_qat), static_qconfig),
254255
OpQuantizer(CatPattern(is_qat=is_qat), static_qconfig),
256+
OpQuantizer(ClampPattern(is_qat=is_qat), static_qconfig),
255257
OpQuantizer(Conv1dPattern(is_qat=is_qat), static_qconfig),
256258
OpQuantizer(Conv2dPattern(self, is_qat=is_qat), static_qconfig),
257259
OpQuantizer(ConvTranspose2dPattern(is_qat=is_qat), static_qconfig),

backends/nxp/quantizer/patterns.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,13 @@ def get_anchors(
377377
)
378378

379379

380+
class ClampPattern(SingleInputBasicPattern):
381+
"""Quantizer for the `aten.clamp.default` operator."""
382+
383+
def partition_types(self):
384+
return [torch.ops.aten.clamp.default]
385+
386+
380387
def _is_batch_norm(node_: Node) -> bool:
381388
return node_.op == "call_function" and node_.target in [
382389
torch.ops.aten.batch_norm.default,
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
# Copyright 2026 NXP
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 numpy as np
7+
import pytest
8+
import torch
9+
10+
from executorch.backends.nxp.backend.edge_program_converter import (
11+
EdgeProgramToIRConverter,
12+
)
13+
from executorch.backends.nxp.tests.executorch_pipeline import to_quantized_edge_program
14+
from executorch.backends.nxp.tests.executors import (
15+
convert_run_compare,
16+
graph_contains_any_of_ops,
17+
)
18+
from executorch.exir.dialects._ops import ops as exir_ops
19+
20+
21+
@pytest.fixture(autouse=True)
22+
def reseed_model_per_test_run():
23+
torch.manual_seed(42)
24+
np.random.seed(23)
25+
26+
27+
# noinspection PyProtectedMember
28+
ExecutorchDelegateCall = torch.ops.higher_order.executorch_call_delegate
29+
Clamp = exir_ops.edge.aten.clamp.default
30+
31+
32+
class ClampModule(torch.nn.Module):
33+
34+
# noinspection PyShadowingBuiltins
35+
def __init__(self, min=None, max=None):
36+
super().__init__()
37+
self.min = min
38+
self.max = max
39+
40+
# noinspection PyMethodMayBeStatic
41+
def forward(self, x):
42+
return torch.clamp(x, self.min, self.max)
43+
44+
45+
class AddClampModule(torch.nn.Module):
46+
47+
# noinspection PyShadowingBuiltins
48+
def __init__(self, min=None, max=None):
49+
super().__init__()
50+
self.clamp = ClampModule(min, max)
51+
52+
def forward(self, x):
53+
x = x + x
54+
return self.clamp(x)
55+
56+
57+
# noinspection PyShadowingBuiltins
58+
@pytest.mark.parametrize(
59+
"min, max",
60+
[
61+
pytest.param(0, 6, id="min = 0, max = 6 (Relu6)"),
62+
pytest.param(0, 1, id="min = 0, max = 1 (Relu0To1)"),
63+
pytest.param(-1, 1, id="min = -1, max = 1 (ReluN1To1)"),
64+
pytest.param(0, None, id="min = 0, max = None (Relu)"),
65+
# float bounds.
66+
pytest.param(0.0, 6.0, id="min = 0.0, max = 6.0 (Relu6)"),
67+
pytest.param(0.0, 1.0, id="min = 0.0, max = 1.0 (Relu0To1)"),
68+
pytest.param(-1.0, 1.0, id="min = -1.0, max = 1.0 (ReluN1To1)"),
69+
pytest.param(0.0, None, id="min = 0.0, max = None (Relu)"),
70+
],
71+
)
72+
def test_convert_clamp__supported(mocker, min, max):
73+
input_shape = (23,)
74+
model = AddClampModule(min, max)
75+
76+
converter_spy = mocker.spy(EdgeProgramToIRConverter, "convert_program")
77+
delegated_ep = to_quantized_edge_program(model, input_shape).exported_program()
78+
79+
# Make sure the `clamp` was delegated.
80+
assert graph_contains_any_of_ops(delegated_ep.graph, [ExecutorchDelegateCall])
81+
assert not graph_contains_any_of_ops(delegated_ep.graph, [Clamp])
82+
83+
# Verify correct behavior of the converted NeutronIR model.
84+
intermediate_ep = converter_spy.call_args.args[1]
85+
neutron_ir_model, _ = converter_spy.spy_return
86+
87+
input_data = (
88+
np.random.random(input_shape).astype(np.float32) * 256.0 - 128.0
89+
).astype(np.int8)
90+
91+
# Make sure the tested program contains the `clamp`.
92+
assert graph_contains_any_of_ops(intermediate_ep.graph, [Clamp])
93+
94+
convert_run_compare(
95+
intermediate_ep,
96+
tfl_model=neutron_ir_model,
97+
input_data=input_data,
98+
)
99+
100+
101+
# noinspection PyShadowingBuiltins
102+
@pytest.mark.parametrize(
103+
"min, max",
104+
[
105+
pytest.param(0, 6, id="min = 0, max = 6 (Relu6)"),
106+
pytest.param(0, None, id="min = 0, max = None (Relu)"),
107+
],
108+
)
109+
def test_convert_clamp__single_op__not_delegated_variants(min, max):
110+
# Test that Clamp representable as Relu6 or Relu is NOT delegated, because it is a single op model which is not
111+
# supported by Neutron.
112+
input_shape = (23,)
113+
model = ClampModule(min, max)
114+
115+
delegated_ep = to_quantized_edge_program(model, input_shape).exported_program()
116+
117+
# Make sure the `clamp` was NOT delegated (single op model).
118+
assert not graph_contains_any_of_ops(delegated_ep.graph, [ExecutorchDelegateCall])
119+
assert graph_contains_any_of_ops(delegated_ep.graph, [Clamp])
120+
121+
122+
# noinspection PyShadowingBuiltins
123+
@pytest.mark.parametrize(
124+
"min, max",
125+
[
126+
pytest.param(0, 1, id="min = 0, max = 1 (Relu0To1)"),
127+
pytest.param(-1, 1, id="min = -1, max = 1 (ReluN1To1)"),
128+
],
129+
)
130+
def test_convert_clamp__single_op__delegated_variants(mocker, min, max):
131+
# Test that Clamp representable as Relu0To1 or ReluN1To1 is delegated, even though it is a single op model.
132+
input_shape = (23,)
133+
model = ClampModule(min, max)
134+
135+
converter_spy = mocker.spy(EdgeProgramToIRConverter, "convert_program")
136+
delegated_ep = to_quantized_edge_program(model, input_shape).exported_program()
137+
138+
# Make sure the `clamp` was delegated.
139+
assert graph_contains_any_of_ops(delegated_ep.graph, [ExecutorchDelegateCall])
140+
assert not graph_contains_any_of_ops(delegated_ep.graph, [Clamp])
141+
142+
# Verify correct behavior of the converted NeutronIR model.
143+
intermediate_ep = converter_spy.call_args.args[1]
144+
neutron_ir_model, _ = converter_spy.spy_return
145+
146+
input_data = (
147+
np.random.random(input_shape).astype(np.float32) * 256.0 - 128.0
148+
).astype(np.int8)
149+
150+
# Make sure the tested program contains the `clamp`.
151+
assert graph_contains_any_of_ops(intermediate_ep.graph, [Clamp])
152+
153+
convert_run_compare(
154+
intermediate_ep,
155+
tfl_model=neutron_ir_model,
156+
input_data=input_data,
157+
)
158+
159+
160+
# noinspection PyShadowingBuiltins
161+
@pytest.mark.parametrize(
162+
"min, max",
163+
[
164+
pytest.param(-3, 3, id="min = -3, max = 3"),
165+
pytest.param(None, 5, id="min = None, max = 5"),
166+
],
167+
)
168+
def test_convert_clamp__no_delegation__unsupported_bounds(min, max):
169+
input_shape = (23,)
170+
model = AddClampModule(min, max)
171+
172+
delegated_ep = to_quantized_edge_program(model, input_shape).exported_program()
173+
174+
# Make sure the `clamp` was NOT delegated.
175+
assert graph_contains_any_of_ops(delegated_ep.graph, [Clamp])

docs/source/backends/nxp/op-support.csv

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ Operator,Compute DType,Quantization,Constraints
22
aten.abs.default,int8,static int8,
33
aten._adaptive_avg_pool2d.default,int8,static int8,"ceil_mode=False, count_include_pad=False, divisor_override=False"
44
aten.addmm.default,int8,static int8,2D tensor only
5-
aten.add.Tensor,int8,static int8,"alpha = 1, input tensor of rame rank"
5+
aten.add.Tensor,int8,static int8,"alpha = 1, input tensor of name rank"
66
aten.avg_pool2d.default,int8,static int8,"ceil_mode=False, count_include_pad=False, divisor_override=False"
77
aten.cat.default,int8,static int8,"input_channels % 8 = 0, output_channels %8 = 0"
8+
aten.clamp.default,int8,static int8,"Bounds = (-1, 1) or (0, 1) or (0, 6) or (0, None)"
89
aten.clone.default,int8,static int8,
910
aten.constant_pad_nd.default,int8,static int8,"H or W padding only"
1011
aten.convolution.default,int8,static int8,"1D or 2D convolution, constant weights, groups=1 or groups=channels_count (depthwise)"

0 commit comments

Comments
 (0)