Skip to content

Commit 4c575c7

Browse files
committed
NXB backend: Add recipes for Neutron backend lowering.
1 parent 6683757 commit 4c575c7

7 files changed

Lines changed: 909 additions & 13 deletions

File tree

backends/nxp/edge_passes/neutron_edge_pass_manager.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from executorch.backends.nxp.edge_passes.remove_as_strided_copy_nodes import (
1818
RemoveUselessAsStridedCopyNodes,
1919
)
20-
from torch.fx.passes.infra.pass_manager import PassManager
20+
from executorch.exir.pass_manager import PassManager
2121

2222

2323
class NeutronEdgePassManager(PassManager):
Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
1+
# Copyright 2026 NXP
2+
#
3+
# This source code is licensed under the BSD-style license found in the
4+
# LICENSE file in the root directory of this source tree.
5+
6+
import logging
7+
from dataclasses import dataclass
8+
from functools import partial
9+
from typing import Any, Callable, cast, Iterable, Optional, Sequence
10+
11+
import torch
12+
13+
from executorch.backends.nxp.backend.custom_delegation_options import (
14+
CustomDelegationOptions,
15+
)
16+
from executorch.backends.nxp.backend.neutron_target_spec import NeutronTargetSpec
17+
from executorch.backends.nxp.edge_passes.neutron_edge_pass import NeutronEdgePass
18+
from executorch.backends.nxp.edge_passes.neutron_edge_pass_manager import (
19+
NeutronEdgePassManager,
20+
)
21+
from executorch.backends.nxp.edge_passes.remove_additional_quantize_dequantize_nodes_pass import (
22+
RemoveAdditionalQDQClustersPass,
23+
)
24+
from executorch.backends.nxp.edge_passes.remove_io_quant_ops_pass import (
25+
RemoveIOQuantOpsPass,
26+
)
27+
from executorch.backends.nxp.neutron_partitioner import NeutronPartitioner
28+
from executorch.backends.nxp.nxp_backend import (
29+
core_aten_ops_exception_list,
30+
generate_neutron_compile_spec,
31+
)
32+
from executorch.backends.nxp.quantizer.utils import calibrate_and_quantize
33+
from executorch.backends.nxp.recipes.nxp_recipe_types import NXP_BACKEND, NXPRecipeType
34+
from executorch.backends.nxp.tests.executorch_pipeline import (
35+
_get_default_quantizer,
36+
get_random_calibration_inputs,
37+
GetCalibrationInputsFn,
38+
handle_kernel_selection,
39+
ModelInputSpec,
40+
to_model_input_spec,
41+
)
42+
from executorch.exir import EdgeCompileConfig, EdgeProgramManager, ExportedProgram
43+
44+
from executorch.exir.backend.compile_spec_schema import CompileSpec
45+
from executorch.exir.backend.partitioner import Partitioner
46+
from executorch.export import (
47+
BackendRecipeProvider,
48+
ExportRecipe,
49+
LoweringRecipe,
50+
QuantizationRecipe,
51+
RecipeType,
52+
)
53+
from torchao.quantization.pt2e.quantizer import Quantizer
54+
55+
56+
class NeutronEdgePassManagerWrapper:
57+
def __init__(self, passes: list[NeutronEdgePass] | None = None):
58+
self.neutron_edge_pass_manager = NeutronEdgePassManager(passes)
59+
60+
def __call__(self, s: str, epm: EdgeProgramManager) -> NeutronEdgePassManager:
61+
return self.neutron_edge_pass_manager
62+
63+
64+
NEUTRON_RECIPE_CONFIG_KEY = "neutron_recipe_config"
65+
66+
67+
@dataclass
68+
class NeutronRecipeConfig:
69+
"""Configuration shared by all NXP recipe types.
70+
71+
Parameters that vary the *type* of export (PTQ vs QAT, delegate vs no-delegate)
72+
are expressed by choosing a different NXPRecipeType rather than by flags here.
73+
74+
Attributes:
75+
input_spec: Model input description. Accepts a single shape tuple, a list of
76+
shape tuples (one per input), or a list of ModelInputSpec objects.
77+
target: Neutron hardware target string. Default: "imxrt700".
78+
operators_not_to_delegate: Optional list of op names excluded from NPU delegation.
79+
intermediates_dir: Optional directory to dump intermediate compilation artefacts.
80+
get_quantizer_fn: Optional factory that returns a custom Quantizer. When None,
81+
the default NeutronQuantizer is used.
82+
get_calibration_inputs_fn: Optional function that, given the input_spec, returns
83+
calibration input samples. When None, random inputs are
84+
used.
85+
train_fn: QAT training callback. Required when using INT8_QAT_NEUTRON.
86+
custom_delegation_options: Optional fine-grained control over which ops are
87+
delegated. Default: CustomDelegationOptions().
88+
remove_quant_io_ops: If True, remove quantize/dequantize ops at the IO boundary
89+
(useful for integer-IO deployments).
90+
use_quant_state_dict: If False, the post-quantization parameter values are not
91+
passed to NeutronPartitioner.
92+
use_neutron_for_format_conversion: Whether Neutron handles data-format conversion.
93+
fetch_constants_to_sram: Place constant tensors in SRAM on the target.
94+
dump_kernel_selection_code: Emit kernel-selection files after compilation.
95+
use_profiling: Enable execution profiling / ETRecord generation.
96+
"""
97+
98+
input_spec: Iterable[ModelInputSpec] | tuple[int, ...] | list[tuple[int, ...]]
99+
target: str = "imxrt700"
100+
operators_not_to_delegate: list[str] = None
101+
intermediates_dir: str | None = None
102+
get_quantizer_fn: Callable[[], Quantizer] | None = None
103+
get_calibration_inputs_fn: GetCalibrationInputsFn | None = None
104+
train_fn: Callable[[torch.fx.GraphModule], None] | None = None
105+
custom_delegation_options: CustomDelegationOptions | None = None
106+
remove_quant_io_ops: bool = False
107+
use_quant_state_dict: bool = True
108+
use_neutron_for_format_conversion: bool = True
109+
fetch_constants_to_sram: bool = False
110+
dump_kernel_selection_code: bool = False
111+
use_profiling: bool = False
112+
113+
114+
class NXPRecipeProvider(BackendRecipeProvider):
115+
116+
@property
117+
def backend_name(self) -> str:
118+
return NXP_BACKEND
119+
120+
def get_supported_recipes(self) -> Sequence[RecipeType]:
121+
return list(NXPRecipeType)
122+
123+
def create_recipe(
124+
self, recipe_type: RecipeType, **kwargs: Any
125+
) -> Optional[ExportRecipe]:
126+
if recipe_type not in self.get_supported_recipes():
127+
logging.warning(f"NXP backend: Recipe `{recipe_type}` is not valid.")
128+
return None
129+
130+
rc = cast(NeutronRecipeConfig, kwargs.get(NEUTRON_RECIPE_CONFIG_KEY))
131+
if rc is None:
132+
raise KeyError(
133+
f"NXP backend: create_recipe() requires `{NEUTRON_RECIPE_CONFIG_KEY}=<NeutronRecipeConfig>`."
134+
)
135+
136+
if rc.custom_delegation_options is None:
137+
rc.custom_delegation_options = CustomDelegationOptions()
138+
139+
rc.input_spec = to_model_input_spec(rc.input_spec)
140+
141+
match recipe_type:
142+
case NXPRecipeType.INT8_PTQ_NEUTRON:
143+
return self._build_recipe(recipe_type, rc, is_qat=False, delegate=True)
144+
case NXPRecipeType.INT8_QAT_NEUTRON:
145+
if rc.train_fn is None:
146+
raise ValueError(
147+
"NXP backend: INT8_QAT_NEUTRON requires train_fn in NeutronRecipeConfig."
148+
)
149+
return self._build_recipe(recipe_type, rc, is_qat=True, delegate=True)
150+
case NXPRecipeType.INT8_PTQ_NO_DELEGATE:
151+
return self._build_recipe(recipe_type, rc, is_qat=False, delegate=False)
152+
case _:
153+
raise NotImplementedError(
154+
f"NXP backend: Recipe `{recipe_type}` is not supported."
155+
)
156+
157+
def _build_recipe(
158+
self,
159+
recipe_type: NXPRecipeType,
160+
rc: NeutronRecipeConfig,
161+
*,
162+
is_qat: bool,
163+
delegate: bool,
164+
) -> ExportRecipe:
165+
neutron_target_spec = NeutronTargetSpec(rc.target)
166+
167+
if rc.get_quantizer_fn is None:
168+
rc.get_quantizer_fn = partial(
169+
_get_default_quantizer, neutron_target_spec, is_qat
170+
)
171+
172+
quantization_recipe = _build_quantization_recipe(rc, is_qat)
173+
compile_spec = generate_neutron_compile_spec(
174+
rc.target,
175+
intermediates_dir=rc.intermediates_dir,
176+
operators_not_to_delegate=rc.operators_not_to_delegate,
177+
use_neutron_for_format_conversion=rc.use_neutron_for_format_conversion,
178+
fetch_constants_to_sram=rc.fetch_constants_to_sram,
179+
dump_kernel_selection_code=rc.dump_kernel_selection_code,
180+
use_profiling=rc.use_profiling,
181+
)
182+
lowering_recipe = _build_lowering_recipe(
183+
compile_spec, neutron_target_spec, rc, delegate=delegate
184+
)
185+
186+
return ExportRecipe(
187+
name=recipe_type.value,
188+
quantization_recipe=quantization_recipe,
189+
lowering_recipe=lowering_recipe,
190+
)
191+
192+
193+
# ---------------------------------------------------------------------------
194+
# Module-level builder helpers
195+
# ---------------------------------------------------------------------------
196+
197+
198+
def _build_quantization_recipe(
199+
rc: NeutronRecipeConfig, is_qat: bool
200+
) -> QuantizationRecipe:
201+
"""Build the QuantizationRecipe for PTQ or QAT.
202+
203+
PTQ uses the standard QuantizeStage flow (prepare_pt2e -> calibrate -> convert_pt2e)
204+
driven by calibration_inputs_fn. quantize_fn is intentionally left None so that
205+
multiple PTQ recipes can be combined with ExportRecipe.combine().
206+
207+
QAT requires a non-standard sequence (BN-fusion passes interleaved with training),
208+
so it delegates to calibrate_and_quantize() via quantize_fn. A recipe with
209+
quantize_fn set cannot be combined with other recipes.
210+
"""
211+
_input_spec = rc.input_spec
212+
_calibration_fn = rc.get_calibration_inputs_fn or get_random_calibration_inputs
213+
_quantizer = rc.get_quantizer_fn()
214+
215+
if is_qat:
216+
_train_fn = rc.train_fn
217+
218+
def _quantize_fn( # noqa: E306
219+
graph_module: torch.fx.GraphModule,
220+
) -> torch.fx.GraphModule:
221+
return calibrate_and_quantize(
222+
model=graph_module,
223+
calibration_inputs=_calibration_fn(_input_spec),
224+
quantizer=_quantizer,
225+
is_qat=True,
226+
train_fn=_train_fn,
227+
)
228+
229+
return QuantizationRecipe(
230+
quantizers=[_quantizer],
231+
quantize_fn=_quantize_fn,
232+
)
233+
else:
234+
235+
def _calibration_inputs_fn() -> Iterable:
236+
return _calibration_fn(_input_spec)
237+
238+
return QuantizationRecipe(
239+
quantizers=[_quantizer],
240+
calibration_inputs_fn=_calibration_inputs_fn,
241+
)
242+
243+
244+
def _build_lowering_recipe(
245+
compile_spec: list[CompileSpec],
246+
neutron_target_spec: NeutronTargetSpec,
247+
rc: NeutronRecipeConfig,
248+
*,
249+
delegate: bool,
250+
) -> LoweringRecipe:
251+
"""Build the LoweringRecipe, optionally including NPU delegation."""
252+
partitioners = _build_partitioners(compile_spec, neutron_target_spec, rc, delegate)
253+
pre_partitioning_callback = _build_pre_partitioning_callback(rc)
254+
post_partitioning_transforms = _build_post_partitioning_transforms(rc)
255+
256+
# The edge pass manager must be wrapped: EdgeTransformAndLowerStage calls
257+
# edge_transform_passes with (method_name, ep) and expects a PassManager back.
258+
return LoweringRecipe(
259+
partitioners=partitioners,
260+
edge_transform_passes=[NeutronEdgePassManagerWrapper()],
261+
edge_compile_config=EdgeCompileConfig(
262+
_check_ir_validity=False,
263+
_core_aten_ops_exception_list=core_aten_ops_exception_list,
264+
),
265+
pre_partitioning_callback=pre_partitioning_callback,
266+
post_partitioning_transforms=post_partitioning_transforms,
267+
)
268+
269+
270+
def _build_partitioners(
271+
compile_spec: list[CompileSpec],
272+
neutron_target_spec: NeutronTargetSpec,
273+
rc: NeutronRecipeConfig,
274+
delegate: bool,
275+
) -> list:
276+
"""Create the NeutronPartitioner list. Empty when delegate=False."""
277+
if not delegate:
278+
return []
279+
return [
280+
NeutronPartitioner(
281+
compile_spec,
282+
neutron_target_spec,
283+
rc.custom_delegation_options,
284+
preserve_ops=[torch.ops.aten.prelu.default],
285+
)
286+
]
287+
288+
289+
def _build_pre_partitioning_callback(rc: NeutronRecipeConfig):
290+
"""Return a callback that assigns the post-quantization state_dict to NeutronPartitioner.
291+
292+
NeutronPartitioner requires static parameter data. Since the partitioner is instantiated
293+
during recipe creation (before model data is available), assignment is deferred to a
294+
callback invoked just before partitioning.
295+
"""
296+
_use_quant_state_dict = rc.use_quant_state_dict
297+
298+
def _callback(
299+
_partitioners: list[Partitioner] | None,
300+
programs: dict[str, ExportedProgram],
301+
) -> None:
302+
if _partitioners is None:
303+
return
304+
305+
if _use_quant_state_dict:
306+
post_quant_state_dict: dict | None = {}
307+
for _, program in programs.items():
308+
post_quant_state_dict.update(program.state_dict)
309+
else:
310+
post_quant_state_dict = None
311+
312+
for _partitioner in _partitioners:
313+
if isinstance(_partitioner, NeutronPartitioner):
314+
_partitioner.post_quantization_state_dict = post_quant_state_dict
315+
316+
return _callback
317+
318+
319+
def _build_post_partitioning_transforms(rc: NeutronRecipeConfig) -> list:
320+
"""Build the list of post-partitioning EdgeProgramManager transforms.
321+
322+
These mirror what the imperative pipeline did after to_edge_transform_and_lower:
323+
- RemoveIOQuantOpsPass (optional, when remove_quant_io_ops=True)
324+
- RemoveAdditionalQDQClustersPass (always applied)
325+
- handle_kernel_selection side-effect (optional, when dump_kernel_selection_code=True)
326+
"""
327+
transforms = []
328+
329+
if rc.remove_quant_io_ops:
330+
331+
def _remove_io_quant_ops(epm: EdgeProgramManager) -> EdgeProgramManager:
332+
return epm.transform([RemoveIOQuantOpsPass(edge_program_manager=epm)])
333+
334+
transforms.append(_remove_io_quant_ops)
335+
336+
def _remove_additional_qdq_clusters(epm: EdgeProgramManager) -> EdgeProgramManager:
337+
return epm.transform(
338+
NeutronEdgePassManager([RemoveAdditionalQDQClustersPass()])
339+
)
340+
341+
transforms.append(_remove_additional_qdq_clusters)
342+
343+
if rc.dump_kernel_selection_code:
344+
345+
def _handle_kernel_selection_transform(
346+
epm: EdgeProgramManager,
347+
) -> EdgeProgramManager:
348+
handle_kernel_selection()
349+
return epm
350+
351+
transforms.append(_handle_kernel_selection_transform)
352+
353+
return transforms
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Copyright 2026 NXP
2+
#
3+
# This source code is licensed under the BSD-style license found in the
4+
# LICENSE file in the root directory of this source tree.
5+
6+
from executorch.export import RecipeType
7+
8+
9+
NXP_BACKEND: str = "nxp"
10+
11+
12+
class NXPRecipeType(RecipeType):
13+
"""NXP-specific recipe types for Neutron NPU export.
14+
15+
Choose the recipe that matches your intended export configuration:
16+
- INT8_PTQ_NEUTRON: standard post-training quantization, delegates to Neutron NPU.
17+
- INT8_QAT_NEUTRON: quantization-aware training flow, delegates to Neutron NPU.
18+
- INT8_PTQ_NO_DELEGATE: PTQ without NPU delegation (useful for debugging or CPU-only deployment).
19+
"""
20+
21+
# INT8 static PTQ (weights + activations). Calibration dataset required.
22+
# Applicable operators are delegated to the Neutron NPU.
23+
INT8_PTQ_NEUTRON = "nxp_int8_ptq_neutron"
24+
25+
# INT8 QAT flow. Requires train_fn in NeutronRecipeConfig.
26+
# Applicable operators are delegated to the Neutron NPU.
27+
INT8_QAT_NEUTRON = "nxp_int8_qat_neutron"
28+
29+
# INT8 PTQ without NPU delegation. Produces a quantized graph that runs on CPU.
30+
# Useful for accuracy evaluation or debugging before enabling delegation.
31+
INT8_PTQ_NO_DELEGATE = "nxp_int8_ptq_no_delegate"
32+
33+
@classmethod
34+
def get_backend_name(cls) -> str:
35+
return NXP_BACKEND

0 commit comments

Comments
 (0)