From a8ceab087dc13aff25a8fdf25166ea9ccc2d07a6 Mon Sep 17 00:00:00 2001 From: RJ Ascani Date: Thu, 13 Aug 2026 14:49:11 -0700 Subject: [PATCH 1/2] Cortex-M: share one to_edge config instead of five hand-copied ones The to_edge configuration the Cortex-M backend needs was written out by hand in five places, and four of them were wrong in different ways: location linear hardsigmoid hardswish silu maxpool exc AOT compiler yes yes yes NO NO test harness yes yes yes yes yes docs overview yes yes yes NO yes examples/arduino yes NO NO NO yes examples/raspberry_pi/pico2 yes NO NO NO NO The missing silu is not a lost optimisation. Left to decompose, silu becomes sigmoid plus an elementwise mul, and because that happens in to_edge, after convert_pt2e, the resulting mul carries no input_qparams. AtenToCortexMPass then raises KeyError: 1 in _get_mul_replacement, so every model containing SiLU failed to compile through the compiler, through the documented recipe, and through both examples. The same model lowers to a single cortex_m.quantized_activation through the test harness, which is why this was invisible: the tests were exercising a different configuration from the one users are told to write. All five now come from cortex_m_edge_compile_config(), so they cannot drift again, and the documented recipe is a call rather than a list to copy incorrectly. The harness config was already the superset, so its behaviour is unchanged and the suite is unaffected: 606 passed. The two examples only ever exercised linear, so they gain the other entries without changing what they currently do. Nothing in CI drives the compiler's Cortex-M path, which is how the divergence survived. Sharing the constant removes this class of drift by construction, but that coverage gap is worth closing separately. Authored with assistance from Claude Code. --- backends/arm/scripts/aot_arm_compiler.py | 14 +++----- backends/cortex_m/BUCK | 12 +++++++ backends/cortex_m/edge_compile_config.py | 35 +++++++++++++++++++ backends/cortex_m/test/tester.py | 25 +++---------- .../arm-cortex-m/arm-cortex-m-overview.md | 22 +++++------- examples/arduino/export_model.py | 11 +++--- .../pico2/export_mlp_mnist_cmsis.py | 10 +++--- 7 files changed, 74 insertions(+), 55 deletions(-) create mode 100644 backends/cortex_m/edge_compile_config.py diff --git a/backends/arm/scripts/aot_arm_compiler.py b/backends/arm/scripts/aot_arm_compiler.py index 405b4a66efd..250606f9a4b 100644 --- a/backends/arm/scripts/aot_arm_compiler.py +++ b/backends/arm/scripts/aot_arm_compiler.py @@ -33,6 +33,9 @@ from executorch.backends.arm.util._factory import create_partitioner, create_quantizer from executorch.backends.arm.vgf import VgfCompileSpec +from executorch.backends.cortex_m.edge_compile_config import ( + cortex_m_edge_compile_config, +) from executorch.backends.cortex_m.passes.cortex_m_pass_manager import CortexMPassManager from executorch.backends.cortex_m.passes.replace_quant_nodes_pass import ( @@ -966,16 +969,7 @@ def _to_channels_last(x): edge = to_edge_transform_and_lower( exported_program, - compile_config=EdgeCompileConfig( - preserve_ops=[ - torch.ops.aten.linear.default, - torch.ops.aten.hardsigmoid.default, - torch.ops.aten.hardsigmoid_.default, - torch.ops.aten.hardswish.default, - torch.ops.aten.hardswish_.default, - ], - _check_ir_validity=False, - ), + compile_config=cortex_m_edge_compile_config(), ) pass_manager = CortexMPassManager( diff --git a/backends/cortex_m/BUCK b/backends/cortex_m/BUCK index 4e35bd0ef00..9302ec36b52 100644 --- a/backends/cortex_m/BUCK +++ b/backends/cortex_m/BUCK @@ -34,6 +34,18 @@ fbcode_target( ], ) +fbcode_target( + _kind = python_library, + name = "edge_compile_config", + srcs = [ + "edge_compile_config.py", + ], + deps = [ + "//caffe2:torch", + "//executorch/exir:lib", + ], +) + fbcode_target( _kind = python_library, name = "target_config", diff --git a/backends/cortex_m/edge_compile_config.py b/backends/cortex_m/edge_compile_config.py new file mode 100644 index 00000000000..f02df2d1712 --- /dev/null +++ b/backends/cortex_m/edge_compile_config.py @@ -0,0 +1,35 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import torch +from executorch.exir import EdgeCompileConfig + +# Ops that must survive to_edge for the Cortex-M passes to lower them directly. +# Left to decompose, silu becomes sigmoid plus an elementwise mul and hardswish +# becomes a clamp plus a mul, which costs an extra kernel and, for hardswish, +# quantizes the gate on the producer's grid instead of using the exact int8 LUT. +_PRESERVE_OPS = ( + torch.ops.aten.linear.default, + torch.ops.aten.hardsigmoid.default, + torch.ops.aten.hardsigmoid_.default, + torch.ops.aten.hardswish.default, + torch.ops.aten.hardswish_.default, + torch.ops.aten.silu.default, +) + + +def cortex_m_edge_compile_config() -> EdgeCompileConfig: + """The to_edge configuration the Cortex-M backend requires. + + Shared by the AOT compiler and the test harness so the two cannot drift: an + entry present in only one of them means the tests exercise a lowering users + never get, or the reverse. + """ + return EdgeCompileConfig( + preserve_ops=list(_PRESERVE_OPS), + _check_ir_validity=False, + _core_aten_ops_exception_list=[torch.ops.aten.max_pool2d.default], + ) diff --git a/backends/cortex_m/test/tester.py b/backends/cortex_m/test/tester.py index 1f7d7f3059d..beed6e3aed0 100644 --- a/backends/cortex_m/test/tester.py +++ b/backends/cortex_m/test/tester.py @@ -12,6 +12,9 @@ import torch from executorch.backends.arm.test.common import get_u55_compile_spec from executorch.backends.arm.test.tester.arm_tester import Serialize +from executorch.backends.cortex_m.edge_compile_config import ( + cortex_m_edge_compile_config, +) from executorch.backends.cortex_m.passes.cortex_m_pass_manager import CortexMPassManager from executorch.backends.cortex_m.quantizer.quantizer import CortexMQuantizer from executorch.backends.cortex_m.target_config import CortexM, CortexMTargetConfig @@ -24,7 +27,6 @@ ToEdge, ToExecutorch, ) -from executorch.exir import EdgeCompileConfig class CortexMQuantize(Quantize): @@ -35,26 +37,7 @@ def __init__(self, calibration_samples=None): class CortexMToEdge(ToEdge): def __init__(self): - config = EdgeCompileConfig( - preserve_ops=[ - torch.ops.aten.linear.default, - torch.ops.aten.hardsigmoid.default, - torch.ops.aten.hardsigmoid_.default, - torch.ops.aten.hardswish.default, - torch.ops.aten.hardswish_.default, - # silu naturally decomposes to sigmoid*x at the to_edge step. - # Preserve it so the LUT lowering can collapse it into a single - # cortex_m.quantized_activation call rather than emitting an - # extra elementwise mul. Set globally because no per-test - # opt-out exists today; any new cortex_m test that uses SiLU - # must therefore expect a single aten.silu op in the edge graph - # (not sigmoid+mul). - torch.ops.aten.silu.default, - ], - _check_ir_validity=False, - _core_aten_ops_exception_list=[torch.ops.aten.max_pool2d.default], - ) - super().__init__(config) + super().__init__(cortex_m_edge_compile_config()) class CortexMRunPasses(RunPasses): diff --git a/docs/source/backends/arm-cortex-m/arm-cortex-m-overview.md b/docs/source/backends/arm-cortex-m/arm-cortex-m-overview.md index bf4e41a73bf..0654e2b12b5 100644 --- a/docs/source/backends/arm-cortex-m/arm-cortex-m-overview.md +++ b/docs/source/backends/arm-cortex-m/arm-cortex-m-overview.md @@ -100,23 +100,19 @@ quantized_exported_program = torch.export.export(quantized, (example_input,)) ### 2. Lower to edge and apply Cortex-M passes -Lower to the edge dialect with a custom `EdgeCompileConfig`, then run the `CortexMPassManager` to replace quantized subgraphs with CMSIS-NN operator implementations: +Lower to the edge dialect with the backend's `EdgeCompileConfig`, then run the `CortexMPassManager` to replace quantized subgraphs with CMSIS-NN operator implementations: ```python -from executorch.exir import EdgeCompileConfig, ExecutorchBackendConfig, to_edge +from executorch.exir import ExecutorchBackendConfig, to_edge +from executorch.backends.cortex_m.edge_compile_config import ( + cortex_m_edge_compile_config, +) from executorch.backends.cortex_m.passes.cortex_m_pass_manager import CortexMPassManager -config = EdgeCompileConfig( - preserve_ops=[ - torch.ops.aten.linear.default, - torch.ops.aten.hardsigmoid.default, - torch.ops.aten.hardsigmoid_.default, - torch.ops.aten.hardswish.default, - torch.ops.aten.hardswish_.default, - ], - _check_ir_validity=False, - _core_aten_ops_exception_list=[torch.ops.aten.max_pool2d.default], -) +# Use the backend's own configuration rather than hand-writing one. Ops such as +# silu and hardswish must survive to_edge for the Cortex-M passes to lower them; +# omitting one does not degrade gracefully, it fails the AtenToCortexMPass. +config = cortex_m_edge_compile_config() edge_program_manager = to_edge(quantized_exported_program, compile_config=config) diff --git a/examples/arduino/export_model.py b/examples/arduino/export_model.py index a8966b8b8bd..688f58dfecc 100644 --- a/examples/arduino/export_model.py +++ b/examples/arduino/export_model.py @@ -24,6 +24,9 @@ import numpy as np import soundfile as sf import torch +from executorch.backends.cortex_m.edge_compile_config import ( + cortex_m_edge_compile_config, +) from executorch.backends.cortex_m.passes.cortex_m_pass_manager import CortexMPassManager from executorch.backends.cortex_m.quantizer.quantizer import CortexMQuantizer @@ -32,7 +35,7 @@ DuplicateDynamicQuantChainPass, ) from executorch.examples.models.mlperf_tiny.ds_cnn import DSCNNKWS -from executorch.exir import EdgeCompileConfig, to_edge +from executorch.exir import to_edge from pte_to_header import to_header from torch.export import export from torchao.quantization.pt2e.quantize_pt2e import convert_pt2e, prepare_pt2e @@ -155,11 +158,7 @@ def export_model( cpu = CortexM.M33 if "m33" in target else CortexM.M55 edge = to_edge( export(converted, (example,)), - compile_config=EdgeCompileConfig( - preserve_ops=[torch.ops.aten.linear.default], - _check_ir_validity=False, - _core_aten_ops_exception_list=[torch.ops.aten.max_pool2d.default], - ), + compile_config=cortex_m_edge_compile_config(), ) pm = CortexMPassManager( edge.exported_program(), diff --git a/examples/raspberry_pi/pico2/export_mlp_mnist_cmsis.py b/examples/raspberry_pi/pico2/export_mlp_mnist_cmsis.py index 4785598c876..9d24048fed6 100644 --- a/examples/raspberry_pi/pico2/export_mlp_mnist_cmsis.py +++ b/examples/raspberry_pi/pico2/export_mlp_mnist_cmsis.py @@ -23,11 +23,14 @@ import os import torch +from executorch.backends.cortex_m.edge_compile_config import ( + cortex_m_edge_compile_config, +) from executorch.backends.cortex_m.passes.cortex_m_pass_manager import CortexMPassManager from executorch.backends.cortex_m.quantizer.quantizer import CortexMQuantizer from executorch.backends.cortex_m.target_config import CortexM, CortexMTargetConfig -from executorch.exir import EdgeCompileConfig, ExecutorchBackendConfig, to_edge +from executorch.exir import ExecutorchBackendConfig, to_edge from executorch.extension.export_util.utils import save_pte_program from export_mlp_mnist import create_balanced_model, IMAGE_SIZE, test_comprehensive @@ -87,10 +90,7 @@ def quantize_model(model, calibration_data): def export_to_pte(quantized_model, example_input, output_path: str): exported_program = torch.export.export(quantized_model, (example_input,)) - edge_config = EdgeCompileConfig( - _check_ir_validity=False, - preserve_ops=[torch.ops.aten.linear.default], - ) + edge_config = cortex_m_edge_compile_config() edge_program = to_edge(exported_program, compile_config=edge_config) logger.info("Edge program created") From 422f0251be9fcf368b7073a365841b70a5f1642a Mon Sep 17 00:00:00 2001 From: RJ Ascani Date: Fri, 14 Aug 2026 10:31:57 -0700 Subject: [PATCH 2/2] Cortex-M: drop the inert exception list, fix the docs, cover the skill recipe Review follow-up on the shared to_edge config. _core_aten_ops_exception_list is removed rather than shared. It feeds the edge-dialect verifier, and _check_ir_validity=False disables that verifier, so on this path it has never done anything. It arrived that way: it was added in a00bad2e3a alongside s8 max_pool2d support, onto a config that already had validation switched off. Enabling validation is not a flag flip either, it fails 50 of the backend's tests with mismatched-dtype SpecViolationErrors, so the reason validation stays off is now recorded in the factory where the next reader will look, along with the note that max_pool2d would need an entry if that ever changes. The claim that omitting a preserve_ops entry fails the lowering was too broad. It depends on the op, because the decompositions happen in to_edge, after convert_pt2e, so the nodes they create were never annotated and carry no qparams. hardsigmoid and hardswish expand into clamp and div, and ActivationFusionPass raises KeyError: 0 reading output_qparams off the clamp. silu expands into sigmoid and mul, and AtenToCortexMPass raises KeyError: 1 reading the second operand's input_qparams. linear is the quiet case: it expands into addmm, no pass tries to claim it, and the graph silently stays on portable float kernels. The docs now say both outcomes. The Cortex-M skill doc was a sixth copy of the recipe, and the worst one: it passed no preserve_ops at all, so anyone following it got the float fallback for every op in the list. It now calls the factory. Two Cortex-M test files keep their own plain config on purpose. They exercise the portable int8 ops and the quant-node replacement pass in isolation, where preserving the backend's op set would change what they test. Authored with assistance from Claude Code. --- .claude/skills/cortex-m/SKILL.md | 7 +++++-- backends/cortex_m/edge_compile_config.py | 13 +++++++++---- .../backends/arm-cortex-m/arm-cortex-m-overview.md | 5 +++-- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.claude/skills/cortex-m/SKILL.md b/.claude/skills/cortex-m/SKILL.md index 0361b76a733..d8dcd2a9aff 100644 --- a/.claude/skills/cortex-m/SKILL.md +++ b/.claude/skills/cortex-m/SKILL.md @@ -16,9 +16,10 @@ Uses standard PT2E quantization (`prepare_pt2e` / `convert_pt2e`), then `CortexM ```python from executorch.backends.cortex_m.quantizer.quantizer import CortexMQuantizer from executorch.backends.cortex_m.passes.cortex_m_pass_manager import CortexMPassManager +from executorch.backends.cortex_m.edge_compile_config import cortex_m_edge_compile_config from torch.export import export from torchao.quantization.pt2e.quantize_pt2e import convert_pt2e, prepare_pt2e -from executorch.exir import to_edge_transform_and_lower, EdgeCompileConfig +from executorch.exir import to_edge_transform_and_lower quantizer = CortexMQuantizer() captured = export(model, example_inputs).module() @@ -27,9 +28,11 @@ prepared(*example_inputs) # calibration quantized = convert_pt2e(prepared) exported = export(quantized, example_inputs) +# The backend's own config: linear and the activations must survive to_edge or the +# lowering either fails or silently falls back to portable float kernels. edge = to_edge_transform_and_lower( exported, - compile_config=EdgeCompileConfig(_check_ir_validity=False), + compile_config=cortex_m_edge_compile_config(), ) edge._edge_programs["forward"] = CortexMPassManager( edge.exported_program(), CortexMPassManager.pass_list diff --git a/backends/cortex_m/edge_compile_config.py b/backends/cortex_m/edge_compile_config.py index f02df2d1712..c691e3cf97f 100644 --- a/backends/cortex_m/edge_compile_config.py +++ b/backends/cortex_m/edge_compile_config.py @@ -8,9 +8,9 @@ from executorch.exir import EdgeCompileConfig # Ops that must survive to_edge for the Cortex-M passes to lower them directly. -# Left to decompose, silu becomes sigmoid plus an elementwise mul and hardswish -# becomes a clamp plus a mul, which costs an extra kernel and, for hardswish, -# quantizes the gate on the producer's grid instead of using the exact int8 LUT. +# Omitting one does not degrade gracefully. The activations decompose into a +# multiply whose qparams are gone by then, which fails AtenToCortexMPass; linear +# decomposes into addmm and silently stays on portable float kernels. _PRESERVE_OPS = ( torch.ops.aten.linear.default, torch.ops.aten.hardsigmoid.default, @@ -27,9 +27,14 @@ def cortex_m_edge_compile_config() -> EdgeCompileConfig: Shared by the AOT compiler and the test harness so the two cannot drift: an entry present in only one of them means the tests exercise a lowering users never get, or the reverse. + + Edge-dialect validation is off because the backend's quantized graphs do not + pass it: enabling it fails the model tests with mismatched-dtype + SpecViolationErrors. That also makes a _core_aten_ops_exception_list pointless + here, since the verifier it feeds never runs. max_pool2d would need an entry if + validation is ever turned on. """ return EdgeCompileConfig( preserve_ops=list(_PRESERVE_OPS), _check_ir_validity=False, - _core_aten_ops_exception_list=[torch.ops.aten.max_pool2d.default], ) diff --git a/docs/source/backends/arm-cortex-m/arm-cortex-m-overview.md b/docs/source/backends/arm-cortex-m/arm-cortex-m-overview.md index 0654e2b12b5..0a6a250f968 100644 --- a/docs/source/backends/arm-cortex-m/arm-cortex-m-overview.md +++ b/docs/source/backends/arm-cortex-m/arm-cortex-m-overview.md @@ -110,8 +110,9 @@ from executorch.backends.cortex_m.edge_compile_config import ( from executorch.backends.cortex_m.passes.cortex_m_pass_manager import CortexMPassManager # Use the backend's own configuration rather than hand-writing one. Ops such as -# silu and hardswish must survive to_edge for the Cortex-M passes to lower them; -# omitting one does not degrade gracefully, it fails the AtenToCortexMPass. +# silu and hardswish must survive to_edge for the Cortex-M passes to lower them, +# and omitting one does not degrade gracefully: an activation fails the +# AtenToCortexMPass, and linear silently falls back to portable float kernels. config = cortex_m_edge_compile_config() edge_program_manager = to_edge(quantized_exported_program, compile_config=config)