From 88af46cad9930a13accbb92cdd5f6c055f59ca8d Mon Sep 17 00:00:00 2001 From: Siddartha Pothapragada Date: Wed, 5 Aug 2026 16:39:54 -0700 Subject: [PATCH 1/6] Qualcomm: don't annotate non-float index_put value for quantization The IndexPut quantizer annotator (HTP and LPAI) put its `value` arg into the input qspec map unconditionally. When the value is an integer tensor (e.g. a Mixtral MoE routing arange, int64), prepare/convert emits quantize_per_tensor on it and the meta kernel asserts float -> to_executorch() fails. Only float tensors carry observers; guard with _is_float_tensor(value), matching XNNPACK (which guards annotated inputs by dtype). Adds a regression test. Co-authored-with: Claude --- .../quantizer/annotators/htp_rules.py | 2 +- .../quantizer/annotators/lpai_rules.py | 7 +++-- backends/qualcomm/tests/test_passes.py | 28 +++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/backends/qualcomm/quantizer/annotators/htp_rules.py b/backends/qualcomm/quantizer/annotators/htp_rules.py index ca8abb246bf..2cbaf75b6ce 100644 --- a/backends/qualcomm/quantizer/annotators/htp_rules.py +++ b/backends/qualcomm/quantizer/annotators/htp_rules.py @@ -839,7 +839,7 @@ def annotate(node: Node, quantization_config: QuantizationConfig) -> None: input_qspec_map = {} input_qspec = quantization_config.input_activation output_qspec = None - if input_qspec is not None: + if input_qspec is not None and _is_float_tensor(value): input_qspec_map[value] = input_qspec output_qspec = SharedQuantizationSpec((value, node)) diff --git a/backends/qualcomm/quantizer/annotators/lpai_rules.py b/backends/qualcomm/quantizer/annotators/lpai_rules.py index 6e5b343c5c7..e0555ad8ace 100644 --- a/backends/qualcomm/quantizer/annotators/lpai_rules.py +++ b/backends/qualcomm/quantizer/annotators/lpai_rules.py @@ -462,11 +462,14 @@ def annotate(node: Node, quantization_config: QuantizationConfig) -> None: value = node.args[2] input_qspec_map = {} - input_qspec_map[value] = quantization_config.input_activation + output_qspec = None + if _is_float_tensor(value): + input_qspec_map[value] = quantization_config.input_activation + output_qspec = SharedQuantizationSpec((value, node)) node.meta[Q_ANNOTATION_KEY] = QuantizationAnnotation( input_qspec_map=input_qspec_map, - output_qspec=SharedQuantizationSpec((value, node)), + output_qspec=output_qspec, _annotated=True, ) diff --git a/backends/qualcomm/tests/test_passes.py b/backends/qualcomm/tests/test_passes.py index 1124b01d613..deea576d580 100644 --- a/backends/qualcomm/tests/test_passes.py +++ b/backends/qualcomm/tests/test_passes.py @@ -378,6 +378,34 @@ def test_decompose_hardsigmoid_backend_aware(self): f"hardsigmoid {'should' if should_decompose else 'should NOT'} be decomposed for {backend.name}", ) + def test_index_put_int64_value_not_quantized(self): + """QNN's IndexPut annotator must skip a non-float (int64) value arg. + + Regression for MoE (Mixtral) routing, where index_put's value is an int64 + arange: annotating it for per-tensor quant makes quantize_per_tensor assert + float32 and to_executorch() fail. Only float tensors may be annotated. + """ + + class IndexPutInt64Value(torch.nn.Module): + def forward(self, x): + buf = torch.zeros(4, dtype=torch.long) + idx = torch.arange(4) # int64 value written by index_put + buf = buf.index_put((torch.tensor([0, 1, 2, 3]),), idx) + return x + buf.to(torch.float32) + + module = IndexPutInt64Value().eval() + sample_input = (torch.randn(4),) + + gm = torch.export.export(module, sample_input).run_decompositions({}).module() + quantizer = QnnQuantizer() + quantizer.set_default_quant_config(quant_dtype=QuantDtype.use_8a8w) + prepared = prepare_pt2e(gm, quantizer) + prepared(*sample_input) + converted = convert_pt2e(prepared) + # Re-export runs the quantize_per_tensor meta kernel; before the dtype guard + # this raised "Expecting input to have dtype torch.float32" on the int64 value. + torch.export.export(converted, sample_input) + if __name__ == "__main__": unittest.main() From f98c57af1e95decf4358e65893043c49e78f2f46 Mon Sep 17 00:00:00 2001 From: Siddartha Pothapragada Date: Wed, 5 Aug 2026 17:07:23 -0700 Subject: [PATCH 2/6] Address review: exercise IndexPut guard on both HTP and LPAI backends Co-authored-with: Claude --- backends/qualcomm/tests/test_passes.py | 33 +++++++++++++++++++------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/backends/qualcomm/tests/test_passes.py b/backends/qualcomm/tests/test_passes.py index 40adacb53fd..72697deb98e 100644 --- a/backends/qualcomm/tests/test_passes.py +++ b/backends/qualcomm/tests/test_passes.py @@ -476,6 +476,7 @@ def test_index_put_int64_value_not_quantized(self): Regression for MoE (Mixtral) routing, where index_put's value is an int64 arange: annotating it for per-tensor quant makes quantize_per_tensor assert float32 and to_executorch() fail. Only float tensors may be annotated. + Exercised on both the HTP and LPAI annotators, which share the guard. """ class IndexPutInt64Value(torch.nn.Module): @@ -488,15 +489,29 @@ def forward(self, x): module = IndexPutInt64Value().eval() sample_input = (torch.randn(4),) - gm = torch.export.export(module, sample_input).run_decompositions({}).module() - quantizer = QnnQuantizer() - quantizer.set_default_quant_config(quant_dtype=QuantDtype.use_8a8w) - prepared = prepare_pt2e(gm, quantizer) - prepared(*sample_input) - converted = convert_pt2e(prepared) - # Re-export runs the quantize_per_tensor meta kernel; before the dtype guard - # this raised "Expecting input to have dtype torch.float32" on the int64 value. - torch.export.export(converted, sample_input) + for backend in ( + QnnExecuTorchBackendType.kHtpBackend, + QnnExecuTorchBackendType.kLpaiBackend, + ): + try: + quantizer = QnnQuantizer(backend=backend) + except Exception as e: + # LPAI needs quantized_aot_lib; skip that leg if it isn't available. + self.skipTest(f"{backend} quantizer unavailable: {e}") + quantizer.set_default_quant_config(quant_dtype=QuantDtype.use_8a8w) + + gm = ( + torch.export.export(module, sample_input) + .run_decompositions({}) + .module() + ) + prepared = prepare_pt2e(gm, quantizer) + prepared(*sample_input) + converted = convert_pt2e(prepared) + # Re-export runs the quantize_per_tensor meta kernel; before the dtype + # guard this raised "Expecting input to have dtype torch.float32" on the + # int64 value. + torch.export.export(converted, sample_input) if __name__ == "__main__": From 9b30592bec67f00c64d56ce8a2c626d018e170ad Mon Sep 17 00:00:00 2001 From: Siddartha Pothapragada Date: Wed, 5 Aug 2026 17:16:52 -0700 Subject: [PATCH 3/6] Address review: guard LPAI IndexPut on input_activation is not None; docstring Match the HTP annotator: skip when input_activation is None (e.g. fp16a8w configs) so a None qspec isn't propagated into the annotation. Co-authored-with: Claude --- backends/qualcomm/quantizer/annotators/lpai_rules.py | 5 +++-- backends/qualcomm/tests/test_passes.py | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/backends/qualcomm/quantizer/annotators/lpai_rules.py b/backends/qualcomm/quantizer/annotators/lpai_rules.py index 2e503d4f5d4..ee721741093 100644 --- a/backends/qualcomm/quantizer/annotators/lpai_rules.py +++ b/backends/qualcomm/quantizer/annotators/lpai_rules.py @@ -467,8 +467,9 @@ def annotate(node: Node, quantization_config: QuantizationConfig) -> None: input_qspec_map = {} output_qspec = None - if _is_float_tensor(value): - input_qspec_map[value] = quantization_config.input_activation + input_qspec = quantization_config.input_activation + if input_qspec is not None and _is_float_tensor(value): + input_qspec_map[value] = input_qspec output_qspec = SharedQuantizationSpec((value, node)) node.meta[Q_ANNOTATION_KEY] = QuantizationAnnotation( diff --git a/backends/qualcomm/tests/test_passes.py b/backends/qualcomm/tests/test_passes.py index 72697deb98e..646384e11c7 100644 --- a/backends/qualcomm/tests/test_passes.py +++ b/backends/qualcomm/tests/test_passes.py @@ -474,8 +474,8 @@ def test_index_put_int64_value_not_quantized(self): """QNN's IndexPut annotator must skip a non-float (int64) value arg. Regression for MoE (Mixtral) routing, where index_put's value is an int64 - arange: annotating it for per-tensor quant makes quantize_per_tensor assert - float32 and to_executorch() fail. Only float tensors may be annotated. + arange: annotating it makes quantize_per_tensor assert a float input, so + to_executorch() fails. Only float tensors may be annotated. Exercised on both the HTP and LPAI annotators, which share the guard. """ From 972a3a0e07ed7138cbab7ff2c9d4ab3a3cd0b9a4 Mon Sep 17 00:00:00 2001 From: Siddartha Pothapragada Date: Thu, 6 Aug 2026 07:55:55 -0700 Subject: [PATCH 4/6] Address review: leave IndexPut unannotated when value is non-float Per @shewu-quic: a non-float value has nothing to quantize and its output stays non-float, so skip the QuantizationAnnotation entirely rather than marking the node _annotated with an empty spec. Applied to HTP and LPAI. Co-authored-with: Claude --- backends/qualcomm/quantizer/annotators/htp_rules.py | 13 ++++++++----- .../qualcomm/quantizer/annotators/lpai_rules.py | 13 ++++++++----- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/backends/qualcomm/quantizer/annotators/htp_rules.py b/backends/qualcomm/quantizer/annotators/htp_rules.py index 4430f715f8c..c68e855856e 100644 --- a/backends/qualcomm/quantizer/annotators/htp_rules.py +++ b/backends/qualcomm/quantizer/annotators/htp_rules.py @@ -846,11 +846,14 @@ def annotate(node: Node, quantization_config: QuantizationConfig) -> None: input_qspec_map[value] = input_qspec output_qspec = SharedQuantizationSpec((value, node)) - node.meta[Q_ANNOTATION_KEY] = QuantizationAnnotation( - input_qspec_map=input_qspec_map, - output_qspec=output_qspec, - _annotated=True, - ) + # A non-float value leaves nothing to quantize; leave the node unannotated + # rather than marking it annotated with an empty spec (its output stays non-float). + if len(input_qspec_map) > 0 or output_qspec is not None: + node.meta[Q_ANNOTATION_KEY] = QuantizationAnnotation( + input_qspec_map=input_qspec_map, + output_qspec=output_qspec, + _annotated=True, + ) @register_annotator( diff --git a/backends/qualcomm/quantizer/annotators/lpai_rules.py b/backends/qualcomm/quantizer/annotators/lpai_rules.py index ee721741093..fa68a9d3d8c 100644 --- a/backends/qualcomm/quantizer/annotators/lpai_rules.py +++ b/backends/qualcomm/quantizer/annotators/lpai_rules.py @@ -472,11 +472,14 @@ def annotate(node: Node, quantization_config: QuantizationConfig) -> None: input_qspec_map[value] = input_qspec output_qspec = SharedQuantizationSpec((value, node)) - node.meta[Q_ANNOTATION_KEY] = QuantizationAnnotation( - input_qspec_map=input_qspec_map, - output_qspec=output_qspec, - _annotated=True, - ) + # A non-float value leaves nothing to quantize; leave the node unannotated + # rather than marking it annotated with an empty spec (its output stays non-float). + if len(input_qspec_map) > 0 or output_qspec is not None: + node.meta[Q_ANNOTATION_KEY] = QuantizationAnnotation( + input_qspec_map=input_qspec_map, + output_qspec=output_qspec, + _annotated=True, + ) @register_annotator( From 19a6eeed093def18084c23662a1dfa0a2505235b Mon Sep 17 00:00:00 2001 From: Siddartha Pothapragada Date: Thu, 6 Aug 2026 16:24:16 -0700 Subject: [PATCH 5/6] Address review: explicit int64 arange; skip only the missing backend leg Per @qti-horodnic: make the index_put value dtype explicit (torch.int64). Per the Copilot loop note: an unavailable backend now skips only its own leg (continue) instead of skipping the whole test, so the other backend's coverage is preserved. Co-authored-with: Claude --- backends/qualcomm/tests/test_passes.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/backends/qualcomm/tests/test_passes.py b/backends/qualcomm/tests/test_passes.py index 646384e11c7..bd592bd9e90 100644 --- a/backends/qualcomm/tests/test_passes.py +++ b/backends/qualcomm/tests/test_passes.py @@ -482,22 +482,26 @@ def test_index_put_int64_value_not_quantized(self): class IndexPutInt64Value(torch.nn.Module): def forward(self, x): buf = torch.zeros(4, dtype=torch.long) - idx = torch.arange(4) # int64 value written by index_put + idx = torch.arange( + 4, dtype=torch.int64 + ) # int64 value written by index_put buf = buf.index_put((torch.tensor([0, 1, 2, 3]),), idx) return x + buf.to(torch.float32) module = IndexPutInt64Value().eval() sample_input = (torch.randn(4),) + ran = 0 for backend in ( QnnExecuTorchBackendType.kHtpBackend, QnnExecuTorchBackendType.kLpaiBackend, ): try: quantizer = QnnQuantizer(backend=backend) - except Exception as e: - # LPAI needs quantized_aot_lib; skip that leg if it isn't available. - self.skipTest(f"{backend} quantizer unavailable: {e}") + except Exception: + # LPAI needs quantized_aot_lib; skip only this backend's leg if it + # isn't available so the other backend's coverage is preserved. + continue quantizer.set_default_quant_config(quant_dtype=QuantDtype.use_8a8w) gm = ( @@ -512,6 +516,10 @@ def forward(self, x): # guard this raised "Expecting input to have dtype torch.float32" on the # int64 value. torch.export.export(converted, sample_input) + ran += 1 + + if ran == 0: + self.skipTest("no QNN quantizer backend available (HTP/LPAI)") if __name__ == "__main__": From 571e6b09025879a7ccce52b076dd6bdb5e26a205 Mon Sep 17 00:00:00 2001 From: Siddartha Pothapragada Date: Thu, 6 Aug 2026 17:17:39 -0700 Subject: [PATCH 6/6] Address review: make HTP mandatory, tolerate only LPAI's missing dependency The broad except could swallow an HTP regression and silently skip. Run HTP unconditionally (a failure is a real regression); wrap only LPAI and skip solely when quantized_aot_lib is unavailable. Co-authored-with: Claude --- backends/qualcomm/tests/test_passes.py | 27 ++++++++++++-------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/backends/qualcomm/tests/test_passes.py b/backends/qualcomm/tests/test_passes.py index bd592bd9e90..9004deda4f1 100644 --- a/backends/qualcomm/tests/test_passes.py +++ b/backends/qualcomm/tests/test_passes.py @@ -491,19 +491,9 @@ def forward(self, x): module = IndexPutInt64Value().eval() sample_input = (torch.randn(4),) - ran = 0 - for backend in ( - QnnExecuTorchBackendType.kHtpBackend, - QnnExecuTorchBackendType.kLpaiBackend, - ): - try: - quantizer = QnnQuantizer(backend=backend) - except Exception: - # LPAI needs quantized_aot_lib; skip only this backend's leg if it - # isn't available so the other backend's coverage is preserved. - continue + def run_backend(backend): + quantizer = QnnQuantizer(backend=backend) quantizer.set_default_quant_config(quant_dtype=QuantDtype.use_8a8w) - gm = ( torch.export.export(module, sample_input) .run_decompositions({}) @@ -516,10 +506,17 @@ def forward(self, x): # guard this raised "Expecting input to have dtype torch.float32" on the # int64 value. torch.export.export(converted, sample_input) - ran += 1 - if ran == 0: - self.skipTest("no QNN quantizer backend available (HTP/LPAI)") + # HTP is the core QNN quantizer backend; a failure here is a real regression, + # not something to swallow. LPAI additionally needs quantized_aot_lib, so only + # that missing optional dependency is allowed to skip the LPAI leg. + run_backend(QnnExecuTorchBackendType.kHtpBackend) + try: + run_backend(QnnExecuTorchBackendType.kLpaiBackend) + except Exception as e: + if "quantized_aot_lib" in str(e): + self.skipTest(f"LPAI quantizer unavailable: {e}") + raise if __name__ == "__main__":