|
| 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