Skip to content

Commit 69014b6

Browse files
authored
Arm backend: Legalize unsigned RESCALE from wide inputs (pytorch#21393)
TOSA RESCALE only allows output_unsigned when the input is int8 or int16. Some preserved uint8 IO paths can produce a final wide accumulator RESCALE with output_unsigned set, which newer model-converter builds reject. Canonicalize that case during TOSA serialization by emitting a signed RESCALE to the target integer dtype first, followed by a legal signed-input to unsigned-output boundary RESCALE. cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell @rascani Signed-off-by: Baris Demir <baris.demir@arm.com>
1 parent fb5b788 commit 69014b6

2 files changed

Lines changed: 166 additions & 0 deletions

File tree

backends/arm/operators/op_tosa_rescale.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,15 @@ def _create_const_ops_for_rescale(
150150
return [multipliers.name, shifts.name, input_zp.name, output_zp.name]
151151

152152

153+
def _unsigned_dtype_offset(dtype: ts.DType) -> int:
154+
if dtype == ts.DType.INT8:
155+
return 128
156+
raise ValueError(
157+
"Wide-to-unsigned RESCALE legalization only supports "
158+
f"INT8 output, got {dtype}"
159+
)
160+
161+
153162
@register_node_visitor
154163
class RescaleVisitor(NodeVisitor):
155164
target = "tosa.RESCALE.default"
@@ -176,6 +185,36 @@ def _build_rescale(
176185
multipliers, otherwise 32-bit multipliers are used.
177186
178187
"""
188+
if output_unsigned and input_node.dtype not in (ts.DType.INT8, ts.DType.INT16):
189+
unsigned_offset = _unsigned_dtype_offset(output.dtype)
190+
signed_output = tosa_graph.addIntermediate(output.shape, output.dtype)
191+
self._build_rescale(
192+
node=node,
193+
tosa_graph=tosa_graph,
194+
scale=scale,
195+
input_node=input_node,
196+
output=signed_output,
197+
input_zp=input_zp,
198+
output_zp=[output_zp[0] - unsigned_offset],
199+
rounding_mode=rounding_mode,
200+
per_channel=per_channel,
201+
input_unsigned=input_unsigned,
202+
output_unsigned=False,
203+
)
204+
self._build_rescale(
205+
node=node,
206+
tosa_graph=tosa_graph,
207+
scale=[1.0],
208+
input_node=signed_output,
209+
output=output,
210+
input_zp=[-unsigned_offset],
211+
output_zp=[0],
212+
rounding_mode=rounding_mode,
213+
input_unsigned=False,
214+
output_unsigned=True,
215+
)
216+
return
217+
179218
scale_width = 16 if input_node.dtype == ts.DType.INT48 else 32
180219
is_scale32 = input_node.dtype != ts.DType.INT48
181220

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
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 types import SimpleNamespace
7+
from typing import Any, cast
8+
9+
import pytest
10+
import tosa_serializer as ts
11+
from executorch.backends.arm.operators.op_tosa_rescale import RescaleVisitor
12+
from executorch.backends.arm.tosa.mapping import TosaArg
13+
from executorch.backends.arm.tosa.specification import TosaSpecification
14+
from torch.fx import Node
15+
16+
17+
class CapturingTosaGraph:
18+
def __init__(self) -> None:
19+
self.consts: dict[str, tuple[Any, list[int]]] = {}
20+
self.intermediate_count = 0
21+
self.operators: list[tuple[Any, tuple[str, ...], tuple[str, ...]]] = []
22+
23+
def addConst(self, _shape, dtype, values, name):
24+
self.consts[name] = (dtype, list(values))
25+
return SimpleNamespace(name=name)
26+
27+
def addIntermediate(self, shape, dtype):
28+
self.intermediate_count += 1
29+
return SimpleNamespace(
30+
name=f"intermediate_{self.intermediate_count}",
31+
shape=shape,
32+
dtype=dtype,
33+
)
34+
35+
def addOperator(self, op, inputs, outputs, attributes=None, location=None):
36+
self.operators.append((op, tuple(inputs), tuple(outputs)))
37+
38+
39+
def _tensor_arg(name: str, dtype: ts.DType) -> TosaArg:
40+
return cast(TosaArg, SimpleNamespace(name=name, dtype=dtype, shape=(7,)))
41+
42+
43+
def _rescale_unsigned_output(output_zp: int) -> CapturingTosaGraph:
44+
visitor = RescaleVisitor(TosaSpecification.create_from_string("TOSA-1.0+INT"))
45+
graph = CapturingTosaGraph()
46+
visitor._build_rescale(
47+
node=cast(Node, SimpleNamespace()),
48+
tosa_graph=graph,
49+
scale=[1.0],
50+
input_node=_tensor_arg("wide_input", ts.DType.INT32),
51+
output=_tensor_arg("unsigned_output", ts.DType.INT8),
52+
input_zp=[0],
53+
output_zp=[output_zp],
54+
rounding_mode=ts.RoundingMode.SINGLE_ROUND,
55+
input_unsigned=False,
56+
output_unsigned=True,
57+
)
58+
return graph
59+
60+
61+
def test_wide_to_unsigned_int16_rescale_is_rejected() -> None:
62+
visitor = RescaleVisitor(TosaSpecification.create_from_string("TOSA-1.0+INT"))
63+
with pytest.raises(
64+
ValueError,
65+
match="Wide-to-unsigned RESCALE legalization only supports INT8 output",
66+
):
67+
visitor._build_rescale(
68+
node=cast(Node, SimpleNamespace()),
69+
tosa_graph=CapturingTosaGraph(),
70+
scale=[1.0],
71+
input_node=_tensor_arg("wide_input", ts.DType.INT32),
72+
output=_tensor_arg("unsigned_output", ts.DType.INT16),
73+
input_zp=[0],
74+
output_zp=[0],
75+
rounding_mode=ts.RoundingMode.SINGLE_ROUND,
76+
input_unsigned=False,
77+
output_unsigned=True,
78+
)
79+
80+
81+
def _clip(value: int, low: int, high: int) -> int:
82+
return min(max(value, low), high)
83+
84+
85+
def _direct_unsigned_rescale(value: int, output_zp: int) -> int:
86+
return _clip(value + output_zp, 0, 255)
87+
88+
89+
def _split_unsigned_rescale(value: int, output_zp: int) -> int:
90+
signed = _clip(value + output_zp - 128, -128, 127)
91+
return _clip(signed - (-128), 0, 255)
92+
93+
94+
def test_wide_to_unsigned_rescale_rebases_to_signed_domain() -> None:
95+
graph = _rescale_unsigned_output(output_zp=0)
96+
97+
assert len(graph.operators) == 2
98+
first_op, first_inputs, first_outputs = graph.operators[0]
99+
second_op, second_inputs, second_outputs = graph.operators[1]
100+
101+
assert first_op == ts.Op.RESCALE
102+
assert second_op == ts.Op.RESCALE
103+
assert first_inputs[0] == "wide_input"
104+
assert first_outputs == ("intermediate_1",)
105+
assert second_inputs[0] == "intermediate_1"
106+
assert second_outputs == ("unsigned_output",)
107+
108+
assert graph.consts["intermediate_1_output_zp"] == (ts.DType.INT8, [-128])
109+
assert graph.consts["unsigned_output_input_zp"] == (ts.DType.INT8, [-128])
110+
assert graph.consts["unsigned_output_output_zp"] == (ts.DType.INT8, [0])
111+
112+
113+
@pytest.mark.parametrize("output_zp", [0, 17, 255])
114+
def test_wide_to_unsigned_rescale_preserves_unsigned_range(output_zp: int) -> None:
115+
graph = _rescale_unsigned_output(output_zp=output_zp)
116+
117+
assert graph.consts["intermediate_1_output_zp"] == (
118+
ts.DType.INT8,
119+
[output_zp - 128],
120+
)
121+
assert graph.consts["unsigned_output_input_zp"] == (ts.DType.INT8, [-128])
122+
assert graph.consts["unsigned_output_output_zp"] == (ts.DType.INT8, [0])
123+
124+
for value in (-1, 0, 1, 126, 127, 128, 200, 254, 255, 256):
125+
assert _split_unsigned_rescale(value, output_zp) == _direct_unsigned_rescale(
126+
value, output_zp
127+
)

0 commit comments

Comments
 (0)