Skip to content

Commit def4e59

Browse files
committed
Arm backend: Add aten.flip support via TOSA REVERSE
flip was staged as supported (quantizer, EthosU55NotSupported, InsertDataLayoutCastsPass) but had no lowering, so it fell back to CPU. TOSA REVERSE reverses a single axis, while aten.flip can take several: - DecomposeFlipPass rewrites a multi-axis flip into a chain of single-axis flips, run after Q/DQ folding so each inherits the shared quant params. - FlipVisitor lowers each single-axis flip to one REVERSE (axis = dim % rank). - aten.flip.default is added to the INT and FP support lists so it delegates. U55 and U65 are unchanged: their TOSA subset has no REVERSE. Signed-off-by: Youngsik Yang <vacu9708@gmail.com>
1 parent d03dee5 commit def4e59

7 files changed

Lines changed: 325 additions & 0 deletions

File tree

backends/arm/_passes/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
from .decompose_embedding_pass import DecomposeEmbeddingPass # noqa # noqa
5454
from .decompose_erfinv_pass import DecomposeErfinvPass # noqa
5555
from .decompose_expm1_pass import DecomposeExpm1Pass # noqa
56+
from .decompose_flip_pass import DecomposeFlipPass # noqa
5657
from .decompose_floor_divide_pass import DecomposeFloorDividePass # noqa
5758
from .decompose_gelu_pass import DecomposeGeluPass # noqa
5859
from .decompose_glu_pass import DecomposeGluPass # noqa

backends/arm/_passes/arm_pass_manager.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
DecomposeEmbeddingPass,
5959
DecomposeErfinvPass,
6060
DecomposeExpm1Pass,
61+
DecomposeFlipPass,
6162
DecomposeFloorDividePass,
6263
DecomposeGeluPass,
6364
DecomposeGluPass,
@@ -538,6 +539,7 @@ def _tosa_pipeline(
538539
PromoteBoolOperandsPass(),
539540
DecomposeSinhPass(),
540541
DecomposeSignPass(),
542+
DecomposeFlipPass(),
541543
DecomposeFloorDividePass(),
542544
DecomposeGeluPass(),
543545
DecomposeAddSubAlphaPass(),
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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+
from executorch.backends.arm._passes import ArmOpTargetedPass
9+
from executorch.exir.dialects._ops import ops as exir_ops
10+
from executorch.exir.pass_base import ExportPass
11+
12+
13+
class DecomposeFlipPass(ArmOpTargetedPass):
14+
"""Decompose a multi-axis ``aten.flip`` into a chain of single-axis flips.
15+
16+
TOSA ``REVERSE`` reverses a single ``axis``; a flip over N dims is the
17+
composition of N independent single-axis reversals. Each single-axis
18+
``aten.flip`` is lowered to one ``REVERSE`` by ``FlipVisitor``. ``flip`` is a
19+
shared-qparam data-movement op, so the chained flips inherit the original
20+
node's quantization parameters via ``meta``.
21+
22+
"""
23+
24+
_passes_required_after: Set[Type[ExportPass]] = set()
25+
target_ops = {exir_ops.edge.aten.flip.default}
26+
27+
def call_operator(self, op, args, kwargs, meta, updated=False):
28+
if op not in self.target_ops or len(args[1]) == 1:
29+
return super().call_operator(op, args, kwargs, meta, updated)
30+
31+
# Chain one single-axis flip per dim; empty dims falls through as a no-op.
32+
out = args[0]
33+
for dim in args[1]:
34+
out = super().call_operator(op, (out, [dim]), kwargs, meta, updated=True)
35+
return out

backends/arm/operator_support/tosa_profile_supported_op_lists.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
exir_ops.edge.aten.linear.default,
6161
exir_ops.edge.aten.split_with_sizes_copy.default,
6262
exir_ops.edge.aten.split_copy.Tensor,
63+
exir_ops.edge.aten.flip.default,
6364
exir_ops.edge.aten.floor.default,
6465
exir_ops.edge.aten.full.default,
6566
exir_ops.edge.aten.full_like.default,
@@ -187,6 +188,7 @@
187188
exir_ops.edge.aten.linear.default,
188189
exir_ops.edge.aten.split_with_sizes_copy.default,
189190
exir_ops.edge.aten.split_copy.Tensor,
191+
exir_ops.edge.aten.flip.default,
190192
exir_ops.edge.aten.floor.default,
191193
exir_ops.edge.aten.full.default,
192194
exir_ops.edge.aten.full_like.default,

backends/arm/operators/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
op_any,
1717
op_cat,
1818
op_cond_if,
19+
op_flip,
1920
op_permute,
2021
op_repeat,
2122
op_sum,

backends/arm/operators/op_flip.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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+
7+
from typing import Any, List
8+
9+
import torch
10+
11+
import tosa_serializer as ts
12+
13+
from executorch.backends.arm.operators.node_visitor import (
14+
NodeVisitor,
15+
register_node_visitor,
16+
)
17+
from executorch.backends.arm.operators.operator_validation_utils import (
18+
validate_num_inputs,
19+
validate_same_dtype,
20+
validate_valid_dtype,
21+
)
22+
from executorch.backends.arm.tosa.mapping import TosaArg
23+
24+
25+
@register_node_visitor
26+
class FlipVisitor(NodeVisitor):
27+
target = "aten.flip.default"
28+
29+
def __init__(self, *args):
30+
super().__init__(*args)
31+
32+
def define_node(
33+
self,
34+
node: torch.fx.Node,
35+
tosa_graph: Any,
36+
inputs: List[TosaArg],
37+
output: TosaArg,
38+
) -> None:
39+
supported_dtypes = [ts.DType.BOOL]
40+
if self.tosa_spec.support_integer():
41+
supported_dtypes.extend([ts.DType.INT8, ts.DType.INT16, ts.DType.INT32])
42+
if self.tosa_spec.support_float():
43+
supported_dtypes.extend([ts.DType.FP16, ts.DType.FP32])
44+
if self.tosa_spec.support_extension("bf16"):
45+
supported_dtypes.append(ts.DType.BF16)
46+
if self.tosa_spec.support_extension("fp8e4m3"):
47+
supported_dtypes.append(ts.DType.FP8E4M3)
48+
if self.tosa_spec.support_extension("fp8e5m2"):
49+
supported_dtypes.append(ts.DType.FP8E5M2)
50+
51+
validate_num_inputs(self.target, inputs, 2)
52+
validate_same_dtype(self.target, [inputs[0], output], ts)
53+
validate_valid_dtype(
54+
self.target,
55+
[inputs[0], output],
56+
supported_dtypes,
57+
self.tosa_spec,
58+
)
59+
60+
dims = inputs[1].special
61+
if len(dims) != 1:
62+
raise ValueError(
63+
f"{self.target} expects a single flip dim after DecomposeFlipPass, "
64+
f"got {dims}"
65+
)
66+
axis = dims[0] % len(inputs[0].shape)
67+
68+
attr = ts.TosaSerializerAttribute()
69+
attr.ReverseAttribute(axis)
70+
self._serialize_operator(
71+
node,
72+
tosa_graph,
73+
ts.Op.REVERSE,
74+
[inputs[0].name],
75+
[output.name],
76+
attr,
77+
)

backends/arm/test/ops/test_flip.py

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
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+
7+
from typing import Tuple
8+
9+
import torch
10+
11+
from executorch.backends.arm.quantizer.arm_quantizer import (
12+
get_symmetric_a16w8_quantization_config,
13+
)
14+
from executorch.backends.arm.test import common
15+
from executorch.backends.arm.test.tester.test_pipeline import (
16+
EthosU85PipelineINT,
17+
OpNotSupportedPipeline,
18+
TosaPipelineFP,
19+
TosaPipelineINT,
20+
VgfPipeline,
21+
)
22+
23+
aten_op = "torch.ops.aten.flip.default"
24+
exir_op = "executorch_exir_dialects_edge__ops_aten_flip_default"
25+
26+
input_t1 = Tuple[torch.Tensor] # Input x
27+
28+
# (input tensor, dims) — single-axis, negative-axis, and multi-axis cases.
29+
test_data_suite = {
30+
"rank1": lambda: (torch.rand(10), [0]),
31+
"rank2_dim0": lambda: (torch.rand(4, 5), [0]),
32+
"rank2_dim1_neg": lambda: (torch.rand(4, 5), [-1]),
33+
"rank3_multi": lambda: (torch.rand(2, 3, 4), [0, 2]),
34+
"rank4_dim2": lambda: (torch.rand(2, 3, 4, 5), [2]),
35+
"rank4_multi": lambda: (torch.rand(2, 3, 4, 5), [1, 3]),
36+
"rank4_all_neg": lambda: (torch.rand(2, 3, 4, 5), [-4, -3, -2, -1]),
37+
}
38+
39+
test_data_suite_bf16 = {
40+
"rank4_multi_bf16": lambda: (
41+
torch.rand(2, 3, 4, 5, dtype=torch.bfloat16),
42+
[1, 3],
43+
),
44+
}
45+
46+
# The fp8 cases compare the lowered output against eager torch.flip. Eager
47+
# torch.flip on a float8 CPU tensor raises NotImplementedError ("flip_cpu not
48+
# implemented for Float8_*") when the flipped dims include the last axis, so
49+
# these cases flip outer axes only to keep that comparison runnable.
50+
test_data_suite_fp8 = {
51+
"rank4_multi_fp8e4m3": lambda: (
52+
torch.rand(2, 3, 4, 5).to(torch.float8_e4m3fn),
53+
[0, 2],
54+
"fp8e4m3",
55+
),
56+
"rank4_multi_fp8e5m2": lambda: (
57+
torch.rand(2, 3, 4, 5).to(torch.float8_e5m2),
58+
[0, 2],
59+
"fp8e5m2",
60+
),
61+
}
62+
63+
# U55 has no REVERSE, so flip must not be delegated there.
64+
test_data_suite_u55_reject = {
65+
"rank4_dim2": lambda: (torch.rand(2, 3, 4, 5), [2]),
66+
}
67+
68+
69+
class Flip(torch.nn.Module):
70+
def __init__(self, dims):
71+
super().__init__()
72+
self.dims = dims
73+
74+
def forward(self, x: torch.Tensor):
75+
return torch.flip(x, self.dims)
76+
77+
78+
class FlipBool(torch.nn.Module):
79+
def forward(self, x: torch.Tensor):
80+
return torch.flip(x > 0, [1])
81+
82+
83+
@common.parametrize("test_data", test_data_suite)
84+
def test_flip_tosa_FP(test_data):
85+
data, dims = test_data()
86+
pipeline = TosaPipelineFP[input_t1](Flip(dims), (data,), aten_op, exir_op)
87+
pipeline.run()
88+
89+
90+
@common.parametrize("test_data", test_data_suite)
91+
def test_flip_tosa_FP_reverse_count(test_data):
92+
"""Each flip dim must lower to exactly one TOSA REVERSE."""
93+
data, dims = test_data()
94+
pipeline = TosaPipelineFP[input_t1](Flip(dims), (data,), aten_op, exir_op)
95+
pipeline.count_tosa_ops({"REVERSE": len(dims)})
96+
pipeline.run()
97+
98+
99+
@common.parametrize("test_data", test_data_suite_bf16)
100+
def test_flip_tosa_FP_bf16(test_data):
101+
data, dims = test_data()
102+
pipeline = TosaPipelineFP[input_t1](
103+
Flip(dims), (data,), aten_op, exir_op, tosa_extensions=["bf16"]
104+
)
105+
pipeline.run()
106+
107+
108+
@common.parametrize("test_data", test_data_suite_fp8)
109+
def test_flip_tosa_FP_fp8(test_data):
110+
data, dims, tosa_extension = test_data()
111+
pipeline = TosaPipelineFP[input_t1](
112+
Flip(dims),
113+
(data,),
114+
aten_op,
115+
exir_op,
116+
tosa_extensions=[tosa_extension],
117+
)
118+
pipeline.count_tosa_ops({"REVERSE": len(dims)})
119+
pipeline.run()
120+
121+
122+
def test_flip_bool_tosa_FP():
123+
"""Flip on a bool mask (REVERSE supports bool)."""
124+
pipeline = TosaPipelineFP[input_t1](
125+
FlipBool(), (torch.randn(2, 4),), aten_op, exir_op
126+
)
127+
pipeline.run()
128+
129+
130+
@common.parametrize("test_data", test_data_suite)
131+
def test_flip_tosa_INT(test_data):
132+
data, dims = test_data()
133+
pipeline = TosaPipelineINT[input_t1](Flip(dims), (data,), aten_op, exir_op)
134+
pipeline.run()
135+
136+
137+
@common.parametrize("test_data", test_data_suite)
138+
def test_flip_16a8w_tosa_INT(test_data):
139+
"""16A8W quantization (16-bit activations, 8-bit weights)."""
140+
data, dims = test_data()
141+
pipeline = TosaPipelineINT[input_t1](
142+
Flip(dims),
143+
(data,),
144+
aten_op,
145+
exir_op=[],
146+
per_channel_quantization=False,
147+
use_to_edge_transform_and_lower=True,
148+
tosa_extensions=["int16"],
149+
)
150+
pipeline.quantizer.set_global(
151+
get_symmetric_a16w8_quantization_config(is_per_channel=False)
152+
)
153+
pipeline.run()
154+
155+
156+
@common.parametrize("test_data", test_data_suite_u55_reject)
157+
def test_flip_u55_INT_not_delegated(test_data):
158+
data, dims = test_data()
159+
pipeline = OpNotSupportedPipeline[input_t1](
160+
Flip(dims),
161+
(data,),
162+
non_delegated_ops={exir_op: 1},
163+
quantize=True,
164+
u55_subset=True,
165+
)
166+
pipeline.run()
167+
168+
169+
@common.parametrize("test_data", test_data_suite)
170+
@common.XfailIfNoCorstone320
171+
def test_flip_u85_INT(test_data):
172+
data, dims = test_data()
173+
pipeline = EthosU85PipelineINT[input_t1](
174+
Flip(dims),
175+
(data,),
176+
aten_ops=[],
177+
exir_ops=[],
178+
)
179+
pipeline.run()
180+
181+
182+
@common.parametrize("test_data", test_data_suite)
183+
@common.SkipIfNoModelConverter
184+
def test_flip_vgf_no_quant(test_data):
185+
data, dims = test_data()
186+
pipeline = VgfPipeline[input_t1](
187+
Flip(dims),
188+
(data,),
189+
aten_op,
190+
exir_op,
191+
quantize=False,
192+
)
193+
pipeline.run()
194+
195+
196+
@common.parametrize("test_data", test_data_suite)
197+
@common.SkipIfNoModelConverter
198+
def test_flip_vgf_quant(test_data):
199+
data, dims = test_data()
200+
pipeline = VgfPipeline[input_t1](
201+
Flip(dims),
202+
(data,),
203+
aten_op,
204+
exir_op,
205+
quantize=True,
206+
)
207+
pipeline.run()

0 commit comments

Comments
 (0)