Skip to content

Commit 5c9cd91

Browse files
NXP backend: Add minimum support using new neutron flow (pytorch#20908)
### Summary Enables support for `minimum` by the Neutron backend using the new Neutron MLIR flow, add tests verifying correct support. Only last commit applies. ### Test plan Unit tests provided. cc @robert-kalmar
1 parent b68c0d0 commit 5c9cd91

10 files changed

Lines changed: 343 additions & 1 deletion

File tree

backends/nxp/backend/edge_program_converter.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
exir_ops.edge.aten.max_pool2d_with_indices.default: MaxPool2DWithIndicesConverter, # noqa F405
4949
exir_ops.edge.aten.maximum.default: MaximumConverter, # noqa F405
5050
exir_ops.edge.aten.mean.dim: MeanDimConverter, # noqa F405
51+
exir_ops.edge.aten.minimum.default: MinimumConverter, # noqa F405
5152
exir_ops.edge.aten.mm.default: MMConverter, # noqa F405
5253
exir_ops.edge.aten.mul.Tensor: MulTensorConverter, # noqa F405
5354
exir_ops.edge.aten.neg.default: NegConverter, # noqa F405

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@
6161
from executorch.backends.nxp.backend.ir.converter.node_converters.ops_converters.mean_dim_converter import (
6262
MeanDimConverter,
6363
)
64+
from executorch.backends.nxp.backend.ir.converter.node_converters.ops_converters.minimum_converter import (
65+
MinimumConverter,
66+
)
6467
from executorch.backends.nxp.backend.ir.converter.node_converters.ops_converters.mm_converter import (
6568
MMConverter,
6669
)
@@ -114,6 +117,7 @@
114117
ViewCopyConverter,
115118
)
116119

120+
117121
__all__ = [
118122
"AbsConverter",
119123
"AdaptiveAvgPool2dConverter",
@@ -136,6 +140,7 @@
136140
"MaxPool2DWithIndicesConverter",
137141
"MaximumConverter",
138142
"MeanDimConverter",
143+
"MinimumConverter",
139144
"MMConverter",
140145
"MulTensorConverter",
141146
"NegConverter",
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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 torch
7+
8+
from executorch.backends.nxp.backend.ir.converter.conversion.common import OpsList
9+
from executorch.backends.nxp.backend.ir.converter.node_converter import (
10+
CustomDelegationOptions,
11+
NodeConverter,
12+
)
13+
from executorch.backends.nxp.backend.ir.tflite_generator.builtin_options import (
14+
minimum_options,
15+
)
16+
from executorch.backends.nxp.backend.neutron_target_spec import NeutronTargetSpec
17+
from torch.fx import Node
18+
from torch.nn import Parameter
19+
20+
21+
class MinimumConverter(NodeConverter):
22+
@staticmethod
23+
def _is_supported_on_target(
24+
node: Node,
25+
neutron_target_spec: NeutronTargetSpec,
26+
parameters_mapping: dict[str, Parameter],
27+
custom_delegation_options: CustomDelegationOptions,
28+
) -> bool:
29+
if not NodeConverter.inputs_satisfy_broadcast_condition(node):
30+
return False
31+
32+
supported_types = [torch.int8, torch.uint8]
33+
if not NodeConverter.uses_quantization_type_for_io(
34+
node, supported_types, [0, 1], [0]
35+
):
36+
return False
37+
38+
return True
39+
40+
@staticmethod
41+
def _is_supported_in_IR(
42+
node: Node,
43+
parameters_mapping: dict[str, Parameter],
44+
custom_delegation_options: CustomDelegationOptions,
45+
) -> bool:
46+
if len(node.args) != 2:
47+
return False
48+
49+
if not NodeConverter._all_io_shares_quantization_parameters(node):
50+
# The IR requires all inputs to have the same quantization parameters as the output.
51+
# The quantizer should quantize the operator so that this case does not happen.
52+
return False
53+
54+
return True
55+
56+
def convert(self, node: Node):
57+
"""Convert 'minimum' operator to NeutronIR 'Minimum'.
58+
The ExecuTorch schema is:
59+
minimum(Tensor input, Tensor other, *, Tensor out=None)
60+
"""
61+
self.assert_convertible(node)
62+
t_op = self._create_tflite_op_with_io_tensors(node)
63+
t_op.builtin_options = minimum_options.Minimum()
64+
65+
ops = OpsList(middle_op=t_op)
66+
# Create additional ops in case of shape broadcasting
67+
ops.add_pre(self.builder.ensure_correct_broadcasting(t_op, t_op.tmp_outputs[0]))
68+
self.builder.append_operators(ops.flatten())

backends/nxp/neutron_partitioner.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ def tag_qdq_clusters(self, nodes: list[torch.fx.Node]):
221221
exir_ops.edge.aten.max_pool2d_with_indices.default: MaxPool2DWithIndicesConverter, # noqa F405
222222
exir_ops.edge.aten.maximum.default: MaximumConverter, # noqa F405
223223
exir_ops.edge.aten.mean.dim: MeanDimConverter, # noqa F405
224+
exir_ops.edge.aten.minimum.default: MinimumConverter, # noqa F405
224225
exir_ops.edge.aten.mm.default: MMConverter, # noqa F405
225226
exir_ops.edge.aten.mul.Tensor: MulTensorConverter, # noqa F405
226227
exir_ops.edge.aten.neg.default: NegConverter, # noqa F405

backends/nxp/quantizer/neutron_quantizer.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
MaxPool1DPattern,
4040
MaxPool2DPattern,
4141
MeanDimPattern,
42+
MinimumPattern,
4243
MmPattern,
4344
MulTensorPattern,
4445
NegPattern,
@@ -290,6 +291,7 @@ def __init__(self, neutron_target_spec: NeutronTargetSpec, is_qat: bool = False)
290291
OpQuantizer(MaxPool1DPattern(is_qat=is_qat), static_qconfig),
291292
OpQuantizer(MaxPool2DPattern(is_qat=is_qat), static_qconfig),
292293
OpQuantizer(MeanDimPattern(is_qat=is_qat), static_qconfig),
294+
OpQuantizer(MinimumPattern(is_qat=is_qat), static_qconfig),
293295
OpQuantizer(MmPattern(self, is_qat=is_qat), static_qconfig),
294296
OpQuantizer(MulTensorPattern(is_qat=is_qat), static_qconfig),
295297
OpQuantizer(NegPattern(is_qat=is_qat), static_qconfig),

backends/nxp/quantizer/patterns.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -965,6 +965,17 @@ def partition_types(self):
965965
return [torch.ops.aten.mean.dim]
966966

967967

968+
class MinimumPattern(SharedSpecMultipleInputPattern):
969+
"""
970+
Quantization pattern for Minimum quantization. Accepts 1 or 2 input nodes.
971+
972+
Basic quantization for all inputs and output.
973+
"""
974+
975+
def partition_types(self) -> list[OpOverload]:
976+
return [torch.ops.aten.minimum.default]
977+
978+
968979
class MmPattern(QuantizationPattern):
969980
def __init__(self, neutron_quantizer, is_qat: bool = False):
970981
super().__init__(is_qat=is_qat)
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
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+
8+
# noinspection PyUnusedImports
9+
import pytest
10+
import torch
11+
12+
from executorch.backends.nxp.tests.dataset_creator import RandomDatasetCreator
13+
from executorch.backends.nxp.tests.executorch_pipeline import (
14+
ModelInputSpec,
15+
to_quantized_edge_program,
16+
)
17+
from executorch.backends.nxp.tests.executors import graph_contains_any_of_ops
18+
from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier
19+
from executorch.backends.nxp.tests.model_output_comparator import (
20+
AllCloseOutputComparator,
21+
)
22+
from executorch.backends.nxp.tests.models import MaxPoolMinimumModule, MinimumModule
23+
from executorch.backends.nxp.tests.nsys_testing import lower_run_compare
24+
from executorch.backends.nxp.tests.ops_aliases import (
25+
ExecutorchDelegateCall,
26+
GetItem,
27+
MaxPool2DWithIndices,
28+
Minimum,
29+
)
30+
from executorch.backends.nxp.tests.use_qat import * # noqa F403
31+
32+
33+
@pytest.fixture(autouse=True)
34+
def reseed_model_per_test_run():
35+
torch.manual_seed(23)
36+
np.random.seed(23)
37+
38+
39+
class TestMinimum:
40+
# noinspection PyMethodMayBeStatic
41+
def assert_delegated(
42+
self,
43+
model,
44+
input_spec,
45+
mocker,
46+
request,
47+
expected_delegated_ops=None,
48+
use_qat=False,
49+
):
50+
graph_verifier = DetailedGraphVerifier(
51+
mocker,
52+
expected_delegated_ops=expected_delegated_ops or {Minimum: 1},
53+
expected_non_delegated_ops={},
54+
)
55+
dataset_creator = RandomDatasetCreator(low=-1.0, high=1.0)
56+
57+
# Quantize the dataset and allow a single bit error.
58+
remove_quant_io_ops = True
59+
comparator = AllCloseOutputComparator(atol=1)
60+
61+
lower_run_compare(
62+
model,
63+
input_spec,
64+
graph_verifier,
65+
request,
66+
dataset_creator,
67+
comparator,
68+
remove_quant_io_ops=remove_quant_io_ops,
69+
use_qat=use_qat,
70+
)
71+
72+
# noinspection PyMethodMayBeStatic
73+
def assert_not_delegated(self, model, input_shape):
74+
delegated_ep = to_quantized_edge_program(model, input_shape).exported_program()
75+
76+
# Make sure the `sum` was NOT delegated.
77+
assert not graph_contains_any_of_ops(
78+
delegated_ep.graph, [ExecutorchDelegateCall]
79+
)
80+
assert graph_contains_any_of_ops(delegated_ep.graph, [Minimum])
81+
82+
@pytest.mark.parametrize(
83+
"x_input_shape",
84+
[
85+
pytest.param((1,), id="1D."),
86+
pytest.param((6, 5), id="2D."),
87+
pytest.param((6, 82), id="2D alt."),
88+
pytest.param((1, 4, 7), id="3D."),
89+
pytest.param((1, 68, 7), id="3D alt."),
90+
pytest.param((2, 4, 3, 15), id="4D."),
91+
pytest.param((1, 4, 9, 11, 4), id="5D."),
92+
],
93+
)
94+
def test__basic_nsys_inference(self, mocker, request, x_input_shape):
95+
input_spec = [ModelInputSpec(x_input_shape), ModelInputSpec(x_input_shape)]
96+
model = MinimumModule()
97+
98+
self.assert_delegated(model, input_spec, mocker, request)
99+
100+
def test__basic_nsys_inference_qat(self, mocker, request):
101+
input_spec = [ModelInputSpec((1, 4, 7)), ModelInputSpec((1, 4, 7))]
102+
model = MinimumModule()
103+
104+
self.assert_delegated(model, input_spec, mocker, request, use_qat=True)
105+
106+
@pytest.mark.parametrize(
107+
"input_spec",
108+
[
109+
pytest.param(
110+
[ModelInputSpec((4, 6)), ModelInputSpec((1, 6))], id="2 inputs 2D."
111+
),
112+
pytest.param(
113+
[ModelInputSpec((69, 73)), ModelInputSpec((1, 73))],
114+
id="2 inputs 2D alt.",
115+
),
116+
pytest.param(
117+
[ModelInputSpec((5, 3, 4)), ModelInputSpec((1, 3, 1))],
118+
id="2 inputs 3D.",
119+
),
120+
pytest.param(
121+
[ModelInputSpec((4,)), ModelInputSpec((4, 4))],
122+
id="2 inputs 1D + 2D.",
123+
marks=pytest.mark.xfail(
124+
reason="AIR-14864: conversion fails", strict=True
125+
),
126+
),
127+
pytest.param(
128+
[ModelInputSpec((1, 4, 8, 8)), ModelInputSpec((8, 8))],
129+
id="2 inputs 4D + 2D.",
130+
),
131+
pytest.param(
132+
[ModelInputSpec((10,)), ModelInputSpec((1, 1))],
133+
id="2 inputs 1D + 2D, num_elems of input == num_elems of output",
134+
),
135+
],
136+
)
137+
def test__broadcast(self, mocker, request, input_spec):
138+
model = MinimumModule()
139+
140+
self.assert_delegated(model, input_spec, mocker, request)
141+
142+
@pytest.mark.parametrize(
143+
"input_spec",
144+
[
145+
pytest.param(
146+
[ModelInputSpec((4, 1)), ModelInputSpec((1, 6))], id="2 inputs 2D."
147+
),
148+
pytest.param(
149+
[ModelInputSpec((1, 3, 4)), ModelInputSpec((5, 3, 1))],
150+
id="2 inputs 3D.",
151+
),
152+
pytest.param(
153+
[ModelInputSpec((6, 4)), ModelInputSpec((6, 6, 1))],
154+
id="2 inputs 2D + 3D.",
155+
),
156+
],
157+
)
158+
def test__broadcast_unsupported(self, input_spec):
159+
# Unsupported broadcasts: an input dimension differs from the output dimension
160+
# and is not equal to 1 or not omitted.
161+
model = MinimumModule()
162+
163+
self.assert_not_delegated(model, input_spec)
164+
165+
@pytest.mark.parametrize(
166+
"input_spec",
167+
[
168+
pytest.param(
169+
ModelInputSpec((1, 4, 5, 5)),
170+
id="4D, product of dims is not a multiple of 8.",
171+
),
172+
],
173+
)
174+
def test__channels_first_input(self, mocker, request, input_spec):
175+
model = MaxPoolMinimumModule()
176+
expected_delegated_ops = {Minimum: 1, MaxPool2DWithIndices: 1, GetItem: 1}
177+
178+
self.assert_delegated(
179+
model, [input_spec, input_spec], mocker, request, expected_delegated_ops
180+
)
181+
182+
@pytest.mark.parametrize(
183+
"input_spec",
184+
[
185+
pytest.param(
186+
[ModelInputSpec((1, 8, 5, 5)), ModelInputSpec((1, 8, 5, 1))],
187+
id="2 inputs 4D + 4D.",
188+
),
189+
pytest.param(
190+
[ModelInputSpec((1, 8, 1, 67)), ModelInputSpec((1, 8, 5, 67))],
191+
id="2 inputs 4D + 4D same width.",
192+
marks=pytest.mark.xfail(
193+
reason="AIR-14864: conversion fails", strict=True
194+
),
195+
),
196+
pytest.param(
197+
[ModelInputSpec((1, 8, 5, 5)), ModelInputSpec((1, 1))],
198+
id="2 inputs 4D + 2D ones tensor.",
199+
),
200+
pytest.param(
201+
[ModelInputSpec((1, 8, 8, 8)), ModelInputSpec((8, 8))],
202+
id="2 inputs 4D + 2D both dims 8.",
203+
),
204+
pytest.param(
205+
[ModelInputSpec((1, 8, 5, 5)), ModelInputSpec((1, 5))],
206+
id="2 inputs 4D + 2D one dim 5.",
207+
),
208+
pytest.param(
209+
[ModelInputSpec((1, 8, 12, 10)), ModelInputSpec((8, 1, 10))],
210+
id="2 inputs 4D + 3D channels dim 1.",
211+
),
212+
pytest.param(
213+
[ModelInputSpec((1, 8, 4, 10)), ModelInputSpec((1, 4, 1))],
214+
id="2 inputs 4D + 3D channels dim 4.",
215+
),
216+
pytest.param(
217+
[ModelInputSpec((1, 8, 25, 18)), ModelInputSpec((4, 1, 8, 25, 18))],
218+
id="2 inputs 4D + 5D conversion fails.",
219+
marks=pytest.mark.xfail(
220+
reason="AIR-14864: conversion fails", strict=True
221+
),
222+
),
223+
],
224+
)
225+
def test__broadcast__channels_first_input(self, mocker, request, input_spec):
226+
model = MaxPoolMinimumModule()
227+
expected_delegated_ops = {Minimum: 1, MaxPool2DWithIndices: 1, GetItem: 1}
228+
229+
self.assert_delegated(
230+
model, input_spec, mocker, request, expected_delegated_ops
231+
)

0 commit comments

Comments
 (0)