From acb65ce6b2e4eccbfedcefe18f328ec20c0a117f Mon Sep 17 00:00:00 2001 From: haowhsu Date: Thu, 25 Jun 2026 15:45:08 +0800 Subject: [PATCH] Qualcomm AI Engine Direct - Test Framework Refactor #2 - fill the test gap for newly-added op - utils test --- .../qualcomm/tests/rework/common/pass/test.py | 5 - .../tests/rework/common/utils/test.py | 5 - backends/qualcomm/tests/rework/conftest.py | 11 + .../qualcomm/tests/rework/htp/op/v68/test.py | 66 ++ backends/qualcomm/tests/rework/src/op.py | 229 +++++++ backends/qualcomm/tests/rework/src/utils.py | 635 ++++++++++++++++++ .../qualcomm/tests/rework/utils/conftest.py | 28 + backends/qualcomm/tests/rework/utils/test.py | 41 ++ 8 files changed, 1010 insertions(+), 10 deletions(-) delete mode 100644 backends/qualcomm/tests/rework/common/pass/test.py delete mode 100644 backends/qualcomm/tests/rework/common/utils/test.py create mode 100644 backends/qualcomm/tests/rework/src/utils.py create mode 100644 backends/qualcomm/tests/rework/utils/conftest.py create mode 100644 backends/qualcomm/tests/rework/utils/test.py diff --git a/backends/qualcomm/tests/rework/common/pass/test.py b/backends/qualcomm/tests/rework/common/pass/test.py deleted file mode 100644 index b5f86874fd4..00000000000 --- a/backends/qualcomm/tests/rework/common/pass/test.py +++ /dev/null @@ -1,5 +0,0 @@ -# Copyright (c) Qualcomm Innovation Center, Inc. -# All rights reserved -# -# This source code is licensed under the BSD-style license found in the -# LICENSE file in the root directory of this source tree. diff --git a/backends/qualcomm/tests/rework/common/utils/test.py b/backends/qualcomm/tests/rework/common/utils/test.py deleted file mode 100644 index b5f86874fd4..00000000000 --- a/backends/qualcomm/tests/rework/common/utils/test.py +++ /dev/null @@ -1,5 +0,0 @@ -# Copyright (c) Qualcomm Innovation Center, Inc. -# All rights reserved -# -# This source code is licensed under the BSD-style license found in the -# LICENSE file in the root directory of this source tree. diff --git a/backends/qualcomm/tests/rework/conftest.py b/backends/qualcomm/tests/rework/conftest.py index 9655aee8650..29b5e16c79b 100644 --- a/backends/qualcomm/tests/rework/conftest.py +++ b/backends/qualcomm/tests/rework/conftest.py @@ -18,6 +18,7 @@ from abc import ABC, abstractmethod from collections import defaultdict from contextlib import contextmanager +from dataclasses import dataclass from functools import partial from typing import Any, List, Tuple @@ -53,6 +54,7 @@ # et framework messages EXCEPTION_EXIR_PROGRAM = "exir/program" EXCEPTION_FROM_PASSES = "backends/qualcomm/_passes" +EXCEPTION_FROM_PREPROCESS = "backends/qualcomm/qnn_preprocess" def check_exception(msg): @@ -62,6 +64,15 @@ def _check(msg, _: Exception): return partial(_check, msg) +# extend this for backend agnostic tests +def default_property(): + @dataclass + class Property: + soc_model: str = "SM8750" + + return Property() + + class Metrics(ABC): @abstractmethod def __init__(self): diff --git a/backends/qualcomm/tests/rework/htp/op/v68/test.py b/backends/qualcomm/tests/rework/htp/op/v68/test.py index 19a7843de65..12c46553475 100644 --- a/backends/qualcomm/tests/rework/htp/op/v68/test.py +++ b/backends/qualcomm/tests/rework/htp/op/v68/test.py @@ -121,6 +121,24 @@ def test_add(request, kwargs): Add.test(request, kwargs) # noqa: F405 +@enumerate_activation_dtype( + [ + Tolerance(), + pytest.raises(AssertionError, match=EXPECT_NOT_FULLY_DELEGATED), + Tolerance(rtol=1e-1), + ] +) +@with_htp_context +def test_addmm(request, kwargs): + AddMM.test(request, kwargs) # noqa: F405 + + +@enumerate_activation_dtype([Tolerance(), Tolerance(), Tolerance(rtol=1e-1)]) +@with_htp_context +def test_alias(request, kwargs): + Alias.test(request, kwargs) # noqa: F405 + + @enumerate_activation_dtype([Tolerance(), Tolerance(), Tolerance(rtol=1e-1)]) @with_htp_context def test_amax(request, kwargs): @@ -464,6 +482,10 @@ def test_conv2d_transpose(request, kwargs): }, id="16a4w_lpbq", ), + pytest.param( + {"act": "fp16", "param": 8, "pcq": True, "expected": Tolerance()}, + id="fp16a8w_pcq", + ), ], ) @with_htp_context @@ -527,6 +549,12 @@ def test_div(request, kwargs): Div.test(request, kwargs) # noqa: F405 +@enumerate_activation_dtype([Tolerance(), Tolerance(), Tolerance(rtol=1e-1)]) +@with_htp_context +def test_div_with_rounding_mode(request, kwargs): + DivWithRoundingMode.test(request, kwargs) # noqa: F405 + + @enumerate_activation_dtype( [ Tolerance(), @@ -595,6 +623,12 @@ def test_expm1(request, kwargs): ExpM1.test(request, kwargs) # noqa: F405 +@enumerate_activation_dtype([Tolerance(), Tolerance(), Tolerance(rtol=1e-1)]) +@with_htp_context +def test_fill(request, kwargs): + Fill.test(request, kwargs) # noqa: F405 + + @enumerate_activation_dtype([Tolerance(), Tolerance(), Tolerance(rtol=1e-1)]) @with_htp_context def test_flip(request, kwargs): @@ -857,6 +891,14 @@ def test_linear_block_quant(request, kwargs): {"act": 16, "param": 8, "pcq": True, "expected": Tolerance()}, id="16a8w_pcq", ), + pytest.param( + {"act": "fp16", "param": 8, "pcq": True, "expected": Tolerance()}, + id="fp16a8w_pcq", + ), + pytest.param( + {"act": 16, "param": 2, "pcq": True, "expected": CosineSimilarity(0.9)}, + id="16a2w_pcq", + ), pytest.param( { "act": None, @@ -1177,12 +1219,24 @@ def test_sdpa(request, kwargs): ScaledDotProductAttention.test(request, kwargs) # noqa: F405 +@enumerate_activation_dtype([Tolerance(), Tolerance(), Tolerance(rtol=1e-1)]) +@with_htp_context +def test_scatter_src(request, kwargs): + ScatterSrc.test(request, kwargs) # noqa: F405 + + @enumerate_activation_dtype([Tolerance(), Tolerance(), Tolerance(rtol=1e-1)]) @with_htp_context def test_select_copy(request, kwargs): SelectCopy.test(request, kwargs) # noqa: F405 +@enumerate_activation_dtype([Tolerance(), Tolerance(), Tolerance(rtol=1e-1)]) +@with_htp_context +def test_select_scatter(request, kwargs): + SelectScatter.test(request, kwargs) # noqa: F405 + + @enumerate_activation_dtype([Tolerance(), Tolerance(), Tolerance(rtol=1e-1)]) @with_htp_context def test_sigmoid(request, kwargs): @@ -1255,6 +1309,12 @@ def test_swapaxes(request, kwargs): SwapAxes.test(request, kwargs) # noqa: F405 +@enumerate_activation_dtype([Tolerance(), Tolerance(), Tolerance(rtol=1e-1)]) +@with_htp_context +def test_tan(request, kwargs): + Tan.test(request, kwargs) # noqa: F405 + + @enumerate_activation_dtype([Tolerance(), Tolerance(), Tolerance(rtol=1e-1)]) @with_htp_context def test_tanh(request, kwargs): @@ -1355,3 +1415,9 @@ def test_view_5d_flatten_last_two_dims(request, kwargs): @with_htp_context def test_where(request, kwargs): Where.test(request, kwargs) # noqa: F405 + + +@enumerate_activation_dtype([Tolerance(), Tolerance(), Tolerance(rtol=1e-1)]) +@with_htp_context +def test_var(request, kwargs): + Var.test(request, kwargs) # noqa: F405 diff --git a/backends/qualcomm/tests/rework/src/op.py b/backends/qualcomm/tests/rework/src/op.py index 14d853ed919..c87eb7d97bd 100644 --- a/backends/qualcomm/tests/rework/src/op.py +++ b/backends/qualcomm/tests/rework/src/op.py @@ -257,6 +257,58 @@ def test(subtests, qnn_config, quantizer, compile_spec, expected): ) +class AddMM(torch.nn.Module): + def __init__(self, alpha, beta): + super().__init__() + self.alpha = alpha + self.beta = beta + + def forward(self, bias, input, mat2): + return torch.addmm(bias, input, mat2, alpha=self.alpha, beta=self.beta) + + @staticmethod + @unpack_fixtures + def test(subtests, qnn_config, quantizer, compile_spec, expected): + for alpha, beta in [(1, 2), (2, 1), (2, 3)]: + with subtests.test(msg=f"alpha={alpha}, beta={beta}"): + with expected as metrics: + export_and_verify( + module=__class__(alpha=alpha, beta=beta), + inputs=( + torch.randn(8), + torch.randn(4, 3), + torch.randn(3, 8), + ), + qnn_config=qnn_config, + quantizer=quantizer, + compile_specs=compile_spec, + metrics=metrics, + ) + + +class Alias(torch.nn.Module): + def __init__(self): + super().__init__() + self.relu = torch.nn.ReLU() + + def forward(self, x): + alias_x = torch.ops.aten.alias.default(x) + return self.relu(alias_x) + + @staticmethod + @unpack_fixtures + def test(qnn_config, quantizer, compile_spec, expected): + with expected as metrics: + export_and_verify( + module=__class__(), + inputs=(torch.randn(1, 10),), + qnn_config=qnn_config, + quantizer=quantizer, + compile_specs=compile_spec, + metrics=metrics, + ) + + class AMax(torch.nn.Module): def __init__(self, dim, keepdim): super().__init__() @@ -1398,6 +1450,39 @@ def test(subtests, qnn_config, quantizer, compile_spec, expected): ) +class DivWithRoundingMode(torch.nn.Module): + def __init__(self, rounding_mode): + super().__init__() + self.rounding_mode = rounding_mode + self.scalar = 2.0 + + def forward(self, *x): + return ( + torch.div(x[0], x[1], rounding_mode=self.rounding_mode) + if len(x) > 1 + else torch.div(x[0], self.scalar, rounding_mode=self.rounding_mode) + ) + + @staticmethod + @unpack_fixtures + def test(subtests, qnn_config, quantizer, compile_spec, expected): + inputs = [ + (torch.randn(2, 3), torch.randn(2, 3).abs() + 1e-6), + (torch.randn(2, 3),), + ] + for input, mode in itertools.product(inputs, [None, "trunc", "floor"]): + with subtests.test(msg=f"rounding_mode={mode}"): + with expected as metrics: + export_and_verify( + module=__class__(rounding_mode=mode), + inputs=input, + qnn_config=qnn_config, + quantizer=quantizer, + compile_specs=compile_spec, + metrics=metrics, + ) + + class Einsum(torch.nn.Module): def __init__(self, equation): super().__init__() @@ -1623,6 +1708,28 @@ def test(qnn_config, quantizer, compile_spec, expected): ) +class Fill(torch.nn.Module): + def __init__(self, value): + super().__init__() + self.value = value + + def forward(self, x): + return torch.add(x, torch.fill(x, self.value)) + + @staticmethod + @unpack_fixtures + def test(qnn_config, quantizer, compile_spec, expected): + with expected as metrics: + export_and_verify( + module=__class__(value=3.14), + inputs=(torch.randn(1, 2, 3, 4),), + qnn_config=qnn_config, + quantizer=quantizer, + compile_specs=compile_spec, + metrics=metrics, + ) + + class Flip(torch.nn.Module): def __init__(self, dims): super().__init__() @@ -4009,6 +4116,40 @@ def test(subtests, qnn_config, quantizer, compile_spec, expected): ) +class ScatterSrc(torch.nn.Module): + def __init__(self, dim): + super().__init__() + self.dim = dim + + def forward(self, data, index, src): + return torch.scatter(data, self.dim, index, src) + + @staticmethod + @unpack_fixtures + def test(subtests, qnn_config, quantizer, compile_spec, expected): + inputs = [ + ( + torch.zeros(3, 5), + torch.tensor( + [[0, 1, 2, 3, 4], [4, 3, 2, 1, 0], [1, 0, 3, 4, 2]], + dtype=torch.int64, + ), + torch.randn(3, 5), + ), + ] + for dim, (data, index, src) in itertools.product([-1, 1], inputs): + with subtests.test(msg=f"dim={dim}"): + with expected as metrics: + export_and_verify( + module=__class__(dim=dim), + inputs=(data, index, src), + qnn_config=qnn_config, + quantizer=quantizer, + compile_specs=compile_spec, + metrics=metrics, + ) + + class ScaledDotProductAttention(torch.nn.Module): def __init__(self, scale, is_causal, use_mask): super().__init__() @@ -4075,6 +4216,38 @@ def test(subtests, qnn_config, quantizer, compile_spec, expected): ) +class SelectScatter(torch.nn.Module): + def __init__(self, dim, index): + super().__init__() + self.dim = dim + self.index = index + + def forward(self, x, y): + return x.select_scatter(y, dim=self.dim, index=self.index) + + @staticmethod + @unpack_fixtures + def test(subtests, qnn_config, quantizer, compile_spec, expected): + cases = [ + (0, 2, (torch.randn(4, 8), torch.randn(8))), + (1, 0, (torch.randn(3, 4, 5), torch.randn(3, 5))), + (1, -1, (torch.randn(3, 4, 5), torch.randn(3, 5))), + (-1, 2, (torch.randn(3, 4, 5), torch.randn(3, 4))), + (3, 1, (torch.randn(2, 3, 4, 5), torch.randn(2, 3, 4))), + ] + for dim, index, inputs in cases: + with subtests.test(msg=f"dim={dim}_index={index}"): + with expected as metrics: + export_and_verify( + module=__class__(dim=dim, index=index), + inputs=inputs, + qnn_config=qnn_config, + quantizer=quantizer, + compile_specs=compile_spec, + metrics=metrics, + ) + + class SelectCopy(torch.nn.Module): def __init__(self, dim, index): super().__init__() @@ -4509,6 +4682,27 @@ def test(subtests, qnn_config, quantizer, compile_spec, expected): ) +class Tan(torch.nn.Module): + def __init__(self): + super().__init__() + + def forward(self, x): + return torch.tan(x) + + @staticmethod + @unpack_fixtures + def test(qnn_config, quantizer, compile_spec, expected): + with expected as metrics: + export_and_verify( + module=__class__(), + inputs=(torch.randn(2, 5, 1, 3),), + qnn_config=qnn_config, + quantizer=quantizer, + compile_specs=compile_spec, + metrics=metrics, + ) + + class Tanh(torch.nn.Module): def __init__(self): super().__init__() @@ -5030,3 +5224,38 @@ def test(subtests, qnn_config, quantizer, compile_spec, expected): compile_specs=compile_spec, metrics=metrics, ) + + +class Var(torch.nn.Module): + def __init__(self, dim, correction, keepdim): + super().__init__() + self.dim = dim + self.correction = correction + self.keepdim = keepdim + + def forward(self, x): + return torch.var( + x, dim=self.dim, correction=self.correction, keepdim=self.keepdim + ) + + @staticmethod + @unpack_fixtures + def test(subtests, qnn_config, quantizer, compile_spec, expected): + dims = [-1, [0, 2]] + corrections = [0, 1] + keepdims = [False, True] + for dim, correction, keepdim in itertools.product(dims, corrections, keepdims): + with subtests.test( + msg=f"dim:{dim}, correction:{correction}, keepdim:{keepdim}" + ): + with expected as metrics: + export_and_verify( + module=__class__( + dim=dim, correction=correction, keepdim=keepdim + ), + inputs=(torch.randn(3, 4, 5),), + qnn_config=qnn_config, + quantizer=quantizer, + compile_specs=compile_spec, + metrics=metrics, + ) diff --git a/backends/qualcomm/tests/rework/src/utils.py b/backends/qualcomm/tests/rework/src/utils.py new file mode 100644 index 00000000000..9202007b18a --- /dev/null +++ b/backends/qualcomm/tests/rework/src/utils.py @@ -0,0 +1,635 @@ +# Copyright (c) Qualcomm Innovation Center, Inc. +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import operator +import os +import tempfile +from collections import defaultdict +from functools import reduce +from typing import Tuple + +import pytest + +import torch + +from executorch.backends.qualcomm._passes.qnn_pass_manager import ( + get_qnn_pass_manager_cls, +) +from executorch.backends.qualcomm.builders.node_visitor_manager import get_node_visitors +from executorch.backends.qualcomm.debugger.utils import DrawGraph as _DrawGraphTool +from executorch.backends.qualcomm.export_utils import ( + convert_pt2e, + make_quantizer, + prepare_pt2e, + prepare_qat_pt2e, + QuantDtype, + to_edge_transform_and_lower_to_qnn, +) +from executorch.backends.qualcomm.tests.rework.conftest import ( + calibrate, + check_exception, + EXCEPTION_FROM_PREPROCESS, +) +from executorch.backends.qualcomm.tests.utils import validate_context_binary +from executorch.backends.qualcomm.utils.utils import ( + capture_program, + dump_context_from_pte, + rewrite_prepared_observer, + skip_annotation, +) +from executorch.exir import to_edge +from torchao.quantization.pt2e.quantizer.quantizer import Q_ANNOTATION_KEY + + +# --------------------------------------------------------------------------- +# Shared model — multi-input / multi-output +# forward(x, y) -> (Tensor, Tensor) +# x -> conv1 -> relu1 -> a ─┐ +# add -> conv3 -> c (outputs: c, a) +# y -> conv2 -> relu2 -> b ─┘ +# Provides: two inputs, two outputs, two conv2d ops (SeqMSE), one add (skip target) +# --------------------------------------------------------------------------- + + +class _UtilsModel(torch.nn.Module): + def __init__(self): + super().__init__() + self.conv1 = torch.nn.Conv2d(4, 4, kernel_size=3, padding=1, bias=True) + self.conv2 = torch.nn.Conv2d(4, 4, kernel_size=3, padding=1, bias=True) + self.conv3 = torch.nn.Conv2d(4, 4, kernel_size=1, bias=True) + self.relu1 = torch.nn.ReLU() + self.relu2 = torch.nn.ReLU() + + def forward(self, x, y): + a = self.relu1(self.conv1(x)) + b = self.relu2(self.conv2(y)) + c = self.conv3(a + b) + return c, a + + +class _CompositeDelegateModule(torch.nn.Module): + def __init__( + self, + compiler_specs, + to_edge_transform_and_lower_method, + quantize_method=None, + ) -> None: + super().__init__() + self.modules = [ + _UtilsModel(), + _UtilsModel(), + ] + self.sample_inputs = [ + (torch.randn(1, 4, 8, 8), torch.randn(1, 4, 8, 8)), + (torch.randn(1, 4, 8, 8), torch.randn(1, 4, 8, 8)), + ] + self.lowered_modules = [] + for module, sample_input in zip(self.modules, self.sample_inputs): + if quantize_method: + module = quantize_method(module, sample_input) + edge_prog = to_edge_transform_and_lower_method( + module, sample_input, compiler_specs + ) + self.lowered_modules.append( + edge_prog.exported_program().graph_module._modules.get( + "lowered_module_0" + ) + ) + + def forward(self, x1, y1, x2, y2): + z11, z12 = self.lowered_modules[0](x1, y1) + z21, z22 = self.lowered_modules[1](x2, y2) + return z11 + z21, z12 + z22 + + def get_random_input(self): + return tuple(e for tup in self.sample_inputs for e in tup) + + def get_reference_module(self): + class CompositeReferenceModule(torch.nn.Module): + def __init__(self, modules): + super().__init__() + self.modules = modules + + def forward(self, x1, y1, x2, y2): + z11, z12 = self.modules[0](x1, y1) + z21, z22 = self.modules[1](x2, y2) + return z11 + z21, z12 + z22 + + return CompositeReferenceModule(self.modules) + + +# --------------------------------------------------------------------------- +# Test Bodies +# --------------------------------------------------------------------------- + + +class DumpContextFromPte: + @staticmethod + def test(quantizer, compile_spec): + module = _UtilsModel() + inputs = (torch.randn(1, 4, 8, 8), torch.randn(1, 4, 8, 8)) + + with calibrate(module, [inputs], quantizer) as quantized: + exec_prog = to_edge_transform_and_lower_to_qnn( + quantized, inputs, compile_spec + ).to_executorch() + + with tempfile.TemporaryDirectory() as tmp_dir: + pte_path = f"{tmp_dir}/model.pte" + with open(pte_path, "wb") as f: + exec_prog.write_to_file(f) + dump_context_from_pte(pte_path) + binary_path = f"{tmp_dir}/forward_0.bin" + assert os.path.isfile(binary_path) + with open(binary_path, "rb") as f: + validate_context_binary(f.read()) + + +class DrawGraph: + _golden = """digraph test { + rankdir=TB + "input_0_x@0" [label=< + + + + + + +
name: input_0_x@0
data_type: Qnn_DataType_t.QNN_DATATYPE_FLOAT_32
tensor_type: Qnn_TensorType_t.QNN_TENSOR_TYPE_APP_WRITE
dims: [1, 4, 8, 8]
quantization_encoding: Qnn_QuantizationEncoding_t.QNN_QUANTIZATION_ENCODING_UNDEFINED
> color=black fillcolor=transparent shape=box style=rounded] + "quantized_decomposed_quantize_per_tensor_default@0" [label=< + + + + + + +
name: quantized_decomposed_quantize_per_tensor_default@0
data_type: Qnn_DataType_t.QNN_DATATYPE_UFIXED_POINT_8
tensor_type: Qnn_TensorType_t.QNN_TENSOR_TYPE_NATIVE
dims: [1, 4, 8, 8]
quantization_encoding: Qnn_QuantizationEncoding_t.QNN_QUANTIZATION_ENCODING_SCALE_OFFSET
> color=black fillcolor=transparent shape=box style=rounded] + "input_1_y@0" [label=< + + + + + + +
name: input_1_y@0
data_type: Qnn_DataType_t.QNN_DATATYPE_FLOAT_32
tensor_type: Qnn_TensorType_t.QNN_TENSOR_TYPE_APP_WRITE
dims: [1, 4, 8, 8]
quantization_encoding: Qnn_QuantizationEncoding_t.QNN_QUANTIZATION_ENCODING_UNDEFINED
> color=black fillcolor=transparent shape=box style=rounded] + "quantized_decomposed_quantize_per_tensor_default_1@0" [label=< + + + + + + +
name: quantized_decomposed_quantize_per_tensor_default_1@0
data_type: Qnn_DataType_t.QNN_DATATYPE_UFIXED_POINT_8
tensor_type: Qnn_TensorType_t.QNN_TENSOR_TYPE_NATIVE
dims: [1, 4, 8, 8]
quantization_encoding: Qnn_QuantizationEncoding_t.QNN_QUANTIZATION_ENCODING_SCALE_OFFSET
> color=black fillcolor=transparent shape=box style=rounded] + "b__frozen_param0@0" [label=< + + + + + + +
name: b__frozen_param0@0
data_type: Qnn_DataType_t.QNN_DATATYPE_SFIXED_POINT_8
tensor_type: Qnn_TensorType_t.QNN_TENSOR_TYPE_STATIC
dims: [3, 3, 4, 4]
quantization_encoding: Qnn_QuantizationEncoding_t.QNN_QUANTIZATION_ENCODING_AXIS_SCALE_OFFSET
> color=black fillcolor=transparent shape=box style=rounded] + "b__frozen_param1@0" [label=< + + + + + + +
name: b__frozen_param1@0
data_type: Qnn_DataType_t.QNN_DATATYPE_SFIXED_POINT_32
tensor_type: Qnn_TensorType_t.QNN_TENSOR_TYPE_STATIC
dims: [4]
quantization_encoding: Qnn_QuantizationEncoding_t.QNN_QUANTIZATION_ENCODING_AXIS_SCALE_OFFSET
> color=black fillcolor=transparent shape=box style=rounded] + "aten_convolution_default@0" [label=< + + + + + + +
name: aten_convolution_default@0
data_type: Qnn_DataType_t.QNN_DATATYPE_UFIXED_POINT_8
tensor_type: Qnn_TensorType_t.QNN_TENSOR_TYPE_NATIVE
dims: [1, 8, 8, 4]
quantization_encoding: Qnn_QuantizationEncoding_t.QNN_QUANTIZATION_ENCODING_SCALE_OFFSET
> color=black fillcolor=transparent shape=box style=rounded] + "aten_relu_default@0" [label=< + + + + + + +
name: aten_relu_default@0
data_type: Qnn_DataType_t.QNN_DATATYPE_UFIXED_POINT_8
tensor_type: Qnn_TensorType_t.QNN_TENSOR_TYPE_NATIVE
dims: [1, 8, 8, 4]
quantization_encoding: Qnn_QuantizationEncoding_t.QNN_QUANTIZATION_ENCODING_SCALE_OFFSET
> color=black fillcolor=transparent shape=box style=rounded] + "output_quantized_decomposed_dequantize_per_tensor_default@0" [label=< + + + + + + +
name: output_quantized_decomposed_dequantize_per_tensor_default@0
data_type: Qnn_DataType_t.QNN_DATATYPE_FLOAT_32
tensor_type: Qnn_TensorType_t.QNN_TENSOR_TYPE_APP_READ
dims: [1, 4, 8, 8]
quantization_encoding: Qnn_QuantizationEncoding_t.QNN_QUANTIZATION_ENCODING_UNDEFINED
> color=black fillcolor=transparent shape=box style=rounded] + "b__frozen_param2@0" [label=< + + + + + + +
name: b__frozen_param2@0
data_type: Qnn_DataType_t.QNN_DATATYPE_SFIXED_POINT_8
tensor_type: Qnn_TensorType_t.QNN_TENSOR_TYPE_STATIC
dims: [3, 3, 4, 4]
quantization_encoding: Qnn_QuantizationEncoding_t.QNN_QUANTIZATION_ENCODING_AXIS_SCALE_OFFSET
> color=black fillcolor=transparent shape=box style=rounded] + "b__frozen_param3@0" [label=< + + + + + + +
name: b__frozen_param3@0
data_type: Qnn_DataType_t.QNN_DATATYPE_SFIXED_POINT_32
tensor_type: Qnn_TensorType_t.QNN_TENSOR_TYPE_STATIC
dims: [4]
quantization_encoding: Qnn_QuantizationEncoding_t.QNN_QUANTIZATION_ENCODING_AXIS_SCALE_OFFSET
> color=black fillcolor=transparent shape=box style=rounded] + "aten_convolution_default_1@0" [label=< + + + + + + +
name: aten_convolution_default_1@0
data_type: Qnn_DataType_t.QNN_DATATYPE_UFIXED_POINT_8
tensor_type: Qnn_TensorType_t.QNN_TENSOR_TYPE_NATIVE
dims: [1, 8, 8, 4]
quantization_encoding: Qnn_QuantizationEncoding_t.QNN_QUANTIZATION_ENCODING_SCALE_OFFSET
> color=black fillcolor=transparent shape=box style=rounded] + "aten_relu_default_1@0" [label=< + + + + + + +
name: aten_relu_default_1@0
data_type: Qnn_DataType_t.QNN_DATATYPE_UFIXED_POINT_8
tensor_type: Qnn_TensorType_t.QNN_TENSOR_TYPE_NATIVE
dims: [1, 8, 8, 4]
quantization_encoding: Qnn_QuantizationEncoding_t.QNN_QUANTIZATION_ENCODING_SCALE_OFFSET
> color=black fillcolor=transparent shape=box style=rounded] + "aten_add_tensor@0" [label=< + + + + + + +
name: aten_add_tensor@0
data_type: Qnn_DataType_t.QNN_DATATYPE_UFIXED_POINT_8
tensor_type: Qnn_TensorType_t.QNN_TENSOR_TYPE_NATIVE
dims: [1, 8, 8, 4]
quantization_encoding: Qnn_QuantizationEncoding_t.QNN_QUANTIZATION_ENCODING_SCALE_OFFSET
> color=black fillcolor=transparent shape=box style=rounded] + "b__frozen_param4@0" [label=< + + + + + + +
name: b__frozen_param4@0
data_type: Qnn_DataType_t.QNN_DATATYPE_SFIXED_POINT_8
tensor_type: Qnn_TensorType_t.QNN_TENSOR_TYPE_STATIC
dims: [1, 1, 4, 4]
quantization_encoding: Qnn_QuantizationEncoding_t.QNN_QUANTIZATION_ENCODING_AXIS_SCALE_OFFSET
> color=black fillcolor=transparent shape=box style=rounded] + "b__frozen_param5@0" [label=< + + + + + + +
name: b__frozen_param5@0
data_type: Qnn_DataType_t.QNN_DATATYPE_SFIXED_POINT_32
tensor_type: Qnn_TensorType_t.QNN_TENSOR_TYPE_STATIC
dims: [4]
quantization_encoding: Qnn_QuantizationEncoding_t.QNN_QUANTIZATION_ENCODING_AXIS_SCALE_OFFSET
> color=black fillcolor=transparent shape=box style=rounded] + "aten_convolution_default_2@0" [label=< + + + + + + +
name: aten_convolution_default_2@0
data_type: Qnn_DataType_t.QNN_DATATYPE_UFIXED_POINT_8
tensor_type: Qnn_TensorType_t.QNN_TENSOR_TYPE_NATIVE
dims: [1, 8, 8, 4]
quantization_encoding: Qnn_QuantizationEncoding_t.QNN_QUANTIZATION_ENCODING_SCALE_OFFSET
> color=black fillcolor=transparent shape=box style=rounded] + "output_quantized_decomposed_dequantize_per_tensor_default_1@0" [label=< + + + + + + +
name: output_quantized_decomposed_dequantize_per_tensor_default_1@0
data_type: Qnn_DataType_t.QNN_DATATYPE_FLOAT_32
tensor_type: Qnn_TensorType_t.QNN_TENSOR_TYPE_APP_READ
dims: [1, 4, 8, 8]
quantization_encoding: Qnn_QuantizationEncoding_t.QNN_QUANTIZATION_ENCODING_UNDEFINED
> color=black fillcolor=transparent shape=box style=rounded] + "input_0_x@0" -> "quantized_decomposed_quantize_per_tensor_default@0" + "input_1_y@0" -> "quantized_decomposed_quantize_per_tensor_default_1@0" + "quantized_decomposed_quantize_per_tensor_default@0" -> "aten_convolution_default@0" + "b__frozen_param0@0" -> "aten_convolution_default@0" + "b__frozen_param1@0" -> "aten_convolution_default@0" + "aten_convolution_default@0" -> "aten_relu_default@0" + "aten_relu_default@0" -> "output_quantized_decomposed_dequantize_per_tensor_default@0" + "quantized_decomposed_quantize_per_tensor_default_1@0" -> "aten_convolution_default_1@0" + "b__frozen_param2@0" -> "aten_convolution_default_1@0" + "b__frozen_param3@0" -> "aten_convolution_default_1@0" + "aten_convolution_default_1@0" -> "aten_relu_default_1@0" + "aten_relu_default@0" -> "aten_add_tensor@0" + "aten_relu_default_1@0" -> "aten_add_tensor@0" + "aten_add_tensor@0" -> "aten_convolution_default_2@0" + "b__frozen_param4@0" -> "aten_convolution_default_2@0" + "b__frozen_param5@0" -> "aten_convolution_default_2@0" + "aten_convolution_default_2@0" -> "output_quantized_decomposed_dequantize_per_tensor_default_1@0" + } + """ + + @staticmethod + def _build_op_wrapper_list(module, inputs): + delegated_program = capture_program(module, inputs) + graph_module = get_qnn_pass_manager_cls()().transform_for_preprocess_pipeline( + delegated_program.exported_program + ) + nodes_to_wrappers = defaultdict(dict) + node_visitors = get_node_visitors( + delegated_program.exported_program, enable_tensor_dump=False + ) + py_op_wrapper_list = [] + for node in graph_module.graph.nodes: + if node.op == "call_function" and node.target.__name__ in node_visitors: + wrapper = node_visitors[node.target.__name__].define_node( + node, nodes_to_wrappers + ) + if wrapper is not None: + if isinstance(wrapper, list): + py_op_wrapper_list.extend(wrapper) + else: + py_op_wrapper_list.append(wrapper) + return py_op_wrapper_list + + @staticmethod + def test(quantizer): + module = _UtilsModel() + inputs = (torch.randn(1, 4, 8, 8), torch.randn(1, 4, 8, 8)) + + with calibrate(module, [inputs], quantizer) as quantized_module: + op_wrappers = __class__._build_op_wrapper_list(quantized_module, inputs) + with tempfile.TemporaryDirectory() as tmp_dir: + _DrawGraphTool("test", tmp_dir, op_wrappers, dot_string=True) + with open(os.path.join(tmp_dir, "test.dot")) as f: + result = f.read() + assert sorted(__class__._golden.split()) == sorted( + result.split() + ), "Generated .dot file does not match the golden file." + + +class FixedPointFloatingPointMixedPrecision: + # test specifically for graph which owns floating point / fixed point + # operators simultaneously + @staticmethod + def test(subtests, quantizer, compile_spec): + module = _UtilsModel() + inputs = (torch.randn(1, 4, 8, 8), torch.randn(1, 4, 8, 8)) + + def calibrator(gm): + gm(*inputs) + + for fallback_to_cpu, expected_partitions in [ + (True, 2), + (False, 3), + ]: + with subtests.test(msg=f"fallback_to_cpu={fallback_to_cpu}"): + _, edge_prog_mgrs = skip_annotation( + nn_module=module, + quantizer=quantizer, + compiler_specs=compile_spec, + sample_input=inputs, + calibration_cb=calibrator, + fp_node_id_set={"add"}, + fallback_to_cpu=fallback_to_cpu, + ) + assert len(edge_prog_mgrs) == expected_partitions + + +class MultiContextsComposite: + @staticmethod + def test(compile_spec): + module = _CompositeDelegateModule( + compiler_specs=compile_spec, + to_edge_transform_and_lower_method=to_edge_transform_and_lower_to_qnn, + ) + sample_input = module.get_random_input() + edge_prog = to_edge( + torch.export.export(module, sample_input, strict=True), + ) + # should complete without error + edge_prog.to_executorch() + + +class RewritePreparedObserver: + @staticmethod + def test(quantizer): + import math + + from torchao.quantization.pt2e import FixedQParamsObserver + + module = _UtilsModel() + inputs = (torch.randn(1, 4, 8, 8), torch.randn(1, 4, 8, 8)) + exported = torch.export.export(module, inputs, strict=True).module() + prepared = prepare_pt2e(exported, quantizer) + prepared(*inputs) + + new_obs = FixedQParamsObserver( + scale=0.004, + zero_point=0, + dtype=torch.uint8, + quant_min=0, + quant_max=255, + qscheme=torch.per_tensor_affine, + ) + rewrite_prepared_observer(prepared, {"activation_post_process_3": new_obs}) + assert ( + prepared.activation_post_process_3 is new_obs + ), "observer is not overridden correctly" + # should complete without error + converted = convert_pt2e(prepared) + q_node = [ + n + for n in converted.graph.nodes + if n.name == "quantize_per_tensor_default_2" + ][0] + assert ( + math.isclose(q_node.args[1], 0.004, abs_tol=1e-8) and q_node.args[2] == 0 + ), "scale / offset do not match the overridden values" + + +class SkipNodePartitioner: + @staticmethod + def _count_lowered_modules(edge_prog_mgr): + gm = edge_prog_mgr.exported_program().graph_module + return len([k for k in gm._modules if k.startswith("lowered_module")]) + + @staticmethod + def test(subtests, quantizer, compile_spec): + module = _UtilsModel() + inputs = (torch.randn(1, 4, 8, 8), torch.randn(1, 4, 8, 8)) + + cases = [ + # skip add by node id: [conv1+relu1 / conv2+relu2], [conv3] → 2 partitions + ("node_id", {"skip_node_id_set": {"aten_add_tensor"}}, 2), + # skip add by op: same split → 2 partitions + ("node_op", {"skip_node_op_set": {"aten.add.Tensor"}}, 2), + ] + + for label, skip_kwargs, expected in cases: + with subtests.test(msg=label): + with calibrate(module, [inputs], quantizer) as quantized: + edge_prog_mgr = to_edge_transform_and_lower_to_qnn( + quantized, inputs, compile_spec, **skip_kwargs + ) + assert __class__._count_lowered_modules(edge_prog_mgr) == expected + + # expected failure due to fallback per-channel weight required + # a per-chennel dequantized op which is unavailable in QNN + cases = [ + ("node_id", {"skip_node_id_set": {"aten_convolution_default"}}, 2), + ] + for label, skip_kwargs, _ in cases: + with subtests.test(msg=label): + with pytest.raises( # noqa: B017 + Exception, check=check_exception(EXCEPTION_FROM_PREPROCESS) + ): + with calibrate(module, [inputs], quantizer) as quantized: + edge_prog_mgr = to_edge_transform_and_lower_to_qnn( + quantized, inputs, compile_spec, **skip_kwargs + ) + + +class SkipNodeQuantizer: + @staticmethod + def test(subtests, quantizer, compile_spec): + module = _UtilsModel() + inputs = (torch.randn(1, 4, 8, 8), torch.randn(1, 4, 8, 8)) + + def calibrator(gm): + gm(*inputs) + + cases = [ + ("node_id", {"fp_node_id_set": {"add"}}, 2), + ("node_id", {"fp_node_id_set": {"conv2d"}}, 1), + ("node_op", {"fp_node_op_set": {torch.ops.aten.add.Tensor}}, 2), + ] + + for label, skip_kwargs, expected_partitions in cases: + with subtests.test(msg=label): + graph_module, edge_prog_mgrs = skip_annotation( + nn_module=module, + quantizer=quantizer, + compiler_specs=compile_spec, + sample_input=inputs, + calibration_cb=calibrator, + **skip_kwargs, + ) + assert len(edge_prog_mgrs) == expected_partitions + + # skipped nodes must not be annotated + skipped_targets = set(skip_kwargs.get("fp_node_id_set", set())) | { + op.__name__ if hasattr(op, "__name__") else str(op) + for op in skip_kwargs.get("fp_node_op_set", set()) + } + for node in graph_module.graph.nodes: + name_matches = node.name in skipped_targets + op_matches = ( + hasattr(node.target, "__name__") + and node.target.__name__ in skipped_targets + ) or str(node.target) in skipped_targets + if name_matches or op_matches: + annotated = ( + Q_ANNOTATION_KEY in node.meta + and node.meta[Q_ANNOTATION_KEY]._annotated + ) + assert ( + not annotated + ), f"node {node.name} should not be annotated" + + +class QAT: + @staticmethod + def _make_qat_quantizer(quant_dtype, block_size_map=None): + q = make_quantizer( + quant_dtype=quant_dtype, + per_channel_conv=True, + is_qat=True, + soc_model="SM8650", + ) + if block_size_map: + q.set_block_size_map(block_size_map) + return q + + @staticmethod + def _get_converted_module( + ori_module: torch.nn.Module, + prepared: torch.nn.Module, + inputs: Tuple[torch.Tensor], + ) -> torch.fx.GraphModule: + optimizer = torch.optim.SGD(prepared.parameters(), lr=0.0001) + criterion = torch.nn.CrossEntropyLoss() + output = prepared(*inputs) + loss = reduce( + operator.add, + [criterion(qdq, ref) for qdq, ref in zip(output, ori_module(*inputs))], + ) + optimizer.zero_grad() + loss.backward() + optimizer.step() + return convert_pt2e(prepared) + + @staticmethod + def test(subtests): + from executorch.backends.qualcomm.quantizer.observers.per_block_param_observer import ( + PerBlockParamFakeQuantize, + ) + from torchao.quantization.pt2e import FusedMovingAvgObsFakeQuantize + + module = _UtilsModel() + inputs = (torch.randn(1, 4, 8, 8), torch.randn(1, 4, 8, 8)) + + with subtests.test(msg="16a4w_pcq"): + # activation: FusedMovingAvgObsFakeQuantize (uint16 range, per-tensor) + # weight: FusedMovingAvgObsFakeQuantize (int4 range [-7,7], per-channel) + q = __class__._make_qat_quantizer(QuantDtype.use_16a4w) + exported = torch.export.export(module, inputs, strict=True).module() + prepared = prepare_qat_pt2e(exported, q) + fq_modules = [ + m + for m in prepared.modules() + if isinstance(m, FusedMovingAvgObsFakeQuantize) + ] + assert len(fq_modules) > 0, "no FusedMovingAvgObsFakeQuantize found" + # activation FQs have uint16 range (quant_max=65535) + act_fqs = [m for m in fq_modules if m.quant_max == 65535] + # weight FQs have int4 range (quant_max=7) + weight_fqs = [m for m in fq_modules if m.quant_max == 7] + assert len(act_fqs) > 0, "no uint16-range activation FQ found for 16a4w" + assert len(weight_fqs) > 0, "no int4-range weight FQ found for 16a4w" + # should complete without error + __class__._get_converted_module(module, prepared, inputs) + + with subtests.test(msg="16a4w_block"): + # activation: FusedMovingAvgObsFakeQuantize (uint16 range) + # weight: PerBlockParamFakeQuantize (int4 range) + q = __class__._make_qat_quantizer( + QuantDtype.use_16a4w_block, + block_size_map={"conv2d": (1, 4, 1, 1)}, + ) + exported = torch.export.export(module, inputs, strict=True).module() + prepared = prepare_qat_pt2e(exported, q) + act_fqs = [ + m + for m in prepared.modules() + if isinstance(m, FusedMovingAvgObsFakeQuantize) and m.quant_max == 65535 + ] + block_fqs = [ + m + for m in prepared.modules() + if isinstance(m, PerBlockParamFakeQuantize) + ] + assert ( + len(act_fqs) > 0 + ), "no uint16-range activation FQ found for 16a4w_block" + assert ( + len(block_fqs) > 0 + ), "no PerBlockParamFakeQuantize found for 16a4w_block" + # should complete without error + __class__._get_converted_module(module, prepared, inputs) + + with subtests.test(msg="fp16a8w"): + # activation: None (FP16 — no activation quantization) + # weight: FusedMovingAvgObsFakeQuantize (int8 range [-127,127], per-tensor or per-channel) + q = __class__._make_qat_quantizer(QuantDtype.use_fp16a8w) + exported = torch.export.export(module, inputs, strict=True).module() + prepared = prepare_qat_pt2e(exported, q) + fq_modules = [ + m + for m in prepared.modules() + if isinstance(m, FusedMovingAvgObsFakeQuantize) + ] + # no activation FQ: quant_max==65535 or quant_max==255 nodes must be absent + act_fqs = [m for m in fq_modules if m.quant_max in (255, 65535)] + # weight FQ: int8 range (quant_max=127) + weight_fqs = [m for m in fq_modules if m.quant_max == 127] + assert len(act_fqs) == 0, "fp16a8w should have no activation FQ" + assert len(weight_fqs) > 0, "no int8-range weight FQ found for fp16a8w" + # should complete without error + __class__._get_converted_module(module, prepared, inputs) diff --git a/backends/qualcomm/tests/rework/utils/conftest.py b/backends/qualcomm/tests/rework/utils/conftest.py new file mode 100644 index 00000000000..46b79325a18 --- /dev/null +++ b/backends/qualcomm/tests/rework/utils/conftest.py @@ -0,0 +1,28 @@ +# Copyright (c) Qualcomm Innovation Center, Inc. +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import pytest + +from executorch.backends.qualcomm.export_utils import ( + generate_htp_compiler_spec, + generate_qnn_executorch_compiler_spec, + make_quantizer, + QcomChipset, +) +from executorch.backends.qualcomm.tests.rework.conftest import default_property + + +@pytest.fixture(scope="session") +def compile_spec(): + return generate_qnn_executorch_compiler_spec( + soc_model=getattr(QcomChipset, default_property().soc_model), + backend_options=generate_htp_compiler_spec(use_fp16=True), + ) + + +@pytest.fixture(scope="session") +def quantizer(): + return make_quantizer(soc_model=default_property().soc_model) diff --git a/backends/qualcomm/tests/rework/utils/test.py b/backends/qualcomm/tests/rework/utils/test.py new file mode 100644 index 00000000000..8330f79ab88 --- /dev/null +++ b/backends/qualcomm/tests/rework/utils/test.py @@ -0,0 +1,41 @@ +# Copyright (c) Qualcomm Innovation Center, Inc. +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from executorch.backends.qualcomm.tests.rework.src.utils import * # noqa: F403 + + +def test_dump_context_from_pte(quantizer, compile_spec): + DumpContextFromPte.test(quantizer, compile_spec) # noqa: F405 + + +def test_draw_graph(quantizer): + DrawGraph.test(quantizer) # noqa: F405 + + +def test_fixed_point_floating_point_mixed_precision(subtests, quantizer, compile_spec): + FixedPointFloatingPointMixedPrecision.test( # noqa: F405 + subtests, quantizer, compile_spec + ) + + +def test_multi_contexts_composite(compile_spec): + MultiContextsComposite.test(compile_spec) # noqa: F405 + + +def test_rewrite_prepared_observer(quantizer): + RewritePreparedObserver.test(quantizer) # noqa: F405 + + +def test_skip_node_partitioner(subtests, quantizer, compile_spec): + SkipNodePartitioner.test(subtests, quantizer, compile_spec) # noqa: F405 + + +def test_skip_node_quantizer(subtests, quantizer, compile_spec): + SkipNodeQuantizer.test(subtests, quantizer, compile_spec) # noqa: F405 + + +def test_qat(subtests): + QAT.test(subtests) # noqa: F405