Skip to content

Commit d0fe35d

Browse files
authored
Qualcomm AI Engine Direct - Adding QNN backend support for the sort core ATen op (pytorch#21386)
### Summary Added support for the `aten.sort` core ATen op for QNN HTP backend. Implements `aten.sort.default` delegation to QNN HTP by reusing the existing `TopK` op with `k = dim_size`. Supports both ascending and descending sort order. Note that this op faces the same limitations as the existing `TopK` op: - Only supports sorting along the last dimension (QNN constraint). - Sort indices are output as `int32`; consuming them with gather in quantized models may cause `dtype` mismatches. Added a floating point `sort_and_index` test for this use-case. ### Test plan ``` pytest backends/qualcomm/tests/rework/htp/op/v68/test.py -k "test_sort" python backends/qualcomm/tests/test_qnn_delegate.py TestQNNQuantizedOperator.test_qnn_backend_sort --soc_model SM8750 --host aisw-vm15-labsd --device 545ee4aa --build_folder build-android python backends/qualcomm/tests/test_qnn_delegate.py TestQNNFloatingPointOperator.test_qnn_backend_sort --soc_model SM8750 --host aisw-vm15-labsd --device 545ee4aa --build_folder build-android python backends/qualcomm/tests/test_qnn_delegate.py TestQNNFloatingPointOperator.test_qnn_backend_sort_and_index --soc_model SM8750 --host aisw-vm15-labsd --device 545ee4aa --build_folder build-android ```
1 parent 94c6c88 commit d0fe35d

9 files changed

Lines changed: 316 additions & 4 deletions

File tree

‎backends/qualcomm/_passes/layout_transform.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ class LayoutTransform(ExportPass):
124124
exir_ops.edge.aten.sigmoid.default,
125125
exir_ops.edge.aten.sign.default,
126126
exir_ops.edge.aten.slice_copy.Tensor,
127+
exir_ops.edge.aten.sort.default,
127128
exir_ops.edge.aten.split_with_sizes.default,
128129
exir_ops.edge.aten.split_with_sizes_copy.default,
129130
exir_ops.edge.aten.sqrt.default,

‎backends/qualcomm/builders/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@
9999
op_slice_copy,
100100
op_slice_scatter,
101101
op_softmax,
102+
op_sort,
102103
op_space_to_depth,
103104
op_split_with_sizes,
104105
op_sqrt,
@@ -214,6 +215,7 @@
214215
op_slice_copy,
215216
op_slice_scatter,
216217
op_softmax,
218+
op_sort,
217219
op_space_to_depth,
218220
op_split_with_sizes,
219221
op_squeeze,
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
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 warnings
7+
from typing import cast, Dict
8+
9+
import executorch.backends.qualcomm.python.PyQnnManagerAdaptor as PyQnnManager
10+
11+
import numpy as np
12+
import torch
13+
from executorch.backends.qualcomm.utils.constants import (
14+
QCOM_AXIS_ORDER,
15+
QCOM_DATA,
16+
QCOM_QUANT_ATTRS,
17+
)
18+
19+
from .node_visitor import NodeVisitor
20+
from .node_visitor_manager import register_node_visitor
21+
from .qnn_constants import OpTopK, QNN_OP_PACKAGE_NAME_QTI_AISW
22+
23+
24+
@register_node_visitor
25+
class Sort(NodeVisitor):
26+
"""Implement aten.sort via QNN TopK with k = dim_size.
27+
28+
Limitations:
29+
- Only supports sorting along the last dimension (channel dim in NHWC).
30+
Sorting on other dimensions will return None (op not delegated).
31+
- Sort indices are output as int32 by QNN. In quantized models, consuming
32+
sort indices with ops like gather (which expect int64) may cause dtype mismatches.
33+
For quantized use cases, prefer consuming only the sorted values output.
34+
"""
35+
36+
target = ["aten.sort.default"]
37+
38+
def __init__(self, *args) -> None:
39+
super().__init__(*args)
40+
41+
def define_node(
42+
self,
43+
node: torch.fx.Node,
44+
nodes_to_wrappers: Dict[torch.fx.Node, PyQnnManager.TensorWrapper],
45+
) -> PyQnnManager.PyQnnOpWrapper:
46+
47+
input_node = self.get_node(node.args[0])
48+
input_tensor = self.get_tensor(input_node, node)
49+
input_tensor_wrapper = self.define_tensor(
50+
input_node,
51+
node,
52+
input_tensor,
53+
PyQnnManager.Qnn_TensorType_t.QNN_TENSOR_TYPE_STATIC,
54+
nodes_to_wrappers,
55+
)
56+
57+
# aten.sort signature: sort(input, dim=-1, descending=False)
58+
dim = cast(int, node.args[1]) if len(node.args) > 1 else -1
59+
descending = cast(bool, node.args[2]) if len(node.args) > 2 else False
60+
61+
if dim < 0:
62+
dim = dim % len(input_tensor.shape)
63+
if QCOM_AXIS_ORDER in node.meta:
64+
dim = node.meta[QCOM_AXIS_ORDER].index(dim)
65+
66+
# Sort is TopK with k = full dimension size
67+
k = input_tensor.shape[dim]
68+
69+
if dim != len(input_tensor.shape) - 1:
70+
warnings.warn(
71+
"[QNN Delegate Op Builder]: QNN currently only supports channel as dimension for TopK/Sort.",
72+
stacklevel=1,
73+
)
74+
return
75+
76+
sort_input_tensors = [input_tensor_wrapper]
77+
78+
output_val_tensor = self.get_tensor(node, node, 0)
79+
output_idx_tensor = self.get_tensor(node, node, 1).to(torch.int32)
80+
81+
# QNN constraint, topk/sort output_0 requires having the same quant config as input
82+
node.meta[QCOM_QUANT_ATTRS] = input_node.meta.get(QCOM_QUANT_ATTRS)
83+
output_val_tensor_wrapper = self.define_tensor(
84+
node,
85+
node,
86+
output_val_tensor,
87+
PyQnnManager.Qnn_TensorType_t.QNN_TENSOR_TYPE_NATIVE,
88+
nodes_to_wrappers,
89+
)
90+
91+
# sort output_1 is index, do not quantize it.
92+
node.meta.pop(QCOM_QUANT_ATTRS, None)
93+
output_index_tensor_wrapper = self.define_tensor(
94+
node,
95+
node,
96+
output_idx_tensor,
97+
PyQnnManager.Qnn_TensorType_t.QNN_TENSOR_TYPE_NATIVE,
98+
nodes_to_wrappers,
99+
wrapper_idx=1,
100+
)
101+
sort_output_tensors = [output_val_tensor_wrapper, output_index_tensor_wrapper]
102+
103+
topk_op = PyQnnManager.PyQnnOpWrapper(
104+
node.name,
105+
QNN_OP_PACKAGE_NAME_QTI_AISW,
106+
OpTopK.op_name,
107+
)
108+
topk_op.AddInputTensors(sort_input_tensors)
109+
topk_op.AddOutputTensors(sort_output_tensors)
110+
111+
topk_op.AddScalarParam(
112+
OpTopK.param_k,
113+
PyQnnManager.Qnn_DataType_t.QNN_DATATYPE_UINT_32,
114+
{QCOM_DATA: np.uint32(k)},
115+
)
116+
117+
# descending=True in sort corresponds to largest=True in TopK
118+
topk_op.AddScalarParam(
119+
OpTopK.param_largest,
120+
PyQnnManager.Qnn_DataType_t.QNN_DATATYPE_BOOL_8,
121+
{QCOM_DATA: descending},
122+
)
123+
124+
return topk_op

‎backends/qualcomm/quantizer/annotators/htp_rules.py‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -648,8 +648,11 @@ def annotate(node: Node, quantization_config: QuantizationConfig) -> None:
648648
return
649649

650650
out_act_quantization_spec = quantization_config.output_activation
651-
# QNN constraint, topk output_0 requires having the same quant config as input
652-
if node.args[0].target == torch.ops.aten.topk.default:
651+
# QNN constraint, topk/sort output_0 requires having the same quant config as input
652+
if node.args[0].target in (
653+
torch.ops.aten.topk.default,
654+
torch.ops.aten.sort.default,
655+
):
653656
out_act_quantization_spec = SharedQuantizationSpec(node.args[0])
654657
node.meta[Q_ANNOTATION_KEY] = QuantizationAnnotation(
655658
output_qspec=out_act_quantization_spec,
@@ -1464,6 +1467,30 @@ def annotate(node: Node, quantization_config: QuantizationConfig) -> None:
14641467
)
14651468

14661469

1470+
@register_annotator([torch.ops.aten.sort.default], QnnConstants.OpTopK.op_name)
1471+
class Sort(GeneralOpDef):
1472+
@staticmethod
1473+
def annotate(node: Node, quantization_config: QuantizationConfig) -> None:
1474+
if _is_annotated([node]):
1475+
return
1476+
1477+
input_qspec_map = {}
1478+
input_act_qspec = quantization_config.input_activation
1479+
out_act_quantization_spec = None
1480+
if input_act_qspec is not None:
1481+
if _is_float_tensor(node.args[0]):
1482+
input_act = node.args[0]
1483+
assert isinstance(input_act, Node)
1484+
input_qspec_map[input_act] = input_act_qspec
1485+
out_act_quantization_spec = SharedQuantizationSpec((input_act, node))
1486+
1487+
node.meta[Q_ANNOTATION_KEY] = QuantizationAnnotation(
1488+
input_qspec_map=input_qspec_map,
1489+
output_qspec=out_act_quantization_spec,
1490+
_annotated=True,
1491+
)
1492+
1493+
14671494
@register_annotator(
14681495
[torch.ops.aten.sigmoid, torch.ops.aten.sigmoid.default],
14691496
QnnConstants.OpSigmoid.op_name,

‎backends/qualcomm/quantizer/annotators/lpai_rules.py‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -414,8 +414,11 @@ def annotate(node: Node, quantization_config: QuantizationConfig) -> None:
414414
return
415415

416416
out_act_quantization_spec = quantization_config.output_activation
417-
# QNN constraint, topk output_0 requires having the same quant config as input
418-
if node.args[0].target == torch.ops.aten.topk.default:
417+
# QNN constraint, topk/sort output_0 requires having the same quant config as input
418+
if node.args[0].target in (
419+
torch.ops.aten.topk.default,
420+
torch.ops.aten.sort.default,
421+
):
419422
out_act_quantization_spec = SharedQuantizationSpec(node.args[0])
420423
node.meta[Q_ANNOTATION_KEY] = QuantizationAnnotation(
421424
output_qspec=out_act_quantization_spec,
@@ -911,6 +914,30 @@ def annotate(node: Node, quantization_config: QuantizationConfig) -> None:
911914
)
912915

913916

917+
@register_annotator([torch.ops.aten.sort.default], QnnConstants.OpTopK.op_name)
918+
class Sort(GeneralOpDef):
919+
@staticmethod
920+
def annotate(node: Node, quantization_config: QuantizationConfig) -> None:
921+
if _is_annotated([node]):
922+
return
923+
924+
input_qspec_map = {}
925+
input_act_qspec = quantization_config.input_activation
926+
out_act_quantization_spec = None
927+
if input_act_qspec is not None:
928+
if _is_float_tensor(node.args[0]):
929+
input_act = node.args[0]
930+
assert isinstance(input_act, Node)
931+
input_qspec_map[input_act] = input_act_qspec
932+
out_act_quantization_spec = SharedQuantizationSpec((input_act, node))
933+
934+
node.meta[Q_ANNOTATION_KEY] = QuantizationAnnotation(
935+
input_qspec_map=input_qspec_map,
936+
output_qspec=out_act_quantization_spec,
937+
_annotated=True,
938+
)
939+
940+
914941
@register_annotator(
915942
[torch.ops.aten.sigmoid, torch.ops.aten.sigmoid.default],
916943
QnnConstants.OpSigmoid.op_name,

‎backends/qualcomm/tests/models.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -901,6 +901,18 @@ def forward(self, x):
901901
return topk_values
902902

903903

904+
class Conv2dSort(torch.nn.Module):
905+
def __init__(self, descending=True):
906+
super().__init__()
907+
self.conv = torch.nn.Conv2d(3, 16, 3)
908+
self.descending = descending
909+
910+
def forward(self, x):
911+
x = self.conv(x)
912+
sort_values, sort_indices = torch.sort(x, dim=1, descending=self.descending)
913+
return sort_values
914+
915+
904916
class Conv2dUnbind(torch.nn.Module):
905917
def __init__(self):
906918
super().__init__()
@@ -2620,6 +2632,33 @@ def forward(self, x):
26202632
return torch.nn.functional.softmax(x, dim=self.dim)
26212633

26222634

2635+
class Sort(torch.nn.Module):
2636+
def __init__(self, dim=-1, descending=True):
2637+
super().__init__()
2638+
self.dim = dim
2639+
self.descending = descending
2640+
2641+
def forward(self, x):
2642+
values, indices = torch.sort(x, dim=self.dim, descending=self.descending)
2643+
return values
2644+
2645+
2646+
class SortAndIndex(torch.nn.Module):
2647+
def __init__(self, shape, dim=-1, descending=True):
2648+
super().__init__()
2649+
self.idx_source = torch.rand(*shape)
2650+
self.dim = dim
2651+
self.descending = descending
2652+
self.rank = len(shape)
2653+
2654+
def forward(self, x):
2655+
normalized_dim = self.dim if self.dim >= 0 else self.rank + self.dim
2656+
2657+
values, indices = torch.sort(x, dim=normalized_dim, descending=self.descending)
2658+
gathered = torch.gather(self.idx_source, normalized_dim, indices)
2659+
return values + gathered
2660+
2661+
26232662
class Sqrt(torch.nn.Module):
26242663
def __init__(self):
26252664
super().__init__()

‎backends/qualcomm/tests/rework/htp/op/v68/test.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1273,6 +1273,18 @@ def test_softmax(request, kwargs):
12731273
Softmax.test(request, kwargs) # noqa: F405
12741274

12751275

1276+
@enumerate_activation_dtype(
1277+
[
1278+
Tolerance(),
1279+
Tolerance(),
1280+
pytest.raises(AssertionError),
1281+
]
1282+
)
1283+
@with_htp_context
1284+
def test_sort(request, kwargs):
1285+
Sort.test(request, kwargs) # noqa: F405
1286+
1287+
12761288
@enumerate_activation_dtype([Tolerance(), Tolerance(), Tolerance(rtol=1e-1)])
12771289
@with_htp_context
12781290
def test_split(request, kwargs):

‎backends/qualcomm/tests/rework/src/op.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4496,6 +4496,38 @@ def test(subtests, qnn_config, quantizer, compile_spec, expected):
44964496
)
44974497

44984498

4499+
class Sort(torch.nn.Module):
4500+
def __init__(self, dim, descending):
4501+
super().__init__()
4502+
self.dim = dim
4503+
self.descending = descending
4504+
4505+
def forward(self, x):
4506+
sorted_values, sorted_indices = torch.sort(
4507+
x, dim=self.dim, descending=self.descending
4508+
)
4509+
return sorted_values
4510+
4511+
@staticmethod
4512+
@unpack_fixtures
4513+
def test(subtests, qnn_config, quantizer, compile_spec, expected):
4514+
inputs = (torch.randn(1, 2, 3, 4),)
4515+
# QNN only supports sort along the last dimension
4516+
dims = [-1]
4517+
descendings = [True, False]
4518+
for dim, descending in itertools.product(dims, descendings):
4519+
with subtests.test(msg=f"dim:{dim}, descending:{descending}"):
4520+
with expected as metrics:
4521+
export_and_verify(
4522+
module=__class__(dim=dim, descending=descending),
4523+
inputs=inputs,
4524+
qnn_config=qnn_config,
4525+
quantizer=quantizer,
4526+
compile_specs=compile_spec,
4527+
metrics=metrics,
4528+
)
4529+
4530+
44994531
class Split(torch.nn.Module):
45004532
def __init__(self, split_size_or_sections, dim):
45014533
super().__init__()

0 commit comments

Comments
 (0)