Skip to content

Commit 6da120a

Browse files
apullinfacebook-github-bot
authored andcommitted
Fuse consecutive clamps into a single clamp (#21013)
Summary: ConvertToClampPass normalizes hardtanh, relu and relu6 into aten.clamp. Chains like HardTanh -> ReLU therefore become two adjacent clamps, the second of which is redundant. Add FuseConsecutiveClampsPass, which folds any chain of adjacent (single-user) clamp.default ops into one clamp using exact composition: clamp(clamp(x,a,b),c,d) == clamp(x, max(a,c), min(b,d)). This subsumes HardTanh->ReLU, ReLU->ReLU, HardTanh->HardTanh, ReLU6->ReLU, explicit clamp->clamp, and longer chains, dropping one op (and one intermediate tensor) per fused pair. Runs after FoldAndAnnotateQParamsPass so it fires in both the FP and INT paths; in the quantized case the surviving clamp inherits the first clamp's input qparams and the second's output qparams, dropping the intermediate requant (qtol=1, same tradeoff as FuseConsecutiveRescalesPass). Branching clamps (multiple users) are left untouched. Differential Revision: D112351522
1 parent 0dec2b6 commit 6da120a

4 files changed

Lines changed: 449 additions & 0 deletions

File tree

backends/arm/_passes/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@
116116
)
117117
from .fold_scalar_mul_into_conv_pass import FoldScalarMulIntoConvPass # noqa
118118
from .fuse_batch_norm2d_pass import FuseBatchNorm2dPass # noqa
119+
from .fuse_consecutive_clamps_pass import FuseConsecutiveClampsPass # noqa
119120
from .fuse_consecutive_concat_shapes import FuseConsecutiveConcatShapesPass # noqa
120121
from .fuse_consecutive_rescales_pass import FuseConsecutiveRescalesPass # noqa
121122
from .fuse_consecutive_slices_pass import FuseConsecutiveSlicesPass # noqa

backends/arm/_passes/arm_pass_manager.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@
110110
FoldAndAnnotateQParamsPass,
111111
FoldScalarMulIntoConvPass,
112112
FuseBatchNorm2dPass,
113+
FuseConsecutiveClampsPass,
113114
FuseConsecutiveConcatShapesPass,
114115
FuseConsecutiveRescalesPass,
115116
FuseConsecutiveSlicesPass,
@@ -506,6 +507,12 @@ def _tosa_pipeline(
506507
self.add_passes(
507508
[
508509
FoldAndAnnotateQParamsPass(exported_program),
510+
# Both hardtanh and relu are normalized to clamp by
511+
# ConvertToClampPass; after q/dq folding above, adjacent clamps
512+
# (e.g. from HardTanh+ReLU) are directly connected and can be
513+
# fused into a single clamp. Runs before QuantizeClampArgumentsPass
514+
# so the min/max args are still float scalars.
515+
FuseConsecutiveClampsPass(),
509516
FuseDuplicateUsersPass(),
510517
# TODO: DecomposeLinearPass should run after InsertRescaleInt32Pass or
511518
# before FoldAndAnnotateQParamsPass but is unable to at the moment.
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
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+
import logging
7+
from typing import cast, Set, Type
8+
9+
from executorch.backends.arm._passes.arm_pass import ArmPass
10+
from executorch.backends.arm._passes.fold_qdq_with_annotated_qparams_pass import (
11+
QuantizeClampArgumentsPass,
12+
)
13+
from executorch.exir.dialects._ops import ops as exir_ops
14+
from executorch.exir.pass_base import ExportPass, PassResult
15+
from torch.fx import GraphModule, Node
16+
17+
logger: logging.Logger = logging.getLogger(__name__)
18+
19+
# aten.clamp.default argument positions: (input, min, max). min and/or max may
20+
# be None (meaning -inf / +inf respectively).
21+
_ARG_INPUT = 0
22+
_ARG_MIN = 1
23+
_ARG_MAX = 2
24+
25+
_CLAMP_OP = exir_ops.edge.aten.clamp.default
26+
27+
28+
class FuseConsecutiveClampsPass(ArmPass):
29+
"""Fuse a chain of consecutive ``clamp.default`` ops into a single clamp.
30+
31+
``ConvertToClampPass`` normalizes ``hardtanh``, ``relu`` (and relu6 via its
32+
hardtanh decomposition) into ``aten.clamp.default``. As a result, common
33+
activation chains such as ``HardTanh(a, b) -> ReLU`` become two adjacent
34+
clamps: ``clamp(a, b) -> clamp(0, None)``. The second clamp is redundant.
35+
36+
Clamp composition is exact::
37+
38+
clamp(clamp(x, a, b), c, d) == clamp(x, max(a, c), min(b, d))
39+
40+
(treating ``None`` bounds as -inf / +inf). This pass rewrites every
41+
``clamp -> clamp`` pair where the first clamp feeds *only* the second into a
42+
single clamp with the composed bounds, then removes the now-dead first
43+
clamp. Chains of length >2 collapse to one clamp by iterating to a fixed
44+
point.
45+
46+
Quantized path: when run after ``FoldAndAnnotateQParamsPass`` the clamps
47+
carry ``input_qparams`` / ``output_qparams`` in their meta. The surviving
48+
clamp takes the first clamp's input qparams and keeps the second clamp's
49+
output qparams, dropping the intermediate requantization. Bypassing that
50+
requant can differ by at most ~1 quantization step at the clamp boundary
51+
(same tradeoff as ``FuseConsecutiveRescalesPass``; tests use qtol=1).
52+
53+
"""
54+
55+
# We emit clamp.default with float min/max args; QuantizeClampArgumentsPass
56+
# must still run afterwards to quantize those args (same requirement as
57+
# ConvertToClampPass). DecomposeTOSAUnsupportedClampPass runs earlier in the
58+
# pipeline and only decomposes int32 clamps, which our fused clamp inherits
59+
# from its (already-processed) inputs, so it is not required after us.
60+
_passes_required_after: Set[Type[ExportPass]] = {QuantizeClampArgumentsPass}
61+
62+
def call(self, graph_module: GraphModule) -> PassResult:
63+
graph = graph_module.graph
64+
clamp_before = sum(1 for n in graph.nodes if _is_clamp(n))
65+
fused = 0
66+
67+
# graph.nodes is topologically ordered, so a single forward sweep
68+
# collapses an entire chain (a -> b -> c): after folding a into b, b is
69+
# still visited later in the same sweep and folds into c. The outer loop
70+
# only re-runs to catch a pair newly exposed by a prior fusion.
71+
while True:
72+
fused_this_pass = 0
73+
for node in list(graph.nodes):
74+
node = cast(Node, node)
75+
if not _is_clamp(node):
76+
continue
77+
if len(node.users) != 1:
78+
continue
79+
user = next(iter(node.users))
80+
if not _is_clamp(user):
81+
continue
82+
if _get_input(user) is not node:
83+
continue
84+
if _fuse_pair(node, user):
85+
graph.erase_node(node)
86+
fused_this_pass += 1
87+
fused += fused_this_pass
88+
if fused_this_pass == 0:
89+
break
90+
91+
if fused:
92+
graph.eliminate_dead_code()
93+
clamp_after = sum(1 for n in graph.nodes if _is_clamp(n))
94+
logger.info(
95+
"FuseConsecutiveClampsPass: fused %d clamp pairs (%d -> %d clamps)",
96+
fused,
97+
clamp_before,
98+
clamp_after,
99+
)
100+
graph_module.recompile()
101+
graph.lint()
102+
# Deliberately skip super().call(): this pass only rewires edges,
103+
# edits scalar args, and removes nodes -- no new ops to retrace.
104+
105+
return PassResult(graph_module, fused > 0)
106+
107+
108+
def _is_clamp(node: Node) -> bool:
109+
return node.op == "call_function" and node.target == _CLAMP_OP
110+
111+
112+
def _get_input(node: Node) -> Node | None:
113+
if len(node.args) > _ARG_INPUT:
114+
arg = node.args[_ARG_INPUT]
115+
else:
116+
arg = node.kwargs.get("self")
117+
return arg if isinstance(arg, Node) else None
118+
119+
120+
def _get_bounds(node: Node) -> tuple[float | None, float | None]:
121+
# Bounds may be spelled positionally or as min=/max= kwargs; fall back to
122+
# kwargs so an explicitly-authored clamp doesn't have a bound silently
123+
# dropped (which would widen the fused range).
124+
args = node.args
125+
kwargs = node.kwargs
126+
min_val = args[_ARG_MIN] if len(args) > _ARG_MIN else kwargs.get("min")
127+
max_val = args[_ARG_MAX] if len(args) > _ARG_MAX else kwargs.get("max")
128+
return cast("float | None", min_val), cast("float | None", max_val)
129+
130+
131+
def _compose_lower(a: float | None, b: float | None) -> float | None:
132+
"""Compose two clamp lower bounds -- the tighter (larger) wins."""
133+
if a is None:
134+
return b
135+
if b is None:
136+
return a
137+
return max(a, b)
138+
139+
140+
def _compose_upper(a: float | None, b: float | None) -> float | None:
141+
"""Compose two clamp upper bounds -- the tighter (smaller) wins."""
142+
if a is None:
143+
return b
144+
if b is None:
145+
return a
146+
return min(a, b)
147+
148+
149+
def _fuse_pair(first: Node, second: Node) -> bool:
150+
"""Fold ``first`` into ``second`` in place. Returns True if fused.
151+
152+
``second`` keeps its identity (downstream users point at it) and its output
153+
qparams; it is rewired to read ``first``'s input with the composed bounds.
154+
155+
"""
156+
first_input = _get_input(first)
157+
if first_input is None:
158+
return False
159+
min1, max1 = _get_bounds(first)
160+
min2, max2 = _get_bounds(second)
161+
new_min = _compose_lower(min1, min2)
162+
new_max = _compose_upper(max1, max2)
163+
164+
# Empty range (min > max) is pathological -- torch.clamp with min>max is not
165+
# associative in that regime, so leave the pair untouched.
166+
if new_min is not None and new_max is not None and new_min > new_max:
167+
return False
168+
169+
# Rewire the survivor to read first's input, writing a canonical
170+
# (input, min, max) layout and dropping any stale min/max spelled as kwargs
171+
# on the original clamp.
172+
second.args = (first_input, new_min, new_max)
173+
if second.kwargs:
174+
second.kwargs = {
175+
k: v for k, v in second.kwargs.items() if k not in ("min", "max")
176+
}
177+
178+
# Transfer input quantization params from the first clamp so the surviving
179+
# clamp is consistent with its new input (quantized path only).
180+
first_in_qparams = first.meta.get("input_qparams")
181+
if first_in_qparams and "input_qparams" in second.meta:
182+
second.meta["input_qparams"] = dict(first_in_qparams)
183+
184+
return True

0 commit comments

Comments
 (0)