Skip to content

Commit ca95da1

Browse files
NXP backend: Extended support of HardTanh with new Neutron C flow (pytorch#20177)
### Summary - Adjusted Clamp-related implementation for reuse in HardTanh as both operators share the same logic. - Add new Neutron C flow support for HardTanh operator. ### Test plan Covered by newly added tests. cc @robert-kalmar @JakeStevens @digantdesai @rascani --------- Co-authored-by: Martin Pavella <martin.pavella@nxp.com>
1 parent 87d9b8a commit ca95da1

7 files changed

Lines changed: 316 additions & 210 deletions

File tree

backends/nxp/backend/graph_utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def get_output_shape(node: Node) -> tuple[torch.Size] | torch.Size | None:
5656

5757

5858
def is_clamp_preserved_under_quantization(
59-
node: Node, min_val: int = 0, max_val: int | None = None
59+
node: Node, min_val: float = 0, max_val: float | None = None
6060
) -> bool:
6161
"""
6262
Checks if Clamp/ReLU/HardTanh is preserved under quantization and did

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

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -42,17 +42,6 @@
4242
from torch.nn import Parameter
4343

4444

45-
def _is_convertible_to_relu(node):
46-
bounds = ClampConverter._get_clamp_bounds(node)
47-
bounds = tuple(v if v is not None and math.isfinite(v) else None for v in bounds)
48-
49-
# Some specific bounds can be replaced with single op ReLU.
50-
if bounds not in ClampConverter.RELU_COMPATIBLE_BOUNDS.values():
51-
return False
52-
53-
return True
54-
55-
5645
class ClampConverter(NodeConverter):
5746
RELU_COMPATIBLE_BOUNDS = {
5847
"ReluN1To1": (-1, 1),
@@ -70,12 +59,25 @@ class ClampConverter(NodeConverter):
7059

7160
# noinspection PyShadowingBuiltins
7261
@staticmethod
73-
def _get_clamp_bounds(clamp_node: Node) -> tuple[float | None, float | None]:
62+
def _get_bounds(node: Node) -> tuple[float | None, float | None]:
7463
"""Extract min and max bounds from `aten.clamp.default` node."""
75-
min = try_get_arg(clamp_node, 1)
76-
max = try_get_arg(clamp_node, 2)
64+
min = try_get_arg(node, 1)
65+
max = try_get_arg(node, 2)
7766
return min, max
7867

68+
@classmethod
69+
def _is_convertible_to_relu(cls, node):
70+
bounds = cls._get_bounds(node)
71+
bounds = tuple(
72+
v if v is not None and math.isfinite(v) else None for v in bounds
73+
)
74+
75+
# Some specific bounds can be replaced with single op ReLU.
76+
if bounds not in cls.RELU_COMPATIBLE_BOUNDS.values():
77+
return False
78+
79+
return True
80+
7981
@staticmethod
8082
def _is_supported_in_IR(
8183
node: Node,
@@ -100,20 +102,21 @@ def _io_quant_is_same(node: Node):
100102
dq_params = dequant.args[1:]
101103
return all(q == dq for q, dq in zip(q_params, dq_params))
102104

103-
@staticmethod
105+
@classmethod
104106
def _is_supported_on_target(
107+
cls,
105108
node: Node,
106109
neutron_target_spec: NeutronTargetSpec,
107110
parameters_mapping: dict[str, Parameter],
108111
custom_delegation_options: CustomDelegationOptions,
109112
) -> bool:
110-
relu_compatible = _is_convertible_to_relu(node)
111-
bounds = ClampConverter._get_clamp_bounds(node)
113+
relu_compatible = cls._is_convertible_to_relu(node)
114+
bounds = cls._get_bounds(node)
112115

113116
if all(b is None or math.isinf(b) for b in bounds):
114117
return False
115118

116-
io_quant_consistent = ClampConverter._io_quant_is_same(node)
119+
io_quant_consistent = cls._io_quant_is_same(node)
117120
quant_supported = NodeConverter.uses_quantization_type_for_io(
118121
node,
119122
supported_types=[torch.int8, torch.uint8],
@@ -138,19 +141,20 @@ def supports_partitioning_result(
138141
neutron_target_spec: NeutronTargetSpec,
139142
parameters_mapping: dict[str, Parameter],
140143
) -> bool:
141-
bounds = cls._get_clamp_bounds(node)
144+
bounds = cls._get_bounds(node)
142145

143146
# Neutron cannot delegate a partition where ReLU or ReLU6 is the only operator
144147
# and at the same time the node does not satisfy delegation requirements.
145-
# In contrast, ReLUN1To1 and ReLU0To1 are supported and delegated successfuly.
148+
# In contrast, ReLUN1To1 and ReLU0To1 are supported and delegated successfully.
146149
if bounds in cls.RELU_COMPATIBLE_BOUNDS.values():
147150
is_alone_in_partition = cls.is_node_alone_in_partition(
148151
node, partition_list, filter_fn=is_not_qdq_node
149152
)
150153
if is_alone_in_partition:
154+
# noinspection PyTypeChecker
151155
return is_clamp_preserved_under_quantization(
152156
node,
153-
min_val=bounds[0],
157+
min_val=bounds[0] if bounds[0] is not None else 0,
154158
max_val=bounds[1],
155159
)
156160

@@ -167,9 +171,9 @@ def convert(self, node: Node):
167171
) -> Tensor
168172
"""
169173
self.assert_convertible(node)
170-
to_relu = _is_convertible_to_relu(node)
174+
to_relu = self._is_convertible_to_relu(node)
171175

172-
bounds = self._get_clamp_bounds(node)
176+
bounds = self._get_bounds(node)
173177
bounds = tuple(
174178
v if v is not None and math.isfinite(v) else None for v in bounds
175179
)

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

Lines changed: 5 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -3,43 +3,16 @@
33
# This source code is licensed under the BSD-style license found in the
44
# LICENSE file in the root directory of this source tree.
55

6-
from executorch.backends.nxp.backend.ir.converter.node_converter import (
7-
CustomDelegationOptions,
8-
is_not_qdq_node,
9-
NodeConverter,
10-
Partition,
11-
)
12-
from executorch.backends.nxp.backend.ir.lib.tflite.BuiltinOperator import (
13-
BuiltinOperator,
14-
)
15-
from executorch.backends.nxp.backend.neutron_operator_support import (
16-
activation_supported_on_target,
6+
7+
from executorch.backends.nxp.backend.ir.converter.node_converters.ops_converters.clamp_converter import (
8+
ClampConverter,
179
)
18-
from executorch.backends.nxp.backend.neutron_target_spec import NeutronTargetSpec
1910
from torch.fx import Node
20-
from torch.nn import Parameter
21-
2211

23-
class HardTanhConverter(NodeConverter):
24-
25-
# Maps possible input parameters of HardTanh to equivalent ReLU-based operators supported by TFLite.
26-
SUPPORTED_MODES_MAP = {
27-
(0.0, 6.0): BuiltinOperator.RELU6,
28-
(-1.0, 1.0): BuiltinOperator.RELU_N1_TO_1,
29-
(0.0, 1.0): BuiltinOperator.RELU_0_TO_1,
30-
(0.0, float("inf")): BuiltinOperator.RELU,
31-
}
32-
33-
# Maps possible modes of HardTanh to equivalent ReLU bounds.
34-
SUPPORTED_BOUNDS_MAP = {
35-
"ReluN1To1": (-1.0, 1.0),
36-
"Relu0To1": (0.0, 1.0),
37-
"Relu6": (0.0, 6.0),
38-
"Relu": (0.0, float("inf")),
39-
}
4012

13+
class HardTanhConverter(ClampConverter):
4114
@staticmethod
42-
def _get_hardtanh_bounds(node: Node) -> tuple[float, float]:
15+
def _get_bounds(node: Node) -> tuple[float | None, float | None]:
4316
args = node.args
4417

4518
match len(args):
@@ -62,51 +35,3 @@ def _get_hardtanh_bounds(node: Node) -> tuple[float, float]:
6235
)
6336

6437
return min_val, max_val
65-
66-
@staticmethod
67-
def _is_supported_in_IR(
68-
node: Node,
69-
parameters_mapping: dict[str, Parameter],
70-
custom_delegation_options: CustomDelegationOptions,
71-
) -> bool:
72-
bounds = HardTanhConverter._get_hardtanh_bounds(node)
73-
return bounds in HardTanhConverter.SUPPORTED_MODES_MAP
74-
75-
@classmethod
76-
def supports_partitioning_result(
77-
cls,
78-
node: Node,
79-
partition_list: list[Partition],
80-
custom_delegation_options: CustomDelegationOptions,
81-
neutron_target_spec: NeutronTargetSpec,
82-
parameters_mapping: dict[str, Parameter],
83-
) -> bool:
84-
bounds = HardTanhConverter._get_hardtanh_bounds(node)
85-
86-
# Neutron cannot delegate a partition where ReLU or ReLU6 is the only operator
87-
# and at the same time the node does not satisfy delegation requirements.
88-
# In contrast, ReLUN1To1 and ReLU0To1 are supported and delegated successfuly.
89-
if bounds in [
90-
cls.SUPPORTED_BOUNDS_MAP["Relu"],
91-
cls.SUPPORTED_BOUNDS_MAP["Relu6"],
92-
]:
93-
is_alone_in_partition = cls.is_node_alone_in_partition(
94-
node, partition_list, filter_fn=is_not_qdq_node
95-
)
96-
if is_alone_in_partition:
97-
return activation_supported_on_target(node)
98-
99-
return True
100-
101-
def convert(self, node: Node):
102-
"""Convert 'aten::hardtanh' to its supported ReLU equivalent."""
103-
self.assert_convertible(node)
104-
105-
t_op = self._create_tflite_op_with_io_tensors(node)
106-
107-
bounds = HardTanhConverter._get_hardtanh_bounds(node)
108-
109-
op = self.SUPPORTED_MODES_MAP[bounds]
110-
t_op.opcode_index = self.builder.op_code_index_for_op_type(op)
111-
112-
self.builder.append_operators([t_op])

backends/nxp/backend/ir/converter/quantization_utils.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2023-2025 NXP
1+
# Copyright 2023-2026 NXP
22
#
33
# This source code is licensed under the BSD-style license found in the
44
# LICENSE file in the root directory of this source tree.
@@ -135,11 +135,12 @@ def set_quantization_parameters_to_tensor(
135135
def quantize_int8(
136136
data: np.ndarray, scale: List[float], zero_point: List[int]
137137
) -> np.ndarray:
138+
# noinspection PyTypeChecker
138139
return quantize(data, zero_point=zero_point, scale=scale)
139140

140141

141142
def quantize(
142-
value: np.ndarray | int,
143+
value: np.ndarray | float,
143144
zero_point: List[int] | int,
144145
scale: List[float] | float,
145146
quant_min: int = -128,

backends/nxp/quantizer/patterns.py

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111

1212
import torch
1313
from executorch.backends.nxp.backend.ir.converter.node_converters.ops_converters.clamp_converter import (
14-
_is_convertible_to_relu,
14+
ClampConverter,
15+
)
16+
from executorch.backends.nxp.backend.ir.converter.node_converters.ops_converters.hardtanh_converter import (
17+
HardTanhConverter,
1518
)
1619
from executorch.backends.nxp.quantizer.utils import (
1720
get_bias_qparams,
@@ -438,7 +441,7 @@ def get_anchors(
438441
) -> PartitionAnchors | None:
439442
node = fused_partition[0].nodes[-1]
440443

441-
if not _is_convertible_to_relu(node):
444+
if not ClampConverter._is_convertible_to_relu(node):
442445
return SharedSpecPattern.get_shared_spec_anchors(gm, fused_partition)
443446
else:
444447
return SingleInputBasicPattern.get_single_input_anchors(gm, fused_partition)
@@ -726,33 +729,28 @@ class HardTanhPattern(SingleInputBasicPattern):
726729
def partition_types(self):
727730
return [torch.ops.aten.hardtanh.default]
728731

732+
def get_anchors(
733+
self, gm: fx.GraphModule, fused_partition: list[fx.GraphModule]
734+
) -> PartitionAnchors | None:
735+
node = fused_partition[0].nodes[-1]
736+
737+
if not HardTanhConverter._is_convertible_to_relu(node):
738+
return SharedSpecPattern.get_shared_spec_anchors(gm, fused_partition)
739+
else:
740+
return SingleInputBasicPattern.get_single_input_anchors(gm, fused_partition)
741+
729742
def replacement_op(self):
730743
raise AssertionError()
731744

732745

733-
class HardTanhInPlacePattern(SingleInputBasicPattern):
746+
class HardTanhInPlacePattern(HardTanhPattern):
734747
"""
735748
Quantizer for HardTanh operator with param inplace=True.
736749
"""
737750

738751
def partition_types(self):
739752
return [torch.ops.aten.hardtanh_.default]
740753

741-
def get_anchors(
742-
self, gm: fx.GraphModule, fused_partition: list[fx.GraphModule]
743-
) -> PartitionAnchors | None:
744-
node = fused_partition[0].nodes[-1]
745-
746-
return PartitionAnchors(
747-
inputs=[(node, NodeArgsIdx(0))],
748-
weights=[],
749-
biases=[],
750-
output=[(node,)],
751-
)
752-
753-
def replacement_op(self):
754-
raise AssertionError()
755-
756754

757755
class LeakyReluPattern(SingleInputBasicPattern):
758756
"""Quantizer for the `aten.leaky_relu.default` operator."""

backends/nxp/tests/ir/converter/node_converter/test_clamp_converter.py

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,6 @@
2424
)
2525
from executorch.backends.nxp.tests.executors import graph_contains_any_of_ops
2626
from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier
27-
from executorch.backends.nxp.tests.model_output_comparator import (
28-
NumericalStatsOutputComparator,
29-
)
3027
from executorch.backends.nxp.tests.nsys_testing import lower_run_compare
3128
from executorch.backends.nxp.tests.ops_aliases import (
3229
AddTensor,
@@ -68,6 +65,35 @@ def forward(self, x):
6865

6966

7067
class TestClamp:
68+
69+
@pytest.mark.parametrize(
70+
"min, max",
71+
[
72+
pytest.param(-1, 2, id="min = -1, max = 2 (Max/Min)"),
73+
pytest.param(0.0, None, id="min = 0, max = None (Relu)"),
74+
],
75+
)
76+
def test__qat(self, mocker, request, min, max, use_qat):
77+
input_shape = (2, 7, 2) # Indivisible by num_macs
78+
model = AddClampModule(min, max)
79+
80+
x_input_spec = ModelInputSpec(input_shape)
81+
graph_verifier = DetailedGraphVerifier(
82+
mocker,
83+
expected_delegated_ops={
84+
AddTensor: 1,
85+
Clamp: 1,
86+
},
87+
expected_non_delegated_ops={},
88+
)
89+
90+
lower_run_compare(
91+
model=model,
92+
input_spec=[x_input_spec],
93+
request=request,
94+
dlg_model_verifier=graph_verifier,
95+
)
96+
7197
@pytest.mark.parametrize(
7298
"min, max",
7399
[
@@ -90,12 +116,11 @@ class TestClamp:
90116
pytest.param(0.0, None, id="min = 0, max = None (Relu)"),
91117
],
92118
)
93-
def test_convert_clamp__full_pipeline(self, mocker, request, min, max, use_qat):
119+
def test_convert_clamp__full_pipeline(self, mocker, request, min, max):
94120
input_shape = (2, 7, 2) # Indivisible by num_macs
95121
model = AddClampModule(min, max)
96122

97123
x_input_spec = ModelInputSpec(input_shape)
98-
comparator = NumericalStatsOutputComparator()
99124
graph_verifier = DetailedGraphVerifier(
100125
mocker,
101126
expected_delegated_ops={
@@ -110,8 +135,6 @@ def test_convert_clamp__full_pipeline(self, mocker, request, min, max, use_qat):
110135
input_spec=[x_input_spec],
111136
dlg_model_verifier=graph_verifier,
112137
request=request,
113-
output_comparator=comparator,
114-
use_qat=use_qat,
115138
)
116139

117140
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)