diff --git a/backends/nxp/backend/edge_program_converter.py b/backends/nxp/backend/edge_program_converter.py index ee926853df9..f80570b1096 100644 --- a/backends/nxp/backend/edge_program_converter.py +++ b/backends/nxp/backend/edge_program_converter.py @@ -41,6 +41,7 @@ exir_ops.edge.aten.convolution.default: ConvolutionConverter, # noqa F405 exir_ops.edge.aten.hardtanh.default: HardTanhConverter, # noqa F405 exir_ops.edge.aten.leaky_relu.default: LeakyReluConverter, # noqa F405 + exir_ops.edge.aten.log.default: LogConverter, # noqa F405 exir_ops.edge.aten.max_pool2d_with_indices.default: MaxPool2DWithIndicesConverter, # noqa F405 exir_ops.edge.aten.mean.dim: MeanDimConverter, # noqa F405 exir_ops.edge.aten.mm.default: MMConverter, # noqa F405 diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/__init__.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/__init__.py index a53ece67534..5f19b2e48dc 100755 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/__init__.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/__init__.py @@ -40,6 +40,9 @@ from executorch.backends.nxp.backend.ir.converter.node_converters.ops_converters.leaky_relu_converter import ( LeakyReluConverter, ) +from executorch.backends.nxp.backend.ir.converter.node_converters.ops_converters.log_converter import ( + LogConverter, +) from executorch.backends.nxp.backend.ir.converter.node_converters.ops_converters.max_pool2d_with_indices_converter import ( MaxPool2DWithIndicesConverter, ) @@ -111,6 +114,7 @@ "GetItemConverter", "HardTanhConverter", "LeakyReluConverter", + "LogConverter", "MaxPool2DWithIndicesConverter", "MeanDimConverter", "MMConverter", diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/log_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/log_converter.py new file mode 100644 index 00000000000..3c289106129 --- /dev/null +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/log_converter.py @@ -0,0 +1,59 @@ +# Copyright 2026 NXP +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. +import torch +from executorch.backends.nxp.backend.ir.converter.node_converter import ( + CustomDelegationOptions, + NeutronTargetSpec, + NodeConverter, +) +from executorch.backends.nxp.backend.ir.lib.tflite.BuiltinOperator import ( + BuiltinOperator, +) +from torch.fx import Node +from torch.nn import Parameter + + +class LogConverter(NodeConverter): + + @staticmethod + def _is_supported_in_IR( + node: Node, + parameters_mapping: dict[str, Parameter], + custom_delegation_options: CustomDelegationOptions, + ) -> bool: + return True + + @staticmethod + def _is_supported_on_target( + node: Node, + neutron_target_spec: NeutronTargetSpec, + parameters_mapping: dict[str, Parameter], + custom_delegation_options: CustomDelegationOptions, + ) -> bool: + # Requirements specified by the new Neutron flow documentation. + # Input and Output must be INT8/UINT8. + if not NodeConverter.uses_quantization_type_for_io( + node, + supported_types=[torch.int8, torch.uint8], + input_indices=[0], + output_indices=[0], + ): + return False + return True + + def convert(self, node: Node): + """Convert the `aten.log.default` operator to Neutron IR `Log`. + The schema is: + aten::log( + Tensor self + ) -> Tensor + """ + + self.assert_convertible(node) + + t_op = self._create_tflite_op_with_io_tensors(node) + t_op.opcode_index = self.builder.op_code_index_for_op_type(BuiltinOperator.LOG) + + self.builder.append_operators([t_op]) diff --git a/backends/nxp/neutron_partitioner.py b/backends/nxp/neutron_partitioner.py index d267acede81..d4262b3a9f6 100644 --- a/backends/nxp/neutron_partitioner.py +++ b/backends/nxp/neutron_partitioner.py @@ -214,6 +214,7 @@ def tag_qdq_clusters(self, nodes: list[torch.fx.Node]): exir_ops.edge.aten.convolution.default: ConvolutionConverter, # noqa F405 exir_ops.edge.aten.hardtanh.default: HardTanhConverter, # noqa F405 exir_ops.edge.aten.leaky_relu.default: LeakyReluConverter, # noqa F405 + exir_ops.edge.aten.log.default: LogConverter, # noqa F405 exir_ops.edge.aten.max_pool2d_with_indices.default: MaxPool2DWithIndicesConverter, # noqa F405 exir_ops.edge.aten.mean.dim: MeanDimConverter, # noqa F405 exir_ops.edge.aten.mm.default: MMConverter, # noqa F405 diff --git a/backends/nxp/quantizer/neutron_quantizer.py b/backends/nxp/quantizer/neutron_quantizer.py index d014be91800..048172ea212 100644 --- a/backends/nxp/quantizer/neutron_quantizer.py +++ b/backends/nxp/quantizer/neutron_quantizer.py @@ -31,6 +31,7 @@ LeakyReluInPlacePattern, LeakyReluPattern, LinearPattern, + LogPattern, MaxPool1DPattern, MaxPool2DPattern, MeanDimPattern, @@ -275,6 +276,7 @@ def __init__(self, neutron_target_spec: NeutronTargetSpec, is_qat: bool = False) OpQuantizer(LeakyReluPattern(is_qat=is_qat), static_fc_qconfig), OpQuantizer(LeakyReluInPlacePattern(is_qat=is_qat), static_fc_qconfig), OpQuantizer(LinearPattern(self, is_qat=is_qat), static_fc_qconfig), + OpQuantizer(LogPattern(is_qat=is_qat), static_qconfig), OpQuantizer(MaxPool1DPattern(is_qat=is_qat), static_qconfig), OpQuantizer(MaxPool2DPattern(is_qat=is_qat), static_qconfig), OpQuantizer(MeanDimPattern(is_qat=is_qat), static_qconfig), diff --git a/backends/nxp/quantizer/patterns.py b/backends/nxp/quantizer/patterns.py index 5d72a206fec..9e21e4f1660 100644 --- a/backends/nxp/quantizer/patterns.py +++ b/backends/nxp/quantizer/patterns.py @@ -836,6 +836,13 @@ def get_anchors( ) +class LogPattern(SingleInputBasicPattern): + """Quantizer for the `aten.log.default` operator.""" + + def partition_types(self): + return [torch.ops.aten.log.default] + + class MaxPool1DPattern(SharedSpecPattern): """Quantizer for the MaxPool1D operator.""" diff --git a/backends/nxp/tests/dataset_creator.py b/backends/nxp/tests/dataset_creator.py index fdfd363c257..72538aed457 100644 --- a/backends/nxp/tests/dataset_creator.py +++ b/backends/nxp/tests/dataset_creator.py @@ -337,3 +337,81 @@ def _gen_samples( bin_file_name = f"{dataset_dir}/example_{label}_{cl}_{i}_i{str(inp_idx).zfill(2)}.bin" dt.tofile(bin_file_name) + + +class LinearRampDatasetCreator(DatasetCreator): + """Dataset creator that generates deterministic linear ramp input samples. + + The generated data forms a monotonic sequence where values are evenly + distributed between a specified range (low to high) and span the full + interval. The first element is equal to `low` and the last element is + equal to `high`, with increments depending on the total number of elements. + """ + + def __init__(self, num_samples=2, low=-1.0, high=1.0): + self._num_samples = num_samples + self.low = low + self.high = high + + def generate_samples( + self, dataset_dir: str, input_spec: list[ModelInputSpec] + ) -> tuple[str, str]: + assert isinstance(input_spec, list) and all( + isinstance(spec, ModelInputSpec) for spec in input_spec + ), "Input_spec must be a list of ModelInputSpec." + + calibration_dir, test_dir = ( + _get_calibration_and_testing_dataset_directory_names(dataset_dir) + ) + + if any(spec.dim_order == torch.channels_last for spec in input_spec): + # We will need to generate a separate testing dataset, containing the same data as is in the calibration + # dataset, just permuted to channels last where necessary. + self._gen_samples(test_dir, input_spec) + + else: + # Use the calibration dataset for testing as well. + test_dir = calibration_dir + + # Make sure the calibration dataset contains contiguous tensors. + contiguous_input_spec = deepcopy(input_spec) + for spec in contiguous_input_spec: + spec.dim_order = torch.contiguous_format + + # Generate the calibration dataset. Calibration amd testing dataset s will contain + # the same data (except for the permutation). + self._gen_samples(calibration_dir, contiguous_input_spec) + + return calibration_dir, test_dir + + def _gen_samples(self, dataset_dir: str, input_spec: list[ModelInputSpec]): + for idx in range(self._num_samples): + sample_dir = dataset_dir + + # Multi-input, use a subdirectory containing the inputs for each sample + if len(input_spec) > 1: + sample_dir = os.path.join(dataset_dir, f"{str(idx).zfill(4)}") + mkdir(sample_dir) + + for spec_idx, spec in enumerate(input_spec): + match spec.dim_order: + case torch.contiguous_format: + shape = spec.shape + case torch.channels_last: + shape = tuple( + translator.dims_to_channels_last(list(spec.shape)) + ) + case _: + raise ValueError(f"Unsupported dim_order: {spec.dim_order}") + + sample_vector = ( + np.linspace(self.low, self.high, num=np.prod(shape)) + .astype(torch_type_to_numpy_type(spec.dtype)) + .reshape(shape) + ) + file_name = ( + f"{str(spec_idx).zfill(2)}.bin" + if len(input_spec) > 1 + else f"{str(idx).zfill(4)}.bin" + ) + sample_vector.tofile(os.path.join(sample_dir, file_name)) diff --git a/backends/nxp/tests/ir/converter/node_converter/test_log_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_log_converter.py new file mode 100644 index 00000000000..3e1d066103a --- /dev/null +++ b/backends/nxp/tests/ir/converter/node_converter/test_log_converter.py @@ -0,0 +1,74 @@ +# Copyright 2026 NXP +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import numpy as np + +# noinspection PyUnusedImports +import pytest +import torch + +from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier +from executorch.backends.nxp.tests.nsys_testing import lower_run_compare +from executorch.backends.nxp.tests.ops_aliases import Log +from executorch.backends.nxp.tests.use_qat import * # noqa F403 +from executorch.backends.nxp.tests.dataset_creator import ( + LinearRampDatasetCreator, + RandomDatasetCreator, +) + + +@pytest.fixture(autouse=True) +def reseed_model_per_test_run(): + torch.manual_seed(42) + np.random.seed(23) + + +class LogModule(torch.nn.Module): + + def __init__(self): + super().__init__() + + def forward(self, x): + return torch.log(x) + + +class TestLog: + def test__basic_nsys_inference(self, mocker): + # Use 256 elements so that, after quantization to int8, the input can + # cover the full discrete range [-128, 127]. + # The dataset is generated as a linear float ramp and later quantized, + # which effectively exercises all int8 values. + input_shape = (256,) + model = LogModule() + graph_verifier = DetailedGraphVerifier( + mocker, expected_delegated_ops={Log: 1}, expected_non_delegated_ops={} + ) + lower_run_compare( + model, + input_shape, + graph_verifier, + dataset_creator=LinearRampDatasetCreator(low=0.0, high=1.0), + ) + + @pytest.mark.parametrize( + "input_shape", + [ + pytest.param((17, 2), id="2D"), + pytest.param((1, 3, 10), id="3D"), + pytest.param((1, 3, 16, 16), id="4D"), + ], + ) + def test__basic_nsys_inference__qat(self, mocker, input_shape, use_qat): + model = LogModule() + graph_verifier = DetailedGraphVerifier( + mocker, expected_delegated_ops={Log: 1}, expected_non_delegated_ops={} + ) + lower_run_compare( + model, + input_shape, + graph_verifier, + dataset_creator=RandomDatasetCreator(low=1.0, high=10.0), + use_qat=use_qat, + ) diff --git a/backends/nxp/tests/ops_aliases.py b/backends/nxp/tests/ops_aliases.py index 3106d32686b..ebeab3abcab 100644 --- a/backends/nxp/tests/ops_aliases.py +++ b/backends/nxp/tests/ops_aliases.py @@ -26,6 +26,7 @@ HardTanh = exir_ops.edge.aten.hardtanh.default HardTanh_ = exir_ops.edge.aten.hardtanh_.default LeakyRelu = exir_ops.edge.aten.leaky_relu.default +Log = exir_ops.edge.aten.log.default MaxPool2DWithIndices = exir_ops.edge.aten.max_pool2d_with_indices.default MeanDim = exir_ops.edge.aten.mean.dim MulTensor = exir_ops.edge.aten.mul.Tensor diff --git a/docs/source/backends/nxp/op-support.csv b/docs/source/backends/nxp/op-support.csv index f55542547dd..8a250dce88d 100644 --- a/docs/source/backends/nxp/op-support.csv +++ b/docs/source/backends/nxp/op-support.csv @@ -15,6 +15,7 @@ aten.dim_order_ops._clone_dim_order.default,,, "See aten.clone.default" aten.div.Tensor,int8,static int8,"divisor - static tensor or scalar value, one dimension must satisfy %8 = 0 or scalar division (all dims = 1)" aten.hardtanh.default,int8,static int8,"supported ranges: <0,6>, <-1, 1>, <0,1>, <0,inf>" aten.leaky_relu.default,int8,static int8, +aten.log.default,int8,static int8, aten.max_pool1d.default,int8,static int8,"dilation=1, ceil_mode=False, channels%8=0, batch_size=1, stride_h=1 or 2" aten.max_pool2d.default,int8,static int8,"dilation=1, ceil_mode=False, channels%8=0, batch_size=1, stride_h=1 or 2" aten.max_pool2d_with_indices.default,int8,static int8,"dilation=1, ceil_mode=False, channels%8=0, batch_size=1, stride_h=1 or 2"