Skip to content

Commit fa342fb

Browse files
authored
Arm backend: Fix lifted int64 index constants. (pytorch#24794)
CastInt64BuffersToInt32Pass previously handled buffers but not tensor constants lifted by torch.export. Extend the pass to convert lifted int64 constants used exclusively as indexing operands, provided their values fit within int32. Preserve the original dtype for constants that are also used as data or other non-index operands. cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell @rascani Signed-off-by: Elena Zhelezina <elena.zhelezina@arm.com>
1 parent 2b061a7 commit fa342fb

2 files changed

Lines changed: 243 additions & 39 deletions

File tree

‎backends/arm/_passes/cast_int64_pass.py‎

Lines changed: 96 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -9,36 +9,94 @@
99

1010
import torch
1111
from executorch.backends.arm._passes.arm_pass import ArmPass
12+
from executorch.exir.dialects._ops import ops as exir_ops
1213
from executorch.exir.pass_base import ExportPass, PassResult
13-
from torch._export.utils import is_buffer
14+
from torch._export.utils import is_buffer, is_lifted_tensor_constant
1415
from torch.export import ExportedProgram
1516

1617
logger = logging.getLogger(__name__)
1718

1819

1920
class CastInt64BuffersToInt32Pass(ArmPass):
20-
"""Cast int64 buffers to int32 if the int64 data is in int32 range."""
21+
"""Cast int64 buffers and lifted tensor constants to int32 when in range."""
2122

2223
_passes_required_after: Set[Type[ExportPass]] = set()
2324

2425
def __init__(self, exported_program: ExportedProgram, *args, **kwargs):
2526
super().__init__(*args, **kwargs)
2627
self.exported_program = exported_program
2728

28-
def should_run_pass(self, graph_module: torch.fx.GraphModule) -> bool:
29-
for node in graph_module.graph.nodes:
30-
if len(node.users) == 0:
31-
continue
32-
fake_tensor = node.meta.get("val")
33-
if not isinstance(fake_tensor, torch._subclasses.fake_tensor.FakeTensor):
34-
continue
35-
if fake_tensor.dtype == torch.int64 and is_buffer(
36-
self.exported_program, node
37-
):
29+
@staticmethod
30+
def _is_index_tensor_input(node: torch.fx.Node) -> bool:
31+
"""Return True only when a constant is used exclusively as an index.
32+
33+
``node.users`` tracks consumer operations, not operand occurrences.
34+
A consumer can use this node as both indexed data and an index.
35+
36+
"""
37+
index_ops = {
38+
torch.ops.aten.index.Tensor,
39+
exir_ops.edge.aten.index.Tensor,
40+
}
41+
42+
def contains_node(value: object) -> bool:
43+
if value is node:
3844
return True
39-
return False
45+
if isinstance(value, (tuple, list)):
46+
return any(contains_node(item) for item in value)
47+
if isinstance(value, dict):
48+
return any(contains_node(item) for item in value.values())
49+
if isinstance(value, slice):
50+
return any(
51+
contains_node(item)
52+
for item in (value.start, value.stop, value.step)
53+
)
54+
return False
55+
56+
if not node.users:
57+
return False
58+
for user in node.users:
59+
if (
60+
user.op != "call_function"
61+
or user.target not in index_ops
62+
or len(user.args) < 2
63+
or not isinstance(user.args[1], (list, tuple))
64+
or not any(index is node for index in user.args[1])
65+
):
66+
return False
67+
68+
# args[1] is the index-list operand. All other uses are unsafe.
69+
if any(
70+
contains_node(argument)
71+
for i, argument in enumerate(user.args)
72+
if i != 1
73+
) or contains_node(user.kwargs):
74+
return False
75+
return True
76+
77+
def _is_cast_candidate(self, node: torch.fx.Node) -> bool:
78+
"""Select int64 buffers and index-only lifted tensor constants."""
79+
if not node.users:
80+
return False
81+
fake_tensor = node.meta.get("val")
82+
if not isinstance(fake_tensor, torch._subclasses.fake_tensor.FakeTensor):
83+
return False
84+
if fake_tensor.dtype != torch.int64:
85+
return False
86+
if is_buffer(self.exported_program, node):
87+
return True
88+
return is_lifted_tensor_constant(
89+
self.exported_program, node
90+
) and self._is_index_tensor_input(node)
91+
92+
def should_run_pass(self, graph_module: torch.fx.GraphModule) -> bool:
93+
return any(self._is_cast_candidate(node) for node in graph_module.graph.nodes)
4094

4195
def _assert_within_int32(self, tensor: torch.Tensor, node: torch.fx.Node):
96+
# An empty tensor has no values outside the int32 range.
97+
if tensor.numel() == 0:
98+
return
99+
42100
if torch.min(tensor) < torch.iinfo(torch.int32).min:
43101
raise RuntimeError(
44102
f"Node {node.name} has value < {torch.iinfo(torch.int32).min}"
@@ -48,44 +106,42 @@ def _assert_within_int32(self, tensor: torch.Tensor, node: torch.fx.Node):
48106
f"Node {node.name} has value > {torch.iinfo(torch.int32).max}"
49107
)
50108

51-
def _cast_buffer_to_int32(self, node: torch.fx.Node) -> bool:
52-
buffer_name = self.exported_program.graph_signature.inputs_to_buffers[node.name]
53-
if buffer_name in self.exported_program.state_dict:
54-
store = self.exported_program.state_dict
55-
elif buffer_name in self.exported_program.constants:
56-
# Non-persistent buffers are tracked by inputs_to_buffers, but their
57-
# tensor values live in ExportedProgram.constants instead of the
58-
# state_dict. Examples include transformer position_ids buffers.
109+
def _cast_constant_to_int32(self, node: torch.fx.Node) -> bool:
110+
signature = self.exported_program.graph_signature
111+
if is_buffer(self.exported_program, node):
112+
constant_name = signature.inputs_to_buffers[node.name]
113+
# Persistent buffers live in state_dict, non-persistent ones in constants.
114+
store = (
115+
self.exported_program.state_dict
116+
if constant_name in self.exported_program.state_dict
117+
else self.exported_program.constants
118+
)
119+
elif is_lifted_tensor_constant(self.exported_program, node):
120+
constant_name = signature.inputs_to_lifted_tensor_constants[node.name]
59121
store = self.exported_program.constants
60122
else:
123+
return False
124+
125+
if constant_name not in store:
61126
logger.warning(
62-
"Skipping int64 buffer %s (%s): value not found in state_dict "
63-
"or constants",
127+
"Skipping int64 constant %s (%s): value not found in its storage",
64128
node.name,
65-
buffer_name,
129+
constant_name,
66130
)
67131
return False
68132

69-
buffer = store[buffer_name]
70-
self._assert_within_int32(buffer, node)
71-
store[buffer_name] = buffer.to(torch.int32)
133+
value = store[constant_name]
134+
# Do not truncate values outside the signed int32 range.
135+
self._assert_within_int32(value, node)
136+
store[constant_name] = value.to(torch.int32)
72137
node.meta["val"] = node.meta["val"].to(torch.int32)
73138
return True
74139

75140
def _to_int32(self, graph_module: torch.fx.GraphModule) -> int:
76141
converted = 0
77142
for node in graph_module.graph.nodes:
78-
if len(node.users) == 0:
79-
continue
80-
if "val" not in node.meta:
81-
continue
82-
fake_tensor = node.meta["val"]
83-
if not isinstance(fake_tensor, torch._subclasses.fake_tensor.FakeTensor):
84-
continue
85-
if fake_tensor.dtype != torch.int64:
86-
continue
87-
if is_buffer(self.exported_program, node):
88-
converted += self._cast_buffer_to_int32(node)
143+
if self._is_cast_candidate(node):
144+
converted += self._cast_constant_to_int32(node)
89145
return converted
90146

91147
def call(self, graph_module: torch.fx.GraphModule):
@@ -94,7 +150,8 @@ def call(self, graph_module: torch.fx.GraphModule):
94150
if modified:
95151
graph_module = super().call(graph_module).graph_module
96152
logger.warning(
97-
"CastInt64BuffersToInt32Pass: cast %d int64 buffer(s) to int32.",
153+
"CastInt64BuffersToInt32Pass: cast %d int64 buffer(s) or "
154+
"lifted tensor constant(s) to int32.",
98155
converted,
99156
)
100157
return PassResult(graph_module, modified)

‎backends/arm/test/passes/test_cast_int64_pass.py‎

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from typing import Tuple
77

8+
import pytest
89
import torch
910
from executorch.backends.arm._passes import CastInt64BuffersToInt32Pass
1011

@@ -79,3 +80,149 @@ def test_cast_non_persistent_int64_buffers_to_int32_tosa_FP(test_data: input_t):
7980
StageType.RUN_PASSES
8081
).exported_program()
8182
assert exported_program.constants["position_ids"].dtype == torch.int32
83+
84+
85+
class LiftedInt64IndexModel(torch.nn.Module):
86+
"""A plain tensor attribute is lifted as CONSTANT_TENSOR, not BUFFER."""
87+
88+
def __init__(self):
89+
super().__init__()
90+
self.indices = torch.tensor([0, 2], dtype=torch.int64)
91+
92+
def forward(self, x: torch.Tensor) -> torch.Tensor:
93+
return x[:, self.indices]
94+
95+
96+
def test_cast_lifted_int64_index_constant_to_int32():
97+
"""Lifted indices must reach gather as int32."""
98+
inputs = (torch.arange(32, dtype=torch.float32).reshape(1, 4, 8),)
99+
pipeline = PassPipeline[input_t](
100+
LiftedInt64IndexModel(),
101+
inputs,
102+
quantize=False,
103+
passes_with_exported_program=[CastInt64BuffersToInt32Pass],
104+
)
105+
# Also compares the output of the transformed Edge graph to the original.
106+
pipeline.run()
107+
108+
ep = pipeline.tester.get_artifact(StageType.RUN_PASSES).exported_program()
109+
lifted = ep.graph_signature.inputs_to_lifted_tensor_constants
110+
assert len(lifted) == 1
111+
placeholder_name, constant_name = next(iter(lifted.items()))
112+
assert ep.constants[constant_name].dtype == torch.int32
113+
placeholder = next(
114+
node for node in ep.graph_module.graph.nodes if node.name == placeholder_name
115+
)
116+
assert placeholder.meta["val"].dtype == torch.int32
117+
118+
119+
@pytest.mark.parametrize(
120+
"bad_index,expected_error",
121+
[
122+
(2**31, "value >"),
123+
(-(2**31) - 1, "value <"),
124+
],
125+
)
126+
def test_lifted_int64_constant_out_of_int32_range_is_not_truncated(
127+
bad_index: int, expected_error: str
128+
):
129+
from executorch.exir import to_edge
130+
131+
inputs = (torch.randn(1, 4, 8),)
132+
ep = to_edge(
133+
torch.export.export(LiftedInt64IndexModel(), inputs)
134+
).exported_program()
135+
lifted = ep.graph_signature.inputs_to_lifted_tensor_constants
136+
assert len(lifted) == 1
137+
_, constant_name = next(iter(lifted.items()))
138+
# Modify after export: an out-of-bounds index need not be run during tracing.
139+
ep.constants[constant_name] = torch.tensor([0, bad_index], dtype=torch.int64)
140+
141+
with pytest.raises(RuntimeError, match=expected_error):
142+
CastInt64BuffersToInt32Pass(ep)(ep.graph_module)
143+
assert ep.constants[constant_name].dtype == torch.int64
144+
145+
146+
def test_lifted_int64_constant_used_as_data_and_index_is_not_narrowed():
147+
"""The same lifted constant is indexed data and index."""
148+
149+
class SelfIndexModel(torch.nn.Module):
150+
def __init__(self):
151+
super().__init__()
152+
self.values = torch.tensor([1, 0], dtype=torch.int64)
153+
154+
def forward(self) -> torch.Tensor:
155+
return self.values[self.values]
156+
157+
model = SelfIndexModel().eval()
158+
expected = model()
159+
ep = torch.export.export(model, ())
160+
lifted = ep.graph_signature.inputs_to_lifted_tensor_constants
161+
assert len(lifted) == 1
162+
placeholder_name, constant_name = next(iter(lifted.items()))
163+
node = next(n for n in ep.graph_module.graph.nodes if n.name == placeholder_name)
164+
users = [user for user in node.users if user.target == torch.ops.aten.index.Tensor]
165+
assert len(users) == 1
166+
assert users[0].args[0] is node
167+
assert any(index is node for index in users[0].args[1])
168+
169+
cast_pass = CastInt64BuffersToInt32Pass(ep)
170+
assert not cast_pass._is_index_tensor_input(node)
171+
assert not cast_pass.should_run_pass(ep.graph_module)
172+
assert cast_pass._to_int32(ep.graph_module) == 0
173+
assert ep.constants[constant_name].dtype == torch.int64
174+
assert node.meta["val"].dtype == torch.int64
175+
result = ep.module()()
176+
assert result.dtype == torch.int64
177+
torch.testing.assert_close(result, expected)
178+
179+
180+
def test_lifted_int64_index_rejects_other_operand_occurrences():
181+
"""The predicate must reject nested positional/keyword operand reuse."""
182+
for location in ("indexed_data", "positional", "keyword"):
183+
graph = torch.fx.Graph()
184+
data = graph.placeholder("data")
185+
index = graph.placeholder("index")
186+
args = (data, [index])
187+
kwargs = {}
188+
if location == "indexed_data":
189+
args = (index, [index])
190+
elif location == "positional":
191+
args += ({"nested": [index]},)
192+
else:
193+
kwargs["extra"] = {"nested": (index,)}
194+
graph.call_function(torch.ops.aten.index.Tensor, args=args, kwargs=kwargs)
195+
assert not CastInt64BuffersToInt32Pass._is_index_tensor_input(index)
196+
197+
graph = torch.fx.Graph()
198+
data = graph.placeholder("data")
199+
index = graph.placeholder("index")
200+
graph.call_function(torch.ops.aten.index.Tensor, args=(data, [index]))
201+
assert CastInt64BuffersToInt32Pass._is_index_tensor_input(index)
202+
203+
204+
def test_cast_empty_lifted_int64_index_constant_to_int32():
205+
"""Empty lifted indices are valid and must convert without reductions."""
206+
from executorch.exir import to_edge
207+
208+
model = LiftedInt64IndexModel()
209+
model.indices = torch.empty(0, dtype=torch.int64)
210+
inputs = (torch.arange(32, dtype=torch.float32).reshape(1, 4, 8),)
211+
ep = to_edge(torch.export.export(model, inputs)).exported_program()
212+
213+
lifted = ep.graph_signature.inputs_to_lifted_tensor_constants
214+
assert len(lifted) == 1
215+
placeholder_name, constant_name = next(iter(lifted.items()))
216+
assert ep.constants[constant_name].numel() == 0
217+
assert ep.constants[constant_name].dtype == torch.int64
218+
219+
cast_pass = CastInt64BuffersToInt32Pass(ep)
220+
assert cast_pass.should_run_pass(ep.graph_module)
221+
cast_pass(ep.graph_module)
222+
223+
assert ep.constants[constant_name].numel() == 0
224+
assert ep.constants[constant_name].dtype == torch.int32
225+
placeholder = next(
226+
node for node in ep.graph_module.graph.nodes if node.name == placeholder_name
227+
)
228+
assert placeholder.meta["val"].dtype == torch.int32

0 commit comments

Comments
 (0)