Skip to content

Commit 20675ac

Browse files
authored
Add Stft op support to the OSS QNN backend (#24031)
Summary: The OSS ExecuTorch QNN backend (`executorch/backends/qualcomm`) had no `torch.stft` support, so any model using it (e.g. the llama4 STITO speech front end) decomposed stft into `unfold` + `_fft_r2c` on CPU. QNN has a native `Stft` op on HTP (FP16/FP32 only, no quantized configuration, per the QNN 2.48/2.50 HtpOpDefSupplement), so this lowers `aten.stft.default` to it. `ConvertComplexStft` (export pass): `torch.stft` returns a complex tensor that QNN cannot represent, so this rewrites the supported complex consumers into the real-valued form: `view_as_real(stft(return_complex=True))` becomes `stft(return_complex=False)`, `abs(stft)` becomes `sqrt(sum(x**2, -1))`, and the common power-spectrum case `abs(stft)**2` becomes `sum(x**2, -1)`. An stft with any other complex consumer is left alone and decomposes as before. `op_stft.py`: lowers the real-valued `aten.stft.default` as Reshape -> Stft -> Transpose [-> Reshape]. QNN takes `[batch, signal_length, 1]` and produces `[batch, frames, freq, 2]` while torch produces `[batch, freq, frames, 2]` (no batch dim for a 1D signal). The window is zero-padded on both sides to `n_fft`, matching torch when `win_length < n_fft`. `partition/utils.py`: `aten.stft.default` is added to `get_skip_decomp_table()`, and `filter_fn` only preserves nodes QNN can run (real output, float input, `normalized=False`, and a window given or `win_length == n_fft`, since QNN's default rectangular window spans `n_fft`). Everything else decomposes to the existing CPU path, so models that don't match are unchanged. fbcode and xplat copies are kept byte-identical. cc @cbilgin @psiddh Differential Revision: D126511338 Pull Request resolved: #24031
1 parent a4e65bf commit 20675ac

10 files changed

Lines changed: 397 additions & 0 deletions

File tree

‎backends/qualcomm/_passes/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from .build_quant_io import BuildQuantIo
1313
from .canonicalize_conv import CanonicalizeConv
1414
from .convert_bmm_to_matmul import ConvertBmmToMatmul
15+
from .convert_complex_stft import ConvertComplexStft
1516
from .convert_linear_to_conv2d import ConvertLinearToConv2d
1617
from .convert_mha_to_sha import ConvertMhaToSha
1718
from .convert_square_to_pow import ConvertSquareToPow
@@ -85,6 +86,7 @@
8586
BuildQuantIo,
8687
CanonicalizeConv,
8788
ConvertBmmToMatmul,
89+
ConvertComplexStft,
8890
ConvertLinearToConv2d,
8991
ConvertMhaToSha,
9092
ConvertSquareToPow,
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
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+
import torch
7+
from executorch.exir.pass_base import ExportPass, PassResult
8+
from executorch.exir.passes import dead_code_elimination_pass
9+
10+
from .utils import copy_meta
11+
12+
13+
class ConvertComplexStft(ExportPass):
14+
"""
15+
Rewrite a complex-valued stft into its real-valued form so the QNN Stft
16+
op, whose output is real with the real/imaginary parts in the last
17+
dimension, can consume it. Only complex consumers that have a real
18+
equivalent are handled:
19+
20+
view_as_real(stft(x, return_complex=True)) -> stft(x, return_complex=False)
21+
abs(stft(x, return_complex=True)) -> sqrt(sum(stft_real ** 2, -1))
22+
pow(abs(stft(...)), 2) -> sum(stft_real ** 2, -1)
23+
24+
An stft with any other complex consumer is left untouched and is
25+
decomposed (and runs on CPU) as before.
26+
"""
27+
28+
stft_op = torch.ops.aten.stft.default
29+
return_complex_index = 7
30+
31+
def __init__(self) -> None:
32+
super().__init__()
33+
34+
@staticmethod
35+
def _is_square(node: torch.fx.Node) -> bool:
36+
return node.target == torch.ops.aten.pow.Tensor_Scalar and node.args[1] == 2
37+
38+
def _create_node(self, graph, target, args, meta, val):
39+
node = graph.create_node("call_function", target, args)
40+
node.meta = copy_meta(meta, callback=lambda m: {**m, "val": val})
41+
return node
42+
43+
def _convert(self, graph: torch.fx.Graph, stft: torch.fx.Node) -> None:
44+
args = list(stft.args)
45+
args += [None] * (self.return_complex_index + 1 - len(args))
46+
args[self.return_complex_index] = False
47+
real_val = torch.view_as_real(stft.meta["val"])
48+
with graph.inserting_after(stft):
49+
real_stft = self._create_node(
50+
graph, self.stft_op, tuple(args), stft.meta, real_val
51+
)
52+
53+
for user in list(stft.users):
54+
if user.target == torch.ops.aten.view_as_real.default:
55+
# stft traces as a [freq, frames] view over frame-major memory,
56+
# but the lowered op writes contiguous output; a reshape traces
57+
# contiguous, so a delegate output keeps the matching dim order.
58+
with graph.inserting_before(user):
59+
contiguous = self._create_node(
60+
graph,
61+
torch.ops.aten.reshape.default,
62+
(real_stft, list(real_val.shape)),
63+
user.meta,
64+
real_val.contiguous(),
65+
)
66+
user.replace_all_uses_with(contiguous)
67+
continue
68+
# The remaining users are abs, as checked by the caller.
69+
square_val = real_val * real_val
70+
sum_val = square_val.sum(dim=-1)
71+
with graph.inserting_before(user):
72+
square = self._create_node(
73+
graph,
74+
torch.ops.aten.mul.Tensor,
75+
(real_stft, real_stft),
76+
user.meta,
77+
square_val,
78+
)
79+
power = self._create_node(
80+
graph,
81+
torch.ops.aten.sum.dim_IntList,
82+
(square, [-1]),
83+
user.meta,
84+
sum_val,
85+
)
86+
for abs_user in list(user.users):
87+
if self._is_square(abs_user):
88+
abs_user.replace_all_uses_with(power)
89+
if user.users:
90+
with graph.inserting_before(user):
91+
magnitude = self._create_node(
92+
graph,
93+
torch.ops.aten.sqrt.default,
94+
(power,),
95+
user.meta,
96+
sum_val.sqrt(),
97+
)
98+
user.replace_all_uses_with(magnitude)
99+
100+
def call(self, graph_module: torch.fx.GraphModule) -> PassResult:
101+
graph = graph_module.graph
102+
for node in list(graph.nodes):
103+
if (
104+
node.target == self.stft_op
105+
and node.meta["val"].is_complex()
106+
and all(
107+
user.target
108+
in (torch.ops.aten.abs.default, torch.ops.aten.view_as_real.default)
109+
for user in node.users
110+
)
111+
):
112+
self._convert(graph, node)
113+
114+
dead_code_elimination_pass(graph_module)
115+
graph_module.recompile()
116+
return PassResult(graph_module, True)

‎backends/qualcomm/_passes/qnn_pass_manager.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
AnnotateUnbind,
1818
CanonicalizeConv,
1919
ConvertBmmToMatmul,
20+
ConvertComplexStft,
2021
ConvertLinearToConv2d,
2122
ConvertMhaToSha,
2223
ConvertSquareToPow,
@@ -217,6 +218,7 @@ def get_annotation_passes(cls, convert_linear_to_conv2d: bool = False):
217218
def get_export_passes(cls):
218219
"""Return export pipeline pass classes. Override in subclasses to add backend-specific passes."""
219220
passes = [
221+
ConvertComplexStft,
220222
DecomposeAsStrided,
221223
DecomposeBinaryAlpha,
222224
DecomposeCDist,

‎backends/qualcomm/builders/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,7 @@ Please help update following table if you are contributing new operators:
483483
| SparseToDense | &cross; |
484484
| Split | &check; |
485485
| Squeeze | &check; |
486+
| Stft | &check; |
486487
| StridedSlice | &check; |
487488
| Tanh | &check; |
488489
| Tile | &check; |

‎backends/qualcomm/builders/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107
op_sqrt,
108108
op_squeeze,
109109
op_stack,
110+
op_stft,
110111
op_sub,
111112
op_sum_int_list,
112113
op_tanh,
@@ -225,6 +226,7 @@
225226
op_squeeze,
226227
op_sqrt,
227228
op_stack,
229+
op_stft,
228230
op_sub,
229231
op_sum_int_list,
230232
op_tanh,
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
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 typing import Dict, List
7+
8+
import executorch.backends.qualcomm.python.PyQnnManagerAdaptor as PyQnnManager
9+
10+
import numpy as np
11+
import torch
12+
from executorch.backends.qualcomm.utils.constants import QCOM_DATA
13+
14+
from .node_visitor import NodeVisitor
15+
from .node_visitor_manager import register_node_visitor
16+
from .qnn_constants import OpReshape, OpStft, OpTranspose, QNN_OP_PACKAGE_NAME_QTI_AISW
17+
from .utils import get_parameter
18+
19+
20+
@register_node_visitor
21+
class Stft(NodeVisitor):
22+
"""
23+
Lowers the real-valued form of aten.stft (return_complex=False), see
24+
ConvertComplexStft, as Reshape -> Stft -> Transpose [-> Reshape].
25+
26+
QNN Stft takes the signal as [batch, signal_length, 1] and produces
27+
[batch, frames, freq, 2], while torch produces [batch, freq, frames, 2]
28+
(without the batch dimension for a 1D signal). The window is zero-padded on
29+
both sides to n_fft, as torch does when win_length < n_fft.
30+
"""
31+
32+
target = ["aten.stft.default"]
33+
34+
def __init__(self, *args) -> None:
35+
super().__init__(*args)
36+
37+
def _define_native_tensor(
38+
self,
39+
name: str,
40+
tensor: torch.Tensor,
41+
source_node: torch.fx.Node,
42+
node: torch.fx.Node,
43+
nodes_to_wrappers: Dict[torch.fx.Node, PyQnnManager.TensorWrapper],
44+
) -> PyQnnManager.TensorWrapper:
45+
quant_encoding, quant_configs = self.get_quant_encoding_conf(source_node, node)
46+
return self.define_custom_tensor_wrapper(
47+
node_name=name,
48+
tensor_type=PyQnnManager.Qnn_TensorType_t.QNN_TENSOR_TYPE_NATIVE,
49+
dtype=self.get_data_type(tensor, quant_configs),
50+
quant_encoding=quant_encoding,
51+
quant_configs=quant_configs,
52+
dims=tensor.size(),
53+
tensor=tensor,
54+
is_fake_tensor=True,
55+
nodes_to_wrappers=nodes_to_wrappers,
56+
)
57+
58+
def _define_window(
59+
self,
60+
node: torch.fx.Node,
61+
n_fft: int,
62+
win_length: int,
63+
nodes_to_wrappers: Dict[torch.fx.Node, PyQnnManager.TensorWrapper],
64+
) -> List[PyQnnManager.TensorWrapper]:
65+
window_node = node.args[4] if len(node.args) > 4 else None
66+
if window_node is None:
67+
return []
68+
window_node = self.get_node(window_node)
69+
window = get_parameter(window_node, self.edge_program)
70+
left = (n_fft - win_length) // 2
71+
window = torch.nn.functional.pad(window, (left, n_fft - win_length - left))
72+
return [
73+
self.define_tensor(
74+
window_node,
75+
node,
76+
window,
77+
PyQnnManager.Qnn_TensorType_t.QNN_TENSOR_TYPE_STATIC,
78+
nodes_to_wrappers,
79+
)
80+
]
81+
82+
def define_node(
83+
self,
84+
node: torch.fx.Node,
85+
nodes_to_wrappers: Dict[torch.fx.Node, PyQnnManager.TensorWrapper],
86+
) -> List[PyQnnManager.PyQnnOpWrapper]:
87+
args = list(node.args) + [None] * (8 - len(node.args))
88+
n_fft = args[1]
89+
hop_length = args[2] or n_fft // 4
90+
win_length = args[3] or n_fft
91+
onesided = True if args[6] is None else args[6]
92+
93+
input_node = self.get_node(node.args[0])
94+
input_tensor = self.get_tensor(input_node, node)
95+
input_tensor_wrapper = self.define_tensor(
96+
input_node,
97+
node,
98+
input_tensor,
99+
PyQnnManager.Qnn_TensorType_t.QNN_TENSOR_TYPE_NATIVE,
100+
nodes_to_wrappers,
101+
)
102+
signal = input_tensor.reshape(-1, input_tensor.shape[-1], 1)
103+
signal_name = f"{node.name}_signal"
104+
signal_wrapper = self._define_native_tensor(
105+
signal_name, signal, input_node, node, nodes_to_wrappers
106+
)
107+
signal_op = PyQnnManager.PyQnnOpWrapper(
108+
signal_name, QNN_OP_PACKAGE_NAME_QTI_AISW, OpReshape.op_name
109+
)
110+
signal_op.AddInputTensors([input_tensor_wrapper])
111+
signal_op.AddOutputTensors([signal_wrapper])
112+
113+
output_tensor = self.get_tensor(node, node)
114+
# torch layout with the batch dimension: [batch, freq, frames, 2]
115+
spectrum = output_tensor.reshape(-1, *output_tensor.shape[-3:])
116+
frames_name = f"{node.name}_frames"
117+
frames_wrapper = self._define_native_tensor(
118+
frames_name, spectrum.transpose(1, 2), node, node, nodes_to_wrappers
119+
)
120+
stft_op = PyQnnManager.PyQnnOpWrapper(
121+
frames_name, QNN_OP_PACKAGE_NAME_QTI_AISW, OpStft.op_name
122+
)
123+
stft_op.AddInputTensors(
124+
[signal_wrapper]
125+
+ self._define_window(node, n_fft, win_length, nodes_to_wrappers)
126+
)
127+
stft_op.AddOutputTensors([frames_wrapper])
128+
stft_op.AddScalarParam(
129+
OpStft.param_frame_step,
130+
PyQnnManager.Qnn_DataType_t.QNN_DATATYPE_UINT_32,
131+
{QCOM_DATA: np.uint32(hop_length)},
132+
)
133+
stft_op.AddScalarParam(
134+
OpStft.param_frame_length,
135+
PyQnnManager.Qnn_DataType_t.QNN_DATATYPE_UINT_32,
136+
{QCOM_DATA: np.uint32(n_fft)},
137+
)
138+
stft_op.AddScalarParam(
139+
OpStft.param_onesided,
140+
PyQnnManager.Qnn_DataType_t.QNN_DATATYPE_BOOL_8,
141+
{QCOM_DATA: onesided},
142+
)
143+
144+
output_tensor_wrapper = self.define_tensor(
145+
node,
146+
node,
147+
output_tensor,
148+
PyQnnManager.Qnn_TensorType_t.QNN_TENSOR_TYPE_NATIVE,
149+
nodes_to_wrappers,
150+
)
151+
is_batched = output_tensor.dim() == 4
152+
transpose_name = node.name if is_batched else f"{node.name}_transpose"
153+
transpose_wrapper = (
154+
output_tensor_wrapper
155+
if is_batched
156+
else self._define_native_tensor(
157+
transpose_name, spectrum, node, node, nodes_to_wrappers
158+
)
159+
)
160+
perm = [0, 2, 1, 3]
161+
transpose_op = PyQnnManager.PyQnnOpWrapper(
162+
transpose_name, QNN_OP_PACKAGE_NAME_QTI_AISW, OpTranspose.op_name
163+
)
164+
transpose_op.AddInputTensors([frames_wrapper])
165+
transpose_op.AddOutputTensors([transpose_wrapper])
166+
transpose_op.AddTensorParam(
167+
OpTranspose.param_perm,
168+
PyQnnManager.Qnn_DataType_t.QNN_DATATYPE_UINT_32,
169+
1,
170+
[len(perm)],
171+
np.array(perm, dtype=np.uint32),
172+
True,
173+
)
174+
op_wrapper_list = [signal_op, stft_op, transpose_op]
175+
176+
if not is_batched:
177+
unbatch_op = PyQnnManager.PyQnnOpWrapper(
178+
node.name, QNN_OP_PACKAGE_NAME_QTI_AISW, OpReshape.op_name
179+
)
180+
unbatch_op.AddInputTensors([transpose_wrapper])
181+
unbatch_op.AddOutputTensors([output_tensor_wrapper])
182+
op_wrapper_list.append(unbatch_op)
183+
return op_wrapper_list

‎backends/qualcomm/builders/qnn_constants.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,14 @@ class OpSqueeze:
654654
op_name: str = "Squeeze"
655655

656656

657+
@dataclass(init=False, frozen=True)
658+
class OpStft:
659+
op_name: str = "Stft"
660+
param_frame_step: str = "frame_step"
661+
param_frame_length: str = "frame_length"
662+
param_onesided: str = "onesided"
663+
664+
657665
@dataclass(init=False, frozen=True)
658666
class OpStridedSlice:
659667
op_name: str = "StridedSlice"

‎backends/qualcomm/partition/utils.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,19 @@ def filter_fn(node: torch.fx.Node) -> bool:
148148
torch.int64,
149149
]:
150150
return False
151+
# QNN Stft only produces the real-valued spectrum and has no normalization.
152+
# ConvertComplexStft rewrites the supported complex cases into this form.
153+
# Without a window, QNN's rectangular default spans n_fft rather than
154+
# win_length.
155+
if node.target == torch.ops.aten.stft.default:
156+
args = list(node.args) + [None] * (8 - len(node.args))
157+
n_fft, win_length, window, normalized = args[1], args[3], args[4], args[5]
158+
return (
159+
not node.meta["val"].is_complex()
160+
and node.args[0].meta["val"].is_floating_point()
161+
and not normalized
162+
and (window is not None or win_length in (None, n_fft))
163+
)
151164
return True
152165

153166

@@ -176,6 +189,7 @@ def get_skip_decomp_table() -> List[torch._ops.OperatorBase]:
176189
torch.ops.aten.scatter_add.default,
177190
torch.ops.aten.scatter_reduce.two,
178191
torch.ops.aten.stack.default,
192+
torch.ops.aten.stft.default,
179193
torch.ops.aten.upsample_bicubic2d.vec,
180194
# This request is ignored because it is in a blocklist. Refer to exir/program/_program.py
181195
torch.ops.aten.unbind.int,

0 commit comments

Comments
 (0)