From 9584e5db5f0cba940b0200169e35d5ab2f9735f7 Mon Sep 17 00:00:00 2001 From: Martin Pavella Date: Thu, 6 Aug 2026 15:46:31 +0200 Subject: [PATCH 1/6] Fix handling of `alpha` attribute for `add` and `sub`. --- .../ops_converters/add_tensor_converter.py | 2 +- .../ops_converters/sub_tensor_converter.py | 2 +- .../test_add_tensor_converter.py | 23 +++++++++++++++++++ .../test_sub_tensor_converter.py | 23 +++++++++++++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/add_tensor_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/add_tensor_converter.py index f38f250113f..b589632ef15 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/add_tensor_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/add_tensor_converter.py @@ -46,7 +46,7 @@ def _is_supported_in_IR( if len(node.args) != 2: return False - if hasattr(node.kwargs, "alpha"): + if node.kwargs.get("alpha", 1) != 1: return False return True diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/sub_tensor_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/sub_tensor_converter.py index d2f33454abc..328a995812a 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/sub_tensor_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/sub_tensor_converter.py @@ -48,7 +48,7 @@ def _is_supported_in_IR( # The `alpha` attribute can be represented by adding an extra `Mul` operator. # However, this is not implemented as `alpha` is rarely used. - if hasattr(node.kwargs, "alpha"): + if node.kwargs.get("alpha", 1) != 1: return False return True diff --git a/backends/nxp/tests/ir/converter/node_converter/test_add_tensor_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_add_tensor_converter.py index 2c89a2f5e51..c01d0ca818d 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_add_tensor_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_add_tensor_converter.py @@ -36,6 +36,15 @@ def reseed_model_per_test_run(): np.random.seed(23) +class AddTensorAlphaModule(torch.nn.Module): + def __init__(self, alpha): + super().__init__() + self.alpha = alpha + + def forward(self, x, y): + return torch.add(x, y, alpha=self.alpha) + + class TestAddTensor: @pytest.mark.parametrize( "x_input_shape", @@ -257,3 +266,17 @@ def test__broadcast__channels_first_input(self, mocker, request, input_spec): comparator, remove_quant_io_ops=remove_quant_io_ops, ) + + def test__alpha(self): + model = AddTensorAlphaModule(alpha=2) + shape = (42,) + + delegated_ep = to_quantized_edge_program( + model, [ModelInputSpec(shape), ModelInputSpec(shape)] + ).exported_program() + + # Make sure the `add.Tensor` was NOT delegated. + assert not graph_contains_any_of_ops( + delegated_ep.graph, [ExecutorchDelegateCall] + ) + assert graph_contains_any_of_ops(delegated_ep.graph, [AddTensor]) diff --git a/backends/nxp/tests/ir/converter/node_converter/test_sub_tensor_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_sub_tensor_converter.py index 76817d53928..1601c1e19c2 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_sub_tensor_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_sub_tensor_converter.py @@ -36,6 +36,15 @@ def reseed_model_per_test_run(): np.random.seed(23) +class SubTensorAlphaModule(torch.nn.Module): + def __init__(self, alpha): + super().__init__() + self.alpha = alpha + + def forward(self, x, y): + return torch.sub(x, y, alpha=self.alpha) + + class TestSubTensor: @pytest.mark.parametrize( "x_input_shape", @@ -252,3 +261,17 @@ def test__broadcast_channels_first_input(self, mocker, request, input_spec): comparator, remove_quant_io_ops=remove_quant_io_ops, ) + + def test__alpha(self): + model = SubTensorAlphaModule(alpha=2) + shape = (42,) + + delegated_ep = to_quantized_edge_program( + model, [ModelInputSpec(shape), ModelInputSpec(shape)] + ).exported_program() + + # Make sure the `sub.Tensor` was NOT delegated. + assert not graph_contains_any_of_ops( + delegated_ep.graph, [ExecutorchDelegateCall] + ) + assert graph_contains_any_of_ops(delegated_ep.graph, [SubTensor]) From ed4936ccd247bd4fddb5489b57a1ac8929114f1b Mon Sep 17 00:00:00 2001 From: Martin Pavella Date: Thu, 6 Aug 2026 15:47:45 +0200 Subject: [PATCH 2/6] Fix handling of `dim=None` attribute for `mean`. --- .../ir/converter/node_converters/shared/reduce_utils.py | 6 ++++++ .../ir/converter/node_converter/test_mean_dim_converter.py | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/backends/nxp/backend/ir/converter/node_converters/shared/reduce_utils.py b/backends/nxp/backend/ir/converter/node_converters/shared/reduce_utils.py index beb035084c7..fc6735f7b8d 100755 --- a/backends/nxp/backend/ir/converter/node_converters/shared/reduce_utils.py +++ b/backends/nxp/backend/ir/converter/node_converters/shared/reduce_utils.py @@ -61,6 +61,12 @@ def _normalize_and_to_channel_last_dim(dim: list[int], rank: int) -> list[int]: def get_reduce_node_attrs(node: Node) -> tuple[list[int], bool]: dim = node.args[1] if len(node.args) >= 2 else None keepdim = node.args[2] if len(node.args) >= 3 else False + + if dim is None: + # The default behavior is to reduce all dimensions. + input_rank = node.args[0].meta["val"].dim() + dim = list(range(input_rank)) + return dim, keepdim diff --git a/backends/nxp/tests/ir/converter/node_converter/test_mean_dim_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_mean_dim_converter.py index 2e9a4062821..1674153540f 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_mean_dim_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_mean_dim_converter.py @@ -168,6 +168,10 @@ def test__tuple_dims(self, mocker, request, input_shape, dim, keep_dim): model = MeanDimModule(dim, keep_dim) assert_delegated(model, input_shape, mocker, request) + def test__default_dims(self, mocker, request, keep_dim): + model = MeanDimModule(dim=None, keepdim=keep_dim) + assert_delegated(model, (2, 4, 6, 8), mocker, request) + @pytest.mark.parametrize( "input_shape, dim", [ From 9951cbace3345ef9abb54a9fdc0e3b128c9e762d Mon Sep 17 00:00:00 2001 From: Martin Pavella Date: Thu, 6 Aug 2026 15:48:29 +0200 Subject: [PATCH 3/6] Fix handling of `dilation` attribute for "transpose conv". --- .../ops_converters/convolution_converter.py | 23 +++--- .../node_converter/test_conv_converter.py | 71 +++++++++++++------ 2 files changed, 63 insertions(+), 31 deletions(-) diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/convolution_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/convolution_converter.py index 581556ca7ce..46bce408d0f 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/convolution_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/convolution_converter.py @@ -246,7 +246,12 @@ def _is_supported_in_IR( if conv_params.transposed and conv_utils.group_conv_convertible_as_depthwise( node, conv_params.groups ): - # TFLite does not support transposed depthwise convolution + # Neutron IR does not support transposed depthwise convolution + return False + + non_default_dilation = any(d != 1 for d in conv_params.dilation) + if conv_params.transposed and non_default_dilation: + # Neutron IR TransposeConv2D does not support dilation. return False if not conv_params.transposed and conv_params.out_padding != [0] * dimensions: @@ -306,7 +311,7 @@ def _normalize_ls_arg(ls): def _convert_unpadded_2D( self, t_op: tflite_model.Operator, conv_params: ConvParameters ) -> conv_utils.ConvConversionResult: - """Convert the `aten.convolution` into TFLite. The `padding` and `builtin_options` must be converted by the + """Convert the `aten.convolution` into Neutron IR. The `padding` and `builtin_options` must be converted by the caller. """ common.assign_2d_strides(t_op.builtin_options, conv_params.stride) @@ -317,7 +322,7 @@ def _convert_unpadded_2D( y: tflite_model.Tensor = t_op.tmp_outputs[0] if (b := try_get_input(t_op, 2)) is None: - # Operator has no bias. Convolution aten op can omit it, TFLite can't. + # Operator has no bias. Convolution aten op can omit it, Neutron IR can't. output_channels = w.shape.vector[0] if w.type == TensorType.FLOAT32: @@ -345,7 +350,7 @@ def _convert_unpadded_2D( b, bias_scale, bias_zero_point, quantized_dimension=0 ) - # Assign the operator its TFLite inputs and outputs + # Assign the operator its Neutron IR inputs and outputs t_op.tmp_inputs = [x, w, b] t_op.tmp_outputs = [y] @@ -357,7 +362,7 @@ def _convert_unpadded_2D( def _convert_transpose_conv( self, t_op: tflite_model.Operator, conv_params: ConvParameters ) -> conv_utils.ConvConversionResult: - """Convert the `aten.convolution` into TFLite TransposeConv. The `builtin_options` must be + """Convert the `aten.convolution` into Neutron IR TransposeConv. The `builtin_options` must be converted by the caller. """ common.assign_2d_strides(t_op.builtin_options, conv_params.stride) @@ -367,8 +372,8 @@ def _convert_transpose_conv( y: tflite_model.Tensor = t_op.tmp_outputs[0] if (b := try_get_input(t_op, 2)) is None: - # Operator has no bias. Convolution aten op can omit it, TFLite can't. - # Weight tensor format in TFLite: [C, kH, kW, O] + # Operator has no bias. Convolution aten op can omit it, Neutron IR can't. + # Weight tensor format in Neutron IR: [C, kH, kW, O] # (C = input channels, O = output channels, kW = kernel width, kH = kernel height) output_channels = w.shape.vector[-1] @@ -397,7 +402,7 @@ def _convert_transpose_conv( b, bias_scale, bias_zero_point, quantized_dimension=0 ) - # TransposeConv weight tensor format in TFLite: [O, kH, kW, C] + # TransposeConv weight tensor format in Neutron IR: [O, kH, kW, C] # (C = input channels, O = output channels, kW = kernel width, kH = kernel height) if tensor_has_data(w): # Transpose cloned tensor statically @@ -415,7 +420,7 @@ def _convert_transpose_conv( output_shape_tensor_data, "output_shape" ) - # Assign the operator its TFLite inputs and outputs + # Assign the operator its Neutron IR inputs and outputs t_op.tmp_inputs = [o, w, x, b] t_op.tmp_outputs = [y] conversion_result = ConvConversionResult(x, w, b, y, o) diff --git a/backends/nxp/tests/ir/converter/node_converter/test_conv_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_conv_converter.py index 85d2f2c3e7c..3d20d38bb54 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_conv_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_conv_converter.py @@ -338,7 +338,7 @@ def test__tr_big( oc := 7, ks := (5, 3), s := (2, 1), - d := (1, 2), + d := (1, 1), p := (2, 1), op := (0, 1), b := True, @@ -350,7 +350,7 @@ def test__tr_big( oc := 9, ks := (7, 7), s := (2, 2), - d := (6, 5), + d := (1, 1), p := (5, 4), op := (2, 1), b := False, @@ -362,21 +362,20 @@ def test__tr_big( oc := 11, ks := (3, 5), s := (2, 2), - d := (2, 2), + d := (1, 1), p := (1, 2), op := (1, 1), b := True, id=f"some params not default: {_conv_id(ins, oc, ks=ks, s=s, d=d, p=p, b=b, op=op)}", - marks=pytest.mark.xfail(reason="AIR-14852", strict=True), ), pytest.param( ins := (3, 2, 40, 20), oc := 13, ks := (1, 5), s := (1, 2), - d := (3, 1), + d := (1, 1), p := (0, 4), - op := (1, 1), + op := (0, 1), b := False, id=f"some params not default: {_conv_id(ins, oc, ks=ks, s=s, d=d, p=p, b=b, op=op)}", ), @@ -385,7 +384,7 @@ def test__tr_big( oc := 5, ks := (3, 3), s := (2, 2), - d := (3, 3), + d := (1, 1), p := (2, 2), op := (2, 2), b := True, @@ -397,7 +396,7 @@ def test__tr_big( oc := 7, ks := (5, 5), s := (1, 2), - d := (1, 3), + d := (1, 1), p := (2, 4), op := (0, 2), b := False, @@ -409,12 +408,11 @@ def test__tr_big( oc := 9, ks := (2, 2), s := (2, 2), - d := (2, 2), + d := (1, 1), p := (1, 1), op := (1, 1), b := True, id=f"some params not default: {_conv_id(ins, oc, ks=ks, s=s, d=d, p=p, b=b, op=op)}", - marks=pytest.mark.xfail(reason="AIR-14852", strict=True), ), ], ) @@ -446,7 +444,7 @@ def test__tr_misc_arg( assert_delegated_and_correct(model, input_shape, mocker, request, use_qat) @pytest.mark.parametrize( - "input_shape, out_channels, kernel_size, stride, padding, groups", + "input_shape, out_channels, kernel_size, stride, padding, groups, dilation", [ pytest.param( ins := (3, 7, 5000, 11), @@ -455,7 +453,8 @@ def test__tr_misc_arg( s := 1, p := 0, g := 1, - id=f"kernel height too big: {_conv_id(ins, oc, ks=ks, s=s, p=p)}", + d := 1, + id=f"kernel height too big: {_conv_id(ins, oc, ks=ks, s=s, p=p, d=d)}", ), pytest.param( ins := (3, 7, 13, 5000), @@ -464,7 +463,8 @@ def test__tr_misc_arg( s := 1, p := 0, g := 1, - id=f"kernel width too big: {_conv_id(ins, oc, ks=ks, s=s, p=p)}", + d := 1, + id=f"kernel width too big: {_conv_id(ins, oc, ks=ks, s=s, p=p, d=d)}", ), pytest.param( ins := (3, 7, 9, 11), @@ -473,7 +473,8 @@ def test__tr_misc_arg( s := (2, 1), p := 0, g := 1, - id=f"stride height > kernel height: {_conv_id(ins, oc, ks=ks, s=s, p=p)}", + d := 1, + id=f"stride height > kernel height: {_conv_id(ins, oc, ks=ks, s=s, p=p, d=d)}", ), pytest.param( ins := (3, 7, 13, 11), @@ -482,7 +483,8 @@ def test__tr_misc_arg( s := (1, 2), p := 0, g := 1, - id=f"stride width > kernel width: {_conv_id(ins, oc, ks=ks, s=s, p=p)}", + d := 1, + id=f"stride width > kernel width: {_conv_id(ins, oc, ks=ks, s=s, p=p, d=d)}", ), pytest.param( ins := (3, 7, 13, 11), @@ -491,7 +493,8 @@ def test__tr_misc_arg( s := (3, 1), p := 0, g := 1, - id=f"stride height too big: {_conv_id(ins, oc, ks=ks, s=s, p=p)}", + d := 1, + id=f"stride height too big: {_conv_id(ins, oc, ks=ks, s=s, p=p, d=d)}", ), pytest.param( ins := (3, 7, 13, 11), @@ -500,7 +503,8 @@ def test__tr_misc_arg( s := (1, 3), p := 0, g := 1, - id=f"stride width too big: {_conv_id(ins, oc, ks=ks, s=s, p=p)}", + d := 1, + id=f"stride width too big: {_conv_id(ins, oc, ks=ks, s=s, p=p, d=d)}", ), pytest.param( ins := (3, 7, 9, 11), @@ -509,7 +513,8 @@ def test__tr_misc_arg( s := 1, p := (3, 1), g := 1, - id=f"padding height >= kernel height: {_conv_id(ins, oc, ks=ks, s=s, p=p)}", + d := 1, + id=f"padding height >= kernel height: {_conv_id(ins, oc, ks=ks, s=s, p=p, d=d)}", ), pytest.param( ins := (3, 7, 9, 11), @@ -518,7 +523,8 @@ def test__tr_misc_arg( s := 1, p := (1, 3), g := 1, - id=f"padding width >= kernel width: {_conv_id(ins, oc, ks=ks, s=s, p=p)}", + d := 1, + id=f"padding width >= kernel width: {_conv_id(ins, oc, ks=ks, s=s, p=p, d=d)}", ), pytest.param( ins := (3, 113, 123, 133), @@ -527,7 +533,8 @@ def test__tr_misc_arg( s := 1, p := 0, g := 1, - id=f"kernel_h * kernel_w * round_ceil(input_channels, num_macs) too big: {_conv_id(ins, oc, ks=ks, s=s, p=p)}", + d := 1, + id=f"kernel_h * kernel_w * round_ceil(input_channels, num_macs) too big: {_conv_id(ins, oc, ks=ks, s=s, p=p, d=d)}", ), pytest.param( ins := (3, 9, 11, 13), @@ -536,12 +543,31 @@ def test__tr_misc_arg( s := 1, p := 0, g := 3, - id=f"groups > 1: {_conv_id(ins, oc, ks=ks, s=s, p=p, g=g)}", + d := 1, + id=f"groups > 1: {_conv_id(ins, oc, ks=ks, s=s, p=p, g=g, d=d)}", + ), + pytest.param( + ins := (3, 9, 11, 13), + oc := 3, + ks := 3, + s := 1, + p := 0, + g := 1, + d := 2, + id=f"dilation != 1: {_conv_id(ins, oc, ks=ks, s=s, p=p, g=g, d=d)}", ), ], ) def test__tr_no_deleg( - self, input_shape, out_channels, kernel_size, stride, padding, groups, use_qat + self, + input_shape, + out_channels, + kernel_size, + stride, + padding, + groups, + dilation, + use_qat, ): in_channels = input_shape[1] @@ -552,6 +578,7 @@ def test__tr_no_deleg( stride=stride, padding=padding, groups=groups, + dilation=dilation, ) assert_not_delegated(model, input_shape, use_qat) From 0c9c0ff5532361fc565d109746e49268df43324d Mon Sep 17 00:00:00 2001 From: Martin Pavella Date: Mon, 10 Aug 2026 10:02:47 +0200 Subject: [PATCH 4/6] Fix delegation of `clamp` and `hardtanh`. --- backends/nxp/backend/edge_helper.py | 36 ++++++---- .../backend/ir/converter/node_converter.py | 13 ++-- .../ops_converters/abs_converter.py | 3 +- .../adaptive_avg_pool_2d_converter.py | 3 +- .../ops_converters/add_tensor_converter.py | 3 +- .../ops_converters/addmm_converter.py | 3 +- .../ops_converters/amax_converter.py | 3 +- .../ops_converters/amin_converter.py | 3 +- .../ops_converters/avg_pool_2d_converter.py | 3 +- .../ops_converters/bmm_converter.py | 3 +- .../ops_converters/cat_converter.py | 3 +- .../ops_converters/clamp_converter.py | 70 +++++++++++++++---- .../constant_pad_nd_converter.py | 3 +- .../ops_converters/convolution_converter.py | 3 +- .../ops_converters/exp_converter.py | 3 +- .../ops_converters/hardswish_converter.py | 3 +- .../ops_converters/leaky_relu_converter.py | 3 +- .../ops_converters/log_converter.py | 3 +- .../max_pool2d_with_indices_converter.py | 3 +- .../ops_converters/maximum_converter.py | 3 +- .../ops_converters/mean_dim_converter.py | 3 +- .../ops_converters/minimum_converter.py | 3 +- .../ops_converters/mm_converter.py | 3 +- .../ops_converters/mul_tensor_converter.py | 3 +- .../ops_converters/permute_copy_converter.py | 3 +- .../ops_converters/prelu_converter.py | 3 +- .../ops_converters/relu_converter.py | 3 +- .../ops_converters/rsqrt_converter.py | 3 +- .../ops_converters/sigmoid_converter.py | 3 +- .../slice_copy_tensor_converter.py | 3 +- .../ops_converters/softmax_converter.py | 3 +- .../ops_converters/sub_tensor_converter.py | 3 +- .../sum_dim_int_list_converter.py | 3 +- .../ops_converters/tanh_converter.py | 3 +- .../upsample_bilinear2d_converter.py | 3 +- .../upsample_nearest2d_converter.py | 3 +- .../node_converter/test_clamp_converter.py | 21 ++++++ .../node_converter/test_hardtanh_converter.py | 20 ++++++ 38 files changed, 194 insertions(+), 65 deletions(-) diff --git a/backends/nxp/backend/edge_helper.py b/backends/nxp/backend/edge_helper.py index 5a48ce930d4..408b90e264d 100644 --- a/backends/nxp/backend/edge_helper.py +++ b/backends/nxp/backend/edge_helper.py @@ -413,18 +413,18 @@ def try_get_arg(node: Node, idx: int) -> Argument | None: return node.args[idx] if idx < len(node.args) else None -def input_quantization_type( +def input_quantization_parameters( node: Node, input_index: int | tuple[int, int] -) -> torch.dtype | None: - """Return the quantization input datatype of the QDQ quantized `node`. +) -> tuple[Scale, ZeroPoint, torch.dtype] | None: + """Return the input quantization parameters of the QDQ quantized `node`. :param node: The compute node. :param input_index: The index into the `node.args`. If a tuple of 2 ints is provided, `args[input_index[0]][input_index[1]]` is used instead. - :return: The input quantization datatype of the QDQ quantized `node`, or `None` if the graph does not follow the + :return: The input quantization parameters of the QDQ quantized `node`, or `None` if the graph does not follow the QDQ pattern or some metadata is incomplete or an invalid input index is given. - │ + │ ┌─────▼──────┐ │ Dequantize │ └─────┬──────┘ @@ -455,17 +455,24 @@ def input_quantization_type( if (dequantize_input_val := dequantize_node.args[0].meta.get("val")) is None: return None # Invalid metadata. - return dequantize_input_val.dtype + params = get_quantization_parameters_for(dequantize_node) + dtype = dequantize_input_val.dtype + if params is None or dtype is None: + return None + + return *params, dtype -def output_quantization_type(node: Node, output_index: int) -> torch.dtype | None: - """Return the quantization output datatype of the QDQ quantized `node`. +def output_quantization_parameters( + node: Node, output_index: int +) -> tuple[Scale, ZeroPoint, torch.dtype] | None: + """Return the output quantization parameters of the QDQ quantized `node`. :param node: The compute node. :param output_index: If the `node` has multiple outputs and therefore multiple `getitem` nodes follow it, the index selects the output. If no `getitem` nodes follow it, the operator produces only 1 output (most common case), and the value `0` must be used. - :return: The output quantization datatype of the QDQ quantized `node`, or `None` if the graph does not follow the + :return: The output quantization parameters of the QDQ quantized `node`, or `None` if the graph does not follow the QDQ pattern or some metadata is incomplete or an invalid input index is given. ┌───▼────┐ @@ -477,10 +484,10 @@ def output_quantization_type(node: Node, output_index: int) -> torch.dtype | Non ┌────▼─────┐ or │ getitem(output_index) │ ... │ Quantize │ └─────────┬─────────────┘ └────┬─────┘ │ float - │ ┌────▼─────┐ + │ ┌────▼─────┐ │ Quantize │ └────┬─────┘ - │ + │ """ users = list(node.users) if len(users) == 1 and _is_quantize(quantize_node := users[0]): @@ -512,4 +519,9 @@ def output_quantization_type(node: Node, output_index: int) -> torch.dtype | Non if (quantize_val := quantize_node.meta.get("val")) is None: return None # Invalid metadata. - return quantize_val.dtype + params = get_quantization_parameters_for(quantize_node) + dtype = quantize_val.dtype + if params is None or dtype is None: + return None + + return *params, dtype diff --git a/backends/nxp/backend/ir/converter/node_converter.py b/backends/nxp/backend/ir/converter/node_converter.py index 73cde6afc4e..5ae19761936 100755 --- a/backends/nxp/backend/ir/converter/node_converter.py +++ b/backends/nxp/backend/ir/converter/node_converter.py @@ -16,8 +16,8 @@ ) from executorch.backends.nxp.backend.data_format import DataFormat, NXP_NODE_FORMAT from executorch.backends.nxp.backend.edge_helper import ( - input_quantization_type, - output_quantization_type, + input_quantization_parameters, + output_quantization_parameters, ) from executorch.backends.nxp.backend.ir import logger as logger from executorch.backends.nxp.backend.ir.conversion_context import ConversionContext @@ -119,8 +119,9 @@ def _is_supported_in_IR( """ pass - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], @@ -390,7 +391,8 @@ def uses_quantization_type_for_inputs( :return: True, if the `node` is QDQ quantized and has quantization input types in `supported_types`. """ return all( - input_quantization_type(node, input_index) in supported_types + (params := input_quantization_parameters(node, input_index)) is not None + and params[2] in supported_types for input_index in input_indices ) @@ -412,7 +414,8 @@ def uses_quantization_type_for_outputs( :return: True, if the `node` is QDQ quantized and has quantization output types in `supported_types`. """ return all( - output_quantization_type(node, output_index) in supported_types + (q_params := output_quantization_parameters(node, output_index)) is not None + and q_params[2] in supported_types for output_index in output_indices ) diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/abs_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/abs_converter.py index 08620ac0d92..4d82d8e00f9 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/abs_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/abs_converter.py @@ -27,8 +27,9 @@ def _is_supported_in_IR( ) -> bool: return True - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/adaptive_avg_pool_2d_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/adaptive_avg_pool_2d_converter.py index ef6d66504bf..bd812cccb76 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/adaptive_avg_pool_2d_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/adaptive_avg_pool_2d_converter.py @@ -58,8 +58,9 @@ def _is_supported_in_IR( return True - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/add_tensor_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/add_tensor_converter.py index b589632ef15..861c89adc41 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/add_tensor_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/add_tensor_converter.py @@ -19,8 +19,9 @@ class AddTensorConverter(NodeConverter): - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/addmm_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/addmm_converter.py index 6fb690fad38..8d3369016dc 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/addmm_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/addmm_converter.py @@ -62,8 +62,9 @@ def _is_supported_in_IR( return True - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/amax_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/amax_converter.py index 47e0d07ec25..935bbcbd45c 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/amax_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/amax_converter.py @@ -25,8 +25,9 @@ class AmaxConverter(NodeConverter): - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/amin_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/amin_converter.py index af03de24086..c420c23eb62 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/amin_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/amin_converter.py @@ -25,8 +25,9 @@ class AminConverter(NodeConverter): - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/avg_pool_2d_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/avg_pool_2d_converter.py index 888a2e6e689..b3157ab4c4b 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/avg_pool_2d_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/avg_pool_2d_converter.py @@ -56,8 +56,9 @@ def _is_supported_in_IR( return True - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/bmm_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/bmm_converter.py index d31522a665d..c81fadfead2 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/bmm_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/bmm_converter.py @@ -38,8 +38,9 @@ def _is_supported_in_IR( return True - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/cat_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/cat_converter.py index c2891775fe3..0225ec94439 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/cat_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/cat_converter.py @@ -69,8 +69,9 @@ def _all_io_shares_quantization_parameters(node: Node) -> bool: return True - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/clamp_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/clamp_converter.py index 57b08cfa731..a6e587e1e09 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/clamp_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/clamp_converter.py @@ -7,7 +7,12 @@ import numpy as np import torch -from executorch.backends.nxp.backend.edge_helper import try_get_arg + +from executorch.backends.nxp.backend.edge_helper import ( + input_quantization_parameters, + output_quantization_parameters, + try_get_arg, +) from executorch.backends.nxp.backend.graph_utils import ( is_clamp_preserved_under_quantization, ) @@ -16,7 +21,6 @@ ) from executorch.backends.nxp.backend.ir.converter.node_converter import ( _is_dequant_node, - _is_quant_node, CustomDelegationOptions, NodeConverter, ) @@ -60,9 +64,9 @@ class ClampConverter(NodeConverter): @staticmethod def _get_bounds(node: Node) -> tuple[float | None, float | None]: """Extract min and max bounds from `aten.clamp.default` node.""" - min = try_get_arg(node, 1) - max = try_get_arg(node, 2) - return min, max + min_ = try_get_arg(node, 1) + max_ = try_get_arg(node, 2) + return min_, max_ @classmethod def _is_convertible_to_relu(cls, node): @@ -77,6 +81,15 @@ def _is_convertible_to_relu(cls, node): return True + @classmethod + def dequantize_val( + cls, + val: int, + scale: float, + zp: int, + ) -> float: + return float(val - zp) * scale + @staticmethod def _is_supported_in_IR( node: Node, @@ -88,18 +101,39 @@ def _is_supported_in_IR( @staticmethod def _io_quant_is_same(node: Node): - quant = next(iter(node.users.keys())) - dequant = node.args[0] - - if not _is_dequant_node(dequant): + input_q_params = input_quantization_parameters(node, 0) + output_q_params = output_quantization_parameters(node, 0) + if input_q_params is None or output_q_params is None: return False - if not _is_quant_node(quant): - return False + return all(i_p == o_p for i_p, o_p in zip(input_q_params, output_q_params)) - q_params = quant.args[1:] - dq_params = dequant.args[1:] - return all(q == dq for q, dq in zip(q_params, dq_params)) + @classmethod + def _bounds_are_looser_than_quantization_range( + cls, node: Node, bounds: tuple[float | None, float | None] + ) -> bool | None: + if (q_params := input_quantization_parameters(node, 0)) is None: + return None # Cannot determine the result. + scale, zp, dtype = q_params + if dtype != torch.int8: + return None # Cannot determine the result. + if isinstance(scale, list): + if len(scale) > 1: + return None # Unexpected case. + scale = scale[0] + if isinstance(zp, list): + if len(zp) > 1: + return None # Unexpected case. + zp = zp[0] + + quant_range_min = cls.dequantize_val(-128, scale, zp) + quant_range_max = cls.dequantize_val(127, scale, zp) + + lower_bound, upper_bound = bounds + + return (lower_bound is None or lower_bound <= quant_range_min) and ( + upper_bound is None or quant_range_max <= upper_bound + ) @classmethod def _is_supported_on_target( @@ -141,12 +175,12 @@ def supports_partitioning_result( parameters_mapping: dict[str, Parameter], ) -> bool: bounds = cls._get_bounds(node) + is_alone_in_partition = cls.is_node_alone_in_partition(node, partition_list) # Neutron cannot delegate a partition where ReLU or ReLU6 is the only operator # and at the same time the node does not satisfy delegation requirements. # In contrast, ReLUN1To1 and ReLU0To1 are supported and delegated successfully. if bounds in cls.RELU_COMPATIBLE_BOUNDS.values(): - is_alone_in_partition = cls.is_node_alone_in_partition(node, partition_list) if is_alone_in_partition: # noinspection PyTypeChecker return is_clamp_preserved_under_quantization( @@ -155,6 +189,12 @@ def supports_partitioning_result( max_val=bounds[1], ) + # If the bounds are outside the range allowed by the quantization parameters, the clamp is a no-op, and + # should only be delegated if it's not the only operator in the partition. + is_noop = cls._bounds_are_looser_than_quantization_range(node, bounds) + if is_noop and is_alone_in_partition: + return False + return True def convert(self, node: Node): diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/constant_pad_nd_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/constant_pad_nd_converter.py index 4e83773fe8a..2c43f6747a7 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/constant_pad_nd_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/constant_pad_nd_converter.py @@ -33,8 +33,9 @@ class ConstantPadNDConverter(NodeConverter): - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/convolution_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/convolution_converter.py index 46bce408d0f..bd4e8053b21 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/convolution_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/convolution_converter.py @@ -214,8 +214,9 @@ def _is_supported_on_target_transp_conv( return True - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/exp_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/exp_converter.py index 4e506cea27e..cfb85d3fda4 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/exp_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/exp_converter.py @@ -25,8 +25,9 @@ def _is_supported_in_IR( ) -> bool: return True - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/hardswish_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/hardswish_converter.py index 454e941242b..729eb18e92c 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/hardswish_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/hardswish_converter.py @@ -18,8 +18,9 @@ class HardswishConverter(NodeConverter): - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/leaky_relu_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/leaky_relu_converter.py index dc1fe34f518..3a82c5956c8 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/leaky_relu_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/leaky_relu_converter.py @@ -28,8 +28,9 @@ def _is_supported_in_IR( ) -> bool: return True - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], 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 index 3c289106129..19b23761b0f 100644 --- 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 @@ -25,8 +25,9 @@ def _is_supported_in_IR( ) -> bool: return True - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/max_pool2d_with_indices_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/max_pool2d_with_indices_converter.py index 39384078df4..c8d24ea34a6 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/max_pool2d_with_indices_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/max_pool2d_with_indices_converter.py @@ -64,8 +64,9 @@ def _is_supported_in_IR( return True - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/maximum_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/maximum_converter.py index 8d1d05ba3d7..fe5a0a62ede 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/maximum_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/maximum_converter.py @@ -19,8 +19,9 @@ class MaximumConverter(NodeConverter): - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/mean_dim_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/mean_dim_converter.py index 2f070c61ea3..4d03e5e97b7 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/mean_dim_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/mean_dim_converter.py @@ -47,8 +47,9 @@ def supports_partitioning_result( return True - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/minimum_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/minimum_converter.py index 593d05198f3..f8cca604a23 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/minimum_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/minimum_converter.py @@ -19,8 +19,9 @@ class MinimumConverter(NodeConverter): - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/mm_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/mm_converter.py index d35f5437be7..20eb29309c4 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/mm_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/mm_converter.py @@ -39,8 +39,9 @@ def _is_supported_in_IR( return True - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/mul_tensor_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/mul_tensor_converter.py index d87d2d638f1..eac54c95040 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/mul_tensor_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/mul_tensor_converter.py @@ -19,8 +19,9 @@ class MulTensorConverter(NodeConverter): - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/permute_copy_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/permute_copy_converter.py index 7fe8c6d71c2..3e4908c2211 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/permute_copy_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/permute_copy_converter.py @@ -351,8 +351,9 @@ def handle_tensor_formats(self, t_op: tflite_model.Operator, node: Node) -> OpsL class PermuteCopyConverter(NodeConverter): - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/prelu_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/prelu_converter.py index 7f952755d72..d8de8000a28 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/prelu_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/prelu_converter.py @@ -20,8 +20,9 @@ class PReLUConverter(NodeConverter): - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/relu_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/relu_converter.py index 486d9f807b5..db4464d0ec6 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/relu_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/relu_converter.py @@ -33,8 +33,9 @@ def _is_supported_in_IR( ) -> bool: return True - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/rsqrt_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/rsqrt_converter.py index ff041652bbb..e8582f93a45 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/rsqrt_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/rsqrt_converter.py @@ -28,8 +28,9 @@ def _is_supported_in_IR( ) -> bool: return True - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/sigmoid_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/sigmoid_converter.py index fcb9ed3fb1d..b2de46c6b09 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/sigmoid_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/sigmoid_converter.py @@ -28,8 +28,9 @@ def _is_supported_in_IR( ) -> bool: return True - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/slice_copy_tensor_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/slice_copy_tensor_converter.py index 312f6826dce..24a87a4d92d 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/slice_copy_tensor_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/slice_copy_tensor_converter.py @@ -21,8 +21,9 @@ class SliceCopyTensorConverter(NodeConverter): - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/softmax_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/softmax_converter.py index 7db799997bb..defa186bd83 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/softmax_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/softmax_converter.py @@ -52,8 +52,9 @@ def _get_channels(node: Node) -> int: """Get the number of channels from the node's input shape.""" return node.meta["val"].shape[SoftmaxConverter._get_channels_dim(node)] - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/sub_tensor_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/sub_tensor_converter.py index 328a995812a..3ecf857b9ee 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/sub_tensor_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/sub_tensor_converter.py @@ -19,8 +19,9 @@ class SubTensorConverter(NodeConverter): - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/sum_dim_int_list_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/sum_dim_int_list_converter.py index 5972ce64ed8..c8c8db5b35d 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/sum_dim_int_list_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/sum_dim_int_list_converter.py @@ -25,8 +25,9 @@ class SumDimIntListConverter(NodeConverter): - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/tanh_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/tanh_converter.py index f66c7e6c5cf..2e9fec3a3d8 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/tanh_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/tanh_converter.py @@ -28,8 +28,9 @@ def _is_supported_in_IR( ) -> bool: return True - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/upsample_bilinear2d_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/upsample_bilinear2d_converter.py index cd15fb677e1..2f0126e9aae 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/upsample_bilinear2d_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/upsample_bilinear2d_converter.py @@ -61,8 +61,9 @@ def _is_supported_in_IR( return True - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], diff --git a/backends/nxp/backend/ir/converter/node_converters/ops_converters/upsample_nearest2d_converter.py b/backends/nxp/backend/ir/converter/node_converters/ops_converters/upsample_nearest2d_converter.py index e24d414724c..a3c8db14f51 100644 --- a/backends/nxp/backend/ir/converter/node_converters/ops_converters/upsample_nearest2d_converter.py +++ b/backends/nxp/backend/ir/converter/node_converters/ops_converters/upsample_nearest2d_converter.py @@ -63,8 +63,9 @@ def _is_supported_in_IR( return True - @staticmethod + @classmethod def _is_supported_on_target( + cls, node: Node, neutron_target_spec: NeutronTargetSpec, parameters_mapping: dict[str, Parameter], diff --git a/backends/nxp/tests/ir/converter/node_converter/test_clamp_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_clamp_converter.py index bd296bb856f..b2147a0d984 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_clamp_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_clamp_converter.py @@ -222,3 +222,24 @@ def test_convert_clamp__relu_vs_maxmin(self, mocker, min, max, expected_tflite_o assert not all( q == dq for q, dq in zip(quant_node.args[1:], dequant_node.args[1:]) ) + + @pytest.mark.parametrize( + "bounds", + [ + # The calibration data is in the range [-2, 2), and the quantization will allow the range ~ [-2.9, 3.4]. + (-3, 3.5), + (-3, None), + (float("-inf"), 3.5), + (None, float("inf")), + ], + ) + def test__bounds_looser_than_quantized_range(self, bounds: tuple[int, int]): + model = ClampModule(*bounds) + + delegated_ep = to_quantized_edge_program( + model, + (123,), + ).exported_program() + + # Make sure the `hardtanh` was NOT delegated. + assert graph_contains_any_of_ops(delegated_ep.graph, [Clamp]) diff --git a/backends/nxp/tests/ir/converter/node_converter/test_hardtanh_converter.py b/backends/nxp/tests/ir/converter/node_converter/test_hardtanh_converter.py index 3799aa91623..66a052dba4f 100644 --- a/backends/nxp/tests/ir/converter/node_converter/test_hardtanh_converter.py +++ b/backends/nxp/tests/ir/converter/node_converter/test_hardtanh_converter.py @@ -272,3 +272,23 @@ def test_convert_clamp__relu_vs_maxmin( assert not all( q == dq for q, dq in zip(quant_node.args[1:], dequant_node.args[1:]) ) + + @pytest.mark.parametrize( + "bounds", + [ + # The calibration data is in the range [-2, 2), and the quantization will allow the range ~ [-2.9, 3.4]. + (-3, 3.5), + (float("-inf"), 3.5), + (float("-inf"), float("inf")), + ], + ) + def test__bounds_looser_than_quantized_range(self, bounds: tuple[int, int]): + model = HardTanhModule(*bounds) + + delegated_ep = to_quantized_edge_program( + model, + (123,), + ).exported_program() + + # Make sure the `hardtanh` was NOT delegated. + assert graph_contains_any_of_ops(delegated_ep.graph, [HardTanh]) From 4e66ebefdd55298f8bf32e9839f9eb15f30d14a2 Mon Sep 17 00:00:00 2001 From: Martin Pavella Date: Mon, 10 Aug 2026 13:46:36 +0200 Subject: [PATCH 5/6] Add Neutron backend to `/backend/test/suite/operators` testing. --- .github/workflows/pull.yml | 48 ++ backends/nxp/quantizer/utils.py | 50 +- backends/nxp/tests/BUCK | 17 + backends/nxp/tests/tester/__init__.py | 8 + backends/nxp/tests/tester/tester.py | 626 ++++++++++++++++++++++++++ backends/test/suite/flow.py | 7 + backends/test/suite/flows/nxp.py | 69 +++ 7 files changed, 823 insertions(+), 2 deletions(-) create mode 100644 backends/nxp/tests/tester/__init__.py create mode 100644 backends/nxp/tests/tester/tester.py create mode 100644 backends/test/suite/flows/nxp.py diff --git a/.github/workflows/pull.yml b/.github/workflows/pull.yml index 8202a9abb35..289a6593903 100644 --- a/.github/workflows/pull.yml +++ b/.github/workflows/pull.yml @@ -1478,6 +1478,54 @@ jobs: PYTHON_EXECUTABLE=python NXP_RUNNER_PATH="./examples/nxp/executor_runner/build/nxp_executor_runner" \ bash backends/nxp/run_unittests.sh + test-nxp-testsuite-linux: + uses: pytorch/test-infra/.github/workflows/linux_job_v2.yml@main + permissions: + id-token: write + contents: read + with: + runner: linux.2xlarge + docker-image: ci-image:executorch-ubuntu-22.04-clang12 + submodules: 'recursive' + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + timeout: 150 + script: | + set -eux + + # The generic Linux job chooses to use base env, not the one setup by the image + CONDA_ENV=$(conda env list --json | jq -r ".envs | .[-1]") + conda activate "${CONDA_ENV}" + + # Install eIQ packages + pip install -r backends/nxp/requirements-eiq.txt + + # Build and install ExecuTorch with Neutron support + PYTHON_EXECUTABLE=python \ + CMAKE_ARGS="-DEXECUTORCH_BUILD_NXP_NEUTRON=ON -DEXECUTORCH_BUILD_NXP_NEUTRON_RUNNER=ON \ + -DEXECUTORCH_BUILD_KERNELS_PORTABLE=ON -DEXECUTORCH_BUILD_PYBIND=ON -DEXECUTORCH_BUILD_EXTENSION_MODULE=ON \ + -DEXECUTORCH_BUILD_EXTENSION_DATA_LOADER=ON -DEXECUTORCH_BUILD_EXTENSION_FLAT_TENSOR=ON \ + -DEXECUTORCH_BUILD_EXTENSION_TENSOR=ON -DEXECUTORCH_BUILD_EXTENSION_NAMED_DATA_MAP=ON" \ + .ci/scripts/setup-linux.sh --build-tool "cmake" --editable true + + # Install test requirements + pip install -r backends/nxp/requirements-tests-pypi.txt + PYTHON_EXECUTABLE=python bash examples/nxp/setup.sh + + # Build nxp_executor_runner as a standalone binary (same approach as unittest-nxp-neutron). + # The cmake-out subproject build may produce a differently-linked binary; the standalone + # build is known to work correctly with the NSYS simulator firmware. + mkdir -p examples/nxp/executor_runner/build + pushd examples/nxp/executor_runner/build + cmake -DCMAKE_BUILD_TYPE=Release .. + make -j$(nproc) nxp_executor_runner + popd + + # Run the shared backend test suite for NXP Neutron. Skip the failing LSTM and cat tests. + export NXP_RUNNER_PATH="$(pwd)/examples/nxp/executor_runner/build/nxp_executor_runner" + PYTHON_EXECUTABLE=python pytest -c /dev/null backends/test/suite/operators/ -m backend_nxp -n auto \ + -k "not (test_cat_different_shapes or test_cat_dimensions or test_lstm)" + + test-samsung-quantmodels-linux: name: test-samsung-quantmodels-linux # Skip this job if the pull request is from a fork (secrets are not available) diff --git a/backends/nxp/quantizer/utils.py b/backends/nxp/quantizer/utils.py index 2f47606df27..4178c5c17fb 100644 --- a/backends/nxp/quantizer/utils.py +++ b/backends/nxp/quantizer/utils.py @@ -32,6 +32,8 @@ ) from torchao.quantization.pt2e import ( + HistogramObserver, + MinMaxObserver, move_exported_model_to_eval, move_exported_model_to_train, ObserverOrFakeQuantize, @@ -203,6 +205,42 @@ def find_sequential_partitions_aten( return fused_partitions +def _replace_histogram_observers_for_integer_inputs( + m: torch.fx.GraphModule, +) -> None: + """Replace HistogramObserver with MinMaxObserver for observer nodes whose + input tensor has a non-floating-point dtype. + + HistogramObserver calls torch.histc internally, which raises + NotImplementedError for integer and bool dtypes. MinMaxObserver only + tracks min/max and handles all dtypes, producing equally valid + quantization ranges for non-float inputs (e.g. embedding indices, bool + masks). + """ + for node in m.graph.nodes: + if node.op != "call_module": + continue + obs = getattr(m, node.target, None) + if not isinstance(obs, HistogramObserver): + continue + input_node = node.args[0] if node.args else None + if input_node is None: + continue + val = input_node.meta.get("val", None) + if val is not None and not val.is_floating_point(): + setattr( + m, + node.target, + MinMaxObserver( + dtype=obs.dtype, + qscheme=obs.qscheme, + quant_min=obs.quant_min, + quant_max=obs.quant_max, + eps=obs.eps, + ), + ) + + def calibrate_and_quantize( model: ExportedProgram | fx.GraphModule, calibration_inputs: Iterable[tuple[torch.Tensor, ...]], @@ -228,7 +266,17 @@ def calibrate_and_quantize( if is_qat: m = prepare_qat_pt2e(model, quantizer) m = AddSimulatedLinearBatchNormFusionQATPass()(m).graph_module + else: + m = prepare_pt2e(model, quantizer) + + # Swap HistogramObserver -> MinMaxObserver for any observer whose input has + # a non-floating-point dtype. Safe for both PTQ and QAT: in QAT the graph + # contains FakeQuantize nodes which are not HistogramObserver instances, so + # the helper is effectively a no-op there, but it protects against the edge + # case where a QAT graph does contain a HistogramObserver. + _replace_histogram_observers_for_integer_inputs(m) + if is_qat: if train_fn: m = move_exported_model_to_train(m) train_fn(m) @@ -236,8 +284,6 @@ def calibrate_and_quantize( m = move_exported_model_to_eval(m) m = RemoveSimulatedLinearBatchNormFusionQATPass()(m).graph_module m = FuseBatchNormWithLinearPass()(m).graph_module - else: - m = prepare_pt2e(model, quantizer) if not is_qat or (is_qat and not train_fn): for data in calibration_inputs: diff --git a/backends/nxp/tests/BUCK b/backends/nxp/tests/BUCK index 41e4f35ca81..7879e1e3db5 100644 --- a/backends/nxp/tests/BUCK +++ b/backends/nxp/tests/BUCK @@ -25,6 +25,23 @@ fbcode_target(_kind = runtime.python_library, ], ) +fbcode_target(_kind = runtime.python_library, + name = "tester", + srcs = [ + "tester/__init__.py", + "tester/tester.py", + ], + deps = [ + "//caffe2:torch", + "//executorch/backends/nxp:edge_passes", + "//executorch/backends/nxp:neutron_backend", + "//executorch/backends/nxp:quantizer", + "//executorch/backends/test/harness:tester", + "//executorch/exir:lib", + "fbsource//third-party/pypi/numpy:numpy", + ], +) + fbcode_target(_kind = runtime.python_library, name = "executorch_pipeline", srcs = [ diff --git a/backends/nxp/tests/tester/__init__.py b/backends/nxp/tests/tester/__init__.py new file mode 100644 index 00000000000..c6a534a7862 --- /dev/null +++ b/backends/nxp/tests/tester/__init__.py @@ -0,0 +1,8 @@ +# 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. + +from .tester import NeutronTester + +__all__ = ["NeutronTester"] diff --git a/backends/nxp/tests/tester/tester.py b/backends/nxp/tests/tester/tester.py new file mode 100644 index 00000000000..72d1a456184 --- /dev/null +++ b/backends/nxp/tests/tester/tester.py @@ -0,0 +1,626 @@ +# 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. + +""" +NeutronTester -- backend-specific Tester subclass for the Neutron backend. + +Usage in a test:: + + NeutronTester(model, example_inputs) \ + .quantize() \ + .export() \ + .to_edge_transform_and_lower() \ + .check_count({"executorch_exir_dialects_edge__ops_aten_convolution_default": 0}) \ + .to_executorch() \ + .serialize() \ + .run_method_and_compare_outputs() + +The tester integrates into the shared test/suite operator test suite when a +NeutronTestFlow is registered in backends/test/suite/flows/nxp.py. +""" + +import logging +import os +import tempfile +from typing import Callable, Iterable, List, Optional, Tuple + +import numpy as np +import torch + +from executorch.backends.nxp.backend.custom_delegation_options import ( + CustomDelegationOptions, +) +from executorch.backends.nxp.backend.neutron_target_spec import NeutronTargetSpec +from executorch.backends.nxp.edge_passes.neutron_edge_pass_manager import ( + NeutronEdgePassManager, +) +from executorch.backends.nxp.edge_passes.remove_additional_quantize_dequantize_nodes_pass import ( + RemoveAdditionalQDQClustersPass, +) +from executorch.backends.nxp.neutron_partitioner import NeutronPartitioner +from executorch.backends.nxp.nxp_backend import ( + core_aten_ops_exception_list, + generate_neutron_compile_spec, +) +from executorch.backends.nxp.quantizer.neutron_quantizer import NeutronQuantizer +from executorch.backends.nxp.quantizer.utils import calibrate_and_quantize +from executorch.backends.nxp.tests.nsys_testing import execute_cmd +from executorch.backends.test.harness import Tester as TesterBase +from executorch.backends.test.harness.stages import ( + Serialize, + Stage, + StageType, + ToExecutorch, +) +from executorch.exir import ( + EdgeCompileConfig, + EdgeProgramManager, + ExecutorchBackendConfig, + to_edge_transform_and_lower, +) +from torch.export import ExportedProgram +from torch.utils._pytree import tree_flatten, tree_unflatten + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Default number of random calibration samples used when no custom calibration +# function is provided. +# --------------------------------------------------------------------------- +_DEFAULT_NUM_CALIBRATION_SAMPLES = 4 + + +def _random_tensor_like(t: torch.Tensor, original: torch.Tensor) -> torch.Tensor: + """Generate a calibration tensor with the same shape and dtype as t. + + For floating-point tensors, returns a uniform [0, 1) random tensor to + avoid NaN in ops like log/sqrt. For integer and bool tensors, clones the + original example tensor so that calibration inputs have realistic values + (e.g. valid embedding indices, bool masks). + """ + if t.is_floating_point(): + # Uniform [0, 1) avoids negative values that cause NaN in log, sqrt. + return torch.rand_like(t) + # Integer and bool dtypes: reuse the original example tensor value so that + # HistogramObserver never receives a freshly-generated integer tensor. + return original.clone() + + +def _make_random_calibration_inputs( + example_inputs, + num_samples: int = _DEFAULT_NUM_CALIBRATION_SAMPLES, +) -> List[Tuple[torch.Tensor, ...]]: + """Generate random calibration samples compatible with example_inputs. + + example_inputs may be a tuple of tensors or a flat sequence that has already + been tree-flattened. Non-tensor items are passed through unchanged. + """ + flat, spec = tree_flatten(example_inputs) + samples = [] + for _ in range(num_samples): + flat_sample = [ + _random_tensor_like(item, item) if isinstance(item, torch.Tensor) else item + for item in flat + ] + samples.append(tree_unflatten(flat_sample, spec)) + return samples + + +# --------------------------------------------------------------------------- +# Stage 1: Quantize +# --------------------------------------------------------------------------- + + +class NeutronQuantize(Stage): + """Quantization stage for the Neutron backend. + + Applies NeutronQuantizer followed by calibration and convert_pt2e. + Accepts either a pre-built list of calibration samples or a callable that + produces them from the example inputs. + """ + + def __init__( + self, + target: str = "imxrt700", + calibration_samples: Optional[Iterable[Tuple[torch.Tensor, ...]]] = None, + get_calibration_inputs_fn: Optional[ + Callable[ + [Tuple[torch.Tensor, ...]], + Iterable[Tuple[torch.Tensor, ...]], + ] + ] = None, + num_calibration_samples: int = _DEFAULT_NUM_CALIBRATION_SAMPLES, + is_qat: bool = False, + train_fn: Optional[Callable[[torch.fx.GraphModule], None]] = None, + ): + """ + :param target: Neutron target string (e.g. 'imxrt700'). + :param calibration_samples: Fixed list of (inputs...) tuples to use for + calibration. If provided, get_calibration_inputs_fn and + num_calibration_samples are ignored. + :param get_calibration_inputs_fn: Callable(example_inputs) -> iterable of + input tuples. Called lazily during run() if calibration_samples is + None. + :param num_calibration_samples: Number of random samples to generate when + neither calibration_samples nor get_calibration_inputs_fn is given. + :param is_qat: Whether to use QAT-style prepare/convert. + :param train_fn: Optional training function for QAT. + """ + self._target = target + self._calibration_samples = calibration_samples + self._get_calibration_inputs_fn = get_calibration_inputs_fn + self._num_calibration_samples = num_calibration_samples + self._is_qat = is_qat + self._train_fn = train_fn + self._quantized_module: Optional[torch.fx.GraphModule] = None + + # Stage protocol --- + + def stage_type(self) -> StageType: + return StageType.QUANTIZE + + def run( + self, + artifact: torch.nn.Module, + inputs: Optional[Tuple[torch.Tensor, ...]], + ) -> None: + target_spec = NeutronTargetSpec(self._target) + quantizer = NeutronQuantizer(target_spec, is_qat=self._is_qat) + + # Resolve calibration data. + if self._calibration_samples is not None: + calibration_inputs = self._calibration_samples + elif self._get_calibration_inputs_fn is not None: + calibration_inputs = self._get_calibration_inputs_fn(inputs) + else: + calibration_inputs = _make_random_calibration_inputs( + inputs, self._num_calibration_samples + ) + + # Export the module before quantization. + from torch.export import export + + exported = export(artifact, inputs, strict=True) + + # Both QAT and PTQ paths go through calibrate_and_quantize, which handles + # BN fusion, observer selection (including HistogramObserver -> MinMaxObserver + # for non-float inputs), and convert_pt2e internally. + self._quantized_module = calibrate_and_quantize( + model=exported, + calibration_inputs=calibration_inputs, + quantizer=quantizer, + is_qat=self._is_qat, + train_fn=self._train_fn, + ) + + @property + def artifact(self) -> torch.fx.GraphModule: + return self._quantized_module + + @property + def graph_module(self) -> torch.fx.GraphModule: + return self._quantized_module + + def run_artifact(self, inputs): + return self._quantized_module(*inputs) + + +# --------------------------------------------------------------------------- +# Stage 2: ToEdgeTransformAndLower (Neutron-specific) +# --------------------------------------------------------------------------- + + +class NeutronToEdgeTransformAndLower(Stage): + """Runs to_edge_transform_and_lower with the Neutron partitioner. + + This is the stage that actually calls neutron-converter to produce the NPU + payload and embeds it in the edge program as a delegate blob. + """ + + def __init__( + self, + target: str = "imxrt700", + operators_not_to_delegate: Optional[List[str]] = None, + custom_delegation_options: Optional[CustomDelegationOptions] = None, + use_neutron_for_format_conversion: bool = True, + use_quant_state_dict: bool = True, + ): + self._target = target + self._operators_not_to_delegate = operators_not_to_delegate or [] + self._custom_delegation_options = ( + custom_delegation_options or CustomDelegationOptions() + ) + self._use_neutron_for_format_conversion = use_neutron_for_format_conversion + self._use_quant_state_dict = use_quant_state_dict + self._edge_program_manager: Optional[EdgeProgramManager] = None + + # Stage protocol --- + + def stage_type(self) -> StageType: + return StageType.TO_EDGE_TRANSFORM_AND_LOWER + + def run( + self, + artifact: ExportedProgram, + inputs=None, + generate_etrecord: bool = False, + ) -> None: + from torch.export import export + + # Re-export the quantized graph module to get a clean ExportedProgram. + if isinstance(artifact, torch.fx.GraphModule): + # artifact is the output of the Quantize stage (a GraphModule). + # We need to re-export it as a proper ExportedProgram. + # Use the inputs stored by the tester's run() as example inputs. + if inputs is None: + raise RuntimeError( + "NeutronToEdgeTransformAndLower requires inputs for re-export." + ) + artifact = export(artifact, inputs, strict=True) + + compile_spec = generate_neutron_compile_spec( + self._target, + operators_not_to_delegate=self._operators_not_to_delegate, + use_neutron_for_format_conversion=self._use_neutron_for_format_conversion, + ) + + # Build the post-quant state dict for the partitioner if requested. + post_quant_state_dict = None + if self._use_quant_state_dict: + try: + post_quant_state_dict = artifact.state_dict() + except Exception: + pass + + preserve_ops = [ + torch.ops.aten.prelu.default, + torch.ops.aten.hardswish.default, + ] + + partitioner = NeutronPartitioner( + compile_spec, + NeutronTargetSpec(self._target), + self._custom_delegation_options, + post_quant_state_dict, + preserve_ops=preserve_ops, + ) + + edge_compile_config = EdgeCompileConfig( + _check_ir_validity=False, + _core_aten_ops_exception_list=core_aten_ops_exception_list, + ) + + edge_program_manager = to_edge_transform_and_lower( + artifact, + transform_passes=NeutronEdgePassManager(), + partitioner=[partitioner], + generate_etrecord=generate_etrecord, + compile_config=edge_compile_config, + ) + + # Remove redundant QDQ clusters added by the partitioner. + edge_program_manager = edge_program_manager.transform( + NeutronEdgePassManager([RemoveAdditionalQDQClustersPass()]) + ) + + self._edge_program_manager = edge_program_manager + + @property + def artifact(self) -> EdgeProgramManager: + return self._edge_program_manager + + @property + def graph_module(self) -> torch.fx.GraphModule: + return self._edge_program_manager.exported_program().graph_module + + +# --------------------------------------------------------------------------- +# Stage 3: ToExecutorch (Neutron-specific config) +# --------------------------------------------------------------------------- + + +class NeutronToExecutorch(ToExecutorch): + """ToExecutorch with Neutron-compatible ExecutorchBackendConfig. + + Uses extract_delegate_segments=False to embed the NPU payload inline + (matching the existing executorch_pipeline.py behaviour). + """ + + def __init__(self): + super().__init__( + config=ExecutorchBackendConfig(extract_delegate_segments=False) + ) + + +# --------------------------------------------------------------------------- +# ExecuTorch ScalarType int -> (torch.dtype, numpy dtype) mapping. +# Values match at::ScalarType in PyTorch / ExecuTorch. +# --------------------------------------------------------------------------- +_SCALAR_TYPE_TO_TORCH = { + 0: torch.uint8, + 1: torch.int8, + 2: torch.int16, + 3: torch.int32, + 4: torch.int64, + 5: torch.float16, + 6: torch.float32, + 7: torch.float64, + 11: torch.bool, +} + +_TORCH_TO_NUMPY = { + torch.uint8: np.uint8, + torch.int8: np.int8, + torch.int16: np.int16, + torch.int32: np.int32, + torch.int64: np.int64, + torch.float16: np.float16, + torch.float32: np.float32, + torch.float64: np.float64, + torch.bool: np.bool_, +} + + +def _output_tensor_specs(buffer: bytes): + """Return a list of (sizes, torch.dtype) tuples for each output of the + 'forward' method, extracted from the serialised .pte buffer via + ExecuTorchModule.method_meta(). + """ + try: + from executorch.extension.pybindings.portable_lib import ( + _load_for_executorch_from_buffer, + Verification, + ) + except ImportError: + return None + + module = _load_for_executorch_from_buffer( + buffer, program_verification=Verification.Minimal + ) + meta = module.method_meta("forward") + specs = [] + for i in range(meta.num_outputs()): + tinfo = meta.output_tensor_meta(i) + dtype = _SCALAR_TYPE_TO_TORCH.get(tinfo.dtype(), torch.float32) + specs.append((list(tinfo.sizes()), dtype)) + return specs + + +# --------------------------------------------------------------------------- +# Stage 4: Serialize -- run inference via the NSYS simulator +# --------------------------------------------------------------------------- + + +def _resolve_nsys_paths(): + """Return (nsys_path, config_path, firmware_path) using the config_importer + shim, which prefers the integration-repo config.py (with the correct firmware + path) and falls back to the pure ExecuTorch config.""" + from executorch.backends.nxp.tests.config_importer import test_config + + return ( + str(test_config.NSYS_PATH), + str(test_config.NSYS_CONFIG_PATH), + str(test_config.NSYS_FIRMWARE_PATH), + ) + + +def _resolve_runner_path() -> Optional[str]: + """Return the path to the nxp_executor_runner binary, or None if not found. + + Delegates to config_importer (which uses the same resolution logic as + config.py: NXP_RUNNER_PATH env var, then PROJECT_DIR-based auto-detect). + Using config_importer avoids duplicating the path arithmetic and ensures + consistent behaviour regardless of how the test is launched. + """ + from executorch.backends.nxp.tests.config_importer import test_config + + runner = str(test_config.NEUTRON_TEST_PATH) + if os.path.isfile(runner): + return runner + return None + + +class NeutronSerialize(Serialize): + """Serialize stage that runs inference via the NSYS Neutron simulator. + + Writes the .pte buffer to a temporary file, invokes nxp_executor_runner, + and reads the binary output tensors back as torch.Tensor objects so that + run_method_and_compare_outputs() can compare them to the eager reference. + + If the simulator infrastructure (nsys, nxp_executor_runner) is not + available, run_artifact() raises RuntimeError with a clear message. + """ + + def __init__(self, target: str = "imxrt700"): + super().__init__() + self._target = target + + def run_artifact(self, inputs: Tuple[torch.Tensor, ...]): + """Run the serialised .pte via the NSYS Neutron simulator. + + :param inputs: Tuple of float32 torch.Tensors (one per model input). + :returns: Tuple of torch.Tensors produced by the NPU simulator. + :raises RuntimeError: If the simulator or runner binary is not available, + or if the runner exits with a non-zero return code. + """ + nsys_path, nsys_config_path, firmware_path = _resolve_nsys_paths() + + runner_path = _resolve_runner_path() + if runner_path is None: + raise RuntimeError( + "nxp_executor_runner not found. " + "Either set NXP_RUNNER_PATH to the compiled binary path, " + "or build it at examples/nxp/executor_runner/build/nxp_executor_runner." + ) + + with tempfile.TemporaryDirectory() as tmp_dir: + # --- Write .pte --- + pte_path = os.path.join(tmp_dir, "model.pte") + with open(pte_path, "wb") as f: + f.write(self.buffer) + + # --- Write input tensors as raw binary files --- + # The runner accepts: + # --dataset for single-input models (one .bin per sample) + # --inputs a.bin,b.bin for multi-input models (one path per tensor) + dataset_dir = os.path.join(tmp_dir, "dataset") + os.makedirs(dataset_dir) + flat_inputs, _ = tree_flatten(inputs) + input_paths = [] + for idx, tensor in enumerate(flat_inputs): + inp_path = os.path.join(dataset_dir, f"{idx:04d}.bin") + arr = tensor.detach().cpu().numpy() + arr.tofile(inp_path) + input_paths.append(inp_path) + + # --- Run simulator --- + output_dir = os.path.join(tmp_dir, "outputs") + os.makedirs(output_dir) + + if len(input_paths) == 1: + # Single input: use --dataset (runner iterates over .bin files in dir) + input_arg = f"--dataset {dataset_dir}" + else: + # Multi-input: use --inputs with comma-separated paths (one per tensor) + input_arg = f"--inputs {','.join(input_paths)}" + + cmd = ( + f"{runner_path} " + f"--model {pte_path} " + f"{input_arg} " + f"--output {output_dir} " + f"--firmware {firmware_path} " + f"--nsys {nsys_path} " + f"--nsys_config {nsys_config_path}" + ) + try: + execute_cmd(cmd) + except Exception as exc: + raise RuntimeError( + f"nxp_executor_runner failed.\ncommand: {cmd}" + ) from exc + + # --- Read output binary files --- + # The runner writes outputs as: + # //.bin + # where matches the input file name (e.g. "0000.bin"). + # Collect all .bin files recursively, sorted so output order is stable. + output_files = sorted( + os.path.join(root, fname) + for root, _dirs, files in os.walk(output_dir) + for fname in files + if fname.endswith(".bin") + ) + if not output_files: + raise RuntimeError( + f"No output .bin files found in {output_dir} after simulator run." + ) + + # --- Determine output shapes and dtypes from the .pte metadata --- + output_specs = _output_tensor_specs(self.buffer) + + results = [] + for i, fpath in enumerate(output_files): + if output_specs is not None and i < len(output_specs): + sizes, dtype = output_specs[i] + np_dtype = _TORCH_TO_NUMPY.get(dtype, np.float32) + else: + # Fallback: assume float32 flat tensor if metadata is unavailable. + sizes = None + np_dtype = np.float32 + + arr = np.fromfile(fpath, dtype=np_dtype) + t = torch.from_numpy(arr) + if sizes is not None: + t = t.reshape(sizes) + results.append(t) + + return tuple(results) + + +# --------------------------------------------------------------------------- +# NeutronTester +# --------------------------------------------------------------------------- + + +class NeutronTester(TesterBase): + """Backend tester for the Neutron NPU delegate. + + Provides Neutron-specific implementations for the Quantize, + ToEdgeTransformAndLower, ToExecutorch, and Serialize stages. + + Example:: + + NeutronTester(MyModel(), example_inputs) \ + .quantize() \ + .export() \ + .to_edge_transform_and_lower() \ + .to_executorch() \ + .serialize() \ + .run_method_and_compare_outputs() + """ + + def __init__( + self, + module: torch.nn.Module, + example_inputs: Tuple[torch.Tensor, ...], + target: str = "imxrt700", + operators_not_to_delegate: Optional[List[str]] = None, + custom_delegation_options: Optional[CustomDelegationOptions] = None, + use_neutron_for_format_conversion: bool = True, + calibration_samples: Optional[Iterable[Tuple[torch.Tensor, ...]]] = None, + get_calibration_inputs_fn: Optional[Callable] = None, + num_calibration_samples: int = _DEFAULT_NUM_CALIBRATION_SAMPLES, + dynamic_shapes=None, + ): + self._neutron_target = target + self._operators_not_to_delegate = operators_not_to_delegate + self._custom_delegation_options = custom_delegation_options + self._use_neutron_for_format_conversion = use_neutron_for_format_conversion + self._calibration_samples = calibration_samples + self._get_calibration_inputs_fn = get_calibration_inputs_fn + self._num_calibration_samples = num_calibration_samples + + # Start from the base defaults so stages like EXPORT, PARTITION, RUN_PASSES, + # and TO_EDGE remain functional, then override with Neutron-specific stages. + stage_classes = TesterBase.default_stage_classes() + stage_classes.update( + { + StageType.QUANTIZE: self._make_quantize_stage, + StageType.TO_EDGE_TRANSFORM_AND_LOWER: self._make_lower_stage, + StageType.TO_EXECUTORCH: NeutronToExecutorch, + StageType.SERIALIZE: lambda: NeutronSerialize( + target=self._neutron_target + ), + } + ) + + super().__init__( + module, + example_inputs, + stage_classes=stage_classes, + dynamic_shapes=dynamic_shapes, + ) + + # ------------------------------------------------------------------ + # Internal factory helpers registered as stage_classes callables + # ------------------------------------------------------------------ + + def _make_quantize_stage(self) -> NeutronQuantize: + return NeutronQuantize( + target=self._neutron_target, + calibration_samples=self._calibration_samples, + get_calibration_inputs_fn=self._get_calibration_inputs_fn, + num_calibration_samples=self._num_calibration_samples, + ) + + def _make_lower_stage(self) -> NeutronToEdgeTransformAndLower: + return NeutronToEdgeTransformAndLower( + target=self._neutron_target, + operators_not_to_delegate=self._operators_not_to_delegate, + custom_delegation_options=self._custom_delegation_options, + use_neutron_for_format_conversion=self._use_neutron_for_format_conversion, + ) diff --git a/backends/test/suite/flow.py b/backends/test/suite/flow.py index 81bfa65949e..547c79326e6 100644 --- a/backends/test/suite/flow.py +++ b/backends/test/suite/flow.py @@ -165,6 +165,12 @@ def _load_qnn() -> list[TestFlow]: ] +def _load_nxp() -> list[TestFlow]: + from executorch.backends.test.suite.flows.nxp import NEUTRON_IMXRT700_INT8_PTQ_FLOW + + return [NEUTRON_IMXRT700_INT8_PTQ_FLOW] + + def _load_arm() -> list[TestFlow]: from executorch.backends.test.suite.flows.arm import ( ARM_ETHOS_U55_FLOW, @@ -209,6 +215,7 @@ def all_flows() -> dict[str, TestFlow]: + _register_flow(_load_qnn, "QNN") + _register_flow(_load_arm, "ARM") + _register_flow(_load_cortex_m, "Cortex-M") + + _register_flow(_load_nxp, "NXP") ) try: diff --git a/backends/test/suite/flows/nxp.py b/backends/test/suite/flows/nxp.py new file mode 100644 index 00000000000..2b673bc1fc0 --- /dev/null +++ b/backends/test/suite/flows/nxp.py @@ -0,0 +1,69 @@ +# 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. + +""" +Test flow registration for the NXP Neutron backend. + +This module registers the Neutron INT8 PTQ lowering flow so that all shared +operator tests under backends/test/suite/operators/ are automatically expanded +to generate a variant for the Neutron backend (e.g. test_add_f32[nxp_neutron]). + +Running all Neutron operator suite tests: + + pytest -c /dev/null backends/test/suite/operators/ -m backend_nxp -n auto + +Generating a JSON report: + + pytest -c /dev/null backends/test/suite/operators/ -m backend_nxp \ + --json-report --json-report-file=neutron_test_report.json +""" + +from executorch.backends.nxp.tests.tester import NeutronTester +from executorch.backends.test.suite.flow import TestFlow + +# Register portable and quantized op kernels so that +# quantized_decomposed::dequantize_per_tensor / quantize_per_tensor are +# available when the suite is run without the NXP integration-repo +# conftest.py being loaded. +try: + import executorch.extension.pybindings.portable_lib # noqa: F401 + import executorch.kernels.quantized # noqa: F401 +except ImportError: + pass + + +def _create_neutron_int8_ptq_flow(target: str = "imxrt700") -> TestFlow: + """Create the standard INT8 PTQ flow for the Neutron backend. + + The tester_factory receives (model, example_inputs) from the suite + framework (see runner.py). All other Neutron-specific parameters use + their defaults (random calibration, full delegation, etc.). + """ + + def tester_factory(model, example_inputs): + return NeutronTester(model, example_inputs, target=target) + + def quantize_stage_factory(): + # Return None so that the tester uses its own NeutronQuantize default. + # The suite runner calls tester.quantize(flow.quantize_stage_factory()) + # which accepts None and falls back to the tester's default stage. + return None + + return TestFlow( + name=f"nxp_neutron_{target}_int8_ptq", + backend="nxp", + tester_factory=tester_factory, + quantize=True, + quantize_stage_factory=quantize_stage_factory, + # The suite framework will call serialize() if supports_serialize=True. + # Neutron requires nsys + nxp_executor_runner to run serialized inference. + # We mark it as supported so tests attempt serialization; if the + # simulator tools are missing, the suite marks the test as + # PTE_RUN_FAIL (which is expected and informative in that environment). + supports_serialize=True, + ) + + +NEUTRON_IMXRT700_INT8_PTQ_FLOW = _create_neutron_int8_ptq_flow(target="imxrt700") From bfb2aa67496c45efcbd068f20a8586be6da5bcf1 Mon Sep 17 00:00:00 2001 From: Martin Pavella Date: Wed, 19 Aug 2026 13:54:50 +0200 Subject: [PATCH 6/6] Updates based on review. --- .ci/scripts/test_backend.sh | 34 ++++++++++ .github/workflows/pull.yml | 48 -------------- .github/workflows/test-backend-nxp.yml | 51 +++++++++++++++ backends/nxp/tests/tester/tester.py | 91 +++++++++++++++----------- backends/test/suite/flows/nxp.py | 22 ++++--- 5 files changed, 149 insertions(+), 97 deletions(-) create mode 100644 .github/workflows/test-backend-nxp.yml diff --git a/.ci/scripts/test_backend.sh b/.ci/scripts/test_backend.sh index 90b6a5ec37d..45e3322a0c8 100755 --- a/.ci/scripts/test_backend.sh +++ b/.ci/scripts/test_backend.sh @@ -104,6 +104,23 @@ if [[ "$FLOW" == *cortex_m* ]]; then backends/cortex_m/test/build_test_runner.sh fi +if [[ "$FLOW" == *nxp* ]]; then + # Install the eIQ Toolkit Python packages (NSYS simulator and Neutron converter). + pip install -r backends/nxp/requirements-eiq.txt + + # Enable the Neutron delegate, portable kernels, pybindings and extensions + # required by the operator test suite. + EXTRA_BUILD_ARGS+=" -DEXECUTORCH_BUILD_NXP_NEUTRON=ON" + EXTRA_BUILD_ARGS+=" -DEXECUTORCH_BUILD_NXP_NEUTRON_RUNNER=ON" + EXTRA_BUILD_ARGS+=" -DEXECUTORCH_BUILD_KERNELS_PORTABLE=ON" + EXTRA_BUILD_ARGS+=" -DEXECUTORCH_BUILD_PYBIND=ON" + EXTRA_BUILD_ARGS+=" -DEXECUTORCH_BUILD_EXTENSION_MODULE=ON" + EXTRA_BUILD_ARGS+=" -DEXECUTORCH_BUILD_EXTENSION_DATA_LOADER=ON" + EXTRA_BUILD_ARGS+=" -DEXECUTORCH_BUILD_EXTENSION_FLAT_TENSOR=ON" + EXTRA_BUILD_ARGS+=" -DEXECUTORCH_BUILD_EXTENSION_TENSOR=ON" + EXTRA_BUILD_ARGS+=" -DEXECUTORCH_BUILD_EXTENSION_NAMED_DATA_MAP=ON" +fi + if [[ "$FLOW" == *openvino* ]]; then # Setup OpenVINO environment source .ci/scripts/setup-openvino.sh --nightly @@ -117,6 +134,23 @@ else fi CMAKE_ARGS="$EXTRA_BUILD_ARGS" ${CONDA_RUN_CMD} $SETUP_SCRIPT --build-tool cmake --build-mode Release --editable true +if [[ "$FLOW" == *nxp* ]]; then + # Install test-time Python requirements (neutron-test helpers, etc.). + pip install -r backends/nxp/requirements-tests-pypi.txt + PYTHON_EXECUTABLE=python bash examples/nxp/setup.sh + + # Build nxp_executor_runner as a standalone binary. The cmake-out subproject + # build may produce a differently-linked binary; the standalone build is known + # to work correctly with the NSYS simulator firmware. + mkdir -p examples/nxp/executor_runner/build + pushd examples/nxp/executor_runner/build + cmake -DCMAKE_BUILD_TYPE=Release .. + make -j"$(nproc)" nxp_executor_runner + popd + + export NXP_RUNNER_PATH="$(pwd)/examples/nxp/executor_runner/build/nxp_executor_runner" +fi + GOLDEN_DIR="${ARTIFACT_DIR}/golden-artifacts" export GOLDEN_ARTIFACTS_DIR="${GOLDEN_DIR}" diff --git a/.github/workflows/pull.yml b/.github/workflows/pull.yml index 289a6593903..8202a9abb35 100644 --- a/.github/workflows/pull.yml +++ b/.github/workflows/pull.yml @@ -1478,54 +1478,6 @@ jobs: PYTHON_EXECUTABLE=python NXP_RUNNER_PATH="./examples/nxp/executor_runner/build/nxp_executor_runner" \ bash backends/nxp/run_unittests.sh - test-nxp-testsuite-linux: - uses: pytorch/test-infra/.github/workflows/linux_job_v2.yml@main - permissions: - id-token: write - contents: read - with: - runner: linux.2xlarge - docker-image: ci-image:executorch-ubuntu-22.04-clang12 - submodules: 'recursive' - ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - timeout: 150 - script: | - set -eux - - # The generic Linux job chooses to use base env, not the one setup by the image - CONDA_ENV=$(conda env list --json | jq -r ".envs | .[-1]") - conda activate "${CONDA_ENV}" - - # Install eIQ packages - pip install -r backends/nxp/requirements-eiq.txt - - # Build and install ExecuTorch with Neutron support - PYTHON_EXECUTABLE=python \ - CMAKE_ARGS="-DEXECUTORCH_BUILD_NXP_NEUTRON=ON -DEXECUTORCH_BUILD_NXP_NEUTRON_RUNNER=ON \ - -DEXECUTORCH_BUILD_KERNELS_PORTABLE=ON -DEXECUTORCH_BUILD_PYBIND=ON -DEXECUTORCH_BUILD_EXTENSION_MODULE=ON \ - -DEXECUTORCH_BUILD_EXTENSION_DATA_LOADER=ON -DEXECUTORCH_BUILD_EXTENSION_FLAT_TENSOR=ON \ - -DEXECUTORCH_BUILD_EXTENSION_TENSOR=ON -DEXECUTORCH_BUILD_EXTENSION_NAMED_DATA_MAP=ON" \ - .ci/scripts/setup-linux.sh --build-tool "cmake" --editable true - - # Install test requirements - pip install -r backends/nxp/requirements-tests-pypi.txt - PYTHON_EXECUTABLE=python bash examples/nxp/setup.sh - - # Build nxp_executor_runner as a standalone binary (same approach as unittest-nxp-neutron). - # The cmake-out subproject build may produce a differently-linked binary; the standalone - # build is known to work correctly with the NSYS simulator firmware. - mkdir -p examples/nxp/executor_runner/build - pushd examples/nxp/executor_runner/build - cmake -DCMAKE_BUILD_TYPE=Release .. - make -j$(nproc) nxp_executor_runner - popd - - # Run the shared backend test suite for NXP Neutron. Skip the failing LSTM and cat tests. - export NXP_RUNNER_PATH="$(pwd)/examples/nxp/executor_runner/build/nxp_executor_runner" - PYTHON_EXECUTABLE=python pytest -c /dev/null backends/test/suite/operators/ -m backend_nxp -n auto \ - -k "not (test_cat_different_shapes or test_cat_dimensions or test_lstm)" - - test-samsung-quantmodels-linux: name: test-samsung-quantmodels-linux # Skip this job if the pull request is from a fork (secrets are not available) diff --git a/.github/workflows/test-backend-nxp.yml b/.github/workflows/test-backend-nxp.yml new file mode 100644 index 00000000000..fed7ab7d19b --- /dev/null +++ b/.github/workflows/test-backend-nxp.yml @@ -0,0 +1,51 @@ +name: Test NXP Backend + +on: + schedule: + - cron: 0 2 * * * + push: + branches: + - main + - release/* + tags: + - ciflow/nightly/* + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref_name }}-${{ github.ref_type == 'branch' && github.sha }}-${{ github.event_name == 'workflow_dispatch' }}-${{ github.event_name == 'schedule' }} + cancel-in-progress: true + +jobs: + # Emits PR diff file list; non-PR events emit '*' so the per-job + # `if:` short-circuits via `event_name != 'pull_request'`. + changed-files: + name: Get changed files + uses: ./.github/workflows/_get-changed-files.yml + + test-nxp: + needs: changed-files + # Every case in this flow runs the NSYS Neutron simulator, so the full + # eIQ Toolkit install and nxp_executor_runner build are always required. + # Path-gate on pull_request to avoid running on unrelated changes, while + # still running in full on the nightly schedule and on pushes to main. + if: | + github.event_name != 'pull_request' || + contains(needs.changed-files.outputs.changed-files, 'backends/nxp') || + contains(needs.changed-files.outputs.changed-files, 'examples/nxp') || + contains(needs.changed-files.outputs.changed-files, 'backends/test/suite') || + contains(needs.changed-files.outputs.changed-files, 'backends/test/harness') || + contains(needs.changed-files.outputs.changed-files, '.ci/scripts/test_backend.sh') || + contains(needs.changed-files.outputs.changed-files, '.github/workflows/test-backend-nxp.yml') || + contains(needs.changed-files.outputs.changed-files, '.github/workflows/_test_backend.yml') + uses: ./.github/workflows/_test_backend.yml + with: + backend: nxp + flows: '["nxp_neutron_imxrt700_int8_ptq"]' + # The models suite (torchvision/torchaudio) is excluded until a curated + # list of models known to pass on the Neutron backend is established. + exclude: '[{"flow": "nxp_neutron_imxrt700_int8_ptq", "suite": "models"}]' + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + timeout: 150 + run-linux: true + docker-image: ci-image:executorch-ubuntu-22.04-clang12 diff --git a/backends/nxp/tests/tester/tester.py b/backends/nxp/tests/tester/tester.py index 72d1a456184..578ca5aab3f 100644 --- a/backends/nxp/tests/tester/tester.py +++ b/backends/nxp/tests/tester/tester.py @@ -6,7 +6,7 @@ """ NeutronTester -- backend-specific Tester subclass for the Neutron backend. -Usage in a test:: +Usage in a test: NeutronTester(model, example_inputs) \ .quantize() \ @@ -52,19 +52,21 @@ Serialize, Stage, StageType, + ToEdgeTransformAndLower, ToExecutorch, ) from executorch.exir import ( EdgeCompileConfig, - EdgeProgramManager, ExecutorchBackendConfig, to_edge_transform_and_lower, ) from torch.export import ExportedProgram from torch.utils._pytree import tree_flatten, tree_unflatten -logger = logging.getLogger(__name__) - +# --------------------------------------------------------------------------- +# Module logger used by NeutronSerialize for diagnostic warnings. +# --------------------------------------------------------------------------- +_log = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Default number of random calibration samples used when no custom calibration @@ -73,17 +75,17 @@ _DEFAULT_NUM_CALIBRATION_SAMPLES = 4 -def _random_tensor_like(t: torch.Tensor, original: torch.Tensor) -> torch.Tensor: - """Generate a calibration tensor with the same shape and dtype as t. +def _calibration_tensor_like(t: torch.Tensor, original: torch.Tensor) -> torch.Tensor: + """Return a tensor suitable for PTQ calibration with the same shape and dtype as t. - For floating-point tensors, returns a uniform [0, 1) random tensor to + For floating-point tensors, returns values uniformly drawn from [eps, 1) to avoid NaN in ops like log/sqrt. For integer and bool tensors, clones the original example tensor so that calibration inputs have realistic values (e.g. valid embedding indices, bool masks). """ if t.is_floating_point(): - # Uniform [0, 1) avoids negative values that cause NaN in log, sqrt. - return torch.rand_like(t) + eps = torch.finfo(torch.float32).eps + return torch.rand_like(t) * (1.0 - eps) + eps # Integer and bool dtypes: reuse the original example tensor value so that # HistogramObserver never receives a freshly-generated integer tensor. return original.clone() @@ -96,13 +98,17 @@ def _make_random_calibration_inputs( """Generate random calibration samples compatible with example_inputs. example_inputs may be a tuple of tensors or a flat sequence that has already - been tree-flattened. Non-tensor items are passed through unchanged. + been tree-flattened. Non-tensor items are passed through unchanged. """ flat, spec = tree_flatten(example_inputs) samples = [] for _ in range(num_samples): flat_sample = [ - _random_tensor_like(item, item) if isinstance(item, torch.Tensor) else item + ( + _calibration_tensor_like(item, item) + if isinstance(item, torch.Tensor) + else item + ) for item in flat ] samples.append(tree_unflatten(flat_sample, spec)) @@ -213,11 +219,12 @@ def run_artifact(self, inputs): # --------------------------------------------------------------------------- -class NeutronToEdgeTransformAndLower(Stage): +class NeutronToEdgeTransformAndLower(ToEdgeTransformAndLower): """Runs to_edge_transform_and_lower with the Neutron partitioner. - This is the stage that actually calls neutron-converter to produce the NPU - payload and embeds it in the edge program as a delegate blob. + Inherits from ToEdgeTransformAndLower (the shared harness base) and + overrides run() to inject the NeutronPartitioner, Neutron edge passes, + and the optional post-quant state dict. """ def __init__( @@ -228,6 +235,10 @@ def __init__( use_neutron_for_format_conversion: bool = True, use_quant_state_dict: bool = True, ): + # Initialize the base with no partitioners -- we build the Neutron + # partitioner inside run() because it requires the compiled spec and + # the post-quant state dict from the exported artifact. + super().__init__() self._target = target self._operators_not_to_delegate = operators_not_to_delegate or [] self._custom_delegation_options = ( @@ -235,12 +246,6 @@ def __init__( ) self._use_neutron_for_format_conversion = use_neutron_for_format_conversion self._use_quant_state_dict = use_quant_state_dict - self._edge_program_manager: Optional[EdgeProgramManager] = None - - # Stage protocol --- - - def stage_type(self) -> StageType: - return StageType.TO_EDGE_TRANSFORM_AND_LOWER def run( self, @@ -253,8 +258,7 @@ def run( # Re-export the quantized graph module to get a clean ExportedProgram. if isinstance(artifact, torch.fx.GraphModule): # artifact is the output of the Quantize stage (a GraphModule). - # We need to re-export it as a proper ExportedProgram. - # Use the inputs stored by the tester's run() as example inputs. + # Re-export it as a proper ExportedProgram before lowering. if inputs is None: raise RuntimeError( "NeutronToEdgeTransformAndLower requires inputs for re-export." @@ -270,12 +274,10 @@ def run( # Build the post-quant state dict for the partitioner if requested. post_quant_state_dict = None if self._use_quant_state_dict: - try: - post_quant_state_dict = artifact.state_dict() - except Exception: - pass + post_quant_state_dict = artifact.state_dict preserve_ops = [ + torch.ops.aten.pad.default, torch.ops.aten.prelu.default, torch.ops.aten.hardswish.default, ] @@ -306,15 +308,9 @@ def run( NeutronEdgePassManager([RemoveAdditionalQDQClustersPass()]) ) - self._edge_program_manager = edge_program_manager - - @property - def artifact(self) -> EdgeProgramManager: - return self._edge_program_manager - - @property - def graph_module(self) -> torch.fx.GraphModule: - return self._edge_program_manager.exported_program().graph_module + # Store using the attribute name expected by the base class so that the + # inherited artifact property returns the correct EdgeProgramManager. + self.edge_dialect_program = edge_program_manager # --------------------------------------------------------------------------- @@ -463,9 +459,11 @@ def run_artifact(self, inputs: Tuple[torch.Tensor, ...]): f.write(self.buffer) # --- Write input tensors as raw binary files --- - # The runner accepts: - # --dataset for single-input models (one .bin per sample) - # --inputs a.bin,b.bin for multi-input models (one path per tensor) + # Input tensors are written as zero-padded numbered files: + # 0000.bin, 0001.bin, ... (one file per tensor in the flat input list) + # The runner CLI flags then differ by arity: + # --dataset for single-input models (reads *.bin files in dir) + # --inputs p0,p1,... for multi-input models (one absolute path per tensor) dataset_dir = os.path.join(tmp_dir, "dataset") os.makedirs(dataset_dir) flat_inputs, _ = tree_flatten(inputs) @@ -528,7 +526,17 @@ def run_artifact(self, inputs: Tuple[torch.Tensor, ...]): sizes, dtype = output_specs[i] np_dtype = _TORCH_TO_NUMPY.get(dtype, np.float32) else: - # Fallback: assume float32 flat tensor if metadata is unavailable. + # Fallback: output tensor metadata could not be read from the + # .pte buffer (pybindings unavailable or output index out of + # range). Interpret raw bytes as a flat float32 tensor. Shape + # and dtype may be incorrect -- compare with caution. + _log.warning( + "Output tensor metadata unavailable for output %d (%s); " + "interpreting raw bytes as a flat float32 tensor. " + "Shape and dtype may be wrong.", + i, + os.path.basename(fpath), + ) sizes = None np_dtype = np.float32 @@ -552,7 +560,7 @@ class NeutronTester(TesterBase): Provides Neutron-specific implementations for the Quantize, ToEdgeTransformAndLower, ToExecutorch, and Serialize stages. - Example:: + Example: NeutronTester(MyModel(), example_inputs) \ .quantize() \ @@ -571,6 +579,7 @@ def __init__( operators_not_to_delegate: Optional[List[str]] = None, custom_delegation_options: Optional[CustomDelegationOptions] = None, use_neutron_for_format_conversion: bool = True, + use_quant_state_dict: bool = True, calibration_samples: Optional[Iterable[Tuple[torch.Tensor, ...]]] = None, get_calibration_inputs_fn: Optional[Callable] = None, num_calibration_samples: int = _DEFAULT_NUM_CALIBRATION_SAMPLES, @@ -580,6 +589,7 @@ def __init__( self._operators_not_to_delegate = operators_not_to_delegate self._custom_delegation_options = custom_delegation_options self._use_neutron_for_format_conversion = use_neutron_for_format_conversion + self._use_quant_state_dict = use_quant_state_dict self._calibration_samples = calibration_samples self._get_calibration_inputs_fn = get_calibration_inputs_fn self._num_calibration_samples = num_calibration_samples @@ -623,4 +633,5 @@ def _make_lower_stage(self) -> NeutronToEdgeTransformAndLower: operators_not_to_delegate=self._operators_not_to_delegate, custom_delegation_options=self._custom_delegation_options, use_neutron_for_format_conversion=self._use_neutron_for_format_conversion, + use_quant_state_dict=self._use_quant_state_dict, ) diff --git a/backends/test/suite/flows/nxp.py b/backends/test/suite/flows/nxp.py index 2b673bc1fc0..1318405d9aa 100644 --- a/backends/test/suite/flows/nxp.py +++ b/backends/test/suite/flows/nxp.py @@ -20,18 +20,21 @@ --json-report --json-report-file=neutron_test_report.json """ +# Register portable and quantized op kernels so that +# quantized_decomposed::dequantize_per_tensor / quantize_per_tensor are available. +import executorch.extension.pybindings.portable_lib # noqa: F401 +import executorch.kernels.quantized # noqa: F401 from executorch.backends.nxp.tests.tester import NeutronTester from executorch.backends.test.suite.flow import TestFlow -# Register portable and quantized op kernels so that -# quantized_decomposed::dequantize_per_tensor / quantize_per_tensor are -# available when the suite is run without the NXP integration-repo -# conftest.py being loaded. -try: - import executorch.extension.pybindings.portable_lib # noqa: F401 - import executorch.kernels.quantized # noqa: F401 -except ImportError: - pass + +# Tests known to fail on Neutron due to known bugs. Marked as xfail +# (strict=True) so that an unexpected pass is also reported. +_NEUTRON_XFAILS = [ + "test_lstm", + "test_cat_different_shapes", + "test_cat_dimensions", +] def _create_neutron_int8_ptq_flow(target: str = "imxrt700") -> TestFlow: @@ -63,6 +66,7 @@ def quantize_stage_factory(): # simulator tools are missing, the suite marks the test as # PTE_RUN_FAIL (which is expected and informative in that environment). supports_serialize=True, + xfail_patterns=_NEUTRON_XFAILS, )