Skip to content

[CoreAI] Add multi-function export and shared runtime loading - #24476

Merged
metascroy merged 4 commits into
coreai-rename-packagingfrom
coreai-multifunction
Nov 4, 2026
Merged

metascroy merged 4 commits into
coreai-rename-packagingfrom
coreai-multifunction

Conversation

@metascroy

@metascroy metascroy commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Adds an opt-in way to export several ExecuTorch methods (and graph-broken partitions) as one Core AI model with multiple functions, so methods that share weights ship and cache them once, plus an opt-in runtime option so those delegates also share one loaded model in memory.

Stacked on #24474.

Usage

lowered = to_edge_transform_and_lower(
    {"forward": forward_ep, "prefill": prefill_ep},
    transform_passes=get_default_passes(),
    partitioner=[CoreAIPartitioner(multifunction=True)],
    compile_config=get_default_compile_config(),
)

Each Core AI partition becomes one function named <method>_<partition index>: here forward_0 and prefill_0, and a graph break adds forward_1, prefill_1, and so on. Graph breaks are allowed; partitions running on CPU stay in ExecuTorch as usual.

To also share one loaded model at runtime, set the backend option when loading:

BackendOptions<2> options;
options.set_option("coreai_share_multifunction_model", true);
LoadBackendOptionsMap map;
map.set_options("CoreAIBackend", options.view());
module.load(map);

How it works

  • Export: CoreAIBackend.preprocess_multimethod receives every method's partitions at once.
    • It stages all of them in one TorchConverter (one entrypoint per partition), calls to_coreai() once, and saves (or AOT-compiles with coreai-build) once.
    • The bundle is embedded in the NamedDataStore once, under a single export_hash.
    • Each delegate still gets its own manifest naming its function, input_names and output_names; all manifests point at the same bundle.
  • Cache: function bindings aren't part of the cache key, so all the delegates share one cache entry: the first load specializes and the others restore from the same bookmark.
  • Manifest: multi-function manifests carry "multifunction": true (optional boolean; absent means false).
  • Runtime sharing (coreai_share_multifunction_model, bool, default false):
    • Without it, each delegate restores its own model with its own copy of the weights, as before.
    • With it, the first delegate acquires the model and the others reuse it from a process-wide table until the last delegate is destroyed.
    • Loads are tracked per key. Delegates that start loading the same model at once wait for that one load, while loads of other models or cache directories proceed in parallel. If the load fails, it leaves no entry and one of the waiters retries.
    • Sharing requires the same cache directory (identified by the open directory's device and inode, so a symlink counts as the same directory) and the same cache key, which includes the compute-unit specialization. Different cache directories or compute units load separate models.
    • Delegates sharing a model run one at a time through a per-model lock. The SDK doesn't document concurrent execution on one model as safe, and without the lock coreai_multithread_test produced wrong outputs.
    • Setting the option on a delegate that isn't from a multi-function export fails init with InvalidArgument.
  • Option off: lowering is unchanged. preprocess and the multi-method path now share the conversion and delivery helpers, and the existing tests still pass.
  • Errors: export fails with a clear message if partitions have different compile specs (including the option set on only some methods), or if input_enumerations is combined with the option. Enumerated shapes rename functions internally and need their own design.

Results

Two-method model (8 × Linear(1024, 1024), fp32; forward with 1 row, prefill with 16 rows), run on an M4 Max with a standalone C++ runner:

Variant PTE size Cold load Cache entries Outputs
multifunction=True 33.6 MB 1 specialization + 1 bookmark restore 1 match eager
Default (one model per delegate) 67.2 MB 2 specializations 2 match eager
multifunction=True + graph break (4 functions) 33.6 MB 1 specialization + 3 bookmark restores 1 match eager

Shared weights are stored once: in a direct converter check, adding a second function to an 8.4 MB model added 2.9 KB.

Runtime sharing, measured as resident memory after one run per delegate, for K delegates of the same 33.6 MB model:

K delegates Option off coreai_share_multifunction_model=true
1 +91 MB +91 MB
2 +157 MB +92 MB
4 +288 MB +92 MB
8 +551 MB +92 MB

With sharing on, 2, 4 and 8 threads × 50 concurrent runs produced exact outputs, in about the same total time as unshared on this model.

Test plan

  • New backends/apple/coreai/test/test_multifunction.py:
    • Partitioner option and compile spec; rejects input_enumerations.
    • Two methods share one bundle (export_hash, path, files, data-store keys) with forward_0/prefill_0, and the combined bundle is about the size of a single method's.
    • A graph break yields forward_0, forward_1, prefill_0, prefill_1 in one bundle.
    • With the option off, there's one main model per delegate (as today).
    • Mismatched compile specs, and the option set on only some methods, raise errors.
    • AOT with a mocked coreai-build (one build, shared archs) and with the real coreai-build.
    • Runtime: loads every method from one PTE through the Python runtime and compares to eager, with and without a graph break.
  • Runtime sharing (native):
    • ParsesOptionalMultifunctionFlag: flag absent, true, false, and non-boolean values.
    • ShareOptionRequiresMultifunctionExport, OptionsRejectWrongTypes.
    • ShareOptionSharesOneModelUntilLastDelegate, SharedModelsAreScopedToCacheDirectory (separate directories don't share; a symlink alias does).
    • SharedModelSerializesConcurrentExecution, ConcurrentFirstSharedLoadsAcquireOnce, FailedSharedAcquisitionLeavesNoEntry.
    • SharedLoadsOfDifferentModelsRunConcurrently, WaiterAcquiresAfterConcurrentSharedLoadFails.
    • The concurrency and directory tests each fail when the lock, per-key tracking or directory scoping they cover is removed.
    • coreai_multithread_test adds cold and warm rounds on a multi-function stress fixture with sharing on (real SDK).
  • All 151 Core AI Python tests pass. All 6 native CTest targets pass.
  • Backend suite operators: coreai_fp32 377 passed, coreai_fp16 368 passed, 0 failures.
  • ufmt and flake8 are clean.

@metascroy
metascroy requested a review from shoumikhin as a code owner November 3, 2026 00:39
@pytorch-bot

pytorch-bot Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/24476

Note: Links to docs will display an error until the docs builds have been completed.

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Nov 3, 2026
@metascroy metascroy changed the title [CoreAI] Add multi-function export across methods and partitions [CoreAI] Add multi-function export and shared runtime loading Nov 3, 2026
@metascroy
metascroy force-pushed the coreai-multifunction branch from d07d65a to f67bf14 Compare November 3, 2026 18:59
@metascroy
metascroy force-pushed the coreai-rename-packaging branch from cfdb000 to 73ded2d Compare November 3, 2026 18:59
CoreAIPartitioner(multifunction=True) lowers every Core AI partition of every
method into one Core AI model with one function per partition, named
<method>_<partition index> (forward_0, prefill_0, and forward_1 after a graph
break). CoreAIBackend.preprocess_multimethod converts all partitions with one
TorchConverter, saves or AOT-compiles once, embeds the bundle once, and writes
one manifest per delegate naming its function and ordered I/O.

Weights shared between methods are stored once, and because function bindings
are not part of the runtime cache key, the delegates share one cache entry:
the first load specializes and the rest restore from the same bookmark. No
runtime or manifest format change is needed.

Every partition must carry identical compile specs, and input_enumerations is
not supported with the option yet. Without the option, lowering is unchanged.
Delegates of a multi-function export reference one Core AI bundle, but each
delegate still loaded its own model and its own copy of the weights. The new
boolean backend option coreai_share_multifunction_model (default false) makes
them share one loaded model per process: the first delegate acquires it and
the others reuse it until the last one is destroyed.

Multi-function manifests now carry "multifunction": true so the runtime can
tell them apart. Setting the option on any other delegate fails init with
InvalidArgument.

Sharing is scoped to the cache directory (identified by the open directory's
device and inode, so symlinks count as the same directory) and to the cache
key, which already includes the compute-unit specialization. Different cache
directories or compute units load separate models.

Delegates sharing a model run one at a time through a per-model lock. The SDK
does not document concurrent execution on one model as safe, and without the
lock coreai_multithread_test produced wrong outputs.

Init now makes one acquire call and every handle holds an ETCoreAILoadedModel;
the three runtime options share one runtime_option helper.
acquire_loaded_model held one process-wide mutex through acquire_cached_model,
which can run a full specialization, so a cold shared load of one model blocked
shared loads of every other model and cache directory.

Shared loads now record in-flight keys: a load of a key already being acquired
waits for that acquisition, while other keys load in parallel. A failed
acquisition leaves no entry and wakes its waiters, one of which then acquires
the model itself. Non-shared loads are unchanged.
@metascroy
metascroy force-pushed the coreai-rename-packaging branch from 5d03055 to 884d214 Compare November 4, 2026 00:11
@metascroy
metascroy force-pushed the coreai-multifunction branch from 2b55aa0 to 216529d Compare November 4, 2026 00:11
metascroy added a commit that referenced this pull request Nov 4, 2026
…24525)

Makes Core AI exports that use `input_enumerations` load and run,
including with multi-function export.

Stacked on #24522.

## Problem

Every enumerated-shape PTE failed at `load_method`:

```
Core AI execution error: Model has no function named 'main'   (0x23)
```

`AIProgram.set_static_shape_config` erases the delegate's dynamic `main`
and replaces it with one static clone per shape (`main_s31_4`,
`main_s31_16`, ...). The manifest still named `main`, and the runtime
binds exactly that name. On the real SDK, a specialized model exposes
only the clones, each with a static input descriptor.

## Export

An enumerated delegate's manifest lists its per-shape functions instead
of one `function`:

```json
"enumerated_functions": [
  {"function": "main_s31_4",  "input_shapes": [[4, 8]]},
  {"function": "main_s31_16", "input_shapes": [[16, 8]]},
  {"function": "main_s31_32", "input_shapes": [[32, 8]]}
]
```

`input_shapes` gives every input's shape in `input_names` order, the
same order ExecuTorch passes arguments in. Delegates without
enumerations keep `function: "main"`.

If an enumerated value makes a derived boundary dim zero or negative
(`s0 - 1` at `s0=1`), export fails with an error naming the input, the
dim and the value.

## Runtime

- **Manifest parsing:** a manifest must have exactly one of `function`
and `enumerated_functions`. Enumerated entries must have distinct names,
one shape per input, positive integer dims, and no two entries with the
same shapes. Enumerated delegates carry an explicit `enumerated` flag.
- **Init:** binds one session per listed function. Any bind failure
fails init.
- **Execute:** validates the arguments once, then picks the function
whose shapes equal the inputs. This happens inside the per-model
execution lock. Inputs that match none fail with `InvalidArgument`
before anything runs, and the log names the shapes received.
- **Sharing:** all functions come from one model, so they share weights,
one specialization and one cache entry.
- **Cost:** the number of functions is the product of the enumerated
value counts. On an M4 Max, a warm load took 7 ms with 1 function, 41 ms
with 16 and 248 ms with 64 (documented in the README).

## Multi-function export

`CoreAIPartitioner(multifunction=True)` accepts `input_enumerations`.
Give each method its own partitioner to enumerate only some methods, or
different values per method:

```python
partitioner={
    "decode":  [CoreAIPartitioner(multifunction=True)],
    "prefill": [CoreAIPartitioner(multifunction=True, input_enumerations=[{0: [64, 128]}])],
}
```

- **Naming:** each enumerated partition expands into its own functions
in the one shared model, `prefill_0_<key>`, plus `prefill_1_<key>` past
a graph break. Unenumerated partitions stay single functions
(`decode_0`).
- **Binding order:** every partition is bound before any is specialized,
because specializing a partition erases its function.
- **Compile specs:** `input_enumerations` may differ between methods.
Every other compile spec (AOT config, deployment floor) must match.
- **Errors:** export fails with a clear error when some methods omit
`multifunction=True`, naming those methods, and when two delegates would
generate the same function name.
- **Runtime sharing:** works with `coreai_share_multifunction_model`;
every delegate and per-shape function shares one loaded model.

## Shared-load robustness

This PR also tightens #24476's per-key shared loading:
- A scope guard clears a load's in-flight key, publishes the model and
wakes waiters on every exit, so a key can't stay marked as loading.
- `WaiterAcquiresAfterConcurrentSharedLoadFails` confirms the second
load is actually waiting before the first one fails. It uses a test-only
`BeforeSharedLoadWait` fault point.

## Test plan

**Python.** All 171 CoreAI tests pass.
- `test_shape_config.py`: `test_manifest_lists_one_function_per_shape`,
`test_unenumerated_lowering_keeps_main`,
`test_derived_dim_that_reaches_zero_is_rejected`. The real-runtime test
`test_runs_each_enumerated_shape_and_rejects_others` runs each shape and
checks rejection.
- `test_multifunction.py`:
- `test_enumerated_method_lists_per_shape_functions`,
`test_graph_break_enumerates_every_partition`.
- `test_other_compile_specs_must_still_match`,
`test_mixed_partitioners_name_the_missing_flag`,
`test_duplicate_function_names_are_rejected`.
- The real-runtime test `test_enumerated_shapes_dispatch_by_input_shape`
covers multi-method with a graph break.

**Native.** All 6 CTest targets pass.
- `ParsesEnumeratedFunctions`, `RejectsInvalidEnumeratedFunctions` (16
malformed cases, plus both or neither field present).
- `EnumeratedFunctionsDispatchByInputShape`: checks which function ran,
the resized output, and that an unmatched shape runs nothing.
- `EnumeratedFunctionBindFailureFailsInit`.
- The sharing tests, including
`WaiterAcquiresAfterConcurrentSharedLoadFails`, passed in 5 of 5 runs.

**Real SDK, M4 Max:**
- **Single method:** rows 4/16/32 match eager (max error ≤1.8e-7) on
cold and warm loads. Rows 8 is rejected with `InvalidArgument`.
- **Multi-method, C++ runner:** a static `forward` plus an enumerated
`prefill`, with a graph break. That's 4 delegates and 8 functions in one
model. With sharing off and on, cold and warm, 100 of 100 calls pass in
each configuration (worst max error 1.4e-6), and an unmatched shape is
rejected with `0x12`.
- **AOT (`aimodelc`, `h16c`):** the enumerated functions compile and run
(max error ≤1.8e-7).

## Compatibility

PTEs exported with `input_enumerations` before this change never loaded,
so nothing that worked before breaks. Every other export keeps the
`function` manifest field.
Adds a `coreai_expect_frequent_reshapes` runtime option that exposes the
Core AI SDK's `SpecializationOptions.expectFrequentReshapes`.

Stacked on #24476.

## Usage

```cpp
BackendOptions<2> options;
options.set_option("coreai_expect_frequent_reshapes", true);
options.set_option("coreai_compute_unit", "prefer_gpu");   // optional; combines with it
LoadBackendOptionsMap map;
map.set_options("CoreAIBackend", options.view());
module.load(map);
```

Bool, default `false`. Set it for models whose input shapes change
between calls. For AOT exports, the matching export-time setting is
`AOTCompileConfig(expect_frequent_reshapes=True)`, which already
existed.

## Why this option

Checked against the Xcode 27 SDK by type-checking each member through
`import CoreAI`. Of `SpecializationOptions`, the public surface is
`.default`, `.cpuOnly`, `init(preferredComputeUnitKind:)`, read-only
`allowedComputeUnitKinds` / `preferredComputeUnitKind`, and read/write
`expectFrequentReshapes`. Everything except `expectFrequentReshapes` is
already covered by `coreai_compute_unit`. The framework exports other
symbols (`.interpreterOnly`,
`init(allowedComputeUnitKinds:preferredComputeUnitKind:)`,
`enableEncodingFunctions`, ...), but none of them are usable from
`import CoreAI`.

The SDK has no doc comment for `expectFrequentReshapes`. The README
describes it by its name only.

## How it works

* `CoreAIBackend::init` reads the option with the shared
`runtime_option` helper. A non-boolean value fails init with
`InvalidArgument`.
* `ETCoreAICreateModelLoader` takes the flag next to the compute unit.
The Swift loader sets `expectFrequentReshapes` on the
`SpecializationOptions` chosen for that compute unit.
* No cache-key change is needed. The specialization identifier already
encodes `expectFrequentReshapes`, so each value gets its own
specialization, bookmark and shared model
(`coreai_share_multifunction_model` only shares between delegates that
agree on it).
* Cache eviction creates its loader with the flag off, since eviction
targets the bookmark and the specialization doesn't matter.

## Test plan

* `CoreAIDelegateTest.ExpectFrequentReshapesOptionReachesLoader`: absent
/ `false` / `true` reach the loader factory, alongside
`coreai_compute_unit`.
* `CoreAIDelegateTest.OptionsRejectWrongTypes`: an integer value fails
init.
* `SwiftBridgeTests.testComputeUnitsMapToSDKSpecializationOptions`:
every compute unit, with and without reshapes, maps to the expected
`SpecializationOptions`, and all 10 combinations have distinct
identifiers.
* Real SDK (M4 Max, multi-function PTE with sharing on): with the
option, the model specializes under its own cache key, a warm load
restores it, and outputs match eager. Without it, the default key is
used. That gives 2 bookmarks in one cache directory.
* All 6 native CTest targets pass.
@metascroy
metascroy merged commit 0508853 into coreai-rename-packaging Nov 4, 2026
111 of 112 checks passed
@metascroy
metascroy deleted the coreai-multifunction branch November 4, 2026 19:35

This branch was previously deployed

1 inactive deployment
cadence — 211bc992 Deployed Nov 4, 2026 by metascroy via hifi-op-test / hifi4 #36614
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants