Skip to content

Commit 0f02959

Browse files
committed
NXP backend: Fix bug when a partition output is also used by nodes inside the partition.
1 parent 1a78804 commit 0f02959

5 files changed

Lines changed: 139 additions & 6 deletions

File tree

backends/nxp/backend/edge_program_converter.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ def convert_program(
106106
self._convert_qdq_cluster_q_dq_nodes(edge_program.graph.nodes, cc)
107107
self._process_nodes(edge_program.graph.nodes, cc)
108108

109+
# Make sure the operators are correctly ordered.
110+
cc.tflite_builder.sort_operators_topologically()
111+
109112
# Assign the model its inputs and outputs.
110113
cc.tflite_builder.assign_model_io_to_subgraph(edge_program.graph_signature)
111114

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
# See the LICENSE_MIT for more details.
77
#
88

9+
import logging
10+
from collections import defaultdict, deque
911
from copy import deepcopy
1012
from typing import List, Optional, Union
1113

@@ -106,6 +108,65 @@ def __init__(
106108
self._skipped_output_map = {}
107109
self._zeros_tensor_map = {}
108110

111+
def sort_operators_topologically(self):
112+
"""Sort the operators in the model graph in topological order using Kahn's algorithm.
113+
114+
Ensures that each operator appears after all operators that produce its input tensors.
115+
This is required for correct behavior of optimization passes and by the Neutron IR format.
116+
117+
The algorithm works in three steps:
118+
1. Build a mapping from each tensor to the operator that produces it.
119+
2. Compute the number of unresolved input dependencies for each operator.
120+
3. Iteratively add operators with no remaining dependencies to the sorted list,
121+
decrementing the number of dependencies of their consumers until all operators are processed.
122+
123+
If the sort succeeds, the operator list in the model's subgraph is replaced with the topologically sorted list.
124+
If it fails (e.g. due to an invalid graph), a warning is logged and the original operator order is preserved.
125+
"""
126+
127+
# Map tensors to operators that produce them.
128+
tensor_to_producer_op = {}
129+
for producer_op in self.get_operators():
130+
for output in producer_op.tmp_outputs:
131+
tensor_to_producer_op[output] = producer_op
132+
133+
# Map operators to operators that consume their outputs.
134+
op_to_child_op = defaultdict(list)
135+
num_unresolved_input_dependencies = {op: 0 for op in self.get_operators()}
136+
for child_op in self.get_operators():
137+
for input_ in child_op.tmp_inputs:
138+
parent_op = tensor_to_producer_op.get(input_)
139+
if parent_op is not None:
140+
op_to_child_op[parent_op].append(child_op)
141+
num_unresolved_input_dependencies[child_op] += 1
142+
143+
# Gradually build the graph.
144+
# Use `deque` for fast appends and pops.
145+
queue = deque(
146+
op
147+
for op, degree in num_unresolved_input_dependencies.items()
148+
if degree == 0
149+
)
150+
sorted_ops = []
151+
while len(queue) != 0:
152+
op = queue.popleft()
153+
sorted_ops.append(op)
154+
for child_op in op_to_child_op[op]:
155+
num_unresolved_input_dependencies[child_op] -= 1
156+
if num_unresolved_input_dependencies[child_op] == 0:
157+
# All input of the `child_op` are produced by operators that are already in `sorted_ops`. So we can
158+
# insert it into the graph.
159+
queue.append(child_op)
160+
161+
if len(sorted_ops) != self.get_operators().len():
162+
logging.warning(
163+
"NXP backend: ModelBuilder.sort_operators_topologically() failed. Please report this."
164+
)
165+
166+
else:
167+
# The topological sort was successful.
168+
self.get_operators().vector = sorted_ops
169+
109170
def create_zeros_tensor(
110171
self, dims: List[int], name: str, dtype: np.dtype, can_reuse: bool = False
111172
) -> tflite_model.Tensor:

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,13 @@ def convert(self, node: Node):
6060
if isinstance(self, QDQPerChannelDequantizeConverter):
6161
quantized_dimension = self.get_quantization_dimension(input_tensor, node)
6262

63-
consumes_model_input = (
63+
consumes_model_io = (
6464
node.args[0].name in self.context.edge_program_signature.user_inputs
65+
or node.args[0].name in self.context.edge_program_signature.user_outputs
6566
)
66-
if consumes_model_input:
67-
# We cannot just skip the operator. Skipping would require changing the input's name, and as the input is
68-
# also a model input, the name cannot be changed.
67+
if consumes_model_io:
68+
# We cannot just skip the operator. Skipping would require changing the input's/output's name, and as the
69+
# input/output is also a model input/output, the name cannot be changed.
6970
# Instead, we convert it into an identity (Transpose that will be removed), and we make the output tensor
7071
# quantized just like the input.
7172
t_op = self._create_tflite_op_with_io_tensors(node)
@@ -80,6 +81,7 @@ def convert(self, node: Node):
8081

8182
self.builder.turn_operator_to_identity(t_op)
8283
self.builder.append_operators([t_op])
84+
8385
else:
8486
# Dequantize consumes an internal tensor, so we can just make it so that any operators which used the float
8587
# output of the dequantize will now use its quantized input. We do this by redirecting the output to the

backends/nxp/tests/ir/edge_passes/test_remove_io_quant_ops_pass.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import executorch.extension.pybindings.portable_lib
99
import executorch.kernels.quantized # noqa F401
1010

11-
import pytest
1211
import torch
1312
from executorch.backends.nxp.tests.executorch_pipeline import to_quantized_edge_program
1413
from executorch.backends.nxp.tests.models import Conv2dReLUModule
@@ -95,7 +94,6 @@ def forward(self, x, y):
9594
return x + y, z
9695

9796

98-
@pytest.mark.xfail(strict=True, reason="Known bug (EIEX-946).")
9997
def test_multiple_inputs__multiple_outputs():
10098
model = MultiInputOutputModule()
10199
model.eval()
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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 copy import deepcopy
7+
8+
from executorch.backends.nxp.backend.ir.converter.builder.model_builder import (
9+
ModelBuilder,
10+
)
11+
from executorch.backends.nxp.backend.ir.tflite_generator.builtin_options.transpose_options import (
12+
Transpose,
13+
)
14+
from executorch.backends.nxp.tests.executorch_pipeline import to_quantized_edge_program
15+
from executorch.backends.nxp.tests.ir.edge_passes.test_remove_io_quant_ops_pass import (
16+
MultiInputOutputModule,
17+
)
18+
19+
20+
def test_topological_sorting(mocker):
21+
# This model requires the insertion of a `Transpose` operator in place of a `dequantize` node, which breaks the
22+
# topological order.
23+
model = MultiInputOutputModule()
24+
25+
# Capture the operators before and after topological sorting.
26+
original_sort_fn = ModelBuilder.sort_operators_topologically
27+
captured_ops = {}
28+
29+
def mock_sort_fn(self: ModelBuilder):
30+
captured_ops["pre_sort"] = deepcopy(self.get_operators().vector)
31+
original_sort_fn(self) # type: ignore
32+
captured_ops["post_sort"] = deepcopy(self.get_operators().vector)
33+
34+
mocker.patch.object(
35+
ModelBuilder,
36+
"sort_operators_topologically",
37+
autospec=True,
38+
side_effect=mock_sort_fn,
39+
)
40+
41+
input_shapes = [(1, 4, 32, 32), (1, 1, 1, 31)]
42+
to_quantized_edge_program(model, input_shapes)
43+
44+
ops_pre_sort = captured_ops["pre_sort"]
45+
ops_post_sort = captured_ops["post_sort"]
46+
assert len(ops_pre_sort) == len(ops_post_sort)
47+
48+
# Before sorting, the operator on index `2` should be an identity `Transpose`, which uses the output of the
49+
# operator on index `3` (breaking the topological order).
50+
assert isinstance(ops_pre_sort[2].builtin_options, Transpose)
51+
assert all(ops_pre_sort[2].tmp_inputs[1].tmp_buffer.data == [0, 1, 2, 3])
52+
assert ops_pre_sort[2].tmp_inputs[0] == ops_pre_sort[3].tmp_outputs[0]
53+
54+
# After the sort, the operators on indices `2` and `3` are swapped.
55+
assert (
56+
ops_post_sort[2].builtin_options is None
57+
) # A Relu, which doesn't have a `BuiltinOptions` in Neutron IR.
58+
assert isinstance(ops_post_sort[3].builtin_options, Transpose)
59+
assert all(ops_post_sort[3].tmp_inputs[1].tmp_buffer.data == [0, 1, 2, 3])
60+
assert ops_post_sort[2].tmp_outputs[0] == ops_post_sort[3].tmp_inputs[0]
61+
62+
# Make sure all nodes follow topological order.
63+
tensor_to_producer_op = {
64+
output_tensor: op for op in ops_post_sort for output_tensor in op.tmp_outputs
65+
}
66+
for op_idx, op in enumerate(ops_post_sort):
67+
for input_tensor in op.tmp_inputs:
68+
if input_tensor in tensor_to_producer_op:
69+
assert ops_post_sort.index(tensor_to_producer_op[input_tensor]) < op_idx

0 commit comments

Comments
 (0)