Skip to content

Commit 43f89fb

Browse files
Qualcomm AI Engine Direct - Add QNN HadamardTransform op support (#21729)
### Summary Add support for the QNN `HadamardTransform` op for the HTP backend. When a constant weight equals a scaled Hadamard matrix (`scipy.linalg.hadamard(dim) * s`), the op is lowered to a single QNN `HadamardTransform` (optionally scaled) instead of the generic op. A first-class custom op `torch.ops.qnn_custom.hadamard_transform` is registered, and a pre-quantization `RecomposeHadamard` pass detects the matching nodes and rewrites them into the custom op, so it is annotated and lowered as a `HadamardTransform` from the start rather than being detected late in the builder and validated as `FullyConnected` / `MatMul` / `Conv`. A shared matcher (`match_hadamard_weight` in `builders/utils.py`) inspects the real constant weight during the annotation pass. Three patterns are covered: - **linear** — bias-less `aten.linear`. - **matmul** — `x @ W`, matching the constant right-hand operand (last-dim transform); this is the R3-rotation pattern used by static LLaMA. - **conv** — bias-less pointwise 1x1 conv with `groups=1`, which mixes only the channel dim. Since `hadamard_transform` operates on the last dim, linear and matmul rewrite directly, while conv is wrapped in permutes that move the channel dim to the last dim and back. A dedicated `htp_rules.py` annotator and `op_hadamard.py` builder handle quantization and lowering. The rewrite is gated on QNN SDK >= 2.47 for backward compatibility. ### Test plan ``` python backends/qualcomm/tests/test_qnn_delegate.py -k "test_qnn_backend_hadamard_transform" --device ef5e4029 --host localhost --soc_model SM8850 --build_folder build-android --executorch_root . --artifact test_qnn_delegate_artifact pytest backends/qualcomm/tests/rework/htp/op/v68/test.py --build_folder build-android --soc_model SM8850 --device ef5e4029 -k test_hadamard pytest backends/qualcomm/tests/rework/passes/test.py -k "test_recompose_hadamard" ``` The tests assert that the graph is rewritten to `qnn_custom.hadamard_transform` before quantization, and inspect the optrace QHAS op types to assert `HadamardTransform` appears after lowering. ### Performance CTX_LEN = 1024 AR_LEN = 128 | Model | w/o | | w/ | | |------------------|:-------------------------------|:-------------------------------|:-------------------------------|:------------------------------| | | **Prefill Graph execute time** | **Decode Graph execute time** | **Prefill Graph execute time** | **Decode Graph execute time** | | qwen2_5-0_5b | 58792 | 5295 | **16361** | 5321 | | qwen2_5-1_5b | 69230 | 15451 | 69329 | 15495 | | qwen3-1_7b | 68005 | 19643 | **39825** | 19466 | | smollm2_135m | 10001 | 2894 | **6688** | 2804 | --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fe7623e commit 43f89fb

18 files changed

Lines changed: 732 additions & 4 deletions

File tree

backends/qualcomm/_passes/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
from .layout_transform import LayoutTransform
6161
from .lift_constant_scalar_operands import LiftConstantScalarOperands
6262
from .lpai_partition_fallback_support import LpaiPartitionFallbackSupport
63+
from .recompose_hadamard import RecomposeHadamard
6364
from .recompose_pad_maxpool2d import RecomposePadMaxPool2d
6465
from .recompose_pixel_unshuffle import RecomposePixelUnshuffle
6566
from .recompose_rms_norm import RecomposeRmsNorm
@@ -127,6 +128,7 @@
127128
LayoutTransform,
128129
LiftConstantScalarOperands,
129130
LpaiPartitionFallbackSupport,
131+
RecomposeHadamard,
130132
RecomposePadMaxPool2d,
131133
RecomposePixelUnshuffle,
132134
RecomposeRmsNorm,

backends/qualcomm/_passes/backends/htp/qnn_htp_pass_manager.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44
# This source code is licensed under the BSD-style license found in the
55
# LICENSE file in the root directory of this source tree.
66

7-
from executorch.backends.qualcomm._passes import DecomposeReciprocal, RemoveRedundancy
7+
from executorch.backends.qualcomm._passes import (
8+
DecomposeReciprocal,
9+
RecomposeHadamard,
10+
RemoveRedundancy,
11+
)
812
from executorch.backends.qualcomm._passes.qnn_pass_manager import QnnPassManager
913

1014

@@ -33,7 +37,7 @@ def get_passes_dependency_for_capture_program(cls):
3337

3438
@classmethod
3539
def get_annotation_passes(cls):
36-
passes = [DecomposeReciprocal]
40+
passes = [DecomposeReciprocal, RecomposeHadamard]
3741
passes.extend(super().get_annotation_passes())
3842
return passes
3943

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
# Copyright (c) Qualcomm Innovation Center, Inc.
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+
from operator import attrgetter
7+
8+
import torch
9+
10+
# Also registers torch.ops.qnn_custom.hadamard_transform.
11+
from executorch.backends.qualcomm.builders.custom_ops import _hadamard_matrix
12+
from executorch.backends.qualcomm.utils.check_qnn_version import (
13+
is_qnn_sdk_version_less_than,
14+
)
15+
from executorch.exir.pass_base import ExportPass, PassResult
16+
from executorch.exir.passes import dead_code_elimination_pass
17+
18+
from .utils import copy_meta
19+
20+
21+
def _is_power_of_2_sqare_matrix(weight: torch.Tensor) -> bool:
22+
dim = weight.shape[0]
23+
# Shape gate: non-square / non-2D / non-power-of-2 weight can never match.
24+
return (
25+
weight.dim() != 2 or weight.shape[0] != weight.shape[1] or dim & (dim - 1) != 0
26+
)
27+
28+
29+
def _match_hadamard_weight(weight: torch.Tensor) -> bool:
30+
# Returns True if `weight == _hadamard_matrix(dim) * s` for some scale s.
31+
# A linear/matmul with such a weight is equivalent to a QNN HadamardTransform.
32+
if _is_power_of_2_sqare_matrix(weight):
33+
return False
34+
35+
w = weight.detach().to(torch.float64)
36+
nonzero = w[w != 0]
37+
if nonzero.numel() == 0:
38+
return False
39+
# The Hadamard weight is H * s for a single global scale s; infer s from any
40+
# nonzero entry (all |H_ij| == 1). For per-channel quant this only matches
41+
# when every channel's dequantized scale reconstructs the same H * s.
42+
scale = float(nonzero.flatten()[0].abs())
43+
hadamard = _hadamard_matrix(w.shape[0], w.device, w.dtype) * scale
44+
return torch.allclose(w, hadamard, rtol=0, atol=1e-4)
45+
46+
47+
class RecomposeHadamard(ExportPass):
48+
"""
49+
Rewrite a bias-less linear / matmul / 1x1 conv whose weight is a Hadamard
50+
matrix into a single qnn_custom.hadamard_transform op, so it is annotated and
51+
lowered as a first-class HadamardTransform instead of being detected late in
52+
the builder and validated as FullyConnected / MatMul / Conv.
53+
54+
Runs in the annotation pipeline (before quantization), where the weight is a
55+
real tensor and can be inspected. hadamard_transform acts on the last dim, so
56+
linear / matmul rewrite directly, while conv (which mixes the channel dim) is
57+
wrapped in permutes that move the channel to the last dim and back.
58+
"""
59+
60+
def __init__(self):
61+
super().__init__()
62+
self.hadamard_target = torch.ops.qnn_custom.hadamard_transform.default
63+
64+
def _is_pointwise_conv(self, node, weight: torch.Tensor) -> bool:
65+
# Only a 1x1, stride-1, no-pad, dilation-1, groups-1 conv is a pure
66+
# channel-mixing matmul equivalent to a Hadamard transform. conv2d args:
67+
# (input, weight, bias, stride, padding, dilation, groups) with defaults.
68+
stride = node.args[3] if len(node.args) > 3 else [1, 1]
69+
padding = node.args[4] if len(node.args) > 4 else [0, 0]
70+
dilation = node.args[5] if len(node.args) > 5 else [1, 1]
71+
groups = node.args[6] if len(node.args) > 6 else 1
72+
return (
73+
weight.dim() == 4
74+
and all(k == 1 for k in weight.shape[2:])
75+
and all(s == 1 for s in stride)
76+
and all(p == 0 for p in padding)
77+
and all(d == 1 for d in dilation)
78+
and groups == 1
79+
)
80+
81+
def _get_hadamard_scale(self, weight: torch.Tensor) -> float:
82+
# weight == H * s (all |H_ij| == 1); linear/matmul(x) = x @ H. The op
83+
# applies the orthonormal H / sqrt(dim), so fold the remaining factor
84+
# s * sqrt(dim) into the op's scale (== 1 for an orthonormal Hadamard).
85+
dim = weight.shape[0]
86+
return float(weight.detach().abs().flatten()[0]) * (dim**0.5)
87+
88+
def _rewrite_last_dim(self, graph, node, scale):
89+
# linear / matmul already transform the last dim: replace in place.
90+
with graph.inserting_before(node):
91+
hadamard_node = graph.create_node(
92+
"call_function",
93+
self.hadamard_target,
94+
(node.args[0], scale),
95+
)
96+
hadamard_node.meta = copy_meta(node.meta)
97+
for user in node.users.copy():
98+
user.replace_input_with(node, hadamard_node)
99+
100+
def _rewrite_channel_dim(self, graph, node, scale):
101+
# conv mixes the channel dim (dim 1). Move it to the last dim, run the
102+
# transform there, then move it back.
103+
input_node = node.args[0]
104+
input_val = input_node.meta["val"]
105+
rank = input_val.dim()
106+
to_last = [0, *range(2, rank), 1]
107+
from_last = [0, rank - 1, *range(1, rank - 1)]
108+
with graph.inserting_before(node):
109+
pre = graph.create_node(
110+
"call_function", torch.ops.aten.permute.default, (input_node, to_last)
111+
)
112+
pre.meta = copy_meta(node.meta)
113+
pre.meta["val"] = input_val.permute(to_last)
114+
hadamard_node = graph.create_node(
115+
"call_function", self.hadamard_target, (pre, scale)
116+
)
117+
hadamard_node.meta = copy_meta(node.meta)
118+
post = graph.create_node(
119+
"call_function",
120+
torch.ops.aten.permute.default,
121+
(hadamard_node, from_last),
122+
)
123+
post.meta = copy_meta(node.meta)
124+
for user in node.users.copy():
125+
user.replace_input_with(node, post)
126+
127+
def _is_hadamard_transform(self, graph_module, node):
128+
if node.op != "call_function":
129+
return False
130+
131+
is_conv = node.target == torch.ops.aten.conv2d.default
132+
is_last_dim = node.target in (
133+
torch.ops.aten.linear.default,
134+
torch.ops.aten.matmul.default,
135+
)
136+
if not (is_conv or is_last_dim):
137+
return False
138+
139+
# linear/conv carry an optional bias in args[2]; matmul never does.
140+
has_bias = len(node.args) >= 3 and node.args[2] is not None
141+
if has_bias:
142+
return False
143+
144+
weight_node = node.args[1]
145+
if weight_node.op != "get_attr":
146+
return False
147+
weight = attrgetter(weight_node.target)(graph_module)
148+
if is_conv and not self._is_pointwise_conv(node, weight):
149+
return False
150+
# A 1x1 conv filter is [out, in, 1, 1]; squeeze to [out, in] to match.
151+
squeezed = weight.reshape(weight.shape[:2]) if is_conv else weight
152+
if not _match_hadamard_weight(squeezed):
153+
return False
154+
return True
155+
156+
def call(self, graph_module: torch.fx.GraphModule):
157+
# HadamardTransform is only supported by QNN 2.47+. On older SDKs skip the
158+
# rewrite so the op keeps its normal lowering path.
159+
if is_qnn_sdk_version_less_than("2.47"):
160+
return PassResult(graph_module, False)
161+
162+
graph = graph_module.graph
163+
modified = False
164+
for node in graph.nodes:
165+
if not self._is_hadamard_transform(graph_module, node):
166+
continue
167+
weight_node = node.args[1]
168+
weight = attrgetter(weight_node.target)(graph_module)
169+
is_conv = node.target == torch.ops.aten.conv2d.default
170+
squeezed = weight.reshape(weight.shape[:2]) if is_conv else weight
171+
scale = self._get_hadamard_scale(squeezed)
172+
if is_conv:
173+
self._rewrite_channel_dim(graph, node, scale)
174+
else:
175+
self._rewrite_last_dim(graph, node, scale)
176+
modified = True
177+
178+
if modified:
179+
dead_code_elimination_pass(graph_module)
180+
return PassResult(graph_module, modified)

backends/qualcomm/builders/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,7 @@ Please help update following table if you are contributing new operators:
436436
| GetSparseValues | &cross; |
437437
| GridSample | &check; |
438438
| GroupNorm | &check; |
439+
| HadamardTransform | &check; |
439440
| HardSwish | &check; |
440441
| InstanceNorm | &check; |
441442
| IsInf | &check; |

backends/qualcomm/builders/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
# LICENSE file in the root directory of this source tree.
66

77
from . import (
8+
custom_ops,
89
node_visitor,
910
op_abs,
1011
op_adaptive_avg_pool2d,
@@ -49,6 +50,7 @@
4950
op_grid_sampler_2d,
5051
op_group_norm,
5152
op_gt,
53+
op_hadamard_transform,
5254
op_hardsigmoid,
5355
op_hardswish,
5456
op_hardtanh,
@@ -121,6 +123,7 @@
121123
)
122124

123125
__all__ = [
126+
custom_ops,
124127
node_visitor,
125128
op_abs,
126129
op_adaptive_avg_pool2d,
@@ -165,6 +168,7 @@
165168
op_grid_sampler_2d,
166169
op_group_norm,
167170
op_gt,
171+
op_hadamard_transform,
168172
op_hardswish,
169173
op_hardtanh,
170174
op_hardsigmoid,
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Copyright (c) Qualcomm Innovation Center, Inc.
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 torch
8+
from torch.library import impl, Library, register_fake
9+
10+
# Dedicated namespace, separate from the "qaisw" context-binary namespace.
11+
hadamard_op_lib = Library("qnn_custom", "DEF")
12+
hadamard_op_lib.define("hadamard_transform(Tensor input, float scale) -> Tensor")
13+
14+
15+
def _hadamard_matrix(dim: int, device, dtype) -> torch.Tensor:
16+
# Sylvester construction of the (unnormalized, ±1) Hadamard matrix.
17+
h = torch.ones((1, 1), device=device, dtype=dtype)
18+
while h.shape[0] < dim:
19+
h = torch.cat([torch.cat([h, h], dim=1), torch.cat([h, -h], dim=1)], dim=0)
20+
return h
21+
22+
23+
@impl(hadamard_op_lib, "hadamard_transform", "CompositeExplicitAutograd")
24+
def hadamard_transform_impl(input: torch.Tensor, scale: float) -> torch.Tensor:
25+
# Normalized Walsh-Hadamard transform along the last dim, times scale.
26+
# Matches a linear/matmul whose weight is scipy.linalg.hadamard(dim) * s,
27+
# where the rewrite pass sets scale = s * sqrt(dim) (scale == 1 when the
28+
# weight is the orthonormal H / sqrt(dim)).
29+
dim = input.shape[-1]
30+
h = _hadamard_matrix(dim, input.device, input.dtype)
31+
return torch.matmul(input, h) * (scale / (dim**0.5))
32+
33+
34+
@register_fake("qnn_custom::hadamard_transform")
35+
def hadamard_transform_fake(input: torch.Tensor, scale: float) -> torch.Tensor:
36+
# Hadamard weight is square, so the transform preserves shape.
37+
return torch.empty_like(input)
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Copyright (c) Qualcomm Innovation Center, Inc.
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+
from typing import Dict
8+
9+
import executorch.backends.qualcomm.python.PyQnnManagerAdaptor as PyQnnManager
10+
11+
import numpy as np
12+
13+
import torch
14+
from executorch.backends.qualcomm.utils.constants import QCOM_DATA
15+
16+
from .node_visitor import NodeVisitor
17+
from .node_visitor_manager import register_node_visitor
18+
from .qnn_constants import OpHadamardTransform, QNN_OP_PACKAGE_NAME_QTI_AISW
19+
20+
21+
@register_node_visitor
22+
class HadamardTransformVisitor(NodeVisitor):
23+
target = ["qnn_custom.hadamard_transform.default"]
24+
25+
def __init__(self, *args) -> None:
26+
super().__init__(*args)
27+
28+
def define_node(
29+
self,
30+
node: torch.fx.Node,
31+
nodes_to_wrappers: Dict[torch.fx.Node, PyQnnManager.TensorWrapper],
32+
) -> PyQnnManager.PyQnnOpWrapper:
33+
input_node = self.get_node(node.args[0])
34+
input_tensor = self.get_tensor(input_node, node)
35+
input_tensor_wrapper = self.define_tensor(
36+
input_node,
37+
node,
38+
input_tensor,
39+
PyQnnManager.Qnn_TensorType_t.QNN_TENSOR_TYPE_NATIVE,
40+
nodes_to_wrappers,
41+
)
42+
43+
output_tensor = self.get_tensor(node, node)
44+
output_tensor_wrapper = self.define_tensor(
45+
node,
46+
node,
47+
output_tensor,
48+
PyQnnManager.Qnn_TensorType_t.QNN_TENSOR_TYPE_NATIVE,
49+
nodes_to_wrappers,
50+
)
51+
52+
hadamard_op = PyQnnManager.PyQnnOpWrapper(
53+
node.name,
54+
QNN_OP_PACKAGE_NAME_QTI_AISW,
55+
OpHadamardTransform.op_name,
56+
)
57+
hadamard_op.AddInputTensors([input_tensor_wrapper])
58+
hadamard_op.AddOutputTensors([output_tensor_wrapper])
59+
60+
scale = node.args[1]
61+
hadamard_op.AddScalarParam(
62+
OpHadamardTransform.param_scale,
63+
PyQnnManager.Qnn_DataType_t.QNN_DATATYPE_FLOAT_32,
64+
{QCOM_DATA: np.float32(scale)},
65+
)
66+
return hadamard_op

backends/qualcomm/builders/qnn_constants.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,12 @@ class OpGroupNorm:
382382
param_group = "group"
383383

384384

385+
@dataclass(init=False, frozen=True)
386+
class OpHadamardTransform:
387+
op_name: str = "HadamardTransform"
388+
param_scale: str = "scale"
389+
390+
385391
@dataclass(init=False, frozen=True)
386392
class OpHardSwish:
387393
op_name: str = "HardSwish"

0 commit comments

Comments
 (0)