|
| 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 |
0 commit comments