Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backends/nxp/backend/edge_program_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -111,6 +114,7 @@
"GetItemConverter",
"HardTanhConverter",
"LeakyReluConverter",
"LogConverter",
"MaxPool2DWithIndicesConverter",
"MeanDimConverter",
"MMConverter",
Expand Down
Original file line number Diff line number Diff line change
@@ -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])
1 change: 1 addition & 0 deletions backends/nxp/neutron_partitioner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions backends/nxp/quantizer/neutron_quantizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
LeakyReluInPlacePattern,
LeakyReluPattern,
LinearPattern,
LogPattern,
MaxPool1DPattern,
MaxPool2DPattern,
MeanDimPattern,
Expand Down Expand Up @@ -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),
Expand Down
7 changes: 7 additions & 0 deletions backends/nxp/quantizer/patterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
78 changes: 78 additions & 0 deletions backends/nxp/tests/dataset_creator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice idea 👍🏻
Does this also work with the "int8 testing" added by @roman-janik-nxp last week?

"""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=0.0, high=1.0):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Perhaps it would be more useful to have the default range include negative values (for example if we want to use this to test relu/prelu/...). How about -1 to 1?

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))
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# 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 Clamp, Convolution, Log, Relu
from executorch.backends.nxp.tests.use_qat import * # noqa F403
from executorch.backends.nxp.tests.dataset_creator import LinearRampDatasetCreator


@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 ConvBlocksWithLogModule(torch.nn.Module):
def __init__(self, conv_in_channels: int = 3):
super().__init__()
self.block1 = torch.nn.Sequential(
torch.nn.Conv2d(
in_channels=conv_in_channels,
out_channels=3,
kernel_size=(2, 2),
stride=(2, 2),
),
torch.nn.ReLU(),
)
self.block2 = torch.nn.Sequential(
torch.nn.Conv2d(
in_channels=conv_in_channels,
out_channels=10,
kernel_size=(2, 2),
stride=(2, 2),
),
torch.nn.ReLU(),
)

def forward(self, x):
x = self.block1(x)
x = x.clamp_min(1e-6).log()
return self.block2(x)
Comment on lines +34 to +59

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No change needed here, but such a complex test model is counterproductive. We prefer simple models (1 to 3 ops at most) which focus on some specific case as efficiently as possible. Here fore example, the Relu, Clamp and Relu have no effect on the testing conditions, but they make the test more complex and on lines 88-89, the test may require changes if the support for the related ops is modified (e.g. Vasek's Conv PR is merged).



class TestLog:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a test using QAT. (All operators should have at least 1)

def test__basic_nsys_inference(self, mocker):
# Use 256 elements so that, after quantization to uint8, the input can
# cover the full discrete range [0, 255].
# The dataset is generated as a linear float ramp and later quantized,
# which effectively exercises all uint8 values.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: uint8 -> int8 (2 occurrences)

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(),
)

def test__basic_nsys_inference__with_conv(self, mocker):
input_shape = (2, 3, 6, 7)
in_channels = input_shape[1]
model = ConvBlocksWithLogModule(conv_in_channels=in_channels)

# `clamp` and one `relu` ends up in the same delegated partition as `log`
graph_verifier = DetailedGraphVerifier(
mocker,
expected_delegated_ops={Log: 1, Relu: 1, Clamp: 1},
expected_non_delegated_ops={Relu: 1, Convolution: 2},
)

lower_run_compare(
model,
input_shape,
graph_verifier,
)
1 change: 1 addition & 0 deletions backends/nxp/tests/ops_aliases.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/source/backends/nxp/op-support.csv
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading