Skip to content

Commit c7bbe0b

Browse files
Arm backend: Cast integer comparisons in FP profile (pytorch#22503)
TOSA section 2.13 permits PRO-FP casts from signed integer types to fp16 or fp32. Cast int8 comparison inputs to fp16 and int16 inputs to fp32, where every value is exactly representable. This allows the comparisons to be lowered without changing their results. Signed-off-by: Sebastian Larsson <sebastian.larsson@arm.com>
1 parent 7490bd4 commit c7bbe0b

4 files changed

Lines changed: 216 additions & 0 deletions

File tree

‎backends/arm/_passes/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from .canonicalize_gather_pass import CanonicalizeGatherPass # noqa
1818
from .canonicalize_view_copy_permute_pass import CanonicalizeViewCopyPermutePass # noqa
1919
from .cast_int64_pass import CastInt64BuffersToInt32Pass # noqa
20+
from .cast_int_comparison_inputs_pass import CastIntComparisonInputsPass # noqa
2021
from .cast_to_int32_pass import CastToInt32Pass # noqa
2122
from .constant_folding_pass import ConstantFoldingPass # noqa
2223
from .conv1d_unsqueeze_pass import Conv1dUnsqueezePass # noqa

‎backends/arm/_passes/arm_pass_manager.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
CanonicalizeGatherPass,
1818
CanonicalizeViewCopyPermutePass,
1919
CastInt64BuffersToInt32Pass,
20+
CastIntComparisonInputsPass,
2021
CastToInt32Pass,
2122
ComputeConstantOpsAOTPass,
2223
ConstantFoldingPass,
@@ -603,6 +604,7 @@ def _tosa_pipeline(
603604
self.add_passes(
604605
[
605606
ReplaceScalarWithTensorByProfilePass(),
607+
CastIntComparisonInputsPass(),
606608
RewriteLeLtToGeGtPass(),
607609
DecomposeLeakyReLUPass(), # Emits full_like so before ConvertFullLikeToFullPass
608610
DecomposePReLUPass(),
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Copyright 2026 Arm Limited and/or its affiliates.
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 typing import Set, Type
7+
8+
import torch
9+
10+
from executorch.backends.arm._passes.arm_pass import ArmOpTargetedPass
11+
from executorch.backends.arm.tosa.specification import get_context_spec
12+
from executorch.exir.dialects._ops import ops as exir_ops
13+
from executorch.exir.pass_base import ExportPass
14+
15+
16+
class CastIntComparisonInputsPass(ArmOpTargetedPass):
17+
"""Cast integer comparison inputs to a lossless floating-point type."""
18+
19+
_passes_required_after: Set[Type[ExportPass]] = set()
20+
21+
target_ops = {
22+
exir_ops.edge.aten.eq.Tensor,
23+
exir_ops.edge.aten.ne.Tensor,
24+
exir_ops.edge.aten.ge.Tensor,
25+
exir_ops.edge.aten.gt.Tensor,
26+
exir_ops.edge.aten.le.Tensor,
27+
exir_ops.edge.aten.lt.Tensor,
28+
}
29+
castable_dtypes = {torch.int8, torch.int16}
30+
31+
def should_run_pass(self, graph_module: torch.fx.GraphModule) -> bool:
32+
tosa_spec = get_context_spec()
33+
return (
34+
tosa_spec.support_float()
35+
and not tosa_spec.support_integer()
36+
and super().should_run_pass(graph_module)
37+
)
38+
39+
def call_operator(self, op, args, kwargs, meta):
40+
if op not in self.target_ops:
41+
return super().call_operator(op, args, kwargs, meta)
42+
43+
if not all(arg.data.dtype in self.castable_dtypes for arg in args):
44+
return super().call_operator(op, args, kwargs, meta)
45+
46+
cast_dtype = (
47+
torch.float16
48+
if all(arg.data.dtype == torch.int8 for arg in args)
49+
else torch.float32
50+
)
51+
casted_args = []
52+
for arg in args:
53+
casted_args.append(
54+
super().call_operator(
55+
exir_ops.edge.dim_order_ops._to_dim_order_copy.default,
56+
(arg,),
57+
{"dtype": cast_dtype},
58+
meta,
59+
)
60+
)
61+
return super().call_operator(op, tuple(casted_args), kwargs, meta)
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# Copyright 2026 Arm Limited and/or its affiliates.
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 operator
7+
from collections.abc import Callable
8+
9+
import pytest
10+
import torch
11+
from executorch.backends.arm._passes import CastIntComparisonInputsPass
12+
from executorch.backends.arm.test.tester.test_pipeline import (
13+
PassPipeline,
14+
TosaPipelineFP,
15+
)
16+
from executorch.backends.test.harness.stages import StageType
17+
from executorch.exir.dialects._ops import ops as edge_ops
18+
19+
20+
class Comparison(torch.nn.Module):
21+
def __init__(self, op: Callable[[torch.Tensor, torch.Tensor], torch.Tensor]):
22+
super().__init__()
23+
self.op = op
24+
25+
def forward(self, x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
26+
return self.op(x, y)
27+
28+
29+
class ScalarComparison(torch.nn.Module):
30+
def __init__(self, op: Callable[[torch.Tensor, int], torch.Tensor]):
31+
super().__init__()
32+
self.op = op
33+
34+
def forward(self, x: torch.Tensor) -> torch.Tensor:
35+
return self.op(x, 0)
36+
37+
38+
comparison_ops = {
39+
"eq": operator.eq,
40+
"ne": operator.ne,
41+
"ge": operator.ge,
42+
"gt": operator.gt,
43+
"le": operator.le,
44+
"lt": operator.lt,
45+
}
46+
aten_ops = {
47+
"eq": "torch.ops.aten.eq.Tensor",
48+
"ne": "torch.ops.aten.ne.Tensor",
49+
"ge": "torch.ops.aten.ge.Tensor",
50+
"gt": "torch.ops.aten.gt.Tensor",
51+
"le": "torch.ops.aten.le.Tensor",
52+
"lt": "torch.ops.aten.lt.Tensor",
53+
}
54+
aten_scalar_ops = {
55+
name: target.replace("Tensor", "Scalar") for name, target in aten_ops.items()
56+
}
57+
exir_ops = {
58+
name: f"executorch_exir_dialects_edge__ops_aten_{name}_Tensor"
59+
for name in comparison_ops
60+
}
61+
exir_scalar_ops = {
62+
name: target.replace("Tensor", "Scalar") for name, target in exir_ops.items()
63+
}
64+
65+
66+
def comparison_inputs(dtype: torch.dtype) -> tuple[torch.Tensor, torch.Tensor]:
67+
limits = torch.iinfo(dtype)
68+
return (
69+
torch.tensor(
70+
[limits.min, limits.min + 1, limits.max - 1, limits.max], dtype=dtype
71+
),
72+
torch.tensor(
73+
[limits.min + 1, limits.min, limits.max, limits.max - 1], dtype=dtype
74+
),
75+
)
76+
77+
78+
@pytest.mark.parametrize("op", comparison_ops.values(), ids=comparison_ops.keys())
79+
@pytest.mark.parametrize(
80+
("dtypes", "expected_dtype"),
81+
(
82+
((torch.int8, torch.int8), torch.float16),
83+
((torch.int16, torch.int16), torch.float32),
84+
((torch.int8, torch.int16), torch.float32),
85+
),
86+
)
87+
def test_cast_int_comparison_inputs(op, dtypes, expected_dtype) -> None:
88+
inputs = (
89+
comparison_inputs(dtypes[0])[0],
90+
comparison_inputs(dtypes[1])[1],
91+
)
92+
pipeline = PassPipeline(
93+
Comparison(op),
94+
inputs,
95+
ops_after_pass={
96+
"executorch_exir_dialects_edge__ops_dim_order_ops__to_dim_order_copy_default": 2
97+
},
98+
pass_list=[CastIntComparisonInputsPass],
99+
)
100+
pipeline.run()
101+
102+
graph_module = (
103+
pipeline.tester.get_artifact(StageType.RUN_PASSES)
104+
.exported_program()
105+
.graph_module
106+
)
107+
cast_op = edge_ops.edge.dim_order_ops._to_dim_order_copy.default
108+
cast_nodes = [node for node in graph_module.graph.nodes if node.target == cast_op]
109+
assert len(cast_nodes) == 2
110+
assert all(node.kwargs["dtype"] == expected_dtype for node in cast_nodes)
111+
112+
113+
def test_cast_int_comparison_inputs_keeps_int32() -> None:
114+
inputs = (
115+
torch.tensor([2**24, 2**24 + 1], dtype=torch.int32),
116+
torch.tensor([2**24 + 1, 2**24], dtype=torch.int32),
117+
)
118+
pipeline = PassPipeline(
119+
Comparison(operator.eq),
120+
inputs,
121+
ops_not_after_pass=[
122+
"executorch_exir_dialects_edge__ops_dim_order_ops__to_dim_order_copy_default"
123+
],
124+
pass_list=[CastIntComparisonInputsPass],
125+
)
126+
pipeline.run()
127+
128+
129+
@pytest.mark.parametrize("name", comparison_ops)
130+
@pytest.mark.parametrize("dtype", (torch.int8, torch.int16))
131+
def test_int_comparison_tosa_fp(name, dtype) -> None:
132+
inputs = comparison_inputs(dtype)
133+
pipeline = TosaPipelineFP(
134+
Comparison(comparison_ops[name]),
135+
inputs,
136+
aten_ops[name],
137+
exir_ops[name],
138+
)
139+
pipeline.run()
140+
141+
142+
@pytest.mark.parametrize("name", comparison_ops)
143+
@pytest.mark.parametrize("dtype", (torch.int8, torch.int16))
144+
def test_int_scalar_comparison_tosa_fp(name, dtype) -> None:
145+
inputs = (comparison_inputs(dtype)[0],)
146+
pipeline = TosaPipelineFP(
147+
ScalarComparison(comparison_ops[name]),
148+
inputs,
149+
aten_scalar_ops[name],
150+
exir_scalar_ops[name],
151+
)
152+
pipeline.run()

0 commit comments

Comments
 (0)