Skip to content

Commit 12dac4a

Browse files
NXP backend: Always convert getitem to identity. (#24418)
### Summary Skipping operators and redirecting their tensors is not very reliable as many edge cases must be handled differently. In the cases where an operator can be skipped, the generated identity would be optimized out completely, so there is no benefit to skipping ops. The skipping and redirecting functionality was kept only for the Q/Dq special case. ### Test plan Tested by the existing NXP backend tests. cc @robert-kalmar @JakeStevens @digantdesai @rascani
1 parent ad3b6d2 commit 12dac4a

4 files changed

Lines changed: 189 additions & 19 deletions

File tree

‎backends/nxp/backend/ir/converter/builder/model_builder.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,9 @@ def redirect_tensor(
314314
is called with an operator that references 'from_tensor', it will be replaced by 'to_tensor'. This ensures
315315
that future operators will not use output tensors of operators, which are not actually in the model.
316316
317+
For converting no-op compute ops, `turn_operator_to_identity()` should be used instead of skipping as it is
318+
more robust.
319+
317320
This method should be explicitly used when an operator is skipped during conversion, so that other operators
318321
which used the output tensors of the skipped operator will be redirected to valid tensors, such as an
319322
appropriate input tensor of the skipped operator.

‎backends/nxp/backend/ir/converter/node_converters/ops_converters/getitem_converter.py‎

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def _is_supported_in_IR(
2626
return True
2727

2828
def convert(self, node: Node):
29-
"""Skip the `GetItem` node, as it serves no purpose in NeutronIR."""
29+
"""Convert the `GetItem` node to identity as it serves no purpose in the Neutron IR."""
3030
self.assert_convertible(node)
3131

3232
t_op = self._create_tflite_op_with_io_tensors(node)
@@ -41,20 +41,6 @@ def convert(self, node: Node):
4141
input_.type = output.type
4242
propagate_quantization(from_tensor=output, to_tensor=input_)
4343

44-
consumes_model_input = (
45-
node.args[0].name in self.context.edge_program_signature.user_inputs
46-
)
47-
produces_model_output = any(
48-
u.name in self.context.edge_program_signature.user_outputs
49-
for u in node.users
50-
)
51-
if consumes_model_input or produces_model_output:
52-
# Convert as identity op (Transpose that will be removed). If we did redirection here, we would change the
53-
# name of a model input or output, which is prohibited.
54-
self.builder.turn_operator_to_identity(t_op)
55-
self.builder.append_operators([t_op])
56-
else:
57-
# The operator will be converted to nothing. That means its output will not be in the model. We need to
58-
# redirect the output to the input, so that any operators that consume the `output` will use the `input_`
59-
# instead.
60-
self.builder.redirect_tensor(output, input_)
44+
# Convert into an identity operator, which will be optimized out later if possible.
45+
self.builder.turn_operator_to_identity(t_op)
46+
self.builder.append_operators([t_op])
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
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 backends.nxp.tests.model_output_comparator import AllCloseOutputComparator
13+
from executorch.backends.nxp.backend.ops_aliases import (
14+
AddTensor,
15+
GetItem,
16+
MaxPool2DWithIndices,
17+
PermuteCopy,
18+
)
19+
20+
from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier
21+
from executorch.backends.nxp.tests.nsys_testing import lower_run_compare
22+
from executorch.backends.nxp.tests.use_qat import * # noqa F403
23+
24+
25+
@pytest.fixture(autouse=True)
26+
def reseed_model_per_test_run():
27+
torch.manual_seed(23)
28+
np.random.seed(23)
29+
30+
31+
class MaxPool2dModule(torch.nn.Module):
32+
"""MaxPool2d whose output (via getitem) is the model output."""
33+
34+
def __init__(self):
35+
super().__init__()
36+
# stride=1 and padding=1 keep spatial dimensions unchanged, which
37+
# simplifies building downstream modules that operate on the same shape.
38+
self.max_pool2d = torch.nn.MaxPool2d(kernel_size=3, padding=1, stride=1)
39+
40+
def forward(self, x):
41+
return self.max_pool2d(x)
42+
43+
44+
class MaxPoolAddModule(torch.nn.Module):
45+
"""MaxPool2d output (via getitem) consumed by a single add operation."""
46+
47+
def __init__(self):
48+
super().__init__()
49+
self.max_pool2d = torch.nn.MaxPool2d(kernel_size=3, padding=1, stride=1)
50+
51+
def forward(self, x, y):
52+
x = self.max_pool2d(x)
53+
return torch.add(x, y)
54+
55+
56+
class MaxPoolPermuteModule(torch.nn.Module):
57+
"""MaxPool2d output (via getitem) consumed by a single permute operation."""
58+
59+
def __init__(self):
60+
super().__init__()
61+
self.max_pool2d = torch.nn.MaxPool2d(kernel_size=3, padding=1, stride=1)
62+
63+
def forward(self, x):
64+
x = self.max_pool2d(x)
65+
return torch.permute(x, (0, 2, 3, 1))
66+
67+
68+
class MaxPoolAddPermuteModule(torch.nn.Module):
69+
"""MaxPool2d output (via getitem) consumed by both an add and a permute.
70+
71+
This models a scenario where two different downstream compute nodes share
72+
the same getitem as their input, exercising getitem with multiple consumers.
73+
"""
74+
75+
def __init__(self):
76+
super().__init__()
77+
self.max_pool2d = torch.nn.MaxPool2d(kernel_size=3, padding=1, stride=1)
78+
79+
def forward(self, x, y):
80+
pooled = self.max_pool2d(x)
81+
added = torch.add(pooled, y)
82+
permuted = torch.permute(pooled, (0, 2, 3, 1))
83+
# Combine both branches into a single tensor to avoid a tuple output,
84+
# which would introduce extra getitem nodes for output unpacking.
85+
return torch.add(added, torch.permute(permuted, (0, 3, 1, 2)))
86+
87+
88+
class TestGetItem:
89+
"""Tests verifying that the GetItem converter always delegates the node.
90+
91+
GetItemConverter._is_supported_in_IR unconditionally returns True, so every
92+
getitem node that appears in the graph (always paired with MaxPool2DWithIndices
93+
in practice) must end up inside the delegated partition.
94+
95+
The test scenarios cover the different downstream contexts for the getitem
96+
output: no consumer (model output), a compute op (add), a layout op
97+
(permute_copy), and a mix of both.
98+
"""
99+
100+
# noinspection PyMethodMayBeStatic
101+
def assert_delegated(
102+
self, model, input_shape, mocker, request, expected_delegated_ops, use_qat=False
103+
):
104+
graph_verifier = DetailedGraphVerifier(
105+
mocker,
106+
expected_delegated_ops=expected_delegated_ops,
107+
expected_non_delegated_ops={},
108+
)
109+
# Some tests produce slightly inaccurate outputs, which however cannot be caused by the `getitem`. For the
110+
# purposes of this testing module, the important part is that the lowering succeeds and the model runs without
111+
# crashing. A small error is tolerable.
112+
output_comparator = AllCloseOutputComparator(atol=0.12)
113+
lower_run_compare(
114+
model,
115+
input_shape,
116+
graph_verifier,
117+
request,
118+
output_comparator=output_comparator,
119+
use_qat=use_qat,
120+
)
121+
122+
def test__basic__getitem_is_model_output(self, mocker, request, use_qat):
123+
# The getitem node is the direct output of the model -- no downstream op.
124+
# Parametrized over PTQ and QAT via the use_qat fixture.
125+
input_shape = (1, 4, 6, 6)
126+
model = MaxPool2dModule()
127+
self.assert_delegated(
128+
model,
129+
input_shape,
130+
mocker,
131+
request,
132+
expected_delegated_ops={MaxPool2DWithIndices: 1, GetItem: 1},
133+
use_qat=use_qat,
134+
)
135+
136+
def test__getitem_followed_by_add(self, mocker, request):
137+
# The getitem output is consumed by an AddTensor node.
138+
input_shape = (1, 4, 6, 6)
139+
model = MaxPoolAddModule()
140+
self.assert_delegated(
141+
model,
142+
[input_shape, input_shape],
143+
mocker,
144+
request,
145+
expected_delegated_ops={MaxPool2DWithIndices: 1, GetItem: 1, AddTensor: 1},
146+
)
147+
148+
def test__getitem_followed_by_permute_copy(self, mocker, request):
149+
# The getitem output is consumed by a PermuteCopy node.
150+
input_shape = (1, 4, 6, 6)
151+
model = MaxPoolPermuteModule()
152+
self.assert_delegated(
153+
model,
154+
input_shape,
155+
mocker,
156+
request,
157+
expected_delegated_ops={
158+
MaxPool2DWithIndices: 1,
159+
GetItem: 1,
160+
PermuteCopy: 1,
161+
},
162+
)
163+
164+
def test__getitem_followed_by_add_and_permute_copy(self, mocker, request):
165+
# The getitem output has two distinct consumers: an AddTensor and a
166+
# PermuteCopy. This verifies that getitem is correctly delegated even
167+
# when it feeds into multiple downstream nodes simultaneously.
168+
input_shape = (1, 4, 6, 6)
169+
model = MaxPoolAddPermuteModule()
170+
self.assert_delegated(
171+
model,
172+
[input_shape, input_shape],
173+
mocker,
174+
request,
175+
expected_delegated_ops={
176+
MaxPool2DWithIndices: 1,
177+
GetItem: 1,
178+
AddTensor: 2,
179+
PermuteCopy: 2,
180+
},
181+
)

‎backends/test/suite/flows/nxp.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
# Model failures:
3939
"dynamic_shapes", # EIEX-1136
4040
"test_convnext_small", # AIR-15298
41-
"test_inception_v3", # EIEX-1103
41+
"test_inception_v3", # Accuracy error.
4242
"test_maxvit_t", # EIEX-1104
4343
"test_resnet50", # EIEX-1105
4444
"test_resnext50_32x4d", # EIEX-1106

0 commit comments

Comments
 (0)