Skip to content

Commit b22a902

Browse files
authored
Fuse consecutive clamps into a single clamp (#21013)
Differential Revision: D112351522 Pull Request resolved: #21013
1 parent 181ded4 commit b22a902

4 files changed

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

0 commit comments

Comments
 (0)