Skip to content

Commit 497b0d9

Browse files
authored
Qualcomm AI Engine Direct - Adding QNN backend support for _pdist_forward core ATen op (pytorch#20892)
### Summary Added support for `aten.pdist` and `aten._pdist_forward` ops for the QNN backend via a decomposition pass. `torch.pdist(x, p=2)` computes pairwise Euclidean distances between all rows of a 2D input tensor `[N, M]`, returning a condensed 1D vector of length `N*(N-1)/2`. __Decomposition (p=2 only):__ Precomputes upper-triangle row/column index pairs at decomposition time, then uses `index_select` to extract only the `N*(N-1)/2` pairs that contribute to the output — avoiding the full `[N, N, M]` broadcast: 1. Precompute `rows`, `cols` index tensors for upper-triangle pairs `(i, j)` where `i < j` 2. `index_select(x, 0, rows)` - `index_select(x, 0, cols)` → pairwise differences `[N*(N-1)/2, M]` 3. `pow(2)` → square differences 4. `sum(dim=-1)` → sum over features → `[N*(N-1)/2]` 5. `sqrt` → Euclidean distances `[N*(N-1)/2]` The index buffers are registered with node-unique names (`_pdist_rows_{node.name}`, `_pdist_cols_{node.name}`) to avoid collisions when multiple `pdist` ops exist in the same graph. Note that only `p=2` is supported, similar to the existing `DecomposeCDist` constraint. Static shapes are required for precomputing the index pairs at decomposition time. Also added a docstring to `DecomposeCDist` to better explain how that decomposition works. ### Test plan ``` python backends/qualcomm/tests/test_qnn_delegate.py TestQNNQuantizedOperator.test_qnn_backend_pdist_forward --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_pdist_forward --soc_model SM8750 --host aisw-vm15-labsd --device 545ee4aa --build_folder build-android python backends/qualcomm/tests/test_qnn_delegate.py TestQNNQuantizedOperator.test_qnn_backend_pdist --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_pdist --soc_model SM8750 --host aisw-vm15-labsd --device 545ee4aa --build_folder build-android ```
1 parent 46fdc13 commit 497b0d9

7 files changed

Lines changed: 182 additions & 1 deletion

File tree

‎backends/qualcomm/_passes/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from .decompose_maxpool3d import DecomposeMaxPool3d
3535
from .decompose_minmaxdim import DecomposeMinMaxDim
3636
from .decompose_pad import DecomposePad
37+
from .decompose_pdist import DecomposePDist
3738
from .decompose_reciprocal import DecomposeReciprocal
3839
from .decompose_remainder import DecomposeRemainder
3940
from .decompose_roll import DecomposeRoll
@@ -99,6 +100,7 @@
99100
DecomposeMaxPool3d,
100101
DecomposeMinMaxDim,
101102
DecomposePad,
103+
DecomposePDist,
102104
DecomposeReciprocal,
103105
DecomposeRemainder,
104106
DecomposeRoll,

‎backends/qualcomm/_passes/decompose_cdist.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,19 @@ def forward(self, x, y):
3333

3434
class DecomposeCDist(ExportPass):
3535
"""
36-
Decompose for math equivalent op.
36+
Decompose aten.cdist and aten._cdist_forward into supported primitives.
37+
38+
torch.cdist(x, y, p=2) computes pairwise Euclidean distances between all
39+
row pairs of two 2D (or batched) input tensors x of shape [..., P, M] and
40+
y of shape [..., R, M], returning a distance matrix of shape [..., P, R].
41+
42+
Decomposition (p=2 only):
43+
1. diff[...,i,j] = x[...,i,:] - y[...,j,:] # broadcast: [...,P,1,M] - [...,1,R,M] -> [...,P,R,M]
44+
2. sq_diff = diff ** 2 # element-wise square
45+
3. sum_sq = sq_diff.sum(dim=-1) # sum over feature dim -> [..., P, R]
46+
4. dist = sqrt(sum_sq) # Euclidean distance matrix [..., P, R]
47+
48+
Only p=2 is supported.
3749
"""
3850

3951
cdist_targets = {
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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 executorch.exir.pass_base import ExportPass, PassResult
9+
from executorch.exir.passes import dead_code_elimination_pass
10+
11+
from .utils import merge_decomposed_graph
12+
13+
14+
class PDist(torch.nn.Module):
15+
def __init__(self, N):
16+
super().__init__()
17+
# Precompute row and column indices for upper triangle pairs
18+
pairs = [(i, j) for i in range(N) for j in range(i + 1, N)]
19+
self.register_buffer(
20+
"rows", torch.tensor([i for i, _ in pairs], dtype=torch.int32)
21+
)
22+
self.register_buffer(
23+
"cols", torch.tensor([j for _, j in pairs], dtype=torch.int32)
24+
)
25+
26+
def forward(self, x):
27+
# Select paired rows directly — only N*(N-1)/2 differences computed
28+
diff = torch.index_select(x, 0, self.rows) - torch.index_select(x, 0, self.cols)
29+
30+
# Square differences
31+
sq_diff = diff**2
32+
33+
# Sum of squares
34+
sum_sq_diff = sq_diff.sum(dim=-1)
35+
36+
# Square root
37+
return torch.sqrt(sum_sq_diff)
38+
39+
40+
class DecomposePDist(ExportPass):
41+
"""
42+
Decompose aten.pdist and aten._pdist_forward into supported primitives.
43+
44+
torch.pdist(x, p=2) computes pairwise Euclidean distances between all
45+
row pairs of a 2D input tensor x of shape [N, M], returning a condensed
46+
1D vector of length N*(N-1)/2 (upper triangle, row-major order).
47+
48+
Decomposition (p=2 only):
49+
1. diff = x[rows] - x[cols] # index_select paired rows: [N*(N-1)/2, M]
50+
2. sq_diff = diff ** 2 # element-wise square
51+
3. sum_sq = sq_diff.sum(dim=-1) # sum over feature dim -> [N*(N-1)/2]
52+
4. out = sqrt(sum_sq) # Euclidean distances [N*(N-1)/2]
53+
54+
Row and column indices for upper triangle pairs are precomputed at
55+
decomposition time (requires static N). This avoids materializing the
56+
full [N, N, M] difference tensor.
57+
58+
Only p=2 is supported.
59+
"""
60+
61+
pdist_targets = {
62+
torch.ops.aten.pdist.default,
63+
torch.ops.aten._pdist_forward.default,
64+
}
65+
66+
def __init__(self) -> None:
67+
super().__init__()
68+
69+
def call(self, graph_module: torch.fx.GraphModule) -> PassResult:
70+
graph = graph_module.graph
71+
for node in list(graph.nodes):
72+
if node.target in self.pdist_targets:
73+
# Check p value - only p=2 is supported
74+
if len(node.args) > 1:
75+
p = node.args[1]
76+
elif "p" in node.kwargs:
77+
p = node.kwargs["p"]
78+
else:
79+
p = 2.0
80+
assert (
81+
p == 2 or p == 2.0
82+
), "Currently only p=2 is supported for PDist Decomposition"
83+
84+
input_val = node.args[0].meta["val"]
85+
N = input_val.shape[0]
86+
assert isinstance(N, int), (
87+
f"DecomposePDist requires static shapes but got symbolic "
88+
f"dimension N={N}. Dynamic shapes are not supported."
89+
)
90+
91+
model = PDist(N)
92+
decomposed_module = torch.export.export(
93+
model,
94+
(input_val,),
95+
strict=True,
96+
).module()
97+
98+
rows_attr = f"_pdist_rows_{node.name}"
99+
cols_attr = f"_pdist_cols_{node.name}"
100+
graph_module.register_buffer(rows_attr, model.rows)
101+
graph_module.register_buffer(cols_attr, model.cols)
102+
103+
for decomposed_node in decomposed_module.graph.nodes:
104+
if decomposed_node.op == "get_attr":
105+
if decomposed_node.target == "rows":
106+
decomposed_node.target = rows_attr
107+
elif decomposed_node.target == "cols":
108+
decomposed_node.target = cols_attr
109+
110+
with graph.inserting_before(node):
111+
# remap is used to map original node values to new node values,
112+
# which ensures that reference to nodes are correctly updated in the new graph
113+
remap = {"x": node.args[0]}
114+
merge_decomposed_graph(
115+
remap=remap,
116+
target_node=node,
117+
target_graph=graph,
118+
decomposed_graph_module=decomposed_module,
119+
)
120+
graph.erase_node(node)
121+
122+
dead_code_elimination_pass(graph_module)
123+
return PassResult(graph_module, True)

‎backends/qualcomm/_passes/qnn_pass_manager.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
DecomposeMaxPool3d,
4040
DecomposeMinMaxDim,
4141
DecomposePad,
42+
DecomposePDist,
4243
DecomposeRemainder,
4344
DecomposeRoll,
4445
DecomposeSelectScatter,
@@ -130,6 +131,7 @@ def get_default_pass_activations(cls):
130131
(DecomposeAtan2, True),
131132
(DecomposeColIm, True),
132133
(DecomposeCDist, True),
134+
(DecomposePDist, True),
133135
(DecomposeDiagonal, True),
134136
(DecomposeDivMode, True),
135137
(DecomposeFill, True),
@@ -170,6 +172,7 @@ def get_annotation_passes(cls):
170172
DecomposeAtan2,
171173
DecomposeBinaryAlpha,
172174
DecomposeCDist,
175+
DecomposePDist,
173176
DecomposeDiagonal,
174177
DecomposeDivMode,
175178
DecomposeMaxPool3d,
@@ -205,6 +208,7 @@ def get_export_passes(
205208
passes = [
206209
DecomposeBinaryAlpha,
207210
DecomposeCDist,
211+
DecomposePDist,
208212
DecomposePad,
209213
DecomposeScaledDotProductAttention,
210214
DecomposeRoll,
@@ -289,6 +293,7 @@ def get_passes_dependency_for_capture_program(cls):
289293
DecomposeAtan2: [RemoveRedundancy],
290294
DecomposeColIm: [FoldQDQ],
291295
DecomposeCDist: [RemoveRedundancy],
296+
DecomposePDist: [RemoveRedundancy],
292297
DecomposeDiagonal: [RemoveRedundancy],
293298
DecomposeDivMode: [RemoveRedundancy],
294299
DecomposeFill: [RemoveRedundancy],

‎backends/qualcomm/builders/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,7 @@ The following PyTorch operators are supported through decomposition or annotatio
521521
| `aten.log10`, `aten.log2`, `aten.log1p` | `DecomposeLogVariants` |
522522
| `aten.max_pool3d` | `DecomposeMaxPool3d` |
523523
| `aten.min.dim`, `aten.max.dim` | `DecomposeMinMaxDim` |
524+
| `aten.pdist`, `aten._pdist_forward` | `DecomposePDist` |
524525
| `aten.reciprocal` | `DecomposeReciprocal` |
525526
| `aten.reflection_pad1d` | PyTorch built-in decomposition |
526527
| `aten.reflection_pad2d` | `DecomposePad` |

‎backends/qualcomm/tests/models.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,22 @@ def forward(self, x, y):
450450
return torch.ops.aten._cdist_forward.default(x, y, 2.0, None)
451451

452452

453+
class PDist(torch.nn.Module):
454+
def __init__(self):
455+
super().__init__()
456+
457+
def forward(self, x):
458+
return torch.pdist(x, p=2)
459+
460+
461+
class PDistForward(torch.nn.Module):
462+
def __init__(self):
463+
super().__init__()
464+
465+
def forward(self, x):
466+
return torch.ops.aten._pdist_forward.default(x, 2.0)
467+
468+
453469
class Ceil(torch.nn.Module):
454470
def __init__(self):
455471
super().__init__()

‎backends/qualcomm/tests/test_qnn_delegate.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,16 @@ def test_qnn_backend_cdist_forward(self):
477477
)
478478
self.lower_module_and_test_output(module, sample_input)
479479

480+
def test_qnn_backend_pdist(self):
481+
module = PDist() # noqa: F405
482+
sample_input = (torch.randn(8, 64),)
483+
self.lower_module_and_test_output(module, sample_input)
484+
485+
def test_qnn_backend_pdist_forward(self):
486+
module = PDistForward() # noqa: F405
487+
sample_input = (torch.randn(8, 64),)
488+
self.lower_module_and_test_output(module, sample_input)
489+
480490
def test_qnn_backend_channel_shuffle(self):
481491
module = ChannelShuffle(2) # noqa: F405
482492
sample_input = (torch.randn(1, 4, 3, 3),)
@@ -3354,6 +3364,18 @@ def test_qnn_backend_cdist_forward(self):
33543364
module = self.get_qdq_module(module, sample_input)
33553365
self.lower_module_and_test_output(module, sample_input)
33563366

3367+
def test_qnn_backend_pdist(self):
3368+
module = PDist() # noqa: F405
3369+
sample_input = (torch.randn(8, 64),)
3370+
module = self.get_qdq_module(module, sample_input)
3371+
self.lower_module_and_test_output(module, sample_input)
3372+
3373+
def test_qnn_backend_pdist_forward(self):
3374+
module = PDistForward() # noqa: F405
3375+
sample_input = (torch.randn(8, 64),)
3376+
module = self.get_qdq_module(module, sample_input)
3377+
self.lower_module_and_test_output(module, sample_input)
3378+
33573379
def test_qnn_backend_channel_shuffle(self):
33583380
module = ChannelShuffle(2) # noqa: F405
33593381
sample_input = (torch.randn(1, 4, 3, 3),)

0 commit comments

Comments
 (0)