Skip to content

Commit b45e85d

Browse files
authored
Qualcomm: give custom op args with no QNN mapping a real error (pytorch#21868)
str is Iterable, so a string argument fell into the tensor-param branch and died on QNN_TENSOR_TYPE_MAP[type(arg[0])] with a bare KeyError: <class 'str'>. A list whose elements have no mapping -- str[] among them -- still does. Name the argument and say what to do. Strings are not a QNN limitation: QNN_DATATYPE_STRING exists and PyQnnManagerAdaptor.cpp already reads it, but AddScalarParam has no case for it, so point at that gap rather than implying the backend cannot represent strings at all. Authored with Claude Code. cc @cbilgin
1 parent ed2d9ec commit b45e85d

2 files changed

Lines changed: 112 additions & 4 deletions

File tree

backends/qualcomm/builders/op_custom_op.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,26 @@
2020
from .node_visitor import NodeVisitor, QNN_TENSOR_TYPE_MAP
2121

2222

23+
def _resolve_qnn_data_type(
24+
py_type: type, arg_name: str, target: str, *, of_element: bool = False
25+
):
26+
"""QNN data type for a custom-op arg type, or a ValueError naming the arg."""
27+
what = "element type" if of_element else "type"
28+
if py_type is str:
29+
raise ValueError(
30+
f"String {what} for argument '{arg_name}' of {target} is unsupported: "
31+
"QNN_DATATYPE_STRING is not plumbed through AddScalarParam in "
32+
"aot/python/PyQnnManagerAdaptor.h. Encode it as an int in the op "
33+
"schema, or add the missing case."
34+
)
35+
if py_type not in QNN_TENSOR_TYPE_MAP:
36+
raise ValueError(
37+
f"Argument '{arg_name}' of {target} has unsupported {what} "
38+
f"{py_type.__name__}: QNN_TENSOR_TYPE_MAP has no entry for it."
39+
)
40+
return QNN_TENSOR_TYPE_MAP[py_type]
41+
42+
2343
class CustomOp(NodeVisitor):
2444
target = ""
2545
op_package_info = QnnExecuTorchOpPackageInfo()
@@ -61,20 +81,31 @@ def define_node(
6181
nodes_to_wrappers,
6282
)
6383
custom_input_tensors.append(input_tensor_wrapper)
84+
elif isinstance(arg, str):
85+
# str is Iterable, so it would otherwise be taken for a tensor param.
86+
_resolve_qnn_data_type(str, arg_name, self.target)
6487
elif isinstance(arg, Iterable):
65-
tensor_parm_shape = [len(arg)]
88+
values = list(arg)
89+
if not values:
90+
raise ValueError(
91+
f"Argument '{arg_name}' of {self.target} is empty: a QNN "
92+
"tensor param needs at least one element."
93+
)
94+
tensor_parm_shape = [len(values)]
6695
custom_op.AddTensorParam(
6796
arg_name,
68-
QNN_TENSOR_TYPE_MAP[type(arg[0])],
97+
_resolve_qnn_data_type(
98+
type(values[0]), arg_name, self.target, of_element=True
99+
),
69100
len(tensor_parm_shape),
70101
tensor_parm_shape,
71-
np.array(arg),
102+
np.array(values),
72103
True,
73104
)
74105
else:
75106
custom_op.AddScalarParam(
76107
arg_name,
77-
QNN_TENSOR_TYPE_MAP[type(arg)],
108+
_resolve_qnn_data_type(type(arg), arg_name, self.target),
78109
{QCOM_DATA: arg},
79110
)
80111

backends/qualcomm/tests/test_passes.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,18 @@
1515
from executorch.backends.qualcomm._passes.qnn_pass_manager import (
1616
get_qnn_pass_manager_cls,
1717
)
18+
from executorch.backends.qualcomm.builders.op_custom_op import (
19+
_resolve_qnn_data_type,
20+
CustomOp,
21+
)
1822
from executorch.backends.qualcomm.builders.qnn_constants import OpContextLoader
1923
from executorch.backends.qualcomm.partition.qnn_partitioner import QnnOperatorSupport
2024
from executorch.backends.qualcomm.qnn_preprocess import QnnBackend
2125
from executorch.backends.qualcomm.quantizer.quantizer import QnnQuantizer, QuantDtype
2226
from executorch.backends.qualcomm.serialization.qc_schema import (
2327
QcomChipset,
2428
QnnExecuTorchBackendType,
29+
QnnExecuTorchOpPackageInfo,
2530
)
2631
from executorch.backends.qualcomm.tests.models import (
2732
BroadcastAndMutate,
@@ -718,6 +723,78 @@ def forward(self, a2d, b):
718723
"dedupe must not rank-promote the USER_INPUT_MUTATION write-back",
719724
)
720725

726+
def _custom_op_node(self, arg, arg_name):
727+
"""A CustomOp builder plus a single-arg node, driven through define_node so
728+
the branch dispatch is exercised rather than the type helper in isolation.
729+
Branch order matters: str must be matched before Iterable, since str is
730+
itself Iterable and would otherwise take the tensor-param path."""
731+
732+
class _Arg:
733+
name = arg_name
734+
735+
class _Schema:
736+
arguments = [_Arg()]
737+
738+
class _Target:
739+
_schema = _Schema()
740+
741+
node = MagicMock()
742+
node.name = "my_ops_foo_default"
743+
node.target = _Target()
744+
node.args = (arg,)
745+
746+
info = QnnExecuTorchOpPackageInfo()
747+
info.custom_op_name = "my_ops.foo.default"
748+
info.op_package_name = "FooOpPackage"
749+
info.qnn_op_type_name = "Foo"
750+
return CustomOp(info, {}, None, False, True), node
751+
752+
def test_custom_op_rejects_str_arg(self):
753+
"""A str custom-op arg must name the argument and the missing binding.
754+
755+
str is Iterable, so before the guard it reached the tensor-param branch and
756+
died on QNN_TENSOR_TYPE_MAP[type(arg[0])] with a bare KeyError. Strings are
757+
not a QNN limitation: QNN_DATATYPE_STRING exists and PyQnnManagerAdaptor.cpp
758+
already reads it; only AddScalarParam has no case for it.
759+
"""
760+
builder, node = self._custom_op_node("bilinear", "soft_nms_method")
761+
with self.assertRaises(ValueError) as ctx:
762+
builder.define_node(node, {})
763+
message = str(ctx.exception)
764+
self.assertIn("soft_nms_method", message)
765+
self.assertIn("my_ops.foo.default", message)
766+
self.assertIn("QNN_DATATYPE_STRING", message)
767+
# A str must not be reported as a tensor param element type.
768+
self.assertNotIn("element type", message)
769+
770+
def test_custom_op_rejects_unmapped_element_type(self):
771+
"""A list whose element type has no QNN mapping -- str[] among them -- must
772+
report the element type rather than KeyError."""
773+
builder, node = self._custom_op_node(["linear", "gaussian"], "modes")
774+
with self.assertRaises(ValueError) as ctx:
775+
builder.define_node(node, {})
776+
self.assertIn("element type", str(ctx.exception))
777+
self.assertIn("modes", str(ctx.exception))
778+
779+
def test_custom_op_rejects_empty_sequence(self):
780+
"""An empty sequence arg used to IndexError on arg[0] inside define_node."""
781+
builder, node = self._custom_op_node([], "sizes")
782+
with self.assertRaises(ValueError) as ctx:
783+
builder.define_node(node, {})
784+
self.assertIn("sizes", str(ctx.exception))
785+
self.assertIn("empty", str(ctx.exception))
786+
787+
def test_custom_op_resolves_supported_types(self):
788+
"""Guard against a vacuous suite: the mapped scalar types still resolve.
789+
790+
Checked at the helper rather than through define_node, because a valid arg
791+
carries on into output-tensor handling, which needs real tensor meta.
792+
"""
793+
for py_type in (int, float, bool):
794+
self.assertIsNotNone(
795+
_resolve_qnn_data_type(py_type, "arg", "my_ops.foo.default")
796+
)
797+
721798

722799
if __name__ == "__main__":
723800
unittest.main()

0 commit comments

Comments
 (0)