Skip to content

Commit e3571c2

Browse files
authored
NXP backend: added support for scalar args of elementary ops (pytorch#21011)
### Summary Added support for scalar args of elementary ops. ### Test plan tests can be manually run using `pytest -c /dev/null backends/nxp/tests/` cc @robert-kalmar @JakeStevens @digantdesai @rascani @MartinPavella
1 parent adaaa8b commit e3571c2

4 files changed

Lines changed: 279 additions & 0 deletions

File tree

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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 logging
7+
8+
import torch
9+
10+
from torch.ao.quantization.fx.utils import get_new_attr_name_with_prefix
11+
12+
from torch.export.unflatten import _assign_attr, _AttrKind
13+
from torch.fx import GraphModule, Node
14+
from torch.fx.passes.infra.pass_base import PassBase, PassResult
15+
16+
17+
class ConvertScalarToAttrPass(PassBase):
18+
"""
19+
Convert scalar (Python `int`/`float`) arguments of elementwise operators into
20+
`get_attr` nodes holding a constant tensor. For example `aten.mul.Tensor(x, 2.0)`
21+
is rewritten so the scalar `2.0` becomes a `get_attr` node referencing a tensor
22+
constant with the value `2.0`.
23+
24+
x x get_attr(2.0)
25+
| | /
26+
┌──────────────▼─────────────┐ replace with ┌────────────────▼───────────────┐
27+
| aten.mul.Tensor(x, 2.0) | ─────────────────► | aten.mul.Tensor(x, get_attr) |
28+
└──────────────┬─────────────┘ └────────────────┬───────────────┘
29+
| |
30+
v v
31+
out out
32+
33+
If not done, the scalar arguments prevent from proper QDQ pattern utilization and
34+
the operator cannot be delegated to Neutron.
35+
"""
36+
37+
_target_ops = (
38+
torch.ops.aten.add.Tensor,
39+
torch.ops.aten.sub.Tensor,
40+
torch.ops.aten.mul.Tensor,
41+
)
42+
43+
@staticmethod
44+
def _get_ref_dtype(node: Node) -> torch.dtype:
45+
# Infer the constant dtype from a sibling tensor input of the operator so
46+
# the created constant matches the operator's tensor operand.
47+
# Fall back to the node's own output dtype, then `float32`.
48+
for arg in node.args:
49+
if isinstance(arg, Node):
50+
arg_val = arg.meta.get("val")
51+
if isinstance(arg_val, torch.Tensor):
52+
return arg_val.dtype
53+
54+
node_val = node.meta.get("val")
55+
if isinstance(node_val, torch.Tensor):
56+
return node_val.dtype
57+
58+
return None
59+
60+
def _create_scalar_attr_node(self, node: Node, scalar_value: float) -> Node:
61+
tensor_dtype = self._get_ref_dtype(node)
62+
# Don't change anything if type cannot be inferred.
63+
if tensor_dtype is None:
64+
logging.warning(f"Cannot infer dtype of node `{node}`")
65+
return None
66+
67+
# Create a constant tensor holding the scalar value.
68+
tensor = torch.tensor(scalar_value, dtype=self._get_ref_dtype(node))
69+
tensor_name = get_new_attr_name_with_prefix("_scalar_const_")(self.graph_module)
70+
71+
_assign_attr(
72+
torch.nn.Parameter(tensor, requires_grad=False),
73+
self.graph_module,
74+
tensor_name,
75+
_AttrKind.PARAMETER,
76+
)
77+
78+
fake_mode = node.meta["val"].fake_mode
79+
with self.graph_module.graph.inserting_before(node):
80+
get_attr_node = self.graph_module.graph.create_node(
81+
"get_attr", tensor_name, (), {}
82+
)
83+
get_attr_node.meta["val"] = fake_mode.from_tensor(
84+
tensor, static_shapes=True
85+
)
86+
87+
return get_attr_node
88+
89+
def call(self, graph_module: GraphModule) -> PassResult:
90+
self.graph_module = graph_module
91+
made_changes = False
92+
93+
for node in list(graph_module.graph.nodes):
94+
if node.op != "call_function":
95+
continue
96+
97+
if node.target not in self._target_ops:
98+
continue
99+
100+
new_args = list(node.args)
101+
node_changed = False
102+
for i, arg in enumerate(new_args):
103+
# Note: `bool` is subclass on `int`.
104+
if isinstance(arg, bool) or not isinstance(arg, (int, float)):
105+
continue
106+
107+
new_arg = self._create_scalar_attr_node(node, arg)
108+
if new_arg is not None:
109+
new_args[i] = new_arg
110+
node_changed = True
111+
112+
if node_changed:
113+
node.args = tuple(new_args)
114+
made_changes = True
115+
116+
self.graph_module.graph.eliminate_dead_code()
117+
self.graph_module.recompile()
118+
119+
return PassResult(graph_module, made_changes)

‎backends/nxp/aten_passes/neutron_aten_pass_manager.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
ConvertConv1dToConv2dPass,
1212
)
1313
from executorch.backends.nxp.aten_passes.convert_div_to_mul import ConvertDivToMulPass
14+
from executorch.backends.nxp.aten_passes.convert_scalar_to_attr import (
15+
ConvertScalarToAttrPass,
16+
)
1417
from executorch.backends.nxp.aten_passes.decompose_split_to_slices_pass import (
1518
DecomposeSplitToSlicesPass,
1619
)
@@ -53,6 +56,7 @@ def _get_default_passes(neutron_target_spec, qat_mode: bool = False) -> list[Pas
5356
MoveActivationBeforeConcat(neutron_target_spec),
5457
ConvertDivToMulPass(),
5558
ConvertConv1dToConv2dPass(neutron_target_spec),
59+
ConvertScalarToAttrPass(),
5660
]
5761

5862
if not qat_mode:
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
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+
from executorch.backends.nxp.aten_passes.convert_scalar_to_attr import (
12+
ConvertScalarToAttrPass,
13+
)
14+
from executorch.backends.nxp.aten_passes.neutron_aten_pass_manager import (
15+
NeutronAtenPassManager,
16+
)
17+
18+
from executorch.backends.nxp.backend.edge_helper import (
19+
try_get_tensor_constant_from_node,
20+
)
21+
from executorch.backends.nxp.tests.dataset_creator import RandomDatasetCreator
22+
from executorch.backends.nxp.tests.executorch_pipeline import neutron_target_spec
23+
from executorch.backends.nxp.tests.executors import graph_contains_any_of_ops
24+
from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier
25+
from executorch.backends.nxp.tests.models import (
26+
AddScalarModule,
27+
MulScalarModule,
28+
SubScalarModule,
29+
)
30+
from executorch.backends.nxp.tests.nsys_testing import (
31+
AllCloseOutputComparator,
32+
lower_run_compare,
33+
)
34+
from executorch.backends.nxp.tests.ops_aliases import AddTensor, MulTensor, SubTensor
35+
36+
37+
@pytest.fixture(autouse=True)
38+
def reseed_model_per_test_run():
39+
torch.manual_seed(42)
40+
np.random.seed(23)
41+
42+
43+
class TestConvertScalarToAttr:
44+
@pytest.mark.parametrize(
45+
"model_cls, expected_op, scalar_val",
46+
[
47+
pytest.param(AddScalarModule, torch.ops.aten.add.Tensor, 2.0, id="Add op."),
48+
pytest.param(SubScalarModule, torch.ops.aten.sub.Tensor, 2.0, id="Sub op."),
49+
pytest.param(MulScalarModule, torch.ops.aten.mul.Tensor, 2.0, id="Mul op."),
50+
],
51+
)
52+
def test__scalar_to_attr(self, model_cls, expected_op, scalar_val):
53+
input_shape = (2, 5, 7, 9)
54+
model = model_cls(scalar=scalar_val)
55+
56+
example_input = torch.rand(input_shape)
57+
exir_program_aten = torch.export.export(model, (example_input,)).module()
58+
59+
# Check if the node with scalar arg did not disappear.
60+
assert graph_contains_any_of_ops(exir_program_aten.graph, [expected_op])
61+
outputs_before = [o.detach().numpy() for o in exir_program_aten(example_input)]
62+
63+
# Apply the optimization.
64+
NeutronAtenPassManager(neutron_target_spec, [ConvertScalarToAttrPass()])(
65+
exir_program_aten
66+
)
67+
68+
exp_op_node = [
69+
n for n in exir_program_aten.graph.nodes if n.target == expected_op
70+
][0]
71+
# Check that no arg is `float` or `int`.
72+
# Note: `bool` is subtype of `int`, but `bool` does not need to be converted to `get_attr`.
73+
assert not any(
74+
isinstance(arg, (int, float)) and not isinstance(arg, bool)
75+
for arg in exp_op_node.args
76+
)
77+
# Check that the scalar value is present.
78+
assert any(
79+
arg.op == "get_attr"
80+
and try_get_tensor_constant_from_node(exir_program_aten, arg).item()
81+
== scalar_val
82+
for arg in exp_op_node.args
83+
)
84+
85+
outputs_after = [o.detach().numpy() for o in exir_program_aten(example_input)]
86+
87+
# Make sure the model still produces the exact same output.
88+
assert len(outputs_before) == len(outputs_after)
89+
for i in range(len(outputs_before)):
90+
assert np.allclose(outputs_before[i], outputs_after[i])
91+
92+
@pytest.mark.parametrize(
93+
"model_cls, expected_op",
94+
[
95+
pytest.param(AddScalarModule, AddTensor, id="Add op."),
96+
pytest.param(SubScalarModule, SubTensor, id="Sub op."),
97+
pytest.param(MulScalarModule, MulTensor, id="Mul op."),
98+
],
99+
)
100+
def test__scalar_to_attr__full_pipeline(
101+
self,
102+
mocker,
103+
request,
104+
model_cls,
105+
expected_op,
106+
):
107+
input_shape = (2, 7, 5, 11)
108+
model = model_cls(scalar=2.0)
109+
110+
graph_verifier = DetailedGraphVerifier(
111+
mocker,
112+
expected_delegated_ops={expected_op: 1},
113+
expected_non_delegated_ops={},
114+
)
115+
dataset_creator = RandomDatasetCreator(low=-1.0, high=1.0)
116+
117+
# Quantize the dataset and allow a single bit error.
118+
remove_quant_io_ops = True
119+
comparator = AllCloseOutputComparator(atol=1)
120+
121+
lower_run_compare(
122+
model,
123+
input_shape,
124+
graph_verifier,
125+
request,
126+
dataset_creator,
127+
comparator,
128+
remove_quant_io_ops=remove_quant_io_ops,
129+
)

‎backends/nxp/tests/models.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,6 +1020,33 @@ def forward(self, x, divisor):
10201020
return x / divisor
10211021

10221022

1023+
class AddScalarModule(torch.nn.Module):
1024+
def __init__(self, scalar: float | int = 2.0):
1025+
super().__init__()
1026+
self.scalar = scalar
1027+
1028+
def forward(self, x):
1029+
return x + self.scalar
1030+
1031+
1032+
class MulScalarModule(torch.nn.Module):
1033+
def __init__(self, scalar: float | int = 2.0):
1034+
super().__init__()
1035+
self.scalar = scalar
1036+
1037+
def forward(self, x):
1038+
return x * self.scalar
1039+
1040+
1041+
class SubScalarModule(torch.nn.Module):
1042+
def __init__(self, scalar: float | int = 2.0):
1043+
super().__init__()
1044+
self.scalar = scalar
1045+
1046+
def forward(self, x):
1047+
return x - self.scalar
1048+
1049+
10231050
class BatchMatMulModel(torch.nn.Module):
10241051
def __init__(self):
10251052
super().__init__()

0 commit comments

Comments
 (0)