Skip to content

Commit f67adf5

Browse files
authored
Qualcomm AI Engine Direct - Adding QNN backend support for scatter.value core ATen op (pytorch#20998)
### Summary Adds support for `aten.scatter.value` on the QNN backend by extending the existing `ScatterElements` builder. `torch.scatter(data, dim, index, value)` scatters a scalar `value` into `data` at positions specified by `index`. Unlike `scatter.src` (which takes a tensor), `scatter.value` takes a scalar which requires special handling in the builder to convert the scalar to a static tensor compatible with QNN's `ScatterElements` op. Also updated the `new_op_development` Claude skill to include testing information for the new testing framework. ### Test plan ``` pytest backends/qualcomm/tests/rework/htp/op/v68/test.py -k "test_scatter_value" -v python backends/qualcomm/tests/test_qnn_delegate.py TestQNNQuantizedOperator.test_qnn_backend_scatter_value --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_scatter_value --soc_model SM8750 --host aisw-vm15-labsd --device 545ee4aa --build_folder build-android ```
1 parent 6bbb75a commit f67adf5

11 files changed

Lines changed: 233 additions & 13 deletions

File tree

‎.claude/skills/qualcomm/new_op_development.md‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,50 @@ python backends/qualcomm/tests/test_qnn_delegate.py \
165165

166166
Always ask user for `--model`, `--host`, `--device`, `--build_folder` values.
167167

168+
## Step 8b: Add Rework Framework Tests (Emulator-Based)
169+
170+
Emulator-based tests at `backends/qualcomm/tests/rework/` — no device needed, preferred for CI. Read existing ops in `src/op.py` for reference patterns.
171+
172+
**Add op class** to `tests/rework/src/op.py` (alphabetical by *class name* in `src/op.py` and by *function name* in `test.py`
173+
). All imports (`torch`, `itertools`, `unpack_fixtures`, `export_and_verify`) are file-level — don't add per-class. Class is auto-exported via `from ... import *` in test.py. Use `__class__` (Python idiom for enclosing class in `@staticmethod`):
174+
```python
175+
class MyOp(torch.nn.Module):
176+
def __init__(self, param):
177+
super().__init__()
178+
self.param = param
179+
def forward(self, x):
180+
return torch.my_op(x, self.param)
181+
182+
@staticmethod
183+
@unpack_fixtures
184+
def test(subtests, qnn_config, quantizer, compile_spec, expected):
185+
for param, inputs in [(1, (torch.randn(3,4),)), (2, (torch.randn(3,4),))]:
186+
with subtests.test(msg=f"param:{param}"):
187+
with expected as metrics:
188+
export_and_verify(module=__class__(param=param), inputs=inputs,
189+
qnn_config=qnn_config, quantizer=quantizer,
190+
compile_specs=compile_spec, metrics=metrics)
191+
```
192+
193+
**Register test** in `tests/rework/htp/op/v68/test.py` (alphabetical). Function signature must be exactly `(request, kwargs)`:
194+
```python
195+
@enumerate_activation_dtype([Tolerance(), Tolerance(), Tolerance(rtol=1e-1)])
196+
@with_htp_context
197+
def test_my_op(request, kwargs):
198+
MyOp.test(request, kwargs) # noqa: F405
199+
```
200+
201+
**Expected results** — list of 3: [8a, 16a, fp]. Options: `Tolerance()`, `Tolerance(rtol=1e-1)`, `CosineSimilarity(0.95)`, `pytest.raises(AssertionError)`, `SkipOutputCheck()`.
202+
203+
**Run:** `pytest backends/qualcomm/tests/rework/htp/op/v68/test.py -k "test_my_op" -v`
204+
205+
**Gotchas:**
206+
- Multi-output ops (sort, topk): only return float tensors — don't expose raw int indices as outputs (causes dtype/memory issues). Use `gather` to consume indices or return only values.
207+
- `subtests` fixture requires `pytest-subtests` package. Omit for single-case ops (use just `qnn_config, quantizer, compile_spec, expected` params).
208+
- Some ops fail on x86 emulator but work on-device (TopK/Sort fp, scatter.value quantized). Mark with `pytest.raises(AssertionError)`.
209+
210+
---
211+
168212
## Step 9: Prevent Decomposition (if needed)
169213

170214
If the ATen op exists in ExecuTorch's decomp table and you have a builder for it:

‎backends/qualcomm/_passes/i64_to_i32.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class I64toI32(ExportPass):
4141
I64_IN_OPS = {
4242
exir_ops.edge.aten.gather.default: [2],
4343
exir_ops.edge.aten.scatter.src: [2],
44+
exir_ops.edge.aten.scatter.value: [2],
4445
}
4546
copy_op = exir_ops.edge.aten._to_copy.default
4647

‎backends/qualcomm/_passes/layout_transform.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ class LayoutTransform(ExportPass):
121121
exir_ops.edge.aten.relu.default,
122122
exir_ops.edge.aten.round.default,
123123
exir_ops.edge.aten.scatter.src,
124+
exir_ops.edge.aten.scatter.value,
124125
exir_ops.edge.aten.sigmoid.default,
125126
exir_ops.edge.aten.sign.default,
126127
exir_ops.edge.aten.slice_copy.Tensor,

‎backends/qualcomm/_passes/lift_constant_scalar_operands.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ class TensorOpInfo:
7676
aten.scalar_tensor.default,
7777
aten.elu.default,
7878
aten.hardtanh.default,
79+
aten.scatter.value,
7980
}
8081

8182

‎backends/qualcomm/builders/op_scatter_elements.py‎

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@
99

1010
import numpy as np
1111
import torch
12-
from executorch.backends.qualcomm.utils.constants import QCOM_AXIS_ORDER, QCOM_DATA
12+
from executorch.backends.qualcomm.utils.constants import (
13+
QCOM_AXIS_ORDER,
14+
QCOM_DATA,
15+
QCOM_QUANT_ATTRS,
16+
)
1317

1418
from .node_visitor import NodeVisitor
1519
from .node_visitor_manager import register_node_visitor
@@ -18,7 +22,7 @@
1822

1923
@register_node_visitor
2024
class ScatterElements(NodeVisitor):
21-
target = ["aten.scatter.src"]
25+
target = ["aten.scatter.src", "aten.scatter.value"]
2226

2327
def __init__(self, *args) -> None:
2428
super().__init__(*args)
@@ -48,15 +52,42 @@ def define_node(
4852
nodes_to_wrappers,
4953
)
5054

51-
updates_node = self.get_node(node.args[3])
52-
updates_tensor = self.get_tensor(updates_node, node)
53-
updates_tensor_wrapper = self.define_tensor(
54-
updates_node,
55-
node,
56-
updates_tensor,
57-
PyQnnManager.Qnn_TensorType_t.QNN_TENSOR_TYPE_NATIVE,
58-
nodes_to_wrappers,
59-
)
55+
# Handle both scatter.src (args[3] is a Node/tensor) and
56+
# scatter.value (args[3] is a scalar)
57+
if isinstance(node.args[3], torch.fx.Node):
58+
# scatter.src path: args[3] is a tensor node
59+
updates_node = self.get_node(node.args[3])
60+
updates_tensor = self.get_tensor(updates_node, node)
61+
updates_tensor_wrapper = self.define_tensor(
62+
updates_node,
63+
node,
64+
updates_tensor,
65+
PyQnnManager.Qnn_TensorType_t.QNN_TENSOR_TYPE_NATIVE,
66+
nodes_to_wrappers,
67+
)
68+
else:
69+
# scatter.value: expand scalar to a float static tensor matching index
70+
# shape. For quantized models, use input_node as quant source so
71+
# define_tensor derives correct encoding and handles quantization via
72+
# get_quant_tensor_value. For fp, use index_node to avoid tensor
73+
# aliasing issues with the input at runtime.
74+
scalar_val = node.args[3]
75+
updates_tensor = torch.full(
76+
index_tensor.shape, scalar_val, dtype=input_tensor.dtype
77+
)
78+
quant_source = (
79+
input_node if input_node.meta.get(QCOM_QUANT_ATTRS) else index_node
80+
)
81+
82+
updates_tensor_wrapper = self.define_tensor(
83+
quant_source,
84+
node,
85+
updates_tensor,
86+
PyQnnManager.Qnn_TensorType_t.QNN_TENSOR_TYPE_STATIC,
87+
nodes_to_wrappers,
88+
node_name=f"{node.name}_updates_value",
89+
wrapper_idx=2,
90+
)
6091

6192
output_tensor = self.get_tensor(node, node)
6293
output_tensor_wrapper = self.define_tensor(

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1430,7 +1430,7 @@ class ScaledDotProductAttention(GeneralOpDef):
14301430

14311431

14321432
@register_annotator(
1433-
[torch.ops.aten.scatter.src],
1433+
[torch.ops.aten.scatter.src, torch.ops.aten.scatter.value],
14341434
qnn_op=None,
14351435
)
14361436
class ScatterElements(GeneralOpDef):

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -878,7 +878,7 @@ class ScaledDotProductAttention(GeneralOpDef):
878878

879879

880880
@register_annotator(
881-
[torch.ops.aten.scatter.src],
881+
[torch.ops.aten.scatter.src, torch.ops.aten.scatter.value],
882882
qnn_op=None,
883883
)
884884
class ScatterElements(GeneralOpDef):

‎backends/qualcomm/tests/models.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2332,6 +2332,16 @@ def forward(self, data, index, src):
23322332
return torch.scatter(data, self.dim, index, src)
23332333

23342334

2335+
class ScatterValue(torch.nn.Module):
2336+
def __init__(self, dim=1, value=0.5):
2337+
super().__init__()
2338+
self.dim = dim
2339+
self.value = value
2340+
2341+
def forward(self, data, index):
2342+
return torch.scatter(data, self.dim, index, self.value)
2343+
2344+
23352345
class SelectCopy(torch.nn.Module):
23362346
def __init__(self):
23372347
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
@@ -1267,6 +1267,18 @@ def test_slice_scatter(request, kwargs):
12671267
SliceScatter.test(request, kwargs) # noqa: F405
12681268

12691269

1270+
@enumerate_activation_dtype(
1271+
[
1272+
Tolerance(),
1273+
Tolerance(),
1274+
pytest.raises(AssertionError),
1275+
]
1276+
)
1277+
@with_htp_context
1278+
def test_scatter_value(request, kwargs):
1279+
ScatterValue.test(request, kwargs) # noqa: F405
1280+
1281+
12701282
@enumerate_activation_dtype([Tolerance(), Tolerance(), Tolerance(rtol=1e-1)])
12711283
@with_htp_context
12721284
def test_softmax(request, kwargs):

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4216,6 +4216,53 @@ def test(subtests, qnn_config, quantizer, compile_spec, expected):
42164216
)
42174217

42184218

4219+
class ScatterValue(torch.nn.Module):
4220+
def __init__(self, dim, value):
4221+
super().__init__()
4222+
self.dim = dim
4223+
self.value = value
4224+
4225+
def forward(self, data, index):
4226+
return torch.scatter(data, self.dim, index, self.value)
4227+
4228+
@staticmethod
4229+
@unpack_fixtures
4230+
def test(subtests, qnn_config, quantizer, compile_spec, expected):
4231+
cases = [
4232+
# (dim, value, data_shape, index_tensor)
4233+
(
4234+
1,
4235+
0.5,
4236+
(3, 5),
4237+
torch.tensor(
4238+
[[0, 1, 2, 3, 4], [4, 3, 2, 1, 0], [1, 0, 3, 4, 2]],
4239+
dtype=torch.int64,
4240+
),
4241+
),
4242+
(
4243+
0,
4244+
1.0,
4245+
(3, 5),
4246+
torch.tensor(
4247+
[[2, 1, 0, 1, 2], [0, 2, 1, 2, 0], [1, 0, 2, 0, 1]],
4248+
dtype=torch.int64,
4249+
),
4250+
),
4251+
]
4252+
for dim, value, data_shape, index in cases:
4253+
with subtests.test(msg=f"dim:{dim}, value:{value}"):
4254+
inputs = (torch.rand(*data_shape), index)
4255+
with expected as metrics:
4256+
export_and_verify(
4257+
module=__class__(dim=dim, value=value),
4258+
inputs=inputs,
4259+
qnn_config=qnn_config,
4260+
quantizer=quantizer,
4261+
compile_specs=compile_spec,
4262+
metrics=metrics,
4263+
)
4264+
4265+
42194266
class SelectScatter(torch.nn.Module):
42204267
def __init__(self, dim, index):
42214268
super().__init__()

0 commit comments

Comments
 (0)