Core AI model zoo

Compute units (ANE / GPU / CPU) & on-device authoring rules

Foundation note: the empirical do’s/don’ts for making a model run correctly + fast on each compute unit. The single most important framing: iOS/ANE = static-shape, BC1S, Conv2d, per-head, fp16-only; macOS/GPU = dynamic-shape, standard layout, fused, custom kernels. These are Apple’s two first-class modes. Sources: coreai-models/skills/.../model-authoring/references/{neural_engine_rules,gpu_rules,common_issues}.md, .../working-with-coreai/{SKILL.md,references/guidance.md}, and the official primitives coreai-models/python/src/coreai_models/primitives/{ios,macos}/ + export/{ios,macos}.py.

The three compute units

| | ANE (Neural Engine) | GPU | CPU (BNNS) | |—|—|—|—| | Best for | energy-efficient inference, fixed shapes, iOS foreground | large models, dynamic shapes, batch, max throughput | validation, fallback | | Shapes | fully static (one fn per shape config) | dynamic OK | any | | Layout | BC1S (B, C, 1, S) | standard (B,S,D) / (B,H,S,D) | any | | Projections | 1×1 Conv2d (Conv engine accumulates fp32) | nn.Linear, fused QKV | any | | Attention | per-head, sequential (no fused SDPA) | fused native SDPA (all heads) | either | | KV cache | readonly functional I/O (host writes), seq on dim 4 | stateful (mutable_slice_update), seq on dim 3 | — | | Custom MSL kernels | NO (fixed ops only) | YES (TorchMetalKernel) | no | | Precision | fp16 only (no fp32 literals/intermediates) | fp16 weights, fp32 intermediates OK | fp32/fp16 |

The “ANE can’t run custom MSL” row is why the GPU speed track exists — see custom-metal-kernels.md (project memory: project_ane_vs_gpu_premise).

ANE authoring rules (the high-leverage ones)

What following every ANE rule actually buys — measured, on a non-LLM encoder

The rules above are Apple’s. Until now nothing here recorded what happens when someone follows all of them on a model that is not an LLM. Rahul Rachuri re-authored the 600M Parakeet FastConformer encoder on the iOS/ANE track — BC1S between blocks, every nn.Linear a 1×1 Conv2d, per-head sequential attention, the mel image transposed so time is the width axis (the GPU track’s freq-last subsampling ends at width 16, under the 64 B granule), the two fp32 literals folded into weights, BatchNorm folded into the depthwise conv, and relative_k_proj(pos_emb) baked at build time since it depends only on constants. Same fp16 weights, no quantization, no approximation. It gates: eager fp32 per-token cos mean 1.000000 against the GPU track’s own re-author, engine cos 0.999833 on ANE against the fp32 oracle.

It does not buy throughput. iPhone 17 Pro, iOS 27, Release, AOT h18p, L=2885 fp16, warm median of 20 passes, runs interleaved (a locked screen caps the GPU, so order must not decide it):

encoder / unit warm median bundle load (cold / warm)
GPU-authored, gpu (what ships) 113.5 / 113.9 / 114.8 ms 1.1 GB 4.9 s / 1.3 s
GPU-authored, neural_engine 180.7 / 197.9 ms 2.3 GB 65.7 s / 2.2 s
ANE-authored, neural_engine 204.5 / 207.4 ms 2.2 GB 11.9 s / 0.59 s

Both ANE routes lose to the GPU one by ~1.7×, and the ANE-authored graph is not faster on the ANE than simply compiling the GPU-authored graph for it — the two ANE rows are within noise.

It buys load. Compiled with --preferred-compute neural-engine, the GPU-authored graph forms 49 ANE regions; the ANE-authored one forms 25. That halving does not show up as steady-state speed; it shows up as 65.7 s → 11.9 s cold and 2.2 s → 0.59 s warm, which for a 1 GB encoder is the difference between a cold start you can ship and one you cannot. Count regions with find <.aimodelc> -name '*ANE_region_*.mlir.bc' | wc -l.

Measure this on a phone, not a Mac: the same bundles JIT-loaded on an M4 Max give 49.2 ms on gpu against 183.9 ms ANE-authored on ane, a 3.7× gap against 1.8× on the phone. Harness: ENCBENCH_SELFTEST in apps/coreai-audio times any single-input .aimodelc and reports load, first call and warm median separately.

Why it buys load and not throughput: 25 regions are 25 submissions

Instruments ane-hw-intervals traces of the same encoder, one phone, same fp16 weights, same authoring — Core AI against a Core ML conversion of the same network, exported and measured by Rahul Rachuri, who published the finding himself (write-up, traces):

per encoder pass Core AI, ANE-authored Core ML, converted
ANE submissions 25.0 1.0
mean submission 5.906 ms 150.4 ms
ANE busy 147.6 ms 150.4 ms
median inter-submission gap 1.125 ms 0.569 ms
ANE idle across the timed window 28.7 % 0.4 %

Both routes give the ANE the same work — 147.6 ms against 150.4 ms, within 2 %. What differs is delivery. Core ML hands the hardware the whole graph as one job and holds 99.6 % residency. Core AI issues one submission per conformer layer and spends ~48 ms per pass in the round trips between them; that is where the idle 28.7 % goes. Rahul ties the gap size to the ~2.3 ms IOSurface round-trip reported in arXiv 2603.06728, paid 25 times instead of once.

The submission count is the region count. 25 regions measured statically in the bundle is exactly what the runtime issues at inference, which makes the 49 → 25 halving above one mechanism rather than two coincidences: half the regions to specialize is the load win, and 25 submissions instead of 1 is why there is no throughput win. It is also the ceiling on this track — an ANE authoring pass can remove regions, but nothing available to us fuses the graph into one submission.

OS control: 26A5388g and 26A5406e are identical here — 25.0 submissions per pass, 5.922 against 5.906 ms mean. Nothing in beta 5 fuses the graph. The exports carry interval timings and one model filename; the full .trace bundles are not published.

Never express a preference over a heterogeneous allowed set

SpecializationOptions(preferredComputeUnitKind: .cpu) and .gpu declare the same allowedComputeUnitKinds[cpu, gpu, neuralEngine] — and differ only in which unit is preferred. That preference is a partitioning input, and on some graphs it places a region on a different unit and silently changes the numbers. cpu_only() collapses the allowed set to one unit and is exact. It scales with fragmentation rather than model size, and the blast radius runs from rounding-scale to anti-correlated. Full evidence, from two unrelated models, in pocket-tts-port.md.

Two consequences: use cpu_only() for parity work, never preferred .cpu, and note that CoreAIKit’s GraphModel(computeUnits:) exposes only .neuralEngine / .gpu / .cpu, so cpuOnly is not currently expressible through the kit.

GPU authoring rules

macOS vs iOS export (the official split)

export/pipeline.py picks dynamic (macOS) vs static (iOS). | | iOS (export/ios.py) | macOS (export/macos.py) | |—|—|—| | Shapes | static buckets (query [8,16,64] × cache 256,512,1024,…) | dynamic torch.export.Dim | | KV | state_names + in_step data-tensor write + IOSurface/interleave | state_names + shape-symint offset | | Engine | CoreAIStaticShapeEngine (host owns KV NDArray, passes state views each step) | CoreAIPipelinedEngine (GPU) | | Target | Neural Engine | GPU |

Runtime compute-unit selection — auto-derived from STRUCTURE, preferred-not-forced, overridable

The official runtime does NOT hard-pin a compute unit; export/ios.py/export/macos.py bake none. Instead the Swift runtime probes the model’s structure and derives a preference (CoreAIShared/Runtime/ModelStructure.swift:57-66):

PreparedModel.prepare(at:)probeStructureAIModel(contentsOf: url, options:) (:137-141). Notes:

Verification gates (PSNR)

working-with-coreai/SKILL.md:94-99, guidance.md:145-153:

Localize divergence with REAL inputs — degenerate constant-input probes lie (they said an ANE chunk was exact when real inputs showed it diverged from layer 1). This project’s hardest-won ANE lesson.

Decision guidance