Skip to content

Commit 99747d3

Browse files
committed
[EIEX-885] Add log support using new Neutron flow
1 parent 2759ef1 commit 99747d3

10 files changed

Lines changed: 250 additions & 0 deletions

File tree

backends/nxp/backend/edge_program_converter.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
exir_ops.edge.aten.convolution.default: ConvolutionConverter, # noqa F405
4242
exir_ops.edge.aten.hardtanh.default: HardTanhConverter, # noqa F405
4343
exir_ops.edge.aten.leaky_relu.default: LeakyReluConverter, # noqa F405
44+
exir_ops.edge.aten.log.default: LogConverter, # noqa F405
4445
exir_ops.edge.aten.max_pool2d_with_indices.default: MaxPool2DWithIndicesConverter, # noqa F405
4546
exir_ops.edge.aten.mean.dim: MeanDimConverter, # noqa F405
4647
exir_ops.edge.aten.mm.default: MMConverter, # noqa F405

backends/nxp/backend/ir/converter/node_converters/ops_converters/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@
4040
from executorch.backends.nxp.backend.ir.converter.node_converters.ops_converters.leaky_relu_converter import (
4141
LeakyReluConverter,
4242
)
43+
from executorch.backends.nxp.backend.ir.converter.node_converters.ops_converters.log_converter import (
44+
LogConverter,
45+
)
4346
from executorch.backends.nxp.backend.ir.converter.node_converters.ops_converters.max_pool2d_with_indices_converter import (
4447
MaxPool2DWithIndicesConverter,
4548
)
@@ -111,6 +114,7 @@
111114
"GetItemConverter",
112115
"HardTanhConverter",
113116
"LeakyReluConverter",
117+
"LogConverter",
114118
"MaxPool2DWithIndicesConverter",
115119
"MeanDimConverter",
116120
"MMConverter",
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Copyright 2026 NXP
2+
#
3+
# This source code is licensed under the BSD-style license found in the
4+
# LICENSE file in the root directory of this source tree.
5+
import torch
6+
from executorch.backends.nxp.backend.ir.converter.node_converter import (
7+
CustomDelegationOptions,
8+
NeutronTargetSpec,
9+
NodeConverter,
10+
)
11+
from executorch.backends.nxp.backend.ir.lib.tflite.BuiltinOperator import (
12+
BuiltinOperator,
13+
)
14+
from torch.fx import Node
15+
from torch.nn import Parameter
16+
17+
18+
class LogConverter(NodeConverter):
19+
20+
@staticmethod
21+
def _is_supported_in_IR(
22+
node: Node,
23+
parameters_mapping: dict[str, Parameter],
24+
custom_delegation_options: CustomDelegationOptions,
25+
) -> bool:
26+
return True
27+
28+
@staticmethod
29+
def _is_supported_on_target(
30+
node: Node,
31+
neutron_target_spec: NeutronTargetSpec,
32+
parameters_mapping: dict[str, Parameter],
33+
custom_delegation_options: CustomDelegationOptions,
34+
) -> bool:
35+
# Requirements specified by the new Neutron flow documentation.
36+
# Input and Output must be INT8/UINT8.
37+
if not NodeConverter.uses_quantization_type_for_io(
38+
node,
39+
supported_types=[torch.int8, torch.uint8],
40+
input_indices=[0],
41+
output_indices=[0],
42+
):
43+
return False
44+
return True
45+
46+
def convert(self, node: Node):
47+
"""Convert the `aten.log.default` operator to Neutron IR `Log`.
48+
The schema is:
49+
aten::log(
50+
Tensor self
51+
) -> Tensor
52+
"""
53+
54+
self.assert_convertible(node)
55+
56+
t_op = self._create_tflite_op_with_io_tensors(node)
57+
t_op.opcode_index = self.builder.op_code_index_for_op_type(BuiltinOperator.LOG)
58+
59+
self.builder.append_operators([t_op])

backends/nxp/neutron_partitioner.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ def tag_qdq_clusters(self, nodes: list[torch.fx.Node]):
214214
exir_ops.edge.aten.convolution.default: ConvolutionConverter, # noqa F405
215215
exir_ops.edge.aten.hardtanh.default: HardTanhConverter, # noqa F405
216216
exir_ops.edge.aten.leaky_relu.default: LeakyReluConverter, # noqa F405
217+
exir_ops.edge.aten.log.default: LogConverter, # noqa F405
217218
exir_ops.edge.aten.max_pool2d_with_indices.default: MaxPool2DWithIndicesConverter, # noqa F405
218219
exir_ops.edge.aten.mean.dim: MeanDimConverter, # noqa F405
219220
exir_ops.edge.aten.mm.default: MMConverter, # noqa F405

backends/nxp/quantizer/neutron_quantizer.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
LeakyReluInPlacePattern,
3232
LeakyReluPattern,
3333
LinearPattern,
34+
LogPattern,
3435
MaxPool1DPattern,
3536
MaxPool2DPattern,
3637
MeanDimPattern,
@@ -275,6 +276,7 @@ def __init__(self, neutron_target_spec: NeutronTargetSpec, is_qat: bool = False)
275276
OpQuantizer(LeakyReluPattern(is_qat=is_qat), static_fc_qconfig),
276277
OpQuantizer(LeakyReluInPlacePattern(is_qat=is_qat), static_fc_qconfig),
277278
OpQuantizer(LinearPattern(self, is_qat=is_qat), static_fc_qconfig),
279+
OpQuantizer(LogPattern(is_qat=is_qat), static_qconfig),
278280
OpQuantizer(MaxPool1DPattern(is_qat=is_qat), static_qconfig),
279281
OpQuantizer(MaxPool2DPattern(is_qat=is_qat), static_qconfig),
280282
OpQuantizer(MeanDimPattern(is_qat=is_qat), static_qconfig),

backends/nxp/quantizer/patterns.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -836,6 +836,13 @@ def get_anchors(
836836
)
837837

838838

839+
class LogPattern(SingleInputBasicPattern):
840+
"""Quantizer for the `aten.log.default` operator."""
841+
842+
def partition_types(self):
843+
return [torch.ops.aten.log.default]
844+
845+
839846
class MaxPool1DPattern(SharedSpecPattern):
840847
"""Quantizer for the MaxPool1D operator."""
841848

backends/nxp/tests/dataset_creator.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,3 +337,81 @@ def _gen_samples(
337337

338338
bin_file_name = f"{dataset_dir}/example_{label}_{cl}_{i}_i{str(inp_idx).zfill(2)}.bin"
339339
dt.tofile(bin_file_name)
340+
341+
342+
class LinearRampDatasetCreator(DatasetCreator):
343+
"""Dataset creator that generates deterministic linear ramp input samples.
344+
345+
The generated data forms a monotonic sequence where values are evenly
346+
distributed between a specified range (low to high) and span the full
347+
interval. The first element is equal to `low` and the last element is
348+
equal to `high`, with increments depending on the total number of elements.
349+
"""
350+
351+
def __init__(self, num_samples=2, low=0.0, high=1.0):
352+
self._num_samples = num_samples
353+
self.low = low
354+
self.high = high
355+
356+
def generate_samples(
357+
self, dataset_dir: str, input_spec: list[ModelInputSpec]
358+
) -> tuple[str, str]:
359+
assert isinstance(input_spec, list) and all(
360+
isinstance(spec, ModelInputSpec) for spec in input_spec
361+
), "Input_spec must be a list of ModelInputSpec."
362+
363+
calibration_dir, test_dir = (
364+
_get_calibration_and_testing_dataset_directory_names(dataset_dir)
365+
)
366+
367+
if any(spec.dim_order == torch.channels_last for spec in input_spec):
368+
# We will need to generate a separate testing dataset, containing the same data as is in the calibration
369+
# dataset, just permuted to channels last where necessary.
370+
self._gen_samples(test_dir, input_spec)
371+
372+
else:
373+
# Use the calibration dataset for testing as well.
374+
test_dir = calibration_dir
375+
376+
# Make sure the calibration dataset contains contiguous tensors.
377+
contiguous_input_spec = deepcopy(input_spec)
378+
for spec in contiguous_input_spec:
379+
spec.dim_order = torch.contiguous_format
380+
381+
# Generate the calibration dataset. Calibration amd testing dataset s will contain
382+
# the same data (except for the permutation).
383+
self._gen_samples(calibration_dir, contiguous_input_spec)
384+
385+
return calibration_dir, test_dir
386+
387+
def _gen_samples(self, dataset_dir: str, input_spec: list[ModelInputSpec]):
388+
for idx in range(self._num_samples):
389+
sample_dir = dataset_dir
390+
391+
# Multi-input, use a subdirectory containing the inputs for each sample
392+
if len(input_spec) > 1:
393+
sample_dir = os.path.join(dataset_dir, f"{str(idx).zfill(4)}")
394+
mkdir(sample_dir)
395+
396+
for spec_idx, spec in enumerate(input_spec):
397+
match spec.dim_order:
398+
case torch.contiguous_format:
399+
shape = spec.shape
400+
case torch.channels_last:
401+
shape = tuple(
402+
translator.dims_to_channels_last(list(spec.shape))
403+
)
404+
case _:
405+
raise ValueError(f"Unsupported dim_order: {spec.dim_order}")
406+
407+
sample_vector = (
408+
np.linspace(self.low, self.high, num=np.prod(shape))
409+
.astype(torch_type_to_numpy_type(spec.dtype))
410+
.reshape(shape)
411+
)
412+
file_name = (
413+
f"{str(spec_idx).zfill(2)}.bin"
414+
if len(input_spec) > 1
415+
else f"{str(idx).zfill(4)}.bin"
416+
)
417+
sample_vector.tofile(os.path.join(sample_dir, file_name))
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Copyright 2026 NXP
2+
#
3+
# This source code is licensed under the BSD-style license found in the
4+
# LICENSE file in the root directory of this source tree.
5+
6+
import numpy as np
7+
8+
# noinspection PyUnusedImports
9+
import pytest
10+
import torch
11+
12+
from executorch.backends.nxp.tests.graph_verifier import DetailedGraphVerifier
13+
from executorch.backends.nxp.tests.nsys_testing import lower_run_compare
14+
from executorch.backends.nxp.tests.ops_aliases import Clamp, Convolution, Log, Relu
15+
from executorch.backends.nxp.tests.use_qat import * # noqa F403
16+
from executorch.backends.nxp.tests.dataset_creator import LinearRampDatasetCreator
17+
18+
19+
@pytest.fixture(autouse=True)
20+
def reseed_model_per_test_run():
21+
torch.manual_seed(42)
22+
np.random.seed(23)
23+
24+
25+
class LogModule(torch.nn.Module):
26+
27+
def __init__(self):
28+
super().__init__()
29+
30+
def forward(self, x):
31+
return torch.log(x)
32+
33+
34+
class ConvBlocksWithLogModule(torch.nn.Module):
35+
def __init__(self, conv_in_channels: int = 3):
36+
super().__init__()
37+
self.block1 = torch.nn.Sequential(
38+
torch.nn.Conv2d(
39+
in_channels=conv_in_channels,
40+
out_channels=3,
41+
kernel_size=(2, 2),
42+
stride=(2, 2),
43+
),
44+
torch.nn.ReLU(),
45+
)
46+
self.block2 = torch.nn.Sequential(
47+
torch.nn.Conv2d(
48+
in_channels=conv_in_channels,
49+
out_channels=10,
50+
kernel_size=(2, 2),
51+
stride=(2, 2),
52+
),
53+
torch.nn.ReLU(),
54+
)
55+
56+
def forward(self, x):
57+
x = self.block1(x)
58+
x = x.clamp_min(1e-6).log()
59+
return self.block2(x)
60+
61+
62+
class TestLog:
63+
def test__basic_nsys_inference(self, mocker):
64+
# Use 256 elements so that, after quantization to uint8, the input can
65+
# cover the full discrete range [0, 255].
66+
# The dataset is generated as a linear float ramp and later quantized,
67+
# which effectively exercises all uint8 values.
68+
input_shape = (256,)
69+
model = LogModule()
70+
graph_verifier = DetailedGraphVerifier(
71+
mocker, expected_delegated_ops={Log: 1}, expected_non_delegated_ops={}
72+
)
73+
lower_run_compare(
74+
model,
75+
input_shape,
76+
graph_verifier,
77+
dataset_creator=LinearRampDatasetCreator(),
78+
)
79+
80+
def test__basic_nsys_inference__with_conv(self, mocker):
81+
input_shape = (2, 3, 6, 7)
82+
in_channels = input_shape[1]
83+
model = ConvBlocksWithLogModule(conv_in_channels=in_channels)
84+
85+
# `clamp` and one `relu` ends up in the same delegated partition as `log`
86+
graph_verifier = DetailedGraphVerifier(
87+
mocker,
88+
expected_delegated_ops={Log: 1, Relu: 1, Clamp: 1},
89+
expected_non_delegated_ops={Relu: 1, Convolution: 2},
90+
)
91+
92+
lower_run_compare(
93+
model,
94+
input_shape,
95+
graph_verifier,
96+
)

backends/nxp/tests/ops_aliases.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
HardTanh = exir_ops.edge.aten.hardtanh.default
2727
HardTanh_ = exir_ops.edge.aten.hardtanh_.default
2828
LeakyRelu = exir_ops.edge.aten.leaky_relu.default
29+
Log = exir_ops.edge.aten.log.default
2930
MaxPool2DWithIndices = exir_ops.edge.aten.max_pool2d_with_indices.default
3031
MeanDim = exir_ops.edge.aten.mean.dim
3132
MulTensor = exir_ops.edge.aten.mul.Tensor

docs/source/backends/nxp/op-support.csv

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ aten.dim_order_ops._clone_dim_order.default,,, "See aten.clone.default"
1515
aten.div.Tensor,int8,static int8,"divisor - static tensor or scalar value, one dimension must satisfy %8 = 0 or scalar division (all dims = 1)"
1616
aten.hardtanh.default,int8,static int8,"supported ranges: <0,6>, <-1, 1>, <0,1>, <0,inf>"
1717
aten.leaky_relu.default,int8,static int8,
18+
aten.log.default,int8,static int8,
1819
aten.max_pool1d.default,int8,static int8,"dilation=1, ceil_mode=False, channels%8=0, batch_size=1, stride_h=1 or 2"
1920
aten.max_pool2d.default,int8,static int8,"dilation=1, ceil_mode=False, channels%8=0, batch_size=1, stride_h=1 or 2"
2021
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"

0 commit comments

Comments
 (0)