Skip to content

Commit c2e8d64

Browse files
authored
Arm backend: Prepare int32 indices for delegated gather (pytorch#22998)
Remove safe int32-to-int64 compatibility casts from indices of supported gather patterns after partitioning. Keep unsupported and shared portable gather paths in int64, and update partition tags when gather support changes. cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @Sebastian-Larsson @robell @rascani Signed-off-by: Måns Nilsson <mans.nilsson@arm.com>
1 parent dd4985d commit c2e8d64

9 files changed

Lines changed: 827 additions & 27 deletions

‎backends/arm/_passes/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@
187187
NormalizeMaxPool2dInputRankPass,
188188
)
189189
from .normalize_while_initial_args_pass import NormalizeWhileInitialArgsPass # noqa
190+
from .prepare_gather_indices_pass import PrepareGatherIndicesPass # noqa
190191
from .promote_bool_operands_pass import PromoteBoolOperandsPass # noqa
191192
from .propagate_view_copy_permute_pass import ( # noqa
192193
PropagateViewCopyPermuteDownPass,

‎backends/arm/_passes/arm_pass_manager.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@
150150
NormalizeMaxPool2dInputRankPass,
151151
NormalizeTransformInputPlaceholdersPass,
152152
NormalizeWhileInitialArgsPass,
153+
PrepareGatherIndicesPass,
153154
PromoteBoolOperandsPass,
154155
PropagateViewCopyPermuteDownPass,
155156
PropagateViewCopyPermuteUpPass,
@@ -538,6 +539,7 @@ def _tosa_pipeline(
538539
NormalizeDelegateIOLayoutPass(exported_program),
539540
FuseQuantizedActivationPass(),
540541
RewriteBoolToFp32CastViaInt8Pass(),
542+
PrepareGatherIndicesPass(self.tosa_spec),
541543
CanonicalizeGatherPass(),
542544
ConvertToClampPass(),
543545
DecomposeTOSAUnsupportedClampPass(),
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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 cast
7+
8+
import torch
9+
10+
from executorch.backends.arm._passes.arm_pass import ArmPass
11+
from executorch.backends.arm._passes.arm_pass_utils import get_first_fake_tensor
12+
from executorch.backends.arm.tosa import TosaSpecification
13+
from executorch.exir.dialects._ops import ops as exir_ops
14+
from executorch.exir.pass_base import ExportPass, PassResult
15+
from torch.fx.experimental.symbolic_shapes import statically_known_true
16+
17+
_EDGE_GATHER = exir_ops.edge.aten.gather.default
18+
_EDGE_CAST = exir_ops.edge.dim_order_ops._to_dim_order_copy.default
19+
_INT32_MAX = torch.iinfo(torch.int32).max
20+
21+
22+
def _is_compatible_gather_user(boundary: torch.fx.Node, gather: torch.fx.Node) -> bool:
23+
if gather.target != _EDGE_GATHER:
24+
return False
25+
if len(gather.args) != 3 or gather.args[2] is not boundary:
26+
return False
27+
28+
values = get_first_fake_tensor(cast(torch.fx.Node, gather.args[0]))
29+
indices = get_first_fake_tensor(boundary)
30+
values_shape = tuple(values.shape)
31+
indices_shape = tuple(indices.shape)
32+
if len(values_shape) not in (2, 3) or len(indices_shape) not in (2, 3):
33+
return False
34+
35+
dim = cast(int, gather.args[1]) % len(values_shape)
36+
if dim != 1 or not statically_known_true(values_shape[dim] <= _INT32_MAX):
37+
return False
38+
if not statically_known_true(values_shape[0] == indices_shape[0]):
39+
return False
40+
return len(indices_shape) == 2 or statically_known_true(
41+
values_shape[-1] == indices_shape[-1]
42+
)
43+
44+
45+
def is_safe_int32_to_int64_gather_boundary(node: torch.fx.Node) -> bool:
46+
"""Return whether a cast is a removable gather compatibility boundary.
47+
48+
Args:
49+
node: Candidate int32-to-int64 cast node.
50+
51+
Returns:
52+
True when every user is a compatible, int32-bounded gather.
53+
54+
"""
55+
if node.target != _EDGE_CAST:
56+
return False
57+
if node.kwargs.get("dtype") != torch.int64:
58+
return False
59+
source = node.args[0]
60+
if not isinstance(source, torch.fx.Node):
61+
return False
62+
source_tensor = get_first_fake_tensor(source)
63+
if source_tensor.dtype != torch.int32:
64+
return False
65+
if source_tensor.dim_order() != get_first_fake_tensor(node).dim_order():
66+
return False
67+
if not node.users:
68+
return False
69+
return all(_is_compatible_gather_user(node, gather) for gather in node.users)
70+
71+
72+
class PrepareGatherIndicesPass(ArmPass):
73+
"""Remove safe int64 compatibility boundaries from gather indices.
74+
75+
Portable ExecuTorch gather requires int64 indices, while TOSA gather uses
76+
int32. This pass runs during backend preprocessing, after delegation is
77+
established, and removes only an int32-to-int64 compatibility cast. It
78+
never narrows an arbitrary int64 tensor.
79+
80+
Args:
81+
tosa_spec: Target capabilities used to gate gather
82+
value dtypes.
83+
84+
"""
85+
86+
_passes_required_after: set[type[ExportPass]] = set()
87+
88+
_edge_gather = _EDGE_GATHER
89+
90+
def __init__(self, tosa_spec: TosaSpecification) -> None:
91+
super().__init__()
92+
self.tosa_spec = tosa_spec
93+
94+
def _values_dtype_supported(self, dtype: torch.dtype) -> bool:
95+
if dtype in (torch.bool, torch.int8, torch.int16, torch.int32):
96+
return self.tosa_spec.support_integer()
97+
floating_profile = (
98+
self.tosa_spec.support_float() or self.tosa_spec.support_integer()
99+
)
100+
if dtype == torch.bfloat16:
101+
return floating_profile and self.tosa_spec.support_extension("bf16")
102+
if dtype == torch.float8_e4m3fn:
103+
return floating_profile and self.tosa_spec.support_extension("fp8e4m3")
104+
if dtype == torch.float8_e5m2:
105+
return floating_profile and self.tosa_spec.support_extension("fp8e5m2")
106+
if dtype in (torch.float16, torch.float32):
107+
return floating_profile
108+
return False
109+
110+
def _is_supported_gather(self, node: torch.fx.Node) -> bool:
111+
if node.target != self._edge_gather:
112+
return False
113+
if len(node.args) != 3 or not isinstance(node.args[2], torch.fx.Node):
114+
return False
115+
116+
values = get_first_fake_tensor(cast(torch.fx.Node, node.args[0]))
117+
indices = get_first_fake_tensor(node.args[2])
118+
if not self._values_dtype_supported(values.dtype):
119+
return False
120+
if indices.dtype != torch.int64 or not is_safe_int32_to_int64_gather_boundary(
121+
node.args[2]
122+
):
123+
return False
124+
125+
values_shape = tuple(values.shape)
126+
indices_shape = tuple(indices.shape)
127+
if len(values_shape) not in (2, 3):
128+
return False
129+
if len(indices_shape) not in (2, 3):
130+
return False
131+
132+
dim = cast(int, node.args[1]) % len(values_shape)
133+
if dim != 1:
134+
return False
135+
if values_shape[0] != indices_shape[0]:
136+
return False
137+
return len(indices_shape) == 2 or values_shape[-1] == indices_shape[-1]
138+
139+
@classmethod
140+
def _int32_input_before_boundary(
141+
cls, indices: torch.fx.Node
142+
) -> torch.fx.Node | None:
143+
if not is_safe_int32_to_int64_gather_boundary(indices):
144+
return None
145+
return cast(torch.fx.Node, indices.args[0])
146+
147+
def should_run_pass(self, graph_module: torch.fx.GraphModule) -> bool:
148+
"""Return whether the graph contains a removable gather boundary."""
149+
return any(
150+
node.op == "call_function" and self._is_supported_gather(node)
151+
for node in graph_module.graph.nodes
152+
)
153+
154+
def call(self, graph_module: torch.fx.GraphModule):
155+
"""Remove safe compatibility casts from delegated gather inputs."""
156+
graph = graph_module.graph
157+
modified = False
158+
for gather in list(graph.nodes):
159+
if gather.op != "call_function" or not self._is_supported_gather(gather):
160+
continue
161+
162+
indices = cast(torch.fx.Node, gather.args[2])
163+
int32_indices = self._int32_input_before_boundary(indices)
164+
if int32_indices is None:
165+
continue
166+
167+
gather.replace_input_with(indices, int32_indices)
168+
modified = True
169+
170+
if modified:
171+
graph.eliminate_dead_code()
172+
graph.lint()
173+
graph_module.recompile()
174+
return PassResult(graph_module, modified)

‎backends/arm/operator_support/gather_support.py‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
- x must be rank-2 or rank-3
1313
- index must be rank-2 or rank-3.
1414
- for rank-3 x.shape[-1] must match index.shape[-1]
15-
- index dtype must be int32
15+
- index dtype must be int32, or use a safe int32-to-int64 boundary
1616
- batch dim must match: x.shape[0] == index.shape[0]
1717
1818
Dtype gating is capability-based:
@@ -35,6 +35,9 @@
3535
import torch
3636
import torch.fx as fx
3737

38+
from executorch.backends.arm._passes.prepare_gather_indices_pass import (
39+
is_safe_int32_to_int64_gather_boundary,
40+
)
3841
from executorch.backends.arm.operator_support.tosa_supported_operators import (
3942
register_tosa_support_check,
4043
SupportedTOSAOperatorCheck,
@@ -70,11 +73,14 @@ def is_node_tosa_supported( # noqa: C901
7073
dim = dim % len(x_shape)
7174

7275
# ---- index dtype ----
73-
if index_val.dtype != torch.int32:
76+
if index_val.dtype != torch.int32 and not (
77+
isinstance(index_arg, torch.fx.Node)
78+
and is_safe_int32_to_int64_gather_boundary(index_arg)
79+
):
7480
self.reporter.report_reject(
7581
node,
7682
f"{node.target}: index dtype {index_val.dtype} not supported; "
77-
"expected int32.",
83+
"expected int32 or a safe int32-to-int64 boundary.",
7884
)
7985
return False
8086

‎backends/arm/operator_support/to_dim_order_copy_support.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616

1717
import torch.fx as fx
1818

19+
from executorch.backends.arm._passes.prepare_gather_indices_pass import (
20+
is_safe_int32_to_int64_gather_boundary,
21+
)
1922
from executorch.backends.arm.operator_support.tosa_supported_operators import (
2023
register_tosa_support_check,
2124
SupportedTOSAOperatorCheck,
@@ -103,6 +106,8 @@ def is_node_tosa_supported(
103106
)
104107
return False
105108
if output_val.dtype not in supported_dtypes[input_dtype]:
109+
if is_safe_int32_to_int64_gather_boundary(node):
110+
return True
106111
if tosa_spec.support_integer() and self._is_quantized_identity_cast(node):
107112
return True
108113
self.reporter.report_reject(

‎backends/arm/operator_support/tosa_supported_operators.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@
3131
FuseQuantizedActivationPass,
3232
)
3333
from executorch.backends.arm._passes.insert_table_ops import TableOps
34+
from executorch.backends.arm._passes.prepare_gather_indices_pass import (
35+
is_safe_int32_to_int64_gather_boundary,
36+
)
37+
3438
from executorch.backends.arm._passes.size_adjust_input_pass import (
3539
get_slices_convolution,
3640
get_slices_pooling,
@@ -1254,6 +1258,8 @@ def inside_int32_bounds(self, node: torch.fx.Node) -> bool:
12541258
def has_rejected_int64_output(
12551259
self, node: torch.fx.Node, tensor_list: Sequence[typing.Any]
12561260
) -> bool:
1261+
if is_safe_int32_to_int64_gather_boundary(node):
1262+
return False
12571263
if node.target in _ARGMAX_OPS:
12581264
return not self._is_tosa_argmax_supported(node)
12591265

@@ -1381,6 +1387,12 @@ def _check_int64_input_nodes(self, node: torch.fx.Node) -> bool:
13811387
if tensor_in.dtype != torch.int64:
13821388
continue
13831389

1390+
if (
1391+
node.target == exir_ops.edge.aten.gather.default
1392+
and is_safe_int32_to_int64_gather_boundary(input_node)
1393+
):
1394+
continue
1395+
13841396
# aten.argmax is nominally int64, but TOSA ARGMAX produces int32.
13851397
# Allow the explicit argmax -> int32 narrowing pattern so both nodes
13861398
# can be placed in the same delegate.

‎backends/arm/test/ops/test_gather.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ def test_gather_tosa_FP(test_data: input_params):
169169
exir_op=Gather.exir_op,
170170
transform_passes=[
171171
InsertInt32CastsAfterInt64PlaceholdersPass(),
172-
], # int64 index are not currently supported and need to be cast to int32
172+
], # int64 indices are not supported and must be cast to int32
173173
tosa_extensions=["bf16"],
174174
)
175175
pipeline.run()
@@ -185,7 +185,7 @@ def test_gather_tosa_FP_fp8(test_data: tuple[input_params, str]):
185185
exir_op=Gather.exir_op,
186186
transform_passes=[
187187
InsertInt32CastsAfterInt64PlaceholdersPass(),
188-
], # int64 index are not currently supported and need to be cast to int32
188+
], # int64 indices are not supported and must be cast to int32
189189
compare_tosa_ref_model_outputs=False,
190190
tosa_extensions=[tosa_extension],
191191
)
@@ -225,7 +225,8 @@ def test_gather_u85_INT(test_data: input_params):
225225
aten_ops=Gather.aten_op,
226226
exir_ops=Gather.exir_op,
227227
)
228-
# U85: keep _to_dim_order_copy portable for int64->int32 index casts (not delegatable).
228+
# U85: keep _to_dim_order_copy portable for int64->int32 index casts;
229+
# the cast is not delegatable.
229230
pipeline.tester.use_portable_ops = True
230231
pipeline.run()
231232

@@ -241,7 +242,7 @@ def test_gather_vgf_no_quant(test_data: input_params):
241242
quantize=False,
242243
transform_passes=[
243244
InsertInt32CastsAfterInt64PlaceholdersPass(),
244-
], # int64 index are not currently supported and need to be cast to int32
245+
], # int64 indices are not supported and must be cast to int32
245246
)
246247
pipeline.run()
247248

0 commit comments

Comments
 (0)