Skip to content

Commit c8220d9

Browse files
NXP backend: Fix adding observers on non-float nodes and Fix incorrect turn of an output buffer into model parameter (#23890)
### Summary Fixing errors observed by executorch-fuzzer. During calibration, the quantizer may inject operations that are incompatible with the graph's data types; for example, it may invoke torch.histc on int64 tensors, which the operation does not support. In other cases, the conversion pass may incorrectly turn an output buffer into amodel parameter while the graph continues to mutate that buffer,causing verification to fail. ### Test plan Unit test introduced Signed-off-by: Robert Kalmar <robert.kalmar@nxp.com>
1 parent 58ad185 commit c8220d9

7 files changed

Lines changed: 168 additions & 87 deletions

File tree

‎backends/nxp/aten_passes/remove_nodes_with_known_outputs.py‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2025 NXP
1+
# Copyright 2025-2026 NXP
22
#
33
# This source code is licensed under the BSD-style license found in the
44
# LICENSE file in the root directory of this source tree.
@@ -70,6 +70,25 @@ def _is_getitem(node_: Node) -> bool:
7070
users = list(node.users.keys())
7171
return all(_is_getitem(user) for user in users)
7272

73+
@staticmethod
74+
def node_is_used_as_out_kwarg(node: Node) -> bool:
75+
"""Return `True` if `node` is passed as the `out=` kwarg of any of its users.
76+
77+
Such a node (typically a `torch.empty(...)` placeholder buffer feeding a `*.out`
78+
overload) is about to be mutated in place by its consumer, so its "known" data
79+
(e.g. uninitialized garbage from `empty.memory_format`) is not actually the value
80+
observed at runtime, and it must not be folded into a static `Parameter`: `torch.export`
81+
would then treat the mutated buffer as a learned weight, which breaks the exported
82+
program's parameter/signature verification later in the pipeline.
83+
"""
84+
for user in node.users:
85+
if user.kwargs.get("out") is node:
86+
return True
87+
out_args = user.kwargs.get("out")
88+
if isinstance(out_args, (list, tuple)) and node in out_args:
89+
return True
90+
return False
91+
7392
def replace_node_with_static_data(self, node: Node, static_data: Parameter):
7493
"""Remove the given `node` from the graph and replace it with a parameter node containing the `static_data`."""
7594
# Generate a unique name for the new static parameter.
@@ -104,7 +123,6 @@ def replace_following_getitem_nodes_with_static_data(
104123
after the `root_node`.
105124
:return: `True` if the replacement was successfully executed. `False` otherwise.
106125
"""
107-
108126
if not self.node_is_followed_only_by_getitem_nodes(root_node):
109127
return False # Unexpected case.
110128

@@ -158,6 +176,12 @@ def call(self, module: GraphModule) -> PassResult:
158176
if node.op != "call_function":
159177
continue # Not a compute operator.
160178

179+
if self.node_is_used_as_out_kwarg(node):
180+
# `node` is the `out=` buffer of a mutating `*.out` overload. Its "known"
181+
# output data is not what gets observed at runtime (it is about to be
182+
# overwritten), so it must not be folded into a static `Parameter`.
183+
continue
184+
161185
# Try to access the static data for the inputs of the node.
162186
args_with_data = self.replace_nodes_in_list_with_their_data(node.args)
163187

‎backends/nxp/quantizer/neutron_quantizer.py‎

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,32 @@
100100
from torchao.quantization.pt2e.quantizer.quantizer import Q_ANNOTATION_KEY
101101

102102

103+
#: Dtypes accepted by `quantized_decomposed.quantize_per_tensor` (inserted by `convert_pt2e`)
104+
_QUANTIZABLE_FLOAT_DTYPES = (torch.float32, torch.float16, torch.bfloat16)
105+
106+
107+
def _is_quantizable_node(node: fx.Node) -> bool:
108+
"""Returns whether node is quantizable float type, per `quantized_decomposed.quantize_per_tensor` torch
109+
operator.
110+
111+
Helper function to avoid placing Observers or FakeQuant on non-floating nodes.
112+
Nodes without a `"val"` meta entry (e.g. linear_weight_bias) are treated as quantizable.
113+
"""
114+
val = node.meta.get("val", None)
115+
if val is None or not hasattr(val, "dtype"):
116+
return True
117+
return val.dtype in _QUANTIZABLE_FLOAT_DTYPES
118+
119+
120+
def _dtype_safe_qspec(
121+
spec: QuantizationSpec | None, node: fx.Node
122+
) -> QuantizationSpec | None:
123+
"""Suppress quantization annotation for a non-floating-point node inputs."""
124+
if _is_quantizable_node(node):
125+
return spec
126+
return None
127+
128+
103129
class NeutronAtenQuantizer(Quantizer):
104130
def __init__(
105131
self, pattern: QuantizationPattern, quantization_config: QuantizationConfig
@@ -109,6 +135,7 @@ def __init__(
109135
self.quantization_config = quantization_config
110136

111137
def annotate(self, model: torch.fx.GraphModule) -> torch.fx.GraphModule:
138+
112139
fused_partitions = find_sequential_partitions_aten(
113140
model,
114141
self.pattern.partition_types(),
@@ -138,12 +165,15 @@ def annotate(self, model: torch.fx.GraphModule) -> torch.fx.GraphModule:
138165
continue
139166

140167
for output, *custom_spec in anchors.output:
168+
spec = custom_spec[0] if custom_spec else output_act_qspec
169+
spec = _dtype_safe_qspec(spec, output)
141170
# pyre-ignore[16]: no attribute
142-
output.meta[Q_ANNOTATION_KEY] = QuantizationAnnotation(
143-
# pyre-ignore[6]: incompatible parameter type
144-
output_qspec=(custom_spec[0] if custom_spec else output_act_qspec),
145-
_annotated=True,
146-
)
171+
if spec is not None:
172+
output.meta[Q_ANNOTATION_KEY] = QuantizationAnnotation(
173+
# pyre-ignore[6]: incompatible parameter type
174+
output_qspec=spec,
175+
_annotated=True,
176+
)
147177

148178
def annotate_inputs(
149179
inputs: (
@@ -165,9 +195,9 @@ def annotate_inputs(
165195
# pyre-ignore[16]: no attribute
166196
else node.args[args_idx.idx][args_idx.inner_idx]
167197
)
168-
annotation.input_qspec_map[arg] = (
169-
custom_spec[0] if custom_spec else spec
170-
)
198+
arg_spec = custom_spec[0] if custom_spec else spec
199+
arg_spec = _dtype_safe_qspec(arg_spec, arg)
200+
annotation.input_qspec_map[arg] = arg_spec
171201
# pyre-ignore[16]: no attribute
172202
node.meta[Q_ANNOTATION_KEY] = annotation
173203

@@ -399,8 +429,10 @@ def _annotate_inputs(self, model: fx.GraphModule):
399429
continue
400430

401431
if node.op == "placeholder" and len(node.users) > 0:
402-
annotate_output_qspec(node, act_qspec(self.is_qat))
403-
self._mark_input_node_as_annotated(node)
432+
spec = _dtype_safe_qspec(act_qspec(self.is_qat), node)
433+
if spec is not None:
434+
annotate_output_qspec(node, spec)
435+
self._mark_input_node_as_annotated(node)
404436

405437
def validate(self, model: torch.fx.GraphModule) -> None:
406438
return super().validate(model)

‎backends/nxp/quantizer/utils.py‎

Lines changed: 0 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,6 @@
3232
)
3333

3434
from torchao.quantization.pt2e import (
35-
HistogramObserver,
36-
MinMaxObserver,
3735
move_exported_model_to_eval,
3836
move_exported_model_to_train,
3937
ObserverOrFakeQuantize,
@@ -202,42 +200,6 @@ def find_sequential_partitions_aten(
202200
return fused_partitions
203201

204202

205-
def _replace_histogram_observers_for_integer_inputs(
206-
m: torch.fx.GraphModule,
207-
) -> None:
208-
"""Replace HistogramObserver with MinMaxObserver for observer nodes whose
209-
input tensor has a non-floating-point dtype.
210-
211-
HistogramObserver calls torch.histc internally, which raises
212-
NotImplementedError for integer and bool dtypes. MinMaxObserver only
213-
tracks min/max and handles all dtypes, producing equally valid
214-
quantization ranges for non-float inputs (e.g. embedding indices, bool
215-
masks).
216-
"""
217-
for node in m.graph.nodes:
218-
if node.op != "call_module":
219-
continue
220-
obs = getattr(m, node.target, None)
221-
if not isinstance(obs, HistogramObserver):
222-
continue
223-
input_node = node.args[0] if node.args else None
224-
if input_node is None:
225-
continue
226-
val = input_node.meta.get("val", None)
227-
if val is not None and not val.is_floating_point():
228-
setattr(
229-
m,
230-
node.target,
231-
MinMaxObserver(
232-
dtype=obs.dtype,
233-
qscheme=obs.qscheme,
234-
quant_min=obs.quant_min,
235-
quant_max=obs.quant_max,
236-
eps=obs.eps,
237-
),
238-
)
239-
240-
241203
def calibrate_and_quantize(
242204
model: ExportedProgram | fx.GraphModule,
243205
calibration_inputs: Iterable[tuple[torch.Tensor, ...]],
@@ -266,13 +228,6 @@ def calibrate_and_quantize(
266228
else:
267229
m = prepare_pt2e(model, quantizer)
268230

269-
# Swap HistogramObserver -> MinMaxObserver for any observer whose input has
270-
# a non-floating-point dtype. Safe for both PTQ and QAT: in QAT the graph
271-
# contains FakeQuantize nodes which are not HistogramObserver instances, so
272-
# the helper is effectively a no-op there, but it protects against the edge
273-
# case where a QAT graph does contain a HistogramObserver.
274-
_replace_histogram_observers_for_integer_inputs(m)
275-
276231
if is_qat:
277232
if train_fn:
278233
m = move_exported_model_to_train(m)

‎backends/nxp/recipes/nxp_recipe_provider.py‎

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,6 @@
3838
default_preserve_ops,
3939
generate_neutron_compile_spec,
4040
)
41-
from executorch.backends.nxp.quantizer.utils import (
42-
_replace_histogram_observers_for_integer_inputs,
43-
)
4441
from executorch.backends.nxp.recipes.nxp_recipe_types import NXP_BACKEND, NXPRecipeType
4542
from executorch.backends.nxp.tests.executorch_pipeline import (
4643
get_default_quantizer,
@@ -132,7 +129,6 @@ class NeutronRecipeConfig:
132129

133130

134131
class NXPRecipeProvider(BackendRecipeProvider):
135-
136132
@property
137133
def backend_name(self) -> str:
138134
return NXP_BACKEND
@@ -245,14 +241,9 @@ def _wrapped(m):
245241
return _wrapped
246242

247243

248-
def _histogram_observer_fix_pass(m):
249-
"""Callable(GraphModule) -> GraphModule that replaces HistogramObserver for integer inputs."""
250-
_replace_histogram_observers_for_integer_inputs(m)
251-
return m
252-
253-
254244
# ---------------------------------------------------------------------------
255245
# Module-level builder helpers
246+
256247
# ---------------------------------------------------------------------------
257248

258249

@@ -269,13 +260,11 @@ def _build_quantization_recipe(
269260
"""
270261
_quantizer = rc.get_quantizer_fn()
271262

272-
# post_prepare_passes: always fix HistogramObserver for non-float inputs.
273-
# For QAT, also insert the simulated linear-BN fusion before training so
274-
# fake-quantize nodes see fused weights during the training loop.
263+
# post_prepare_passes: for QAT, insert the simulated linear-BN fusion before
264+
# training so fake-quantize nodes see fused weights during the training loop.
275265
post_prepare: list[Callable] = []
276266
if is_qat:
277267
post_prepare.append(_wrap_exir_pass(AddSimulatedLinearBatchNormFusionQATPass))
278-
post_prepare.append(_histogram_observer_fix_pass)
279268

280269
if is_qat:
281270
# pre_convert_passes: tear down the simulated fusion and fold BN into

‎backends/nxp/tests/generic_tests/test_quantizer.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
#
33
# This source code is licensed under the BSD-style license found in the
44
# LICENSE file in the root directory of this source tree.
5+
import functools
56

67
# Tests for NeutronQuantizer.
78

@@ -39,6 +40,7 @@
3940
)
4041
from executorch.backends.nxp.tests.executors import (
4142
convert_run_compare,
43+
graph_contains_any,
4244
graph_contains_any_of_ops,
4345
tflite,
4446
ToChannelFirstPreprocess,
@@ -760,3 +762,44 @@ def is_conv(node):
760762
assert len(graph_nodes) == 21
761763
else:
762764
assert len(graph_nodes) == 15
765+
766+
767+
def test_quantizer__non_float_input_does_not_crash_prepare_or_convert():
768+
"""A non-floating-point input (e.g. int64) must not crash the
769+
prepare_pt2e/convert_pt2e.
770+
771+
Uses prepare_pt2e/convert_pt2e directly (not calibrate_and_quantize test
772+
wrapper), matching how any external PT2E caller might use NeutronQuantizer.
773+
"""
774+
775+
class PowModule(torch.nn.Module):
776+
def forward(self, x, y):
777+
return torch.ops.aten.pow.Tensor_Tensor(x, y)
778+
779+
x = torch.randint(-4, 5, (2,), dtype=torch.int64)
780+
y = torch.randn((2,), dtype=torch.float32)
781+
782+
exported_model = export(PowModule(), (x, y), strict=True)
783+
quantizer = NeutronQuantizer(neutron_target_spec)
784+
785+
prepared_model = prepare_pt2e(exported_model.module(), quantizer)
786+
prepared_model(x, y) # calibration must not raise (torch.histc on int64)
787+
788+
quantized_model = convert_pt2e(
789+
prepared_model
790+
) # must not raise (quantize_per_tensor dtype check)
791+
quantized_model(x, y) # sanity: the converted graph still runs
792+
793+
def quant_node_on(n, name):
794+
return (
795+
hasattr(n, "target")
796+
and n.target is torch.ops.quantized_decomposed.quantize_per_tensor.default
797+
and n.all_input_nodes[0].name == name
798+
)
799+
800+
assert graph_contains_any(
801+
quantized_model.graph, condition=functools.partial(quant_node_on, name="y")
802+
)
803+
assert not graph_contains_any(
804+
quantized_model.graph, condition=functools.partial(quant_node_on, name="x")
805+
)

‎backends/nxp/tests/generic_tests/test_recipe_export.py‎

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
)
2525
from executorch.backends.nxp.neutron_partitioner import NeutronPartitioner
2626
from executorch.backends.nxp.recipes.nxp_recipe_provider import (
27-
_histogram_observer_fix_pass,
2827
NEUTRON_RECIPE_CONFIG_KEY,
2928
NeutronRecipeConfig,
3029
NXPRecipeProvider,
@@ -609,14 +608,6 @@ def test__post_prepare_passes_start_with_add_bn_fusion(self, recipe_type):
609608
AddSimulatedLinearBatchNormFusionQATPass.__name__ in first_pass.__qualname__
610609
)
611610

612-
def test__post_prepare_passes_end_with_histogram_observer_fix(self, recipe_type):
613-
"""QAT post_prepare_passes: last pass is _histogram_observer_fix_pass."""
614-
recipe = self._get_qat_recipe(recipe_type)
615-
qr = recipe.quantization_recipe
616-
assert qr.post_prepare_passes is not None
617-
last_pass = qr.post_prepare_passes[-1]
618-
assert last_pass is _histogram_observer_fix_pass
619-
620611
def test__pre_convert_passes_include_remove_bn_fusion_and_fold(self, recipe_type):
621612
"""QAT pre_convert_passes: contains RemoveSimulatedLinearBatchNormFusionQATPass
622613
followed by FuseBatchNormWithLinearPass (each applied once)."""
@@ -671,12 +662,6 @@ def test__no_pre_or_post_convert_passes(self):
671662
assert qr.pre_convert_passes is None
672663
assert qr.post_convert_passes is None
673664

674-
def test__post_prepare_passes_include_histogram_observer_fix(self):
675-
"""PTQ recipe post_prepare_passes contains _histogram_observer_fix_pass."""
676-
qr = self._get_ptq_recipe().quantization_recipe
677-
assert qr.post_prepare_passes is not None
678-
assert _histogram_observer_fix_pass in qr.post_prepare_passes
679-
680665
def test__post_prepare_passes_do_not_include_add_bn_fusion(self):
681666
"""PTQ recipe post_prepare_passes must NOT contain AddSimulatedLinearBatchNormFusionQATPass."""
682667
qr = self._get_ptq_recipe().quantization_recipe
@@ -685,6 +670,13 @@ def test__post_prepare_passes_do_not_include_add_bn_fusion(self):
685670
p, "__qualname__", ""
686671
)
687672

673+
def test__post_prepare_passes_is_empty(self):
674+
"""PTQ recipe post_prepare_passes is empty: NeutronQuantizer itself suppresses
675+
quantization annotation for non-float nodes, so no post-prepare fixup pass is
676+
needed (previously _histogram_observer_fix_pass was appended here)."""
677+
qr = self._get_ptq_recipe().quantization_recipe
678+
assert qr.post_prepare_passes == []
679+
688680

689681
# ---------------------------------------------------------------------------
690682
# QAT recipe: e2e test on a real model - equivalent to imperative QAT tests

0 commit comments

Comments
 (0)