From 53629b5f1dd03595f7e972110b8bc35b6e55b13f Mon Sep 17 00:00:00 2001 From: Scott Roy Date: Thu, 30 Jul 2026 16:58:46 -0700 Subject: [PATCH 1/2] [ExecuTorch][Core AI] Dev tooling: test runner + code-writing skill --- .../apple/coreai/.llms/skills/code-writing.md | 54 +++++++++++++++++++ backends/apple/coreai/run_all_tests.sh | 27 ++++++++++ 2 files changed, 81 insertions(+) create mode 100644 backends/apple/coreai/.llms/skills/code-writing.md create mode 100755 backends/apple/coreai/run_all_tests.sh diff --git a/backends/apple/coreai/.llms/skills/code-writing.md b/backends/apple/coreai/.llms/skills/code-writing.md new file mode 100644 index 00000000000..fabf66b7bec --- /dev/null +++ b/backends/apple/coreai/.llms/skills/code-writing.md @@ -0,0 +1,54 @@ +--- +name: coreai-code-writing +description: >- + Conventions for writing code in the Core AI ExecuTorch backend + (backends/apple/coreai): comment style (clear, concise, self-documenting) and + how to run the test suite. Apply when editing or adding code/tests here. +--- + +# Core AI backend: code-writing conventions + +## Comments and docstrings + +Write self-documenting code first; reach for a comment only when the code cannot +speak for itself. + +- **Explain WHY, not WHAT.** Well-named identifiers already say what the code + does. Comment only non-obvious reasons: a hidden constraint, a subtle + invariant, a workaround, or behavior that would surprise a reader. +- **Be clear and concise.** One short line is usually enough. Prefer a plain + sentence over a paragraph. Delete a comment if removing it wouldn't confuse a + future reader. +- **No LLMisms.** Do not use em-dashes (`—`) or ` -- ` as sentence separators; + use periods, commas, semicolons, or parentheses. Do not use decorative dash + banners (`# --- section ---`); a plain `# Section.` line is enough. Avoid + markdown emphasis (`*x*`, `**x**`) and arrows (`=>`) in prose. +- **Don't restate the task or the diff.** No "added for X", "used by Y", or + "handles the case from T123"; that belongs in the diff description and rots. +- **Keep comments true.** When you change code, update or delete any comment it + affects rather than leaving a stale claim. + +Legitimate non-prose dashes are fine: CLI flags (`--platform`, +`--min-deployment-version`) and shell/code tokens. + +## Running the tests + +Run the whole backend suite (both `test/` and `passes/test/`) with the helper +script, from inside the Core AI conda env: + +```bash +conda run -n coreai backends/apple/coreai/run_all_tests.sh +``` + +Extra args are forwarded to `unittest`, e.g. `... run_all_tests.sh -v` or +`... run_all_tests.sh -k sidecar`. + +**Use this script (unittest), not `pytest` directly.** pytest's default +discovery puts `backends/apple/` on `sys.path`, so `import coreai` resolves to +this backend directory and shadows the real Apple `coreai` SDK (`coreai_torch` +then fails importing `coreai.authoring`). unittest imports via the full +`executorch.backends.apple.coreai.*` path and avoids the shadowing. + +The real-toolchain AOT test (`CoreAIAOTCompileTest`) is gated on +`xcrun coreai-build`; it runs on macOS with the Metal Toolchain and skips +elsewhere. The mocked AOT tests always run. diff --git a/backends/apple/coreai/run_all_tests.sh b/backends/apple/coreai/run_all_tests.sh new file mode 100755 index 00000000000..c7dd1b59a62 --- /dev/null +++ b/backends/apple/coreai/run_all_tests.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Dev convenience: run all Core AI backend tests (test/ + passes/test/). +# +# WIP/under-construction backend -- a local helper, not a landing artifact. +# +# Uses unittest (NOT pytest): pytest's default discovery puts backends/apple/ on +# sys.path, so `import coreai` resolves to THIS backend dir and shadows the real +# Apple `coreai` SDK (coreai_torch then fails to import coreai.authoring). +# unittest imports via the full executorch.backends.apple.coreai.* path, so the +# SDK is not shadowed. +# +# Run inside the Core AI conda env, e.g.: +# conda run -n coreai backends/apple/coreai/run_all_tests.sh +# Extra args are forwarded to unittest, e.g. `... run_all_tests.sh -v`. +set -euo pipefail + +cd "$(sl root 2>/dev/null || git rev-parse --show-toplevel)" + +# Discover every test_*.py under the backend and turn it into a dotted module +# name (executorch.backends.apple.coreai...). +modules=$( + find backends/apple/coreai -name 'test_*.py' \ + | sed -E 's#^#executorch.#; s#/#.#g; s#\.py$##' \ + | sort +) + +exec python -m unittest ${modules} "$@" From a8fbce4a36b41cb347c9ebe8a7babb69ee4b35d6 Mon Sep 17 00:00:00 2001 From: Scott Roy Date: Thu, 30 Jul 2026 17:06:30 -0700 Subject: [PATCH 2/2] [ExecuTorch][Core AI] Graph pass: int64/float64 dtype narrowing --- backends/apple/coreai/passes/__init__.py | 11 ++ backends/apple/coreai/passes/narrow_dtypes.py | 104 ++++++++++++++++++ backends/apple/coreai/passes/test/__init__.py | 5 + .../coreai/passes/test/test_narrow_dtypes.py | 95 ++++++++++++++++ 4 files changed, 215 insertions(+) create mode 100644 backends/apple/coreai/passes/__init__.py create mode 100644 backends/apple/coreai/passes/narrow_dtypes.py create mode 100644 backends/apple/coreai/passes/test/__init__.py create mode 100644 backends/apple/coreai/passes/test/test_narrow_dtypes.py diff --git a/backends/apple/coreai/passes/__init__.py b/backends/apple/coreai/passes/__init__.py new file mode 100644 index 00000000000..e1355736369 --- /dev/null +++ b/backends/apple/coreai/passes/__init__.py @@ -0,0 +1,11 @@ +# 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.apple.coreai.passes.narrow_dtypes import ( + NarrowToCoreAIDtypesPass, +) + +__all__ = ["NarrowToCoreAIDtypesPass"] diff --git a/backends/apple/coreai/passes/narrow_dtypes.py b/backends/apple/coreai/passes/narrow_dtypes.py new file mode 100644 index 00000000000..6bdd88cc885 --- /dev/null +++ b/backends/apple/coreai/passes/narrow_dtypes.py @@ -0,0 +1,104 @@ +# 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. + +"""Boundary dtype-narrowing pass for the Core AI backend.""" + +import torch +from executorch.exir.dialects.edge._ops import EdgeOpOverload +from executorch.exir.pass_base import PassResult +from torch.fx import GraphModule +from torch.fx.passes.fake_tensor_prop import FakeTensorProp + +# Core AI represents only up-to-32-bit dtypes (coreai-torch narrows int64->si32 +# and float64->f32 internally), so it can't carry a 64-bit tensor across the +# delegate boundary. +_NARROW = {torch.int64: torch.int32, torch.float64: torch.float32} +_SIXTY_FOUR_BIT = (torch.int64, torch.float64) + + +class NarrowToCoreAIDtypesPass: + """Narrow int64/float64 to 32-bit inside the graph while preserving I/O. + + Casts int64/float64 graph inputs to 32-bit right after the placeholder, + re-propagates dtypes, then casts 32-bit values feeding int64/float64 outputs + back to 64-bit. The model's external input/output dtypes stay unchanged, + while the interior (what Core AI sees) becomes 32-bit. + + The inserted ``_to_copy`` casts keep a 64-bit operand/result, so + :class:`CoreAIPartitioner` (which rejects nodes with int64/float64 tensors) + leaves them outside the delegate; the delegate boundary then sees only 32-bit + tensors that match what coreai declares. + + Runs pre-partition (e.g. via ``get_default_passes``) at either the ATen or + edge level (it picks the matching ``_to_copy`` overload). + """ + + def __call__(self, graph_module: GraphModule) -> PassResult: + graph = graph_module.graph + placeholders = [n for n in graph.nodes if n.op == "placeholder"] + if not any(_is_64bit(p.meta.get("val")) for p in placeholders): + return PassResult(graph_module, False) + + uses_edge = any( + isinstance(node.target, EdgeOpOverload) + for node in graph.nodes + if node.op == "call_function" + ) + if uses_edge: + from executorch.exir.dialects._ops import ops as exir_ops + + to_copy = exir_ops.edge.aten._to_copy.default + else: + to_copy = torch.ops.aten._to_copy.default + + output_node = next(n for n in graph.nodes if n.op == "output") + # Remember which outputs were 64-bit so we can restore them afterwards. + orig_output_dtypes = [ + val.dtype if _is_64bit(val := _node_val(a)) else None + for a in output_node.args[0] + ] + + # 1. Narrow 64-bit inputs right after the placeholder. + for placeholder in placeholders: + val = placeholder.meta.get("val") + if not _is_64bit(val): + continue + narrow_dtype = _NARROW[val.dtype] + with graph.inserting_after(placeholder): + cast = graph.call_function( + to_copy, (placeholder,), {"dtype": narrow_dtype} + ) + placeholder.replace_all_uses_with(cast) + cast.args = (placeholder,) # restore the cast's own (detached) input + cast.meta["val"] = val.to(narrow_dtype) + + # 2. Re-propagate dtypes through the (now-32-bit) interior. + FakeTensorProp(graph_module).propagate(*[p.meta["val"] for p in placeholders]) + + # 3. Widen 32-bit values feeding originally-64-bit outputs back. + out_args = list(output_node.args[0]) + for i, arg in enumerate(out_args): + orig_dtype = orig_output_dtypes[i] + if orig_dtype is None or not isinstance(arg, torch.fx.Node): + continue + cur = _node_val(arg) + if isinstance(cur, torch.Tensor) and cur.dtype != orig_dtype: + with graph.inserting_before(output_node): + widen = graph.call_function(to_copy, (arg,), {"dtype": orig_dtype}) + widen.meta["val"] = cur.to(orig_dtype) + out_args[i] = widen + output_node.args = (tuple(out_args),) + + graph_module.recompile() + return PassResult(graph_module, True) + + +def _node_val(arg): + return arg.meta.get("val") if isinstance(arg, torch.fx.Node) else None + + +def _is_64bit(val) -> bool: + return isinstance(val, torch.Tensor) and val.dtype in _SIXTY_FOUR_BIT diff --git a/backends/apple/coreai/passes/test/__init__.py b/backends/apple/coreai/passes/test/__init__.py new file mode 100644 index 00000000000..2e41cd717f6 --- /dev/null +++ b/backends/apple/coreai/passes/test/__init__.py @@ -0,0 +1,5 @@ +# 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. diff --git a/backends/apple/coreai/passes/test/test_narrow_dtypes.py b/backends/apple/coreai/passes/test/test_narrow_dtypes.py new file mode 100644 index 00000000000..d9d295ec0b6 --- /dev/null +++ b/backends/apple/coreai/passes/test/test_narrow_dtypes.py @@ -0,0 +1,95 @@ +# 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. + +"""Unit tests for :class:`NarrowToCoreAIDtypesPass`.""" + +import unittest + +import torch +import torch.nn as nn + +from executorch.backends.apple.coreai.passes.narrow_dtypes import ( + NarrowToCoreAIDtypesPass, +) +from executorch.exir import to_edge + + +def _edge_gm(module, example_inputs): + ep = torch.export.export(module.eval(), example_inputs) + return to_edge(ep).exported_program().graph_module + + +def _placeholders(gm): + return [n for n in gm.graph.nodes if n.op == "placeholder"] + + +def _copy_nodes(gm): + return [ + n + for n in gm.graph.nodes + if n.op == "call_function" and "_to_copy" in str(n.target) + ] + + +def _dtype(node): + val = node.meta.get("val") + return getattr(val, "dtype", None) + + +class _Add(nn.Module): + def forward(self, x): + return x + x + + +class NarrowToCoreAIDtypesPassTest(unittest.TestCase): + def test_int64_boundary_narrowed_and_widened(self): + # x + x with an int64 input: output dtype follows the input, so the pass + # must narrow the input to i32 and widen the output back to i64. + gm = _edge_gm(_Add(), (torch.randint(-8, 8, (2, 8), dtype=torch.int64),)) + before = len(_copy_nodes(gm)) + result = NarrowToCoreAIDtypesPass()(gm) + self.assertTrue(result.modified) + + # External input placeholder dtype is unchanged. + self.assertEqual(_dtype(_placeholders(gm)[0]), torch.int64) + # Boundary casts were inserted (input narrow + output widen). + cast_dtypes = {_dtype(c) for c in _copy_nodes(gm)} + self.assertGreater(len(_copy_nodes(gm)), before) + self.assertIn(torch.int32, cast_dtypes) # input narrowed to i32 + self.assertIn(torch.int64, cast_dtypes) # output widened back to i64 + # External output dtype preserved as int64. + out = gm.graph.output_node().args[0][0] + self.assertEqual(_dtype(out), torch.int64) + + def test_interior_is_narrowed_to_32bit(self): + gm = _edge_gm(_Add(), (torch.randint(-8, 8, (2, 8), dtype=torch.int64),)) + NarrowToCoreAIDtypesPass()(gm) + add = next( + n + for n in gm.graph.nodes + if n.op == "call_function" and "add" in str(n.target) + ) + self.assertEqual(_dtype(add), torch.int32) + + def test_float64_narrowed_io_preserved(self): + gm = _edge_gm(_Add(), (torch.randn(2, 8, dtype=torch.float64),)) + result = NarrowToCoreAIDtypesPass()(gm) + self.assertTrue(result.modified) + self.assertEqual(_dtype(_placeholders(gm)[0]), torch.float64) # I/O kept + cast_dtypes = {_dtype(c) for c in _copy_nodes(gm)} + self.assertIn(torch.float32, cast_dtypes) + self.assertIn(torch.float64, cast_dtypes) + + def test_noop_when_no_64bit(self): + gm = _edge_gm(_Add(), (torch.randn(2, 8),)) # float32 only + before = len(_copy_nodes(gm)) + result = NarrowToCoreAIDtypesPass()(gm) + self.assertFalse(result.modified) + self.assertEqual(len(_copy_nodes(gm)), before) # nothing inserted + + +if __name__ == "__main__": + unittest.main()