Skip to content

Commit 368a849

Browse files
authored
Fold DyT alpha scalar into tanh LUT (pytorch#21952) (pytorch#21952)
Summary: Dynamic Tanh (DyT) normalization computes tanh(alpha * x) with a learned scalar alpha. Quantized, that lowers to a full-tensor integer Mul followed by a tanh TABLE. This adds FoldDyTAlphaIntoLUTPass, which replays the exact TOSA SINGLE_ROUND integer arithmetic over all 256 int8 codes and folds the alpha Mul into the TABLE without approximation. The matcher fails closed for unsupported shapes, ranges, layouts, or quantization metadata. This revision contains only the public pass implementation, tests, and package exports; pass registration is intentionally outside this public change. Reviewed By: rascani Differential Revision: D116560649
1 parent 0699c5d commit 368a849

3 files changed

Lines changed: 790 additions & 0 deletions

File tree

‎backends/arm/_passes/__init__.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
13
# Copyright 2025-2026 Arm Limited and/or its affiliates.
24
#
35
# This source code is licensed under the BSD-style license found in the
@@ -114,6 +116,7 @@
114116
from .deduplicate_get_attr_pass import DeduplicateGetAttrPass # noqa
115117
from .ensure_unique_output_nodes_pass import EnsureUniqueOutputNodesPass # noqa
116118
from .exir_to_tosa_pass import ExirToTosaPass # noqa
119+
from .fold_dyt_alpha_into_lut_pass import FoldDyTAlphaIntoLUTPass # noqa
117120
from .fold_qdq_with_annotated_qparams_pass import ( # noqa
118121
FoldAndAnnotateQParamsPass,
119122
QuantizeClampArgumentsPass,
Lines changed: 379 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,379 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
# pyre-strict
7+
"""Fold quantized tanh(alpha * x) into one exact INT8 TOSA table."""
8+
9+
from __future__ import annotations
10+
11+
import math
12+
from dataclasses import dataclass
13+
from typing import cast, Optional, Set, Type
14+
15+
import torch
16+
from executorch.backends.arm._passes.arm_pass import ArmPass
17+
from executorch.backends.arm._passes.arm_pass_utils import create_node, get_param_tensor
18+
from executorch.backends.arm._passes.insert_table_ops import InsertTableOpsPass
19+
from executorch.backends.arm._passes.quant_args import QuantArgs
20+
from executorch.backends.arm.operators.op_tosa_rescale import (
21+
_compute_multiplier_and_shift,
22+
)
23+
from executorch.backends.transforms.utils import create_constant_placeholder
24+
from executorch.exir import ExportedProgram
25+
from executorch.exir.dialects._ops import ops as exir_ops
26+
from executorch.exir.pass_base import ExportPass, PassResult
27+
from torch.export.graph_signature import InputKind
28+
from torch.fx import GraphModule, Node
29+
30+
31+
@dataclass(frozen=True)
32+
class _RescaleParams:
33+
scale: float
34+
input_zp: int
35+
output_zp: int
36+
output_dtype: torch.dtype
37+
38+
@classmethod
39+
def from_node(cls, node: Node) -> Optional["_RescaleParams"]:
40+
if node.target != exir_ops.backend.tosa.RESCALE.default:
41+
return None
42+
scales = cast(list[float], node.args[2])
43+
if len(scales) != 1:
44+
return None
45+
if node.kwargs.get("input_unsigned", False) or node.kwargs.get(
46+
"output_unsigned", False
47+
):
48+
return None
49+
return cls(
50+
scale=float(scales[0]),
51+
input_zp=cast(int, node.args[3]),
52+
output_zp=cast(int, node.args[4]),
53+
output_dtype=cast(torch.dtype, node.args[1]),
54+
)
55+
56+
57+
def _apply_tosa_rescale(
58+
values: torch.Tensor,
59+
params: _RescaleParams,
60+
) -> torch.Tensor:
61+
"""Apply TOSA-1.0 RESCALE SINGLE_ROUND exactly."""
62+
multipliers, shifts = _compute_multiplier_and_shift([params.scale])
63+
multiplier = multipliers[0]
64+
shift = shifts[0]
65+
# _compute_multiplier_and_shift asserts shift is in [2, 62], so shift - 1 is
66+
# never negative here. Do not "guard" this by zeroing rounding on small
67+
# shifts: that would silently change SINGLE_ROUND's round-half behaviour and
68+
# break the bit-exactness this whole pass depends on.
69+
rounding = 1 << (shift - 1)
70+
centered = values.to(torch.int64) - params.input_zp
71+
scaled = (centered * multiplier + rounding) >> shift
72+
shifted = scaled + params.output_zp
73+
dtype_range = torch.iinfo(params.output_dtype)
74+
return shifted.clamp(dtype_range.min, dtype_range.max).to(params.output_dtype)
75+
76+
77+
def _generate_dyt_lut(
78+
*,
79+
activation_qargs: QuantArgs,
80+
alpha_code: torch.Tensor,
81+
activation_rescale: _RescaleParams,
82+
alpha_rescale: _RescaleParams,
83+
mul_output_rescale: _RescaleParams,
84+
tanh_input_qargs: QuantArgs,
85+
tanh_output_qargs: QuantArgs,
86+
) -> torch.Tensor:
87+
"""Compose the integer Mul/RESCALE path with the existing tanh mapping."""
88+
if alpha_code.numel() != 1:
89+
raise ValueError(f"Expected scalar DyT alpha, got shape {alpha_code.shape}")
90+
91+
domain = InsertTableOpsPass._get_8bit_table_domain().clamp(
92+
activation_qargs.qmin, activation_qargs.qmax
93+
)
94+
activation_i32 = _apply_tosa_rescale(domain, activation_rescale)
95+
alpha_i32 = _apply_tosa_rescale(alpha_code, alpha_rescale)
96+
product = (activation_i32.to(torch.int64) * alpha_i32.to(torch.int64)).to(
97+
torch.int32
98+
)
99+
mul_codes = _apply_tosa_rescale(product, mul_output_rescale)
100+
mul_codes = mul_codes.clamp(tanh_input_qargs.qmin, tanh_input_qargs.qmax)
101+
tanh_values = torch.tanh(tanh_input_qargs.dequantize_value(mul_codes))
102+
return tanh_output_qargs.quantize_value(tanh_values).to(torch.int8).reshape(-1)
103+
104+
105+
@dataclass(frozen=True)
106+
class _DyTMatch:
107+
tanh: Node
108+
activation: Node
109+
activation_qargs: QuantArgs
110+
activation_rescale: _RescaleParams
111+
alpha_code: torch.Tensor
112+
alpha_rescale: _RescaleParams
113+
mul_output_rescale: _RescaleParams
114+
tanh_input_qargs: QuantArgs
115+
tanh_output_qargs: QuantArgs
116+
117+
118+
@dataclass(frozen=True)
119+
class _MulOperand:
120+
index: int
121+
source: Node
122+
rescale: _RescaleParams
123+
scalar_constant: Optional[torch.Tensor]
124+
had_view: bool
125+
126+
127+
class FoldDyTAlphaIntoLUTPass(ArmPass):
128+
"""Replace an INT8 DyT alpha Mul, requantize, and tanh with one TABLE."""
129+
130+
_passes_required_after: Set[Type[ExportPass]] = {InsertTableOpsPass}
131+
132+
def __init__(self, exported_program: ExportedProgram) -> None:
133+
super().__init__()
134+
self.exported_program = exported_program
135+
136+
@staticmethod
137+
def _unwrap_view(node: Node) -> Node:
138+
while (
139+
node.target == exir_ops.edge.aten.view_copy.default
140+
and len(node.args) > 0
141+
and isinstance(node.args[0], Node)
142+
):
143+
node = node.args[0]
144+
return node
145+
146+
def _get_scalar_constant(self, rescale_node: Node) -> Optional[torch.Tensor]:
147+
source = rescale_node.args[0]
148+
if not isinstance(source, Node):
149+
return None
150+
try:
151+
value = get_param_tensor(self.exported_program, source)
152+
except RuntimeError:
153+
return None
154+
if value is None or value.numel() != 1:
155+
return None
156+
return value
157+
158+
@staticmethod
159+
def _single_qargs(node: Node, key: str) -> Optional[QuantArgs]:
160+
qparams = cast(dict[int, QuantArgs], node.meta.get(key, {}))
161+
if len(qparams) != 1:
162+
return None
163+
qargs = next(iter(qparams.values()))
164+
if qargs.per_channel:
165+
return None
166+
return qargs
167+
168+
@staticmethod
169+
def _source_qargs(
170+
source: Node,
171+
rescale: _RescaleParams,
172+
int32_qargs: QuantArgs,
173+
) -> Optional[QuantArgs]:
174+
candidates = cast(
175+
dict[int, QuantArgs],
176+
source.meta.get("output_qparams", {}),
177+
)
178+
for qargs in candidates.values():
179+
if qargs.per_channel or qargs.dtype != torch.int8:
180+
continue
181+
if qargs.get_zp_per_tensor() != rescale.input_zp:
182+
continue
183+
expected_scale = (
184+
qargs.get_scale_per_tensor() / int32_qargs.get_scale_per_tensor()
185+
)
186+
if math.isclose(expected_scale, rescale.scale, rel_tol=1e-6, abs_tol=0.0):
187+
return qargs
188+
return None
189+
190+
def _match_mul_operands(
191+
self, mul: Node
192+
) -> Optional[tuple[_MulOperand, _MulOperand]]:
193+
if len(mul.args) < 2:
194+
return None
195+
196+
operands = []
197+
for index, arg in enumerate(mul.args[:2]):
198+
if not isinstance(arg, Node):
199+
return None
200+
rescale_node = self._unwrap_view(arg)
201+
rescale = _RescaleParams.from_node(rescale_node)
202+
if rescale is None or rescale.output_dtype != torch.int32:
203+
return None
204+
source = rescale_node.args[0]
205+
if not isinstance(source, Node):
206+
return None
207+
operands.append(
208+
_MulOperand(
209+
index=index,
210+
source=source,
211+
rescale=rescale,
212+
scalar_constant=self._get_scalar_constant(rescale_node),
213+
had_view=rescale_node is not arg,
214+
)
215+
)
216+
return operands[0], operands[1]
217+
218+
@staticmethod
219+
def _match_output_rescale_and_mul(
220+
tanh: Node,
221+
) -> Optional[tuple[Node, _RescaleParams]]:
222+
if tanh.target != exir_ops.edge.aten.tanh.default:
223+
return None
224+
if len(tanh.args) != 1 or not isinstance(tanh.args[0], Node):
225+
return None
226+
227+
output_rescale_node = tanh.args[0]
228+
output_rescale = _RescaleParams.from_node(output_rescale_node)
229+
if (
230+
output_rescale is None
231+
or output_rescale.output_dtype != torch.int8
232+
or len(output_rescale_node.users) != 1
233+
or not isinstance(output_rescale_node.args[0], Node)
234+
):
235+
return None
236+
237+
mul = output_rescale_node.args[0]
238+
if mul.target != exir_ops.edge.aten.mul.Tensor or len(mul.users) != 1:
239+
return None
240+
return mul, output_rescale
241+
242+
def _match_mul_and_operands(
243+
self, mul: Node
244+
) -> Optional[tuple[_MulOperand, _MulOperand, QuantArgs]]:
245+
mul_qparams = cast(dict[int, QuantArgs], mul.meta.get("input_qparams", {}))
246+
if len(mul_qparams) != 2:
247+
return None
248+
249+
operands = self._match_mul_operands(mul)
250+
if operands is None:
251+
return None
252+
scalar_operands = [
253+
operand for operand in operands if operand.scalar_constant is not None
254+
]
255+
if len(scalar_operands) != 1:
256+
return None
257+
alpha_operand = scalar_operands[0]
258+
activation_operand = next(
259+
operand for operand in operands if operand is not alpha_operand
260+
)
261+
# A view of the proven-scalar alpha only changes its broadcast shape.
262+
# An activation-side view can change layout/indexing and is unsupported.
263+
if activation_operand.had_view:
264+
return None
265+
266+
activation_int32_qargs = mul_qparams.get(activation_operand.index)
267+
if (
268+
activation_int32_qargs is None
269+
or activation_int32_qargs.dtype != torch.int32
270+
):
271+
return None
272+
return alpha_operand, activation_operand, activation_int32_qargs
273+
274+
def _match_qargs(
275+
self,
276+
tanh: Node,
277+
activation_operand: _MulOperand,
278+
activation_int32_qargs: QuantArgs,
279+
output_rescale: _RescaleParams,
280+
) -> Optional[tuple[QuantArgs, QuantArgs, QuantArgs]]:
281+
activation_qargs = self._source_qargs(
282+
activation_operand.source,
283+
activation_operand.rescale,
284+
activation_int32_qargs,
285+
)
286+
tanh_input_qargs = self._single_qargs(tanh, "input_qparams")
287+
tanh_output_qargs = self._single_qargs(tanh, "output_qparams")
288+
if (
289+
activation_qargs is None
290+
or tanh_input_qargs is None
291+
or tanh_output_qargs is None
292+
or tanh_input_qargs.dtype != torch.int8
293+
or tanh_output_qargs.dtype != torch.int8
294+
or output_rescale.output_zp != tanh_input_qargs.get_zp_per_tensor()
295+
):
296+
return None
297+
return activation_qargs, tanh_input_qargs, tanh_output_qargs
298+
299+
def _match(self, tanh: Node) -> Optional[_DyTMatch]:
300+
output_match = self._match_output_rescale_and_mul(tanh)
301+
if output_match is None:
302+
return None
303+
mul, output_rescale = output_match
304+
305+
mul_match = self._match_mul_and_operands(mul)
306+
if mul_match is None:
307+
return None
308+
alpha_operand, activation_operand, activation_int32_qargs = mul_match
309+
310+
qargs_match = self._match_qargs(
311+
tanh,
312+
activation_operand,
313+
activation_int32_qargs,
314+
output_rescale,
315+
)
316+
if qargs_match is None:
317+
return None
318+
activation_qargs, tanh_input_qargs, tanh_output_qargs = qargs_match
319+
320+
alpha_code = alpha_operand.scalar_constant
321+
if alpha_code is None:
322+
return None
323+
324+
return _DyTMatch(
325+
tanh=tanh,
326+
activation=activation_operand.source,
327+
activation_qargs=activation_qargs,
328+
activation_rescale=activation_operand.rescale,
329+
alpha_code=alpha_code,
330+
alpha_rescale=alpha_operand.rescale,
331+
mul_output_rescale=output_rescale,
332+
tanh_input_qargs=tanh_input_qargs,
333+
tanh_output_qargs=tanh_output_qargs,
334+
)
335+
336+
def call(self, graph_module: GraphModule) -> PassResult:
337+
modified = False
338+
for node in list(graph_module.graph.nodes):
339+
match = self._match(node)
340+
if match is None:
341+
continue
342+
343+
table = _generate_dyt_lut(
344+
activation_qargs=match.activation_qargs,
345+
alpha_code=match.alpha_code,
346+
activation_rescale=match.activation_rescale,
347+
alpha_rescale=match.alpha_rescale,
348+
mul_output_rescale=match.mul_output_rescale,
349+
tanh_input_qargs=match.tanh_input_qargs,
350+
tanh_output_qargs=match.tanh_output_qargs,
351+
)
352+
insert_pos = next(iter(graph_module.graph.nodes))
353+
with graph_module.graph.inserting_before(insert_pos):
354+
table_constant = create_constant_placeholder(
355+
exp_program=self.exported_program,
356+
graph=graph_module.graph,
357+
kind=InputKind.BUFFER,
358+
name=f"b_{match.tanh.name}_dyt_table_constant",
359+
data=table,
360+
persistent_buffer=True,
361+
)
362+
with graph_module.graph.inserting_before(match.tanh):
363+
table_node = create_node(
364+
graph=graph_module.graph,
365+
op_target=exir_ops.backend.tosa.TABLE.default,
366+
args=(match.activation, table_constant),
367+
from_node=match.tanh,
368+
)
369+
table_node.meta["input_qparams"] = {0: match.activation_qargs}
370+
table_node.meta["output_qparams"] = {0: match.tanh_output_qargs}
371+
match.tanh.replace_all_uses_with(table_node)
372+
graph_module.graph.erase_node(match.tanh)
373+
modified = True
374+
375+
if modified:
376+
graph_module.graph.eliminate_dead_code()
377+
graph_module.graph.lint()
378+
graph_module.recompile()
379+
return PassResult(graph_module, modified)

0 commit comments

Comments
 (0)