Skip to content

Commit f11805c

Browse files
authored
Arm backend: Support division with integer tensors (#20536)
Arm backend: Support integer division. torch.div() with two integer tensors and a rounding mode gives integer output. The decomposition for this case instead yielded floating point output, causing issues in indexing operations which is typically where such integer division happens. When supported, utilize the TOSA operator INTDIV. It directly corresponds to the trunc case, and can be adjusted in the floor case. When not supported, use float path by first casting int tensors to float, and then casting the output back. Additionally - Improve scalar handling. - Add cast op to u55 testrunner to match u85 better. Arm backend: Support quantizing no-op fp casts. If a int to fp cast goes directly into a quantized operator, the cast is a noop if it is quantized with unit quantization parameters (scale=1, zp=0). This means it can be handled, even if the backend doesn't support fp. - Add Fixed unit qparam annotation to such a cast. - Allow it in the partitioner. - Finally, make sure the cast dtype is correct after folding quant nodes. This pattern can appear in the wild in a model, or in a decomposition where you want to quantize an integer tensor. cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell @rascani --------- Signed-off-by: Erik Lundell <erik.lundell@arm.com>
1 parent 821b5a9 commit f11805c

12 files changed

Lines changed: 468 additions & 67 deletions

backends/arm/_passes/decompose_div_tensor_mode.py

Lines changed: 209 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,32 +4,49 @@
44
# LICENSE file in the root directory of this source tree.
55

66

7-
from typing import Set, Type
7+
from typing import cast, Literal, Set, Type
88

99
import torch
1010
from executorch.backends.arm._passes.arm_pass import ArmOpTargetedPass
1111
from executorch.backends.arm._passes.decompose_div_pass import DecomposeDivPass
12+
from executorch.backends.arm.tosa.specification import get_context_spec
1213
from executorch.exir.dialects._ops import ops as exir_ops
1314
from executorch.exir.pass_base import ExportPass
1415

1516
edge_div_mode_ops = (exir_ops.edge.aten.div.Tensor_mode,)
1617
aten_div_mode_ops = (torch.ops.aten.div.Tensor_mode,)
18+
RoundingMode = Literal["trunc", "floor"]
1719

1820
edge_unary = {
1921
"div": exir_ops.edge.aten.div.Tensor,
2022
"floor": exir_ops.edge.aten.floor.default,
2123
"ceil": exir_ops.edge.aten.ceil.default,
24+
"eq": exir_ops.edge.aten.eq.Tensor,
2225
"full": exir_ops.edge.aten.full.default,
2326
"gt": exir_ops.edge.aten.gt.Tensor,
27+
"logical_and": exir_ops.edge.aten.logical_and.default,
28+
"logical_not": exir_ops.edge.aten.logical_not.default,
29+
"logical_xor": exir_ops.edge.aten.logical_xor.default,
30+
"intdiv": exir_ops.backend.tosa.INTDIV.default,
31+
"mul": exir_ops.edge.aten.mul.Tensor,
32+
"sub": exir_ops.edge.aten.sub.Tensor,
33+
"to": exir_ops.edge.dim_order_ops._to_dim_order_copy.default,
2434
"where": exir_ops.edge.aten.where.self,
2535
}
2636

2737
aten_unary = {
2838
"div": torch.ops.aten.div.Tensor,
2939
"floor": torch.ops.aten.floor.default,
3040
"ceil": torch.ops.aten.ceil.default,
41+
"eq": torch.ops.aten.eq.Tensor,
3142
"full": torch.ops.aten.full.default,
3243
"gt": torch.ops.aten.gt.Tensor,
44+
"logical_and": torch.ops.aten.logical_and.default,
45+
"logical_not": torch.ops.aten.logical_not.default,
46+
"logical_xor": torch.ops.aten.logical_xor.default,
47+
"mul": torch.ops.aten.mul.Tensor,
48+
"sub": torch.ops.aten.sub.Tensor,
49+
"to": torch.ops.aten.to.dtype,
3350
"where": torch.ops.aten.where.self,
3451
}
3552

@@ -43,9 +60,9 @@ def _get_opset(op):
4360

4461

4562
class DecomposeDivTensorModePass(ArmOpTargetedPass):
46-
"""Rewrites aten.div.Tensor_mode into.
63+
"""Rewrites aten.div.Tensor_mode into supported arithmetic ops.
4764
48-
Example:
65+
Floating-point flow:
4966
rounding_mode=None -> div(a, b)
5067
rounding_mode="floor" -> floor(div(a, b))
5168
rounding_mode="trunc" -> where(
@@ -54,48 +71,213 @@ class DecomposeDivTensorModePass(ArmOpTargetedPass):
5471
floor(div(a, b)),
5572
)
5673
74+
Integer flow:
75+
During transform-for-annotation, keep div.Tensor_mode intact, don't quantize it.
76+
During backend lowering, rewrite the div to a TOSA INTDIV (corresponding to trunc rounding_mode)
77+
+ correcting factor for floor mode.
78+
5779
"""
5880

5981
_passes_required_after: Set[Type[ExportPass]] = {DecomposeDivPass}
6082
target_ops = edge_div_mode_ops + aten_div_mode_ops
6183
check_allowed_to_transform = True
6284

85+
def _is_integer_tensor(self, arg) -> bool:
86+
data = getattr(arg, "data", None)
87+
if data is not None:
88+
return arg.data.dtype in {
89+
torch.uint8,
90+
torch.int8,
91+
torch.int16,
92+
torch.int32,
93+
torch.int64,
94+
}
95+
return isinstance(arg, int)
96+
97+
def _cast(self, opset, arg, dtype: torch.dtype, meta):
98+
if isinstance(arg, int):
99+
if dtype.is_floating_point:
100+
return float(arg)
101+
else:
102+
return arg
103+
if isinstance(arg, float):
104+
if dtype.is_floating_point:
105+
return arg
106+
else:
107+
return int(arg)
108+
data = getattr(arg, "data", None)
109+
if data is not None and data.dtype == dtype:
110+
return arg
111+
return super().call_operator(
112+
opset["to"],
113+
(arg,),
114+
{"dtype": dtype},
115+
meta,
116+
updated=True,
117+
)
118+
119+
def _full(self, opset, value, dtype: torch.dtype, meta):
120+
return super().call_operator(
121+
opset["full"],
122+
args=((1,) * len(meta["val"].size()), value),
123+
kwargs={"dtype": dtype, "device": meta["val"].device},
124+
meta=meta,
125+
updated=True,
126+
)
127+
128+
def _correct_intdiv_floor(
129+
self, opset, numerator, denominator, trunced_quotient, meta
130+
):
131+
"""Apply a correcting factor for converting the truncated division to
132+
floored division.
133+
134+
Done by subtracting one from the result when, elementwise,
135+
- The remainder is nonzero (otherwise the division is even and the rounding trivial)
136+
- The numerator and denominator have different signs (causing a negative quotient)
137+
The sign of the quotient can't be checked directly, there are cases when it is 0 and still needs correction.
138+
139+
"""
140+
# Condition 1: non-zero remainder
141+
product = super().call_operator(
142+
opset["mul"], (trunced_quotient, denominator), {}, meta, updated=True
143+
)
144+
remainder = super().call_operator(
145+
opset["sub"], (numerator, product), {}, meta, updated=True
146+
)
147+
zero = self._full(opset, 0, torch.int32, meta)
148+
remainder_is_zero = super().call_operator(
149+
opset["eq"], (remainder, zero), {}, meta, updated=True
150+
)
151+
remainder_is_nonzero = super().call_operator(
152+
opset["logical_not"], (remainder_is_zero,), {}, meta, updated=True
153+
)
154+
# Condition 2: un-rounded quotient is negative
155+
a_is_negative = super().call_operator(
156+
opset["gt"], (zero, numerator), {}, meta, updated=True
157+
)
158+
b_is_negative = super().call_operator(
159+
opset["gt"], (zero, denominator), {}, meta, updated=True
160+
)
161+
signs_differ = super().call_operator(
162+
opset["logical_xor"],
163+
(a_is_negative, b_is_negative),
164+
{},
165+
meta,
166+
updated=True,
167+
)
168+
# Use conditions to correct quotient.
169+
needs_correction = super().call_operator(
170+
opset["logical_and"],
171+
(remainder_is_nonzero, signs_differ),
172+
{},
173+
meta,
174+
updated=True,
175+
)
176+
# (TOSA spec enforces that int(bool_var) == 1 ? bool_var : 0)
177+
correction = self._cast(opset, needs_correction, torch.int32, meta)
178+
return super().call_operator(
179+
opset["sub"], (trunced_quotient, correction), {}, meta, updated=True
180+
)
181+
182+
def _call_integer_div(self, opset, a, b, rounding_mode: RoundingMode, meta):
183+
"""Cast inputs to int32, do TOSA INTDIV, and apply correcting factor for
184+
floor rounding mode.
185+
"""
186+
187+
a_int32 = self._cast(opset, a, torch.int32, meta)
188+
b_int32 = self._cast(opset, b, torch.int32, meta)
189+
intdiv = super().call_operator(
190+
opset["intdiv"],
191+
(a_int32, b_int32),
192+
{},
193+
meta,
194+
updated=True,
195+
)
196+
if rounding_mode == "floor":
197+
intdiv = self._correct_intdiv_floor(opset, a_int32, b_int32, intdiv, meta)
198+
199+
output_dtype = meta["val"].dtype
200+
return self._cast(opset, intdiv, output_dtype, meta)
201+
202+
def _call_fp_div(self, opset, a, b, rounding_mode: RoundingMode | None, meta):
203+
q = super().call_operator(opset["div"], (a, b), {}, meta, updated=True)
204+
205+
match rounding_mode:
206+
case None:
207+
return q
208+
case "floor":
209+
return super().call_operator(
210+
opset["floor"], (q,), {}, meta, updated=True
211+
)
212+
case "trunc":
213+
zero = self._full(opset, 0.0, torch.float32, meta)
214+
is_neg = super().call_operator(
215+
opset["gt"], (zero, q), {}, meta, updated=True
216+
)
217+
ceilq = super().call_operator(
218+
opset["ceil"], (q,), {}, meta, updated=True
219+
)
220+
floorq = super().call_operator(
221+
opset["floor"], (q,), {}, meta, updated=True
222+
)
223+
return super().call_operator(
224+
opset["where"], (is_neg, ceilq, floorq), {}, meta, updated=True
225+
)
226+
63227
def call_operator(self, op, args, kwargs, meta):
64228
if op not in self.target_ops or not self.allowed_to_transform(meta):
65229
return super().call_operator(op, args, kwargs, meta)
66230

67231
opset = _get_opset(op)
68232

69233
a, b = args[0], args[1]
234+
a_is_int = self._is_integer_tensor(a)
235+
b_is_int = self._is_integer_tensor(b)
70236
rounding_mode = kwargs.get("rounding_mode", None)
71237
if rounding_mode is None and len(args) > 2:
72238
rounding_mode = args[2]
239+
if rounding_mode not in ("floor", "trunc", None):
240+
raise RuntimeError(
241+
"Integer div.Tensor_mode requires rounding_mode floor, trunc, or None."
242+
f"got {rounding_mode!r}"
243+
)
244+
rounding_mode = cast(RoundingMode | None, rounding_mode)
73245

74-
q = super().call_operator(opset["div"], (a, b), {}, meta, updated=True)
246+
int_operation = rounding_mode is not None and a_is_int and b_is_int
247+
sufficient_int_support = (
248+
rounding_mode == "trunc" or get_context_spec().support_integer()
249+
)
250+
sufficient_int_support &= not get_context_spec().is_U55_subset
75251

76-
if rounding_mode is None:
77-
return q
252+
if int_operation and sufficient_int_support:
253+
"""Integer operation and necessary int ops supported -> pure integer
254+
path.
255+
"""
256+
if self.is_tfa_pass:
257+
# No quantization neccessary, so don't do anything in TFA.
258+
return super().call_operator(op, args, kwargs, meta)
259+
return self._call_integer_div(opset, a, b, rounding_mode, meta)
260+
else:
261+
"""Otherwise floating point operation -> do fp path.
78262
79-
if rounding_mode == "floor":
80-
return super().call_operator(opset["floor"], (q,), {}, meta, updated=True)
81-
82-
if rounding_mode == "trunc":
83-
zero = super().call_operator(
84-
opset["full"],
85-
args=((1,) * len(meta["val"].size()), 0.0),
86-
kwargs={"dtype": torch.float32, "device": meta["val"].device},
87-
meta=meta,
88-
updated=True,
89-
)
90-
is_neg = super().call_operator(
91-
opset["gt"], (zero, q), {}, meta, updated=True
92-
)
93-
ceilq = super().call_operator(opset["ceil"], (q,), {}, meta, updated=True)
94-
floorq = super().call_operator(opset["floor"], (q,), {}, meta, updated=True)
95-
return super().call_operator(
96-
opset["where"], (is_neg, ceilq, floorq), {}, meta, updated=True
263+
Cast to and from fp if neccessary.
264+
265+
"""
266+
if a_is_int:
267+
a = self._cast(opset, a, torch.float32, meta)
268+
if b_is_int:
269+
b = self._cast(opset, b, torch.float32, meta)
270+
271+
result = self._call_fp_div(
272+
opset,
273+
a,
274+
b,
275+
rounding_mode,
276+
meta,
97277
)
98278

99-
raise RuntimeError(
100-
f"Unsupported rounding_mode for div.Tensor_mode: {rounding_mode!r}"
101-
)
279+
output_dtype = meta["val"].dtype
280+
if output_dtype != torch.float32:
281+
result = self._cast(opset, result, output_dtype, meta)
282+
283+
return result

backends/arm/_passes/fold_qdq_with_annotated_qparams_pass.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,25 @@ def is_foldable(node: Node) -> bool:
305305
return False
306306
return True
307307

308+
@staticmethod
309+
def _correct_output_dtype(node: torch.fx.Node):
310+
if node.target not in {
311+
exir_ops.edge.aten.sum.dim_IntList,
312+
exir_ops.edge.dim_order_ops._to_dim_order_copy.default,
313+
}:
314+
return
315+
if len(node.meta["output_qparams"]) == 0:
316+
return
317+
output_qparams = cast(QuantArgs, node.meta["output_qparams"][0])
318+
319+
if node.target == exir_ops.edge.dim_order_ops._to_dim_order_copy.default:
320+
if output_qparams.scale != 1.0 or output_qparams.zp != 0.0:
321+
raise ValueError(
322+
f"Expected quantized {node.target} '{node.name}' to have unit scale and zero point."
323+
)
324+
325+
set_node_arg(node, "dtype", output_qparams.dtype)
326+
308327
def call(self, graph_module: GraphModule) -> PassResult: # noqa: C901
309328

310329
# Loop over the graph nodes and find any node in the 'targeted_ops' list.
@@ -355,13 +374,7 @@ def call(self, graph_module: GraphModule) -> PassResult: # noqa: C901
355374

356375
# Some op(s) contain a "dtype" key in their node kwargs. Set this
357376
# to the type of output qparams.
358-
output_qparams = n.meta["output_qparams"]
359-
if (
360-
n.target in {exir_ops.edge.aten.sum.dim_IntList}
361-
and len(output_qparams) > 0
362-
):
363-
output_dtype = output_qparams[0].dtype
364-
set_node_arg(n, "dtype", output_dtype)
377+
FoldAndAnnotateQParamsPass._correct_output_dtype(n)
365378

366379
if n.target in (
367380
torch.ops.higher_order.cond,

backends/arm/operator_support/to_dim_order_copy_support.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,20 @@ def _merge_supported_types(
139139
torch.float8_e5m2: [torch.bfloat16],
140140
}
141141

142+
@staticmethod
143+
def _is_quantized_identity_cast(node: torch.fx.Node) -> bool:
144+
for user in node.users:
145+
if (
146+
not user.target
147+
== exir_ops.edge.quantized_decomposed.quantize_per_tensor.default
148+
):
149+
return False
150+
scale = user.args[1]
151+
zp = user.args[2]
152+
if scale != 1.0 or zp != 0.0:
153+
return False
154+
return True
155+
142156
def is_node_tosa_supported( # noqa: C901
143157
self, node: fx.Node, tosa_spec: TosaSpecification
144158
) -> bool:
@@ -228,6 +242,11 @@ def is_node_tosa_supported( # noqa: C901
228242
)
229243
return False
230244
if output_val.dtype not in supported_dtypes[input_dtype]:
245+
if (
246+
tosa_spec.support_integer()
247+
and ToCopySupported._is_quantized_identity_cast(node)
248+
):
249+
return True
231250
self.reporter.report_reject(
232251
node,
233252
(

backends/arm/operator_support/tosa_profile_supported_op_lists.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@
126126
exir_ops.edge.aten.celu.default,
127127
exir_ops.edge.aten.bitwise_not.default,
128128
exir_ops.edge.aten.copy.default,
129+
exir_ops.edge.aten.div.Tensor_mode,
129130
exir_ops.edge.aten.tan.default,
130131
exir_ops.edge.aten.silu.default,
131132
exir_ops.edge.aten.detach_copy.default,

0 commit comments

Comments
 (0)