Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .ci/scripts/test_cortex_m_e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ MODEL=$1
script_dir=$(realpath "$(dirname "${BASH_SOURCE[0]}")")
et_root_dir=$(realpath "${script_dir}/../..")

# Quantization is the default for the cortex-m55+int8 target; run.sh's
# Quantization is the default for the cortex-m55 target; run.sh's
# arg parser only recognizes --no_quantize, so we omit any explicit flag.
bash "${et_root_dir}/examples/arm/run.sh" \
--model_name="${MODEL}" \
--target=cortex-m55+int8 \
--target=cortex-m55 \
--bundleio
30 changes: 24 additions & 6 deletions backends/arm/scripts/aot_arm_compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
ReplaceQuantNodesPass,
)
from executorch.backends.cortex_m.quantizer.quantizer import CortexMQuantizer
from executorch.backends.cortex_m.target_config import CortexMTargetConfig
from executorch.devtools import BundledProgram, generate_etrecord
from executorch.devtools.backend_debug import get_delegation_info
from executorch.devtools.bundled_program.config import MethodTestCase, MethodTestSuite
Expand Down Expand Up @@ -465,7 +466,16 @@ def forward(self, x):
"TOSA-1.0+INT",
"TOSA-1.0+FP",
"TOSA-1.0+INT+int16",
"cortex-m55+int8",
"cortex-m0",
"cortex-m0plus",
"cortex-m3",
"cortex-m4",
"cortex-m7",
"cortex-m23",
"cortex-m33",
"cortex-m35p",
"cortex-m55",
"cortex-m85",
]


Expand Down Expand Up @@ -566,7 +576,7 @@ def _get_args():
required=False,
default="ethos-u55-128",
choices=TARGETS,
help=f"Target backend. For delegated models: Ethos-U/VGF/TOSA variants. For non-delegated: cortex-m55+int8 (CMSIS-NN portable kernels). Valid targets: {TARGETS}",
help=f"Target backend. For delegated models: Ethos-U/VGF/TOSA variants. For non-delegated: cortex-m<variant> (CMSIS-NN portable kernels). Valid targets: {TARGETS}",
)
# TODO: Remove --evaluate and --evaluate_config completely after a suitable time.
# They are deprecated and no longer functional in this script.
Expand Down Expand Up @@ -860,9 +870,13 @@ def _to_edge_cortex_m(
model: GraphModule,
example_inputs: Tuple[torch.Tensor],
calibration_samples: Optional[List[Tuple[torch.Tensor, ...]]],
target_config: CortexMTargetConfig,
):
"""Cortex-M/CMSIS-NN compilation path with no delegation."""
logging.info("Using Cortex-M/CMSIS-NN compilation path (no delegation)")
logging.info(
f"Using Cortex-M/CMSIS-NN compilation path for cpu={target_config.cpu.name} "
f"backend={target_config.backend.name}"
)

def _to_channels_last(x):
if isinstance(x, torch.Tensor):
Expand Down Expand Up @@ -915,7 +929,9 @@ def _to_channels_last(x):
),
)

pass_manager = CortexMPassManager(edge.exported_program())
pass_manager = CortexMPassManager(
edge.exported_program(), target_config=target_config
)
edge._edge_programs["forward"] = pass_manager.transform()

return model_quant, edge
Expand Down Expand Up @@ -1007,11 +1023,12 @@ def main() -> None: # noqa: C901
else:
quant_mode = None

if args.target == "cortex-m55+int8":
if args.target.startswith("cortex-m"):
# Cortex-M path: CMSIS-NN portable kernels, no delegation
target_config = CortexMTargetConfig.from_target_string(args.target)
if args.delegate:
logging.warning(
"--delegate is ignored for target 'cortex-m55+int8' "
f"--delegate is ignored for target {args.target!r} "
"(this target does not use delegated ops)."
)
args.delegate = False
Expand All @@ -1021,6 +1038,7 @@ def main() -> None: # noqa: C901
model,
example_inputs,
calibration_samples,
target_config,
)
elif args.delegate:
# As we can target multiple output encodings, one must
Expand Down
1 change: 1 addition & 0 deletions backends/cortex_m/passes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ def _ensure_cortex_m_dependencies() -> None:
from .activation_fusion_pass import ActivationFusionPass # noqa
from .clamp_hardswish_pass import ClampHardswishPass # noqa
from .convert_to_cortex_m_pass import ConvertToCortexMPass # noqa
from .cortex_m_pass import CortexMPass # noqa
from .decompose_hardswish_pass import DecomposeHardswishPass # noqa
from .decompose_mean_pass import DecomposeMeanPass # noqa
from .quantized_clamp_activation_pass import QuantizedClampActivationPass # noqa
Expand Down
35 changes: 35 additions & 0 deletions backends/cortex_m/passes/cortex_m_pass.py
Original file line number Diff line number Diff line change
@@ -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.

from executorch.backends.cortex_m.target_config import CortexMTargetConfig
from executorch.exir.pass_base import ExportPass
from torch.export import ExportedProgram


class CortexMPass(ExportPass):
"""Base class for passes that need the Cortex-M target config.

Passes that subclass this declare `exported_program` and `target_config`
in their `__init__`; `CortexMPassManager.transform()` injects both
automatically when running the pass list.
"""

def __init__(
self,
exported_program: ExportedProgram,
target_config: CortexMTargetConfig,
) -> None:
super().__init__()
self._exported_program = exported_program
self._target_config = target_config

@property
def exported_program(self) -> ExportedProgram:
return self._exported_program

@property
def target_config(self) -> CortexMTargetConfig:
return self._target_config
60 changes: 45 additions & 15 deletions backends/cortex_m/passes/cortex_m_pass_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@


import inspect
from typing import Callable, cast, Optional, Type
from typing import Any, Optional, Type

from executorch.backends.arm._passes import (
FoldAndAnnotateQParamsPass,
ScalarsToAttributePass,
)
from executorch.backends.cortex_m.target_config import CortexM, CortexMTargetConfig
from executorch.backends.transforms.remove_getitem_op import RemoveGetItemPass
from executorch.backends.transforms.replace_scalar_with_tensor import (
ReplaceScalarWithTensorArgPass,
Expand All @@ -19,9 +20,6 @@
from executorch.exir.pass_manager import PassManager
from executorch.exir.program._program import _transform, lift_constant_tensor_pass
from torch.export import ExportedProgram
from torch.fx.passes.infra.pass_base import PassResult

from torch.nn import Module

from .activation_fusion_pass import ActivationFusionPass
from .clamp_hardswish_pass import ClampHardswishPass
Expand Down Expand Up @@ -57,14 +55,33 @@ class CortexMPassManager(PassManager):
]

def __init__(
self, exported_program, passes: Optional[list[PassClass]] = None
self,
exported_program: ExportedProgram | None,
passes: Optional[list[PassClass]] = None,
target_config: Optional[CortexMTargetConfig] = None,
) -> None:
"""Initialize the Cortex-M pass manager.

Args:
exported_program: The exported program to transform. Required
before calling ``transform()``; may be ``None`` for callers
that only use ``transform_for_annotation()``.
passes: Optional override of the pass list. Defaults to
``CortexMPassManager.pass_list``.
target_config: Compilation target for passes that need it.
Defaults to ``CortexMTargetConfig(cpu=CortexM.M55)``, which
resolves through cmsis_nn to the MVE backend — matching the
pre-config historical behaviour.
"""
super().__init__(passes=[])
self.exported_program = exported_program
# PassManager.passes is typed as callables; this manager stores pass classes which are initialized at transform time with the exported_program.
self.passes: list[PassClass] = ( # type: ignore[assignment]
passes if passes is not None else self.pass_list # type: ignore[assignment]
)
self.target_config: CortexMTargetConfig = target_config or CortexMTargetConfig(
cpu=CortexM.M55
)

def transform_for_annotation(self, model):
passes = self.pass_list_transform_for_annotation
Expand All @@ -73,18 +90,31 @@ def transform_for_annotation(self, model):
return model

def transform(self) -> ExportedProgram:
ep = self.exported_program
exported_program = self.exported_program
if not isinstance(exported_program, ExportedProgram):
raise ValueError(
f"{type(self).__name__}.transform() needs a real ExportedProgram, "
f"got {exported_program!r}"
)

for pass_cls in self.passes:
if not isinstance(pass_cls, type):
raise ValueError(
f"{type(self).__name__} expects pass classes, not instances; "
f"got {pass_cls!r}"
)

signature = inspect.signature(pass_cls)
kwargs: dict[str, Any] = {}
if "exported_program" in signature.parameters:
ep_pass_ctor = cast(Callable[[ExportedProgram], ExportPass], pass_cls)
transform_pass = ep_pass_ctor(ep)
else:
transform_pass = pass_cls()
pass_callable = cast(Callable[[Module], PassResult], transform_pass)
ep = _transform(ep, pass_callable)
kwargs["exported_program"] = exported_program
if "target_config" in signature.parameters:
kwargs["target_config"] = self.target_config

transform_pass = pass_cls(**kwargs)
exported_program = _transform(exported_program, transform_pass)

# All constant tensors should be lifted to buffers at this point, re-run
# lift_constant_tensor_pass in case new ones have been introduced by the passes above.
ep = lift_constant_tensor_pass(ep)
return ep
# lift_constant_tensor_pass in case new ones have been introduced.
exported_program = lift_constant_tensor_pass(exported_program)
return exported_program
110 changes: 110 additions & 0 deletions backends/cortex_m/target_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# 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.

from __future__ import annotations

from dataclasses import dataclass
from enum import auto, Enum
from typing import Optional

import cmsis_nn # type: ignore[import-not-found, import-untyped]


class CortexM(Enum):
"""Cortex-M CPU variant. Names mirror cmsis_nn.CortexM so the cmsis_nn
enum can be looked up by name."""

M0 = auto()
M0PLUS = auto()
M3 = auto()
M4 = auto()
M7 = auto()
M23 = auto()
M33 = auto()
M35P = auto()
M55 = auto()
M85 = auto()


# Per-CPU set of cmsis_nn backends the core can execute. SCALAR is
# universal; DSP requires the Armv7E-M or Armv8-M-Mainline DSP option;
# MVE requires Armv8.1-M Mainline with the MVE extension. The supersession
# (SCALAR < DSP < MVE) reflects that an MVE-capable core also runs DSP
# and scalar code, which is what makes "M55 without MVE" → DSP override
# legitimate.
_SUPPORTED_BACKENDS: dict[CortexM, frozenset[cmsis_nn.Backend]] = {
CortexM.M0: frozenset({cmsis_nn.Backend.SCALAR}),
CortexM.M0PLUS: frozenset({cmsis_nn.Backend.SCALAR}),
CortexM.M3: frozenset({cmsis_nn.Backend.SCALAR}),
CortexM.M23: frozenset({cmsis_nn.Backend.SCALAR}),
CortexM.M4: frozenset({cmsis_nn.Backend.SCALAR, cmsis_nn.Backend.DSP}),
CortexM.M7: frozenset({cmsis_nn.Backend.SCALAR, cmsis_nn.Backend.DSP}),
CortexM.M33: frozenset({cmsis_nn.Backend.SCALAR, cmsis_nn.Backend.DSP}),
CortexM.M35P: frozenset({cmsis_nn.Backend.SCALAR, cmsis_nn.Backend.DSP}),
CortexM.M55: frozenset(
{cmsis_nn.Backend.SCALAR, cmsis_nn.Backend.DSP, cmsis_nn.Backend.MVE}
),
CortexM.M85: frozenset(
{cmsis_nn.Backend.SCALAR, cmsis_nn.Backend.DSP, cmsis_nn.Backend.MVE}
),
}


@dataclass(frozen=True)
class CortexMTargetConfig:
"""AOT compile target configuration for the Cortex-M backend.

`cpu` selects the CPU variant. `isa` optionally overrides the cmsis_nn
backend that would normally be derived from `cpu` — useful for cores
with optional ISA extensions (M55 without MVE, M33 without DSP, etc.).
Overrides are validated against the CPU's architectural capability set
on construction; e.g. forcing MVE on an M0 raises ValueError.
"""

cpu: CortexM
isa: Optional[cmsis_nn.Backend] = None

def __post_init__(self) -> None:
if self.isa is None:
return
supported = _SUPPORTED_BACKENDS.get(self.cpu)
if supported is None or self.isa not in supported:
allowed = sorted(b.name for b in supported) if supported else []
raise ValueError(
f"Backend {self.isa.name} is not supported on "
f"{self.cpu.name}; supported: {allowed}"
)

@property
def backend(self) -> cmsis_nn.Backend:
if self.isa is not None:
return self.isa
try:
cmsis_member = getattr(cmsis_nn.CortexM, self.cpu.name)
except AttributeError as e:
raise ValueError(
f"cmsis_nn does not yet support {self.cpu.name}; pass an "
f"explicit `isa=` override or wait for upstream support."
) from e
return cmsis_nn.resolve_backend(cmsis_member)

@classmethod
def from_target_string(cls, target: str) -> CortexMTargetConfig:
"""Parse a `cortex-m<variant>` target string."""
if not target.startswith("cortex-m"):
raise ValueError(
f"Cortex-M target string must start with 'cortex-m', "
f"got: {target!r}"
)
enum_name = "M" + target[len("cortex-m") :].upper()
try:
cpu = CortexM[enum_name]
except KeyError as e:
raise ValueError(
f"Unsupported Cortex-M target string: {target!r}. "
f"Supported: {sorted('cortex-m' + m.name[1:].lower() for m in CortexM)}"
) from e
return cls(cpu=cpu)
Loading
Loading