AIModel, InferenceFunction, NDArray, and the memory model¶
Part 7 · Core AI: the Swift runtime · Reference 01
Requirements: Core AI starts at OS 27.0 on iOS, iPadOS, macOS, Mac Catalyst, tvOS, visionOS, and watchOS. Build with Xcode 27 and install its Metal Toolchain component. There is no Core AI framework in the 26.x SDKs.
Inspection on 2026-10-07 of Xcode 27 final (27A266a) still finds Core AI in the device SDK and absent from the iPhone Simulator SDK. Gate shared targets with #if canImport(CoreAI) and use Mac or device checks for Core AI behavior. The dated beta probe below remains its original runtime provenance.
⚠️ No Simulator destination in this beta. ✅ Probe-verified, 2026-07-31 (
probes/package, Xcode 27.0 beta):CoreAI.frameworkand all six of its SubFrameworks are present iniPhoneOS27.0.sdkand ABSENT fromiPhoneSimulator27.0.sdk—canImport(CoreAI)is false for a simulator build, so Core AI code cannot even compile for that destination, let alone run. Gate any shared target with#if canImport(CoreAI)and plan on device (or Mac) testing only. Three APIs are narrower than the framework per the doc pages —NDArray.RawView.init(metalBuffer:…),InferenceFunction.AsyncValue.init(unsafeBuffer:…)andComputeStream.init(commandQueue:)drop watchOS — and are marked as such where they appear. (⚠️ The captured macOS 27.0 beta SDK interface declares all threewatchOS 27.0; see §16.3 for the discrepancy.) Build with Xcode 27, and install the Metal Toolchain (see §0.3) or your first build with a.aimodelin it will fail.
What this covers¶
Load a .aimodel, specialize it into an AIModel, obtain an InferenceFunction, and supply NDArray values. The sections below cover descriptor-driven IO, borrowed views and ownership, image inputs, output buffers, concurrency, and errors.
.aimodel ──specialize──▶ AIModel ──loadFunction──▶ InferenceFunction ──run──▶ NDArray
(portable source) (device-specific) (one compute graph) (your data)
What this does not cover¶
Related references: Part 3, Part 8, Part 9, Part 10.
What you need¶
- Xcode 27 and the Metal Toolchain component (§0.3). Without it, any target containing a
.aimodelfails to build with a missing-Metal-compiler error. - A
.aimodelto point at. If you don't have one, the fastest source is Apple'sapple/coreai-modelsrepository, which ships export recipes for ~22 model families. - A real device for anything you intend to trust. Specialization output is tied to the exact device and OS version.
- No prior Core ML knowledge. Core AI is not Core ML and shares no API surface with it. Apple explicitly routes decision trees and tabular feature engineering back to Core ML; Core AI is for neural networks.
⚠️ Read this before you trust a single signature below¶
Use the SDK declarations for API spelling and the pinned apple/coreai-models source for package behavior. Reconstructed examples remain labelled. Some documentation examples need corrections (§16.2).
Recorded native checks cover their named fixtures and profiles, not every API or device. See the shared evidence conventions.
Contents¶
- 0. Orientation: the pipeline, the file, the toolchain
- 1. The five types, and what each one owns
- 2.
.aimodelis a portable source representation - 3.
AIModel: why the initializer isasync - 4.
loadFunctionvsfunctionDescriptor:nilandthrowsmean different things - 5.
InferenceFunctionisSendable— and what that costs - 6. Runtime introspection: descriptors all the way down
- 7.
NDArrayand non-escapable types - 8. Writing inputs
- 9. Reading outputs:
InferenceValueand the take-once bag - 10. States and pre-allocated outputs:
MutableViews - 11. The three low-level performance APIs
- 12. Image-typed values, and whose problem orientation is
- 13. ✅ The error-type answer, and how to write a
catchblock - 14. A complete runner you can paste
- 15. Quick reference
- 16. Sources and evidence ledger
0. Orientation: the pipeline, the file, the toolchain¶
0.1 The pipeline, end to end¶
PyTorch model
│
│ coreai-torch (TorchConverter → AIProgram → save_asset)
▼
.aimodel ← portable SOURCE. A directory. Runs nowhere yet.
│
│ optional: xcrun coreai-build compile (on your Mac, at build time)
▼
.aimodelc (one per device architecture) ← still not executable; just pre-compiled
│
│ SPECIALIZATION (on the user's device, once, cached)
▼
specialized asset in AIModelCache
│
│ AIModel(contentsOf:options:) ← async, because of the step above
▼
AIModel ──loadFunction(named:)──▶ InferenceFunction ──run(inputs:states:outputViews:)──▶ Outputs
Everything from AIModel rightward is this guide. Everything to the left is Parts 8–10.
0.2 The import, and the default function name¶
✅ VERIFIED — Apple's article "Integrating on-device AI models in your app with Core AI" (
/documentation/coreai/integrating-on-device-ai-models-in-your-app-with-core-ai), verbatim:
The module is CoreAI (framework identifier CoreAI, documentation slug coreai). The
conventional entry-point function name is "main" — that is coreai-torch's
entrypoint_name default, it is what the Instruments template calls out as "the default function
name", and it is the constant Apple's own Swift package hardcodes:
✅ VERIFIED —
apple/coreai-models,swift/Sources/CoreAIShared/Runtime/ModelStructure.swift:13-20:public enum GraphNames { public static let main = "main" public static let loadEmbeddings = "load_embeddings" public static let extendPrefix = "extend" // Multi-function segmenter (lite SAM3 export for iOS). public static let imageEncode = "image_encode" public static let textEncode = "text_encode" public static let detect = "detect" }
Note the snake_case. The WWDC26 session 326 narration says "imageEncode"; the actual graph name is
image_encode. Spoken camel-case in a transcript is a presenter reading a screen, not an API name.
0.3 The build-time footgun you will hit first¶
Before any of the code below runs, your project has to build, and the very first build of a target
containing a .aimodel fails on a stock Xcode install.
⚠️ The Metal Toolchain is not installed by default.
✅ VERIFIED — Apple's integration article, verbatim IMPORTANT callout: "If the Metal toolchain isn't included, builds that include
.aimodelfiles fail with a missing Metal compiler error."Two fixes, both from the same page: - Xcode ▸ Settings ▸ Components ▸ Other Components ▸ Get next to Metal Toolchain; or - select any
.aimodelin your project and click Get in the download bar that appears.From CI or a script:
Also verified on the same page: after you add the file, "you should also see the model in the Compile Sources build phase for that target." If it landed in Copy Bundle Resources instead, move it.
A second, quieter one, if you plan to use the debug gauge later:
✅ VERIFIED — "Monitoring model performance with the debug gauge", verbatim NOTE: "The gauge only appears in projects that link the Core AI framework. The gauge does not support the Core ML framework." Transitive linkage through a Swift package is not enough — the framework must appear in General ▸ Frameworks, Libraries, and Embedded Content for the target you run.
1. The five types, and what each one owns¶
Session 324 introduces three types. There are really five you need on day one, and the thing that makes the API make sense is asking what owns memory.
| Type | Kind | Owns | Lifetime you should give it |
|---|---|---|---|
AIModel |
struct, Sendable |
Nothing heavy. A handle onto a cache entry. | One per model, for the app's life |
InferenceFunction |
struct, Sendable |
Weights and intermediate buffers. | One per function, for the feature's life |
NDArray |
struct, Sendable, Escapable |
Its own backing storage | Per call, or pre-allocated and reused |
NDArray.View<T> / MutableView<T> |
non-escapable view | Nothing — borrows the NDArray |
The narrowest scope that compiles |
InferenceValue |
struct |
An NDArray or a pixel buffer |
Taken out of Outputs, consumed once |
The AIModel-owns-nothing point is stated outright by Apple and it is the single most useful fact
for structuring an app:
✅ VERIFIED —
/documentation/coreai/aimodel, NOTE, verbatim: "The model instance is lightweight and doesn't own weights or intermediate buffers. Those resources belong to the functions you load from it."
and, from the same page's overview:
✅ VERIFIED: "An
AIModelrepresents a specialized.aimodelasset, optimized for the current device's hardware." … "UsefunctionDescriptor(for:)to inspect a function's inputs and outputs, then load anInferenceFunctionto run inference."
Contrast that with InferenceFunction:
✅ VERIFIED —
/documentation/coreai/inferencefunction, overview, verbatim: "AnInferenceFunctionowns the resources needed for inference, including model weights and intermediate buffers. You load a function from anAIModeland callrun(inputs:states:outputViews:)to perform inference."
So the memory question — "when does this model actually cost me a gigabyte?" — has a precise
answer: when you call loadFunction, not when you construct the AIModel. That is also why
AIModel pins a cache entry (it is a handle to one) while holding it costs almost nothing, and
why the presenter's advice about when to construct these objects is what it is:
✅ VERIFIED — WWDC26 session 324, lines 71–77, verbatim: "An AIModel is initialized from a URL to a
.aimodelfile and is used primarily to inspect and load one or more inference functions. An InferenceFunction is the runnable object which represents a single loaded compute graph. In the common case, your AIModel will only have a single main InferenceFunction, though you can convert a single model with multiple functions. The AIModel and InferenceFunction are typically objects you'll construct when preparing your app's AI feature. For example this could be on app initialization."
1.1 There is a sixth type, and it is the one people miss¶
AIModelAsset is listed under "Essentials" alongside AIModel and never appears in a single Apple
code sample. It is the unspecialized view of the same file:
✅ VERIFIED —
/documentation/coreai/aimodelasset, verbatim: "An unspecialized source model asset." … "Use a model asset to inspect a model's structure and metadata without specializing it for a specific device. This lets you query model information without performing specialization, which is an expensive operation." … "UnlikeAIModel, a model asset can't perform inference."init(contentsOf url: URL) throws // note: NOT async — no specialization static func isValid(at url: URL) -> Bool var metadata: AIModelAsset.Metadata { get } func summary(includingStatistics: Bool) throws -> AIModelAsset.Summary? let url: URL mutating func updateMetadata(_ updates: (inout AIModelAsset.Metadata) throws -> Void) throws mutating func removeDerivedArtifacts() throws
The throws-not-async throws initializer is the tell: no specialization happens, so there is
nothing to await. And this is not a theoretical API — it is how Apple's own package decides how
to specialize before it specializes:
✅ VERIFIED —
apple/coreai-models,PreparedModel.prepare(at:):
That two-phase load — probe cheaply, then specialize with the right options — is the single most reusable pattern in Apple's Core AI Swift code, and §4.3 shows why the options it picks matter so much.
⚠️ Pass
includingStatistics: falseunless you actually want the numbers. Apple: "Including model statistics is considerably slower for large models." Withfalseyou get "only version information and function signatures" — which is exactly what a structure probe needs.
2. .aimodel is a portable source representation¶
Everything about the loading API follows from one sentence, said the same way in the transcript and in the docs.
✅ VERIFIED — WWDC26 session 324, lines 141–147, verbatim: "One thing that was glossed over in the snake game implementation is the process of model specialization. When you ship an AI model with your app, that is a source representation of the model, which can be run on any Apple device. However, to actually load and run the model within your app, it must be specialized for the device that the app is running on. When your model is loaded it is checked to see if it has already been specialized and cached. The specialization process can take a significant amount of time for very large models. While future loads are from the cache and fast, that first time is something you may need to plan for. It is recommended you avoid having model specialization occur within user interactive flows."
✅ VERIFIED — "Managing model specialization and caching", verbatim: "The
.aimodelfile contains your model in a portable format that works across Apple devices. Before the model can run, Core AI specializes it for the current device, producing executable code tied to that device's hardware and OS version."
Three consequences that will bite you if you skip them:
(a) A .aimodel is not a program. It is closer to LLVM IR than to a .dylib. Nothing in it
runs until specialization produces device-specific executable artifacts. This is why there is no
"just mmap and go" path, and why the initializer is async (§3).
(b) A .aimodel is a directory, not a file.
✅ VERIFIED —
apple/coreai-torchdocs,construct-a-graph.ipynb:164: "AIProgram.save_asset(path)writes the program out as an.aimodeldirectory", and:192: "An.aimodelis a directory." Corroborated on the Swift side:AIModelAsset(contentsOf:)'s parameter doc says "the URL of an.aimodelbundle on disk", and Apple's Python export pipeline callsshutil.rmtree(aimodel_path)when overwriting.
Finder and Xcode present it as a bundle, so you may never notice — until you write a file-size calculation, a copy, or a download-and-unpack path. Apple's own helper is recursive for exactly this reason:
✅ VERIFIED —
apple/coreai-models,CoreAIShared/Runtime/FileSize.swift:17:extension URL { public func recursiveFileSizeInBytes() -> Int? }, doc comment: "This reflects bytes on disk, not resident memory."
(c) Specialized output dies on every OS update, unconditionally.
✅ VERIFIED —
AIModelCache.Policyoverview NOTE, verbatim: "Regardless of policy, the system always purges assets when the OS updates, as specialized assets are OS-version specific."
So "the model is already prepared" is never a permanent state. Design the first-run experience you
will need after every point release. The specialization-and-caching guide in this part covers the
scheduling; what matters here is only that it explains the async.
2.1 The one place .aimodel and .aimodelc differ at the call site: nowhere¶
Ahead-of-time compilation produces a second extension, and the loading code is identical.
✅ VERIFIED — "Compiling Core AI models ahead of time", verbatim: "To load the downloaded
.aimodelcasset, useinit(contentsOf:options:). This is the same API you use to load.aimodelfiles, so you don't need to change your loading code when you adopt ahead-of-time compilation." And: "init(contentsOf modelURL: URL, …)— modelURL: The URL of a.aimodelor.aimodelcfile."⚠️ AOT does not remove specialization. Apple, verbatim: "Even with ahead-of-time compilation, the compiled asset still requires some specialization on the device." Nor does
AIModel.specialize(…)reduce work: "Withspecialize, the full specialization process runs on the person's device. You are controlling when specialization happens, not reducing the work it does." Two different levers, frequently confused.
3. AIModel: why the initializer is async¶
3.1 The full type¶
✅ VERIFIED —
/documentation/coreai/aimodel.struct AIModel, conforming toSendable,SendableMetatype.// Creating a model init(contentsOf modelURL: URL, options: SpecializationOptions = .default) async throws init?(resolvingBookmark bookmark: Data) throws // Loading inference functions func loadFunction(named functionName: String) throws -> InferenceFunction? func functionDescriptor(for functionName: String) -> InferenceFunctionDescriptor? var functionNames: [String] { get } // Specializing a model @discardableResult static func specialize(contentsOf modelURL: URL, options: SpecializationOptions = .default, cache: AIModelCache = .default, cachePolicy: AIModelCache.Policy = .default) async throws -> AIModel // Inspecting a model var bookmarkData: Data { get } static var deviceArchitectureName: String { get }
Eight members. That is the whole type. Note what is absent: there is no run, no predict, no
input dictionary. AIModel cannot perform inference; it is a directory of functions.
3.2 The async is not about I/O¶
The reason is stated once, plainly, and it is worth internalising because it changes how you structure app startup:
✅ VERIFIED — Apple's integration article, verbatim: "Core AI specializes the model for the current device, considering all available compute units and selecting the combination that delivers the best performance.
init(contentsOf:options:)is asynchronous because specialization needs to complete before a validAIModelis returned. Depending on the model size, specialization can take a significant amount of time."
Read that again: the await is not hiding a disk read. It is hiding a compiler. Apple splits
specialization into two phases:
✅ VERIFIED — session 324, lines 162–173: "During specialization, the model goes through two main transformations. First, it goes through a core set of compilation steps which segment, plan and optimize compute. Second, executable artifacts are generated for the compute units used. These artifacts are tied to the device and OS version they were generated on. Of these two steps, compilation is the one which incurs most of the latency."
Session 326 repeats the same two-phase description almost word for word, which is a good sign that it is the team's settled framing rather than one presenter's simplification.
What that means for your code. A try await AIModel(contentsOf:) on a cold cache is not a
"maybe 100 ms" await. Apple's own Instruments documentation shows a specialize event of roughly
800 ms in a screenshot; session 326's demo shows the same thing as a visible UI stall that the
presenter treats as a bug in her app, not in the framework.
Numbers, attributed. Apple-published, from screenshots in "Analyzing model runtime performance with Instruments": a
Compile Asset, Specializeevent spanning roughly 00:13.000 → 00:13.800 (~800 ms) with a nestedCompile segmentsub-event;Load model::main10.54 µs;Setup for model::main66.96 µs with a nestedContext.alloc22.83 µs. Hardware tracks in the same trace are labelledGPU (M3 Max), so read these as an unnamed model on an M3 Max Mac, Apple's own capture, no model or build identified. They are useful for shape — specialization is milliseconds-to-seconds, load and setup are microseconds — and useless as a budget for your model.
3.3 The load ladder, in the order you should write it¶
Putting §1.1 and §3.2 together gives a four-step ladder. Each step is cheap and answers a question before you pay for the next one.
import CoreAI
import Foundation
// Step 0 — is this even a model asset? Cheap, synchronous, no throw.
guard AIModelAsset.isValid(at: modelURL) else { … }
// Step 1 — inspect WITHOUT specializing. Throws; does not await.
let asset = try AIModelAsset(contentsOf: modelURL)
let summary = try asset.summary(includingStatistics: false) // -> AIModelAsset.Summary?
// Step 2 — has this (URL, options) pair already been specialized? Never specializes.
if let model = try AIModelCache.default.model(for: modelURL, options: options) {
return model // fast path: no await needed
}
// Step 3 — the expensive one. Show UI first.
let model = try await AIModel(contentsOf: modelURL, options: options)
✅ VERIFIED —
AIModelAsset.isValid(at:)discussion, verbatim: "This checks that: the URL is a file URL; the extension is one of the known model asset extensions; the model contains either a source program or a derived artifact."✅ VERIFIED —
AIModelCache.model(for:options:)discussion, verbatim: "If this cache holds a specialized asset from previously specializing the model atmodelURLwith the specifiedoptions, this method loads and returns the model. This method never performs specialization." Session 324, line 151: "If nil is returned, it is not present and requires specialization. You can use this to gate features or inform the users that they may need to wait a bit while your app prepares the model."
Apple ships this exact pattern as a code sample:
✅ VERIFIED — "Managing model specialization and caching", verbatim:
func loadModel(from modelURL: URL) async throws -> AIModel { // The default cache stores all specialized assets for your app bundle. let cache = AIModelCache.default // A non-`nil` result means the model was previously specialized and cached. if let model = try cache.model(for: modelURL, options: .default) { return model } // No cached specialization exists. Inform the person and specialize now. Task { @MainActor in informUser("Preparing AI features. This may take a while…") } // This call performs specialization, caches the result, and returns the model. return try await AIModel(contentsOf: modelURL, options: .default) }⚠️ The cache key includes the options.
SpecializationOptionsisHashable, and Apple states the key explicitly: "Each cache entry contains a specialized asset formed from a specific.aimodelor.aimodelcandSpecializationOptionscombination." If step 2 checks.defaultand step 3 specializes withSpecializationOptions(preferredComputeUnitKind: .gpu), you will miss the cache forever and re-specialize on every launch — silently, because both calls succeed. Thread oneoptionsvalue through both, as the ladder above does.
3.4 AIModel is Sendable, and that is not free advice¶
AIModel conforms to Sendable and SendableMetatype. You can store one in an actor, pass it
across task boundaries, and hold it in a @MainActor view model without ceremony. Since it owns no
weights (§1), holding several is cheap — but each live AIModel pins its cache entry:
✅ VERIFIED —
AIModel.bookmarkDataNOTE, verbatim: "Bookmark data is just data. It does not pin entries in the cache. Only aAIModelwill pin its associated entry in the cache while it is held."
Which produces a contradiction in Apple's own documentation that you should know about before you write a cache-eviction path:
⚠️ Documentation conflict — deletion while an
AIModelis alive.The reference pages for all four
AIModelCachedelete APIs say, verbatim: "For each entry, if noAIModelinstance currently references it, deletion happens immediately. Otherwise, an error is thrown. Deletion can only occur for an entry when the lastAIModelreleases it."The prose article says, verbatim: "If an
AIModelinstance still uses a cache entry, Core AI defers deletion until that instance is deallocated."Those are different contracts. The iOS 27 beta build
24A5408druntime follows the reference pages: on an iPhone 15 Pro,deleteEntries(for:)threw while a liveAIModelretained the entry, the entry remained findable, and deletion succeeded after the model was released. The observed dynamic error wasAIModelCacheError.failedToPurge, but that type is absent from the public beta interface, so keep a genericcatch; see §13. Release, delete, and verify the cache lookup.
4. loadFunction vs functionDescriptor: nil and throws mean different things¶
4.1 The distinction, stated by Apple¶
func loadFunction(named functionName: String) throws -> InferenceFunction?
func functionDescriptor(for functionName: String) -> InferenceFunctionDescriptor?
var functionNames: [String] { get }
Look at those three signatures side by side. functionDescriptor(for:) does not throw — it
only answers "does a function with this name exist, and what is its signature". loadFunction
both throws and returns an Optional, and the two channels carry different information:
✅ VERIFIED — Apple's integration article, verbatim: "Call
loadFunction(named:)to get anInferenceFunctionfor running the model with your inputs and receiving its outputs. Loading a function prepares the resources needed to run that function and can also be expensive. The method throws on a load failure, and returnsnilwhen no function with that name exists."✅ VERIFIED —
functionDescriptor(for:)return doc, verbatim: "A descriptor for the function, ornilif the model doesn't contain a function with the specified name." Discussion: "Use the descriptor to inspect the function's inputs, outputs, and state names before loading it for inference."
So:
| Outcome | Meaning | What you should do |
|---|---|---|
| returns a function | ✅ loaded, weights resident | proceed |
returns nil |
the name is wrong. The model has no such function. | a programming or asset-versioning bug. Log functionNames. Do not retry. |
| throws | the name was right; loading it failed. | a runtime failure — memory, a corrupt cache entry, an incompatible artifact. Degrading or retrying may be reasonable. |
Getting this backwards is the most common shape of broken Core AI code, because the ergonomic
temptation is to write try? model.loadFunction(named:) and collapse both channels into one
nil — which turns a recoverable runtime failure and an unrecoverable name typo into the same,
uninformative branch.
Write it like this:
enum ModelSetupError: Error {
case functionNotFound(requested: String, available: [String])
case functionLoadFailed(name: String, underlying: any Error)
}
func loadMainFunction(from model: AIModel,
named name: String = "main") throws -> InferenceFunction {
let loaded: InferenceFunction?
do {
loaded = try model.loadFunction(named: name)
} catch {
// The name existed; preparing its resources failed. Runtime problem.
throw ModelSetupError.functionLoadFailed(name: name, underlying: error)
}
guard let function = loaded else {
// No such function. Asset/version problem — surface what IS there.
throw ModelSetupError.functionNotFound(requested: name,
available: model.functionNames)
}
return function
}
The available: model.functionNames payload is the whole point. When someone hands you a
re-exported model six months from now and the entry point is extend_4096_64 instead of main,
that array is the entire diagnosis.
🟡 RECONSTRUCTED — the
do/catchsplit above is composed from the two verified sentences, not copied from an Apple sample; no Apple sample separates the channels. The signatures are ✅ VERIFIED; the error enum is mine. See §13 before you write anything narrower thancatch { … }in that inner block.⚠️ A cross-language trap. The Python runtime does not mirror this. ✅ VERIFIED —
apple/coreai-torchdocs,construct-a-graph.ipynb:231: Python'sai_model.load_function("main")raisesKeyErrorwhen the name is missing. Swift returnsnil. If you are porting a parity test from the notebook to Swift, the missing-name branch changes shape.
4.2 functionDescriptor(for:) is the pre-flight check¶
Because loadFunction is expensive and functionDescriptor is not, the descriptor is how you
validate an asset before paying for it. This matters most in the download-a-model case: you
have just fetched 600 MB over the network and you would like to know it is the model you think it
is before you specialize it.
// Cheap: no resources prepared, no weights resident.
guard let descriptor = model.functionDescriptor(for: "main") else {
throw ModelSetupError.functionNotFound(requested: "main",
available: model.functionNames)
}
// …validate descriptor.inputNames / shapes / scalar types here (§6)…
// Only now:
let function = try loadMainFunction(from: model)
4.3 functionNames and the multi-function model — a bigger deal than it looks¶
✅ VERIFIED — Apple's integration article, verbatim: "Most models have a single function. If the model contains multiple functions, check
functionNamesto see all available names."
Session 324 mentions multi-function models in passing ("you can convert a single model with
multiple functions"). Session 325 presents splitting SAM 3 into image_encode / text_encode /
detect as a latency trick — run each at a different cadence, and the second inference is
76% faster (Apple-published, session 325, no hardware stated). But reading the optional
apple/coreai-models Swift package shows that its loader does something else as well:
✅ VERIFIED —
apple/coreai-models,swift/Sources/CoreAIShared/Runtime/ModelStructure.swift:70-81:public var specializationOptions: SpecializationOptions { switch self { case .chunkedStatic, .multiFunctionSegmenter: return SpecializationOptions(preferredComputeUnitKind: .neuralEngine) case .dynamic: var opts = SpecializationOptions(preferredComputeUnitKind: .gpu) opts.expectFrequentReshapes = true return opts } }and the detection that feeds it:
extend*+load_embeddings→.chunkedStatic;image_encode+text_encode+detect→.multiFunctionSegmenter; a baremain→.dynamic.
For callers using this package’s PreparedModel helper, the set of names in functionNames
selects its compute-unit preference. A single-main model receives the helper’s GPU preference
with expectFrequentReshapes = true; the recognized three-entrypoint form receives its Neural
Engine preference. Direct AIModel callers choose their own SpecializationOptions, and Core AI’s
.default is independent of these names.2
⚠️ But Apple's own package does not cash the latency cheque for you. ✅ VERIFIED —
CoreAISegmentationEngine.runMultiFunctionInferencere-runsimage_encodeon everysegment()call and exposes no way to reusebackbone_features. The 76% figure requires caller-side caching of the encoder output that the shipped package does not do. If you adopt the multi-function pattern for latency, you are writing that cache. See Part 16 for the vision-pipeline treatment.⚠️
SpecializationOptions.expectFrequentReshapesis real but undocumented. ✅ VERIFIED as a mutable property in Apple's shipping Swift (above) and now ✅ SDK-verified (CoreAIDelegates-27.0-macos.swiftinterface:100):public var expectFrequentReshapes: Bool, the only settable property on the type. Its documentation page carries an abstract — "Setting to allow more optimal specialization if the model performs frequent reshapes based on usage" — and no Discussion section at all. The default remains undocumented, but it is no longer unmeasured: ✅probes/printedfalsefor bothSpecializationOptions.default.expectFrequentReshapesand.cpuOnly.expectFrequentReshapeson a physical iPhone 15 Pro running iOS 27 beta 5 (24A5408d, 2026-08-20). On that device,.defaultallowed CPU + GPU + Neural Engine with no preferred unit;.cpuOnlyallowed only CPU. Treat those as runtime evidence, not an API promise. Safe default: leave the flag alone unless your model has dynamic shapes that change every call, in which case follow Apple's own code and set it on avarcopy of the options.
5. InferenceFunction is Sendable — and what that costs¶
5.1 The guarantee¶
✅ VERIFIED —
/documentation/coreai/inferencefunction, overview, verbatim: "This type isSendable, so you can run it concurrently from multiple tasks. The function automatically allocates additional intermediate buffers as needed to support concurrency."✅ VERIFIED — Apple's integration article, verbatim: "If your app processes multiple inputs simultaneously, you can safely call the same inference function from different tasks."
This is a real, useful guarantee and session 326 leans on it: the macOS build of the
language-learning app parallelises segmentation across a folder of photos with no extra
synchronisation, because the same InferenceFunction is safe to hit from N tasks.
// Safe. One function, many tasks. No lock, no actor needed.
let results = try await withThrowingTaskGroup(of: (Int, [Float]).self) { group in
for (index, image) in images.enumerated() {
group.addTask {
(index, try await self.classify(image)) // self.function is shared
}
}
var out = [Int: [Float]]()
for try await (i, v) in group { out[i] = v }
return out
}
5.2 ⚠️ SILENT FAILURE: concurrency buys you memory you never asked for¶
⚠️ SILENT FAILURE — parallelism silently multiplies your scratch footprint.
That second sentence in Apple's overview — "The function automatically allocates additional intermediate buffers as needed to support concurrency" — is a memory contract disguised as a convenience. Every concurrent
runin flight needs its own intermediate buffers. The framework allocates them for you, on demand, with no API to cap them, no callback, no warning, and no error.The failure mode is not a thrown error. It is that a
TaskGroupwith unbounded width works beautifully on your M4 Max with 64 GB and gets jetsammed on an iPhone — or, worse, works on the phone with 4 images and dies at 9, because the growth is invisible until it isn't.There is no "how much" in the documentation. Intermediate-buffer size is model-dependent and unpublished.
Safe default: bound the width yourself. Never write an unbounded
TaskGroupover anInferenceFunction. Pick a small constant (2–4 on iOS), measure resident memory with the Core AI instrument and the Memory gauge, and treat the number as device-class-specific.// Bounded fan-out: at most `width` inferences in flight at once. func classifyAll(_ images: [CGImage], width: Int = 3) async throws -> [[Float]] { var results = [Int: [Float]]() try await withThrowingTaskGroup(of: (Int, [Float]).self) { group in var next = 0 for _ in 0..<min(width, images.count) { let i = next; next += 1 group.addTask { (i, try await self.classify(images[i])) } } while let (i, v) = try await group.next() { results[i] = v if next < images.count { let j = next; next += 1 group.addTask { (j, try await self.classify(images[j])) } } } } return (0..<images.count).map { results[$0]! } }
Apple's own package independently arrived at the same conclusion for its GPU pipeline, with a different mechanism and a very precise failure description:
✅ VERIFIED —
apple/coreai-models,CoreAIPipelinedEngine.swift,PipelineGatedoc comment: "Without this, the decode loop submits encodes (~220/s) faster than the sampler callback drains them (~70/s); depth grows untilMPSCommandBufferImageCachefails to allocate another privateMTLBuffer." The gate's capacity ispipelineDepth, a hardcoded 3.Two numbers there — ~220 encodes/s produced against ~70/s consumed — are Apple-authored source comments in a shipping package, not a benchmark: no hardware, model or OS build is stated. Treat them as evidence that the ratio can be 3×, not as a figure for your workload.
Backpressure is not optional in this framework. Apple hardcodes 3; you should pick your own number and measure it.
5.3 What is not safe¶
Sendable covers InferenceFunction. It does not cover the mutable state you thread through it.
states: (§10) reads and writes the arrays you pass in-place, so two concurrent run calls sharing
one KV cache are a data race that the type system will fight you about (insert(_:for:) takes
inout) but that you can still contrive with reference-typed backing storage. One state set per
concurrent stream. Apple's engines serialise generation with an explicit hand-rolled async mutex
for exactly this reason:
✅ VERIFIED —
1amageek/swift-lm(⚠️ community, not Apple: single-author repo,0.11.0-alpha.1, HEADdb7a802, 2026-07-18),CoreAIStateSession:private func acquireExecution() async { guard isExecuting else { isExecuting = true; return } await withCheckedContinuation { continuation in executionWaiters.append(continuation) } }A hand-rolled async mutex, because actor reentrancy is not sufficient to serialise a stateful function. Apple's own
CoreAISequentialEnginereaches the same place by a different route (isBusy+drain()).
6. Runtime introspection: descriptors all the way down¶
6.1 Why this exists¶
Apple states the motivation in one sentence, and it is more ambitious than "validate your inputs":
✅ VERIFIED — Apple's integration article, verbatim: "You can use this descriptor to verify that a function accepts the inputs your app provides, or to dynamically adapt your app's behavior as the model's inputs and outputs change between deployments, without needing to change your code."
That is the design goal: your app should survive a model re-export. If the model you ship in
version 1.4 takes a [1, 3, 224, 224] float32 image and the model you push over Background Assets
in 1.4.1 takes [1, 3, 336, 336], code written against the descriptor keeps working; code written
against a hardcoded shape ships a bug to everyone who updates the asset but not the binary.
This is not hypothetical in a Core AI app, because the whole point of the .aimodelc +
BackgroundAssets pattern Apple recommends is that the model updates independently of the app
binary. Descriptor-driven code is what makes that safe.
6.2 The three-level descriptor chain¶
InferenceFunctionDescriptor ← names + counts, per function
└─ inputDescriptor(of:) ──▶ InferenceValue.Descriptor ← enum: image or ndArray?
└─ case .ndArray ──▶ NDArrayDescriptor ← shape, scalarType, strides, dynamism
└─ case .image ──▶ ImageDescriptor ← pixel format, width, height
✅ VERIFIED —
/documentation/coreai/inferencefunctiondescriptor.struct InferenceFunctionDescriptor,Sendable,SendableMetatype:var name: String { get } var inputCount: Int { get } var inputNames: [String] { get } func inputDescriptor(of inputName: String) -> InferenceValue.Descriptor? var outputCount: Int { get } var outputNames: [String] { get } func outputDescriptor(of outputName: String) -> InferenceValue.Descriptor? var stateNames: [String] { get } func stateDescriptor(of stateName: String) -> InferenceValue.Descriptor?Overview: "Use a descriptor to inspect the names and types of a function's inputs, outputs, and states before running inference. You obtain a descriptor from
functionDescriptor(for:)or from thedescriptorproperty."
Two asymmetries worth noticing, both real:
- There is
inputCountandoutputCountbut nostateCount. UsestateNames.count. - On
InferenceFunction,descriptoris a storedlet, not a computed property: ✅ VERIFIED —let descriptor: InferenceFunctionDescriptor. Reading it is free; cache it or don't, it doesn't matter.
✅ VERIFIED —
/documentation/coreai/inferencevalue/descriptor:enum Descriptor: Sendable, SendableMetatype { case image(ImageDescriptor) case ndArray(NDArrayDescriptor) }Overview: "You obtain descriptors from
InferenceFunctionDescriptorto inspect what kind of value a function expects for each input or output."
Exactly two cases. Core AI's entire I/O type system is "a tensor, or an image". There is no string type, no dictionary type, no sequence type — tokenization and detokenization are your problem, on the outside.
6.3 The canonical pattern, from Apple¶
✅ VERIFIED — Apple's integration article, verbatim:
let function: InferenceFunction = ... let functionDescriptor = function.descriptor guard let valueDescriptor = functionDescriptor.inputDescriptor(of: "input"), case .ndArray(let arrayDescriptor) = valueDescriptor else { // Handle input not found, or an unexpected type. } guard arrayDescriptor.shape == [3, 4] else { // Handle an unexpected shape. } guard arrayDescriptor.scalarType == .float32 else { // Handle an unexpected scalar type. }
The guard let … , case .ndArray(let x) = … line is the idiom. You will write it dozens of times.
It does two jobs at once: "is there an input by this name" and "is it a tensor rather than an
image".
6.4 NDArrayDescriptor, and the -1 sentinel¶
✅ VERIFIED —
/documentation/coreai/ndarraydescriptor.struct NDArrayDescriptor,Equatable,Sendable,SendableMetatype:var shape: [Int] { get } var scalarType: NDArray.ScalarType { get } var rank: Int { get } var hasDynamicShape: Bool { get } var interleaveLayout: NDArray.InterleaveLayout? { get } var minimumByteCount: Int { get } var preferredStrides: [Int] { get } func resolvingDynamicDimensions(_ newShape: [Int]) -> NDArrayDescriptorOverview: "The descriptor contains the expectations for an array value that you provide to an
InferenceFunction. Most expectations are strict: for example, if the descriptor specifiesscalarTypeas.float32, the array you provide must use.float32."
shapediscussion: "The shape containsrankelements. A value of-1in any dimension indicates a dynamic size."⚠️
-1in the API,?in Xcode. ✅ VERIFIED — the Xcode model viewer's Functions tab shows a question mark for a dynamic dimension ("A question mark in anNDArraydimension means the dimension is dynamic and is supplied or determined at runtime"), whileNDArrayDescriptor.shapereports-1. Same fact, two spellings; do not go looking for a?in your Swift.
Resolving a dynamic dimension is a pure function that returns a new descriptor:
✅ VERIFIED —
resolvingDynamicDimensions(_:)discussion + example, verbatim:// The 'dynamic_shape_input' argument is a rank 3 ndArray with a dynamic shape for the final dimension. // ndArrayDescriptor.shape == [128, 128, -1] // Make a resolved descriptor which fills in the -1 dimension with the concrete value 10 let resolvedDescriptor = ndArrayDescriptor.resolvingDynamicDimensions([128, 128, 10])NOTE: "
newShapemust be the same size as theshapeof the descriptor it is called on. Also for each dimension, it must hold true that the provided new shape either matches the existing shape, or the existing shape is-1."⚠️
preferredStridesandminimumByteCountare programming errors on an unresolved descriptor. ✅ VERIFIED, verbatim: "Accessing this property on a descriptor for whichhasDynamicShapeis true, is a programming error. If the descriptor has a dynamic shape, you must first callresolvingDynamicDimensionsto provide a concrete size for each dimension." "Programming error" is Apple's language for this may trap, not this returns garbage. Always resolve first. Same rule forNDArray.init(descriptor:): "The descriptor'shasDynamicShapemust befalse."
And do not reach for the obvious shortcut of mutating shape. Apple's own package explicitly
switched away from it:
✅ VERIFIED —
apple/coreai-modelsPR #74, merged: "UseNDArrayDescriptor.resolvingDynamicDimensions(_:)instead of modifyingNDArrayDescriptor.shapedirectly — 'will check for validity of swapped shaped'." (shapeisget-only in the public API anyway; the PR title is the guidance.)
6.5 A reusable validator¶
Here is the descriptor pattern packaged so you write it once. Every member it touches is ✅ VERIFIED above; the assembly is mine.
import CoreAI
/// What a call site needs to know about one tensor argument.
struct TensorContract: Sendable, Equatable {
let name: String
let shape: [Int] // -1 for dynamic
let scalarType: NDArray.ScalarType
let isDynamic: Bool
}
enum ContractError: Error {
case notFound(String, available: [String])
case notATensor(String) // it's an image-typed argument
case scalarTypeMismatch(String, expected: NDArray.ScalarType, actual: NDArray.ScalarType)
case rankMismatch(String, expected: Int, actual: Int)
case dimensionMismatch(String, axis: Int, expected: Int, actual: Int)
}
extension InferenceFunctionDescriptor {
func tensorInput(_ name: String) throws -> TensorContract {
guard let value = inputDescriptor(of: name) else {
throw ContractError.notFound(name, available: inputNames)
}
guard case .ndArray(let d) = value else { throw ContractError.notATensor(name) }
return TensorContract(name: name, shape: d.shape,
scalarType: d.scalarType, isDynamic: d.hasDynamicShape)
}
func tensorOutput(_ name: String) throws -> TensorContract {
guard let value = outputDescriptor(of: name) else {
throw ContractError.notFound(name, available: outputNames)
}
guard case .ndArray(let d) = value else { throw ContractError.notATensor(name) }
return TensorContract(name: name, shape: d.shape,
scalarType: d.scalarType, isDynamic: d.hasDynamicShape)
}
}
extension TensorContract {
/// Check a concrete shape against this contract. `-1` axes accept anything.
func validate(concreteShape: [Int],
scalarType actual: NDArray.ScalarType) throws {
guard actual == scalarType else {
throw ContractError.scalarTypeMismatch(name, expected: scalarType, actual: actual)
}
guard concreteShape.count == shape.count else {
throw ContractError.rankMismatch(name, expected: shape.count,
actual: concreteShape.count)
}
for (axis, expected) in shape.enumerated() where expected != -1 {
guard concreteShape[axis] == expected else {
throw ContractError.dimensionMismatch(name, axis: axis,
expected: expected,
actual: concreteShape[axis])
}
}
}
}
The where expected != -1 clause is the whole trick, and it is exactly what a community
implementation independently arrived at:
✅ VERIFIED —
1amageek/swift-lm(⚠️ community),CoreAIStateSessioninput validation: checks "unexpected inputs, missing inputs, non-NDArray inputs,scalarTypeequality,shape.count == arrayDescriptor.rank, and per-axisexpected == -1 || expected == actual."
6.6 Discovering names instead of hardcoding them¶
Apple's own package does not hardcode tensor names for its vision products; it discovers them. It is worth knowing both approaches exist because they fail differently.
| Approach | Where Apple uses it | Fails how |
|---|---|---|
Positional — inputNames[0] is input_ids, [1] is position_ids |
CoreAISequentialEngine (validates inputNames.count == 2, stateNames.count == 2, outputNames.count >= 1, then indexes) |
silently wrong if the exporter reorders |
Substring matching — find the input whose name contains text |
ImageSegmentationEngine.findTextInputName |
silently wrong on an ambiguous name |
| Literal names — six hardcoded descriptor lookups | WhisperDecoder, StaticShapeEngine (out_logits, key_cache, value_cache) |
throws loudly if the model changes |
✅ VERIFIED — all three patterns are in
apple/coreai-models.CoreAIStaticShapeEngine.swifteven carries the comment// MARK: I/O name contracts — models must use these exact names.
Recommendation: hardcode the names, validate them against inputNames at load, and put the
discovery logic behind a fallback. Positional indexing is the one to avoid: it is the only one of
the three that can be wrong without throwing.
7. NDArray and non-escapable types¶
This is the section readers find hardest, and it is worth slowing down for, because the difficulty is not accidental complexity — it is a deliberate design choice that Apple called out from the stage:
✅ VERIFIED — WWDC26 session 324, lines 66–70, verbatim: "The Core AI framework is a new Swift API surface for loading and running Core AI models. It offers a progressively disclosing set of APIs, which makes it simple to get things up and running, while also having deeper layers of flexibility for supporting performance critical applications. Also, it uses modern Swift language features like non-escapable types, to offer memory-safe APIs while not sacrificing performance."
7.1 What "non-escapable" means, in one page¶
A normal Swift value can be stored anywhere: in a property, in an array, captured by a closure that outlives the current scope. That freedom is what forces Swift to own the memory behind it — either by copying it or by retaining it. Both cost something.
A non-escapable type (~Escapable) gives that freedom up. The compiler tracks its lifetime and
refuses to let it outlive the thing it borrows from. In exchange, it doesn't have to own anything:
it can be a raw pointer plus a length, with zero retain traffic and zero copying, and still be
memory-safe, because the compiler has statically proven the pointer cannot dangle.
The standard-library carriers for this are Span<T> (read-only), MutableSpan<T>, RawSpan and
MutableRawSpan. Core AI's views are built on them.
So the two-line summary:
NDArrayis a normal, escapable,Sendablevalue that owns storage. Store it, pass it, keep it in a property.NDArray.View<T>/NDArray.MutableView<T>/RawView/MutableRawVieware non-escapable borrows of that storage. Use them, don't keep them.
✅ VERIFIED —
/documentation/coreai/ndarrayconformance list:NDArrayconforms toEscapable,InferenceValue.MutableViewRepresentable,InferenceValue.ViewRepresentable,Sendable,SendableMetatype.📌 A correction to a claim you may have seen. It is sometimes said that "
NDArrayis a non-escapable type". It is not — Apple's conformance list saysEscapableexplicitly, and it isSendable, which a non-escapable type could not usefully be. What session 324 actually says is narrower and correct: "TheNDArray.MutableViewtype is a non-escapable type which provides safe and efficient access to the backing storage of the NDArray" (324:88–89). The views are the non-escapable part. Getting this backwards leads people to fight the compiler over the wrong type.
7.2 Read-only by default¶
func view<T>(as type: T.Type = T.self) -> NDArray.View<T> where T : BitwiseCopyable
mutating func mutableView<T>(as type: T.Type = T.self) -> NDArray.MutableView<T> where T : BitwiseCopyable
func rawView() -> NDArray.RawView
mutating func mutableRawView() -> NDArray.MutableRawView
✅ VERIFIED —
/documentation/coreai/ndarray. Note which two aremutating.
The mutating keyword is doing real work here. It means:
let a = NDArray(shape: [3, 4], scalarType: .float32)
let r = a.view(as: Float.self) // ✅ fine
var w = a.mutableView(as: Float.self) // ❌ compile error: `a` is a `let`
You need var input = NDArray(...) to write into it. That is not a nuisance — it is the API
telling you, at compile time, which of your arrays are being written. Apple states the intent:
✅ VERIFIED — Apple's integration article, verbatim: "For
NDArrayvalues, write input data withMutableViewand read results withView. Swift enforces this at compile time. A mutable view allows writes, and a view allows only reads, so you always know how your data is accessed."
Also note the defaulted type parameter — as type: T.Type = T.self. That is why Apple's sample can
write prediction.view() with no argument: T is inferred from the call site.
✅ VERIFIED — from Apple's own article, the no-argument form in context:
Tis inferred fromprocessOutput's parameter type. Spell it out (view(as: Float.self)) when the call site does not pin it — an unspecifiedTin a generic context is a confusing error.
7.3 Creating an NDArray¶
✅ VERIFIED — all five initializers,
/documentation/coreai/ndarray:init(shape: [Int], scalarType: NDArray.ScalarType) init(shape: [Int], scalarType: NDArray.ScalarType, strides: [Int]) init(shape: [Int], scalarType: NDArray.ScalarType, strides: [Int], interleaveLayout: NDArray.InterleaveLayout) init<Scalar>(scalars: some Sequence, shape: [Int]) where Scalar : BitwiseCopyable init(descriptor: consuming NDArrayDescriptor)
init(shape:scalarType:): "This initializer creates an array with contiguous, row-major strides."init(shape:scalarType:strides:): "Theshapeandstridesarrays must have the same number of elements."init(scalars:shape:)example, verbatim:with the parameter note: "var ndArray = NDArray(scalars: (0..<4) as Range<Int32>, shape: [2, 2]) // The resulting NDArray has contents: [[0, 1], [2, 3]]Scalarmust be a type that corresponds to a scalar type found on theNDArray.ScalarTypeenum", and "The ndArray will be stored in row-major order and the scalars will be assigned in row-major order."⚠️ Do not assume shape-based allocation zero-initializes storage. Six allocation rounds on stable macOS 27 and six on iPhone 15 Pro / iOS build
24A435happened to read all zeroes, but the API does not promise initialization and the probe cannot prove it. Explicitly write every input and state element whose value matters. Guide 7.3 §8.3 owns the full evidence history and safe default.
init(descriptor:) is the one you will use in production, because it is how you get the
framework's preferred layout (§11.1) — and it comes with the most consequential doc note in the
whole type:
✅ VERIFIED —
init(descriptor:)discussion, verbatim: "The resulting array may not have a contiguous layout. The strides match the values returned by the descriptor's preferred strides, socontiguousElementson a view of this array may returnnil. In that case, usewithUnsafePointerorwithUnsafeMutablePointerto access the data while respecting the strides." … "If the descriptor has anInterleaveLayout, the resulting ndArray will carry that interleave metadata." … "The descriptor'shasDynamicShapemust befalse. If the descriptor has dynamic shapes, callresolvingDynamicDimensions(_:)first."
7.4 The read view: NDArray.View<Element>¶
✅ VERIFIED —
/documentation/coreai/ndarray/view:struct View<Element> where Element : BitwiseCopyable init(span: Span<Element>, shape: [Int], strides: [Int]) var isContiguous: Bool { get } // "true if the elements … have a row-major contiguous layout" var rank: Int { get } var shape: Span<Int> { get } var strides: Span<Int> { get } var interleaveLayout: NDArray.InterleaveLayout? { get } var contiguousElements: Span<Element>? { get } subscript<let rank : Int>(scalarAt index: InlineArray<rank, Int>) -> Element { get } func withUnsafePointer<R, E>(_ body: (UnsafePointer<Element>, Span<Int>, Span<Int>) throws(E) -> R) throws(E) -> R func slice(at ranges: [any NDArray.RangeExpression]) -> NDArray.View<Element> func slice<let indexRank : Int>(at: [indexRank of any NDArray.RangeExpression]) -> NDArray.View<Element> var rawView: NDArray.RawView { get }
Every modern-Swift feature in one declaration: Span, value generics (<let rank: Int>),
InlineArray, the fixed-count array parameter syntax [indexRank of any …], and typed throws
(throws(E) -> R). You do not need to write any of that to use the type — but you do need to
read it, because the compiler will echo it back at you in diagnostics.
contiguousElements is the fast path, and it is optional for a reason.
✅ VERIFIED —
contiguousElementsNOTE, verbatim: "contiguoushere refers to elements in row-major order with zero padding."
Three things can make it nil: you built the array with explicit non-contiguous strides; you
built it from a descriptor whose preferredStrides were padded for hardware alignment (§11.1); or
the array carries an InterleaveLayout. In all three cases the data is fine — it is just not a
flat run of elements, so there is no single Span that can describe it.
Apple's own doc example, showing the "I know this one is contiguous" force-unwrap:
✅ VERIFIED — verbatim from
/documentation/coreai/ndarray/view/contiguouselements:/// Returns the sum of the given row. func sumOfRow( of view: borrowing NDArray.View<Float>, row: Int ) -> Float { let rowSlice = view.slice(at: [row]) let elements = rowSlice.contiguousElements! // contiguous row expected in this case var sum: Float = 0 for i in elements.indices { sum += elements[i] } return sum }
Two details to steal from that: the parameter is borrowing (a non-escapable view can't be
owned by the callee), and the loop uses elements.indices with subscripting rather than
for x in elements — which brings us to the single most surprising practical consequence of the
whole non-escapable design.
7.5 ⚠️ Span does not conform to Sequence¶
⚠️
Spanis non-escapable, so it cannot conform toSequence. Nomap. Noreduce. Nofilter. Nofor … inover it as aSequence.shapeandstridescome back asSpan<Int>, and the obviousshape.reduce(1, *)to compute an element count does not compile.✅ VERIFIED — this is not a guess; Apple hit it in their own package and wrote the workaround with the reason in a comment.
apple/coreai-models,swift/Sources/CoreAIShared/Runtime/NDArray+Helpers.swift:/// `Span` doesn't conform to `Sequence` (non-escapable by design), so `.reduce` isn't available. extension Span where Element == Int { var product: Int { var result = 1 for i in 0..<count { result *= self[i] } return result } }Copy that extension into your project on day one. You will want it inside every
withUnsafePointerclosure, because theshapeandstridesthe closure hands you areSpans.
7.6 The write view: NDArray.MutableView<Element>¶
✅ VERIFIED —
/documentation/coreai/ndarray/mutableview:struct MutableView<Element> where Element : BitwiseCopyable init(mutableSpan: consuming MutableSpan<Element>, shape: [Int], strides: [Int]) var isContiguous: Bool { get } var rank: Int { get } var shape: Span<Int> { get } var strides: Span<Int> { get } var interleaveLayout: NDArray.InterleaveLayout? { get } var contiguousElements: MutableSpan<Element>? { get } subscript<let rank : Int>(scalarAt _: InlineArray<rank, Int>) -> Element { get } mutating func copyElements(from sequence: some Sequence<Element>) mutating func copyElements(fromContentsOf: some Collection<Element>) func withUnsafeMutablePointer<R, E>((UnsafeMutablePointer<Element>, Span<Int>, Span<Int>) throws(E) -> R) throws(E) -> R func slice(at: [any NDArray.RangeExpression]) -> NDArray.MutableView<Element> func slice<let indexRank : Int>(at: [indexRank of any NDArray.RangeExpression]) -> NDArray.MutableView<Element> mutating func mutatingSlice(at ranges: [any NDArray.RangeExpression]) -> NDArray.MutableView<Element> mutating func mutatingSlice<let indexRank : Int>(at: [indexRank of any NDArray.RangeExpression]) -> NDArray.MutableView<Element> var view: NDArray.View<Element> { get } var mutableRawView: NDArray.MutableRawView { get }
Three traps live in that list.
(a) slice vs mutatingSlice. Both exist on MutableView. mutatingSlice is mutating — it
borrows self mutably and yields a writable sub-view. slice is not. Use mutatingSlice when
you intend to write through the sub-view. Apple's own example:
✅ VERIFIED — verbatim from
/documentation/coreai/ndarray/mutableview/mutatingslice(at:):/// Updates the desired channel and range of rows func incrementRegion( of mutableView: inout NDArray.MutableView<Float>, channel: Int, startRow: Int, endRow: Int ) { var region = mutableView.mutatingSlice(at: [channel, startRow..<endRow, .all]) var mutableSpan = region.contiguousElements! // contiguous region expected in this case for i in mutableSpan.indices { mutableSpan[i] += 1 } }Note the heterogeneous range list in one array: an
Int, aRange<Int>, and.all. That works because the parameter type is[any NDArray.RangeExpression].
(b) Trailing dimensions default to .all.
✅ VERIFIED —
slice(at:)parameter doc, verbatim: "The range expressions describing where to slice along each dimension.ranges.countmust be ≤rank. Unspecified trailing dimensions are assumed to be.all."
So view.slice(at: [row]) on a rank-2 view gives you the whole row. Convenient, and easy to
misread as an error when you meant to index a scalar.
(c) contiguousElements behaves like a consuming read. The documentation declares it as a
getter, but Apple's own code treats obtaining it as a one-shot operation and says so:
✅ VERIFIED —
apple/coreai-models,CoreAIObjectDetector, doc comment: "contiguousElementsis consuming, so theMutableSpanis obtained once outside the loop and indexed per image; being a safe, bounds-checked handle letstrystay inline. It isnilfor a non-contiguous buffer, which the slot arithmetic's dense row-major assumption treats as an error."var view = imageArray.mutableView(as: Float.self) guard var span = view.contiguousElements else { throw DetectionRuntimeError.invalidConfiguration("Image input NDArray is not contiguous") } for (b, image) in images.enumerated() { let chw = try preprocessor.preprocessCHW(cgImage: image) let start = b * slotCount // slotCount = 3 * H * W for i in 0..<slotCount { span[start + i] = chw[i] } }
Pattern to internalise: hoist contiguousElements out of your loop. Not for style — because
re-obtaining it inside the loop is at best redundant and at worst does not compile.
7.7 The raw views: MTLBuffer and IOSurface interop¶
When you need Core AI to read memory you already own — a Metal buffer you filled with a compute
kernel, an IOSurface from a capture session — the raw views are the door.
✅ VERIFIED —
/documentation/coreai/ndarray/rawview:struct RawView // "A type-erased immutable view over the memory owned by a tensor." init(bytes: RawSpan, byteOffset: Int, scalarType: NDArray.ScalarType, shape: [Int], strides: [Int], interleaveLayout: NDArray.InterleaveLayout?) init(metalBuffer: borrowing any MTLBuffer, byteOffset: Int = 0, scalarType: NDArray.ScalarType, shape: [Int], strides: [Int] = [], interleaveLayout: NDArray.InterleaveLayout? = nil) // ⚠️ no watchOS init(ioSurface: borrowing IOSurface, byteOffset: Int, scalarType: NDArray.ScalarType, shape: [Int], strides: [Int], interleaveLayout: NDArray.InterleaveLayout?) var scalarType: NDArray.ScalarType { get } var shape: Span<Int> { get } var strides: Span<Int> { get } var bytes: RawSpan { get } var interleaveLayout: NDArray.InterleaveLayout? { get } consuming func view<T>(as: T.Type = T.self) -> NDArray.View<T> where T : BitwiseCopyable func slice(at: [any NDArray.RangeExpression]) -> NDArray.RawView func withUnsafeBytes<R, E>((UnsafeRawPointer, Span<Int>, Span<Int>) throws(E) -> R) throws(E) -> R⚠️ The
metalBuffer:initializer is explicitly unsafe, in Apple's own words. Verbatim from the discussion: - "metalBuffermust havesharedstorage mode." - "Note that the providedscalarTypewill be stored and later checked if you attempt to convert the raw view to a typed view." - "Also note that theshape/stridesmust not be able to produce offsets that go outside of the range ofmetalBuffer." - "This initializer is unsafe, you are responsible for ensuring that no other code (or GPU pipeline) writes to the buffer while the resulting view is alive." -strides: "If left empty, they will be computed as contiguous row-major."
Two naming traps in the raw views, both real, both easy to trip over:
NDArray.RawView.view(as:)isconsumingand asserts the type matches the storedscalarType. ✅ VERIFIED: "Consume this raw view to create a typed view", NOTE: "Tmust matchself.scalarType.type."NDArray.MutableRawView.view(as:)returns aMutableView, not aView, despite the name. ✅ VERIFIED from the declaration:func view<T>(as: T.Type) -> NDArray.MutableView<T>.
7.8 Strides, and InterleaveLayout¶
strides[i] is the distance between consecutive elements along axis i. ✅ VERIFIED, verbatim:
"The strides array has the same number of elements as shape, where strides[i] describes the
distance between consecutive elements in the ith dimension."
InterleaveLayout is the one place that rule bends, and it has the most carefully written
documentation page in the framework:
✅ VERIFIED —
/documentation/coreai/ndarray/interleavelayout:struct InterleaveLayout: Equatable, Sendable, SendableMetatype init(dimension: Int, factor: Int) var dimension: Int { get } var factor: Int { get }Overview, verbatim: "An interleaved layout means that elements of the interleaved
dimensionare stored in physically contiguous blocks offactorelements (stride 1 between adjacent elements within a block). … A common use case is representing an image with interleaved channels: a[C, H, W]tensor usesInterleaveLayout(dimension: 0, factor: C)to store all channels for each pixel contiguously — likeRGBRGB...— rather than in separate planar slices — likeRRR...GGG...BBB...."Stride semantics, verbatim: "The stride for the interleaved dimension (as reported by
NDArray.strides) is a block stride — the distance in memory between adjacent blocks offactorelements, not between individual elements. Within a block, adjacent elements have stride 1. The element offset formula is:"// Given strides and InterleaveLayout with dimension d and factor f: // offset = (index[d] / f) * strides[d] + (index[d] % f) // + Σ index[i] * strides[i] for all i ≠ d⚠️ If you index by hand and ignore
interleaveLayout, you read the wrong elements — silently. The strides array looks usable. The values in it are block strides for one axis. Apple restates the warning insidewithUnsafePointer's note: "If the view has aninterleaveLayout, the strides for that dimension are block strides and must be interpreted accordingly."Safe default: check
view.interleaveLayout == nilbefore doing manual stride arithmetic, and take thecontiguousElementspath or throw otherwise. Only reach for the offset formula when you have a measured reason to.
Apple also documents when the interleaved form is necessary rather than merely convenient:
✅ VERIFIED, verbatim: "When
factordivides the size of the interleaved dimension evenly, the layout can equivalently be expressed as a shape/stride transformation without interleave metadata." — with a worked example,shape=[8,256,256] strides=[262144,1024,4]+InterleaveLayout(dimension: 0, factor: 4)≡shape=[2,256,256,4] strides=[262144,1024,4,1],interleaveLayout=nil— and then: "Whenfactordoes not divide the dimension size evenly, the shape/stride equivalence is not possible. In such case the interleaved representation is the only way to express the layout."
7.9 ScalarType: 35 cases, and the two that don't exist in Swift¶
NDArray.ScalarType is CaseIterable, Equatable, Hashable, Sendable, SendableMetatype and has
35 cases — far more than the Swift standard library has types.1
| Group | Cases |
|---|---|
| Float | float16, float32, float64, bfloat16 |
| 8-bit float | float8e4m3fn, float8e5m2 |
| MX block formats | float4e2m1fn, float8e8m0fn |
| Complex | cfloat16, cfloat32, cfloat64 |
| Signed int | int8, int16, int32, int64, int128 |
| Unsigned int | uint8, uint16, uint32, uint64, uint128 |
| Sub-byte signed | int2, int3, int4, int5, int6, int7 |
| Sub-byte unsigned | uint1, uint2, uint3, uint4, uint5, uint6, uint7 |
| Bool | bool |
✅ VERIFIED — full enumeration from
/documentation/coreai/ndarray/scalartype.int4doc, verbatim: "Four-bit signed integers can represent values in the range [-8, 7]. Widely used in model quantization for efficient storage and computation." Note there is noint1(onlyuint1) and nouint0. All 35 cases confirmed, byte for byte, against the SDK (✅ SDK-verified —CoreAIRuntime-27.0-macos.swiftinterface:1361-1396).
Why those exist at all is a Part 9 question — they are the storage types Core AI Optimization produces. What matters here is the runtime consequence:
✅ RESOLVED (was a GAP) — there is no typed
Viewfor sub-byte or 8-bit-float scalar types in the macOS 27.0 beta SDK.The interface dump settles both halves (✅ SDK-verified —
CoreAIRuntime-27.0-macos.swiftinterface, captured 2026-07-29, recaptured 2026-08-20):
view(as:)requiresT : BitwiseCopyable(:591-599), and the module declares no element type forint4,uint3,float8e4m3fnorfloat8e8m0fn— no Core AIInt4orFloat8E4M3FNexists anywhere in the public surface. The raw-bytes path is the only public route to these tensors.ScalarType.typeis not in the public interface. TheScalarTypedeclaration (:1359-1408) contains only the 35 cases,CaseIterable/Hashablemachinery, and nothing else — the memberRawView.view(as:)'s doc note references is internal, leaked into the docs.Safe default (unchanged, now the only option): treat sub-byte and 8-bit-float tensors as opaque. Use
rawView()/mutableRawView()andwithUnsafeBytes/withUnsafeMutableBytes, and do your own bit unpacking. In practice you rarely need to: these types appear as weight storage inside the model, not as function I/O. If one shows up as an input or output scalar type, that is worth a second look at the export before you write an unpacker.
7.10 ⚠️ SILENT FAILURE: assuming the output dtype from the input descriptor¶
⚠️ The output's scalar type is not the input's scalar type, and the model can change it under you.
✅ VERIFIED —
apple/coreai-models,NDArray+Helpers.swift, doc comment, verbatim: "Output dtype can differ from the model's input dtype, so always inspect the array rather than threading anisFloat16flag from input descriptors."The implementation branches on the array's own
scalarType:public func flattenAsFloat(_ array: NDArray) -> [Float] { switch array.scalarType { #if !((os(macOS) || targetEnvironment(macCatalyst)) && arch(x86_64)) case .float16: return flattenNDArray(array, as: Float16.self) #endif case .float32: return flattenNDArray(array, as: Float.self) default: preconditionFailure("flattenAsFloat: unsupported scalar type \(array.scalarType)") } }If you cache "this model is fp16" at load and later read an fp32 output through
view(as: Float16.self), you do not get a type error — you get numeric garbage, becauseview(as:)reinterprets bits. TheNDArrayDescriptorfor the output is the authority, or thescalarTypeof the returned array itself.Safe default: branch on
array.scalarTypeat the point of read. Never carry a dtype flag across a function boundary.
Note also the #if guard in Apple's code, which is a real portability constraint:
⚠️
Float16does not exist on Intel macOS. ✅ VERIFIED — that guard appears at nine sites acrossapple/coreai-models' Swift, and the#elsebranch isfatalError("Float16 is not supported on this platform"). Consequence: an fp16 model cannot run on an Intel Mac through this code path — you get a crash, not a graceful degradation. If you still ship an x86_64 slice, gate the feature, don't gate the type.
8. Writing inputs¶
8.1 The simple path, from Apple¶
✅ VERIFIED — Apple's integration article, four consecutive snippets, verbatim:
// Create an `NDArray` that matches the expected type and shape. var input = NDArray(shape: [3, 4], scalarType: .float32)// Access a mutable view to write data into the array. var mutableView = input.mutableView(as: Float.self) guard let elements = mutableView.contiguousElements else { // Handle non-contiguous memory layout. } // Your function that writes input data into the mutable span. writeInputData(into: elements)
That is the whole "hello world". Three notes on it:
var input, notlet—mutableView(as:)ismutating.var mutableView—contiguousElementson aMutableViewyields aMutableSpan, which needs a mutable binding.var outputs—Outputs.remove(_:)ismutating(§9).
8.2 The three ways to get data in¶
Apple's own package wraps all three in free functions, which is a good sign they are the three you need:
✅ VERIFIED —
apple/coreai-models,swift/Sources/CoreAIShared/Runtime/NDArray+Helpers.swift:public func resolvedStrides(descriptor: NDArrayDescriptor, shape: [Int]) throws -> [Int] public func fillNDArray<T: BitwiseCopyable>( _ array: inout NDArray, as type: T.Type, with elements: some Collection<T>) public func fillNDArray<T: BitwiseCopyable>( _ array: inout NDArray, as type: T.Type, count: Int, using generator: (Int) -> T) public func readNDArray<T: BitwiseCopyable>( _ array: NDArray, as type: T.Type, count: Int) -> [T] public func flattenAsFloat(_ array: NDArray) -> [Float]with the bodies:
/// Fill an NDArray from a collection of elements. public func fillNDArray<T: BitwiseCopyable>( _ array: inout NDArray, as type: T.Type, with elements: some Collection<T> ) { var view = array.mutableView(as: type) view.copyElements(fromContentsOf: elements) } /// Fill an NDArray using a closure that maps index → value. public func fillNDArray<T: BitwiseCopyable>( _ array: inout NDArray, as type: T.Type, count: Int, using generator: (Int) -> T ) { var view = array.mutableView(as: type) view.withUnsafeMutablePointer { ptr, shape, _ in let capacity = shape.product precondition(count <= capacity, "fillNDArray: count \(count) exceeds array capacity \(capacity)") for i in 0..<count { ptr[i] = generator(i) } } }
(a) copyElements(fromContentsOf:) — you already have a Swift Array or other Collection.
Simplest, and it works regardless of contiguity because the view handles the walk.
✅ VERIFIED — both overloads exist:
copyElements(from: some Sequence<Element>)andcopyElements(fromContentsOf: some Collection<Element>). The doc note on the first: "The number of elements insequencemust be less than or equal tolayout.scalarCount."⚠️ That doc references
layout.scalarCount, which is not in the public API — an internal-doc leak. Read it as "the array's element count"; compute it yourself with theSpan.productextension from §7.5.
(b) contiguousElements + direct indexing — the fastest path when contiguous, and the one to
prefer when you are writing many elements. Bounds-checked, no unsafe pointers.
(c) withUnsafeMutablePointer { ptr, shape, strides in … } — the escape hatch. Required when
contiguousElements is nil. You are responsible for honouring the strides:
✅ VERIFIED —
withUnsafePointer(_:)NOTE, verbatim: "This function is intended for situations where you may not be working with contiguous layouts, and as such cannot usecontiguousElements. You are responsible for reading thestridespassed in when indexing the backing data. If the view has aninterleaveLayout, the strides for that dimension are block strides and must be interpreted accordingly."
Notice that Apple's own fillNDArray(_:as:count:using:) above indexes ptr[i] linearly and ignores
the strides it was handed. That is correct only for a contiguous array — which it is, in every
call site in that package. Do not copy it into a preferredStrides-allocated array (§11.1) without
adding the stride walk.
8.3 A stride-respecting writer you can actually reuse¶
import CoreAI
extension Span where Element == Int {
/// `Span` is non-escapable and doesn't conform to `Sequence`, so `.reduce` is unavailable.
var product: Int {
var result = 1
for i in 0..<count { result *= self[i] }
return result
}
}
enum TensorWriteError: Error {
case interleavedLayoutUnsupported
case countMismatch(expected: Int, provided: Int)
}
/// Write a dense row-major `[T]` into an `NDArray` that may have arbitrary strides.
/// Takes the fast path when the destination is contiguous.
func writeRowMajor<T: BitwiseCopyable>(
_ source: [T],
into array: inout NDArray,
as type: T.Type
) throws {
var view = array.mutableView(as: type)
guard view.interleaveLayout == nil else {
// Block strides — the linear walk below would be silently wrong. See §7.8.
throw TensorWriteError.interleavedLayoutUnsupported
}
if var elements = view.contiguousElements {
guard elements.count == source.count else {
throw TensorWriteError.countMismatch(expected: elements.count, provided: source.count)
}
for i in 0..<source.count { elements[i] = source[i] }
return
}
// Non-contiguous: walk an odometer over the logical shape, honouring strides.
try view.withUnsafeMutablePointer { ptr, shape, strides in
let total = shape.product
guard total == source.count else {
throw TensorWriteError.countMismatch(expected: total, provided: source.count)
}
let rank = shape.count
var index = [Int](repeating: 0, count: rank)
for linear in 0..<total {
var offset = 0
for axis in 0..<rank { offset += index[axis] * strides[axis] }
ptr[offset] = source[linear]
// increment the odometer, least-significant axis last
var axis = rank - 1
while axis >= 0 {
index[axis] += 1
if index[axis] < shape[axis] { break }
index[axis] = 0
axis -= 1
}
}
}
}
🟡 RECONSTRUCTED assembly, ✅ VERIFIED members. Every member used —
mutableView(as:),interleaveLayout,contiguousElements,withUnsafeMutablePointer { ptr, shape, strides in },Span.count,Spansubscript — is verified above. The odometer walk mirrors the one Apple'sflattenNDArrayuses on the read side (✅ VERIFIED as existing: "flattenNDArraychecks whether the strides are already row-major and, if so, does a flat copy; otherwise it walks an odometer of indices"), but the code is mine, not a transcription.
8.4 InferenceFunction.Inputs — the zero-copy input collection¶
The dictionary overload of run is a convenience. The other overload takes a purpose-built
collection that can hold views rather than owned arrays, which is what you want when the data
lives in a MTLBuffer or an IOSurface.
✅ VERIFIED —
/documentation/coreai/inferencefunction/inputs:struct Inputs init() mutating func insert(_ rawView: consuming NDArray.RawView, for inputName: String) mutating func insert(_ value: borrowing some InferenceValue.ViewRepresentable & ~Copyable, for inputName: String) mutating func insert<Element>(_ view: consuming NDArray.View<Element>, for inputName: String) where Element : BitwiseCopyableOverview: "Build an
Inputscollection by callinginsert(_:for:)for each named input the function expects, then pass it toInferenceFunction/run(inputs:states:outputViews:)."
Both run overloads are otherwise identical:
✅ VERIFIED —
/documentation/coreai/inferencefunction, both declarations:func run(inputs: [String : NDArray], states: consuming InferenceFunction.MutableViews = MutableViews(), outputViews: consuming InferenceFunction.MutableViews = MutableViews()) async throws -> InferenceFunction.Outputs func run(inputs: borrowing InferenceFunction.Inputs, states: consuming InferenceFunction.MutableViews = MutableViews(), outputViews: consuming InferenceFunction.MutableViews = MutableViews()) async throws -> InferenceFunction.OutputsThe discussion on the first: "This is a convenience overload that accepts a dictionary of
NDArrayvalues instead of anInferenceFunction.Inputscollection."
Note that states: and outputViews: both default to an empty MutableViews(), which is why
try await function.run(inputs: ["input": input]) compiles. Apple's own code passes the empty
collection explicitly in one place, which reads oddly until you know the defaults exist:
✅ VERIFIED —
apple/coreai-models,SpeechModel.swift:81:states: InferenceFunction.MutableViews(), outputViews: consume out)
9. Reading outputs: InferenceValue and the take-once bag¶
9.1 Outputs is not a dictionary¶
✅ VERIFIED —
/documentation/coreai/inferencefunction/outputs:struct Outputs mutating func remove(_ outputName: String) -> InferenceValue? var count: Int { get } var names: some Collection<String> { get }
remove(_:)discussion, verbatim: "After you remove a value, subsequent calls with the same name returnnil."
There is no subscript. There is no first. There is remove, and it is mutating, and it is
destructive. Outputs is a take-once bag: each value can be extracted exactly once, and after
that the name reads as absent.
Apple's canonical extraction:
✅ VERIFIED — Apple's integration article, verbatim:
// Extract the returned output. guard let predictionValue = outputs.remove("prediction") else { // Handle output not found. } guard let prediction = predictionValue.ndArray else { // Handle output of unexpected type of value. } // Read the output data through a view. // Your function that processes the output. processOutput(prediction.view())
Two guards, two different meanings — same shape as loadFunction in §4:
removereturnsnil→ no output by that name (or you already took it)..ndArrayreturnsnil→ the output exists but is an image, not a tensor.
9.2 ⚠️ InferenceValue.ndArray is a consuming read wearing a getter's clothes¶
✅ VERIFIED —
/documentation/coreai/inferencevalue/ndarray, discussion, verbatim: "This property isnilwhen the value contains an image instead of an array. Accessing this property consumes the value and transfers ownership of the array to the caller."
struct InferenceValue
var kind: InferenceValue.Kind { get } // .image or .ndArray
var ndArray: NDArray? { get } // ⚠️ consuming
var pixelBuffer: CVMutablePixelBuffer? { get }
init(_ pixelBuffer: consuming CVMutablePixelBuffer)
enum Kind { case image; case ndArray }
✅ VERIFIED —
/documentation/coreai/inferencevalue. Overview: "AnInferenceValuewraps either anNDArrayor a pixel buffer, and you retrieve it after inference using thendArrayproperty."
So this is a bug:
guard let value = outputs.remove("prediction") else { … }
if value.ndArray != nil { // ⚠️ consumes here…
let array = value.ndArray! // …and this is a second consuming read
}
Check kind first, or bind once:
guard let value = outputs.remove("prediction") else { throw … }
guard value.kind == .ndArray else { throw … } // cheap, non-consuming
guard let array = value.ndArray else { throw … } // consume exactly once
9.3 Returned arrays are always row-major contiguous¶
This is a genuinely useful guarantee, stated on both run overloads:
✅ VERIFIED — verbatim: "Any
NDArrayvalues in the returned outputs have a row-major contiguous layout."
So on the output side, contiguousElements is safe to force-unwrap for values that came out of
run — but only those. An array you built with preferredStrides (§11.1), or one you got back
from AsyncValue.ndArray, carries no such promise. Write the guard let … else { throw } anyway;
it costs a line and it survives refactoring.
9.4 ⚠️ SILENT FAILURE: outputs you pre-allocate disappear from Outputs¶
⚠️ SILENT FAILURE — an output with a pre-allocated view is not in the returned
Outputs.✅ VERIFIED —
run'soutputViews:parameter documentation, verbatim: "Pre-allocated output values that the function updates during inference. Outputs with a provided view are updated in-place and are not included in the returnedInferenceFunction.Outputs. Outputs without a provided view produce new values in the returnedInferenceFunction.Outputs."The same rule is restated for
encode: "The returned dictionary doesn't containInferenceFunctionoutputs for which you provide a view, because the inference updates the mutable view in place."The failure shape. You add an
outputViews:entry for"logits"as a performance optimisation (§11.2). Every existing call site that doesguard let logits = outputs.remove("logits")?.ndArray else { throw RunError.missingOutput("logits") // ← now fires on every single call }starts throwing "missing output". Nothing warns you.
runreturned successfully, the inference ran, the data is in your pre-allocated array — the returned bag is simply empty for that name. A reader debugging this looks at the model, the export, the shapes, and the tokenizer before they look at the parameter they added three commits ago.Safe default: make the two mutually exclusive in your own wrapper, so the type system stops you:
enum OutputBinding { case returned // read it from Outputs case preallocated // read it from the array you passed in }and assert it: after a
runwhere you passedoutputViewsfor namen,outputs.names.contains(n)must befalse. If it istrue, your view was not accepted and you are reading stale data from your own buffer.
Apple's own package hits this and simply discards the return value, which is the honest expression
of "everything I care about is in outputViews":
✅ VERIFIED —
apple/coreai-models,CoreAISequentialEngine.swift:275-291, verbatim:// Build states (KV cache — persistent, inout) var states = InferenceFunction.MutableViews() states.insert(&keyCache, for: keyCacheName) states.insert(&valueCache, for: valueCacheName) // Build output backings (logits — written in-place) var outputViews = InferenceFunction.MutableViews() outputViews.insert(&logitsArray, for: logitsName) // Execute _ = try await function.run( inputs: [inputIdsName: inputIdsArray, positionIdsName: positionIds], states: consume states, outputViews: consume outputViews )Note
_ =. TheOutputsis empty oflogitsby design.
9.5 NamedMutableViews.take(_:) crashes on a double take¶
A cousin of §9.1 with a much less forgiving failure mode:
✅ VERIFIED —
/documentation/coreai/inferencevalue/namedmutableviews. Overview: "Each view can only be taken once to ensure exclusive access."take(_:)discussion, verbatim: "Each value can only be taken once. Requesting the same value again produces a fatal error."
So on this type nil means "no value with that name" and a second take traps. Compare
Outputs.remove, where a second call returns nil benignly. Two collections, two different
double-access policies. Do not build intuition from one and apply it to the other.
10. States and pre-allocated outputs: MutableViews¶
10.1 One type, two parameters¶
states: and outputViews: take the same type, InferenceFunction.MutableViews, and it exists
because both parameters mean "here is memory I own; write into it".
✅ VERIFIED —
/documentation/coreai/inferencefunction/mutableviews:struct MutableViews init() mutating func insert(_ value: inout some InferenceValue.MutableViewRepresentable & ~Copyable, for name: String) mutating func insert<Element>(_ mutableView: consuming NDArray.MutableView<Element>, for name: String) where Element : BitwiseCopyable mutating func insert(_ mutableRawView: consuming NDArray.MutableRawView, for name: String)
The first overload is the one you will use, because NDArray conforms to
InferenceValue.MutableViewRepresentable — so insert(&myArray, for: "key_cache") works directly
on the array, no view construction needed.
10.2 States: what they are, in one paragraph¶
✅ VERIFIED — session 324, lines 109–112, verbatim: "This can be achieved through Core AI by using states. States are inputs to the model which are both read, and updated in-place during inference. By introducing the key and value caches as states on the model, we both avoid recomputing them on each inference, and also remove the need to provide the full history of the game as an input since the data needed from older steps are stored in the states."
On the Python side they come from torch.register_buffer + in-place mutation, named through
state_names at conversion time. That is Part 8's material. On the Swift side, the entire surface
is: descriptor.stateNames, descriptor.stateDescriptor(of:), and the states: parameter.
10.3 ⚠️ You must supply a view for every state¶
✅ VERIFIED —
run'sstates:parameter documentation, verbatim: "The in-out arguments of the function, which the function reads and writes during inference. You must provide views for all states; omitting any state produces an error."Restated on
InferenceFunctionDescriptor.stateNames: "States are function arguments that the function both reads and writes during inference. You must provide a mutable view for every state when callingInferenceFunction/run(inputs:states:outputViews:)."And again on
encode: "Note that views for states are not optional. Omitting a view for any state results in an error."
Three separate documentation pages say the same thing, which usually means the API team got tired
of the bug report. Since there is no stateCount, drive it from stateNames:
// Allocate one NDArray per state, sized from its own descriptor. Do this ONCE, at setup.
var stateArrays: [String: NDArray] = [:]
for name in function.descriptor.stateNames {
guard let value = function.descriptor.stateDescriptor(of: name),
case .ndArray(let d) = value else {
throw ModelSetupError.unsupportedStateKind(name)
}
let resolved = d.hasDynamicShape
? d.resolvingDynamicDimensions(concreteShape(for: name)) // your policy
: d
stateArrays[name] = NDArray(descriptor: resolved)
}
and then, on every call:
var states = InferenceFunction.MutableViews()
for name in function.descriptor.stateNames {
states.insert(&stateArrays[name]!, for: name) // see the note below
}
let outputs = try await function.run(inputs: inputs, states: consume states)
⚠️
&stateArrays[name]!through a dictionary subscript is legal but fragile — a force-unwrap and an exclusivity check per iteration. For anything beyond two or three states, hold the arrays in named stored properties (as Apple's engines do:keyCache,valueCache) or in a small~Copyablebox.1amageek/swift-lm(⚠️ community) solves the general-N case with recursion — one stack frame per state, becauseAsyncMutableValueis non-copyable and inserted byinout:✅ VERIFIED —
swift-lm,CoreAIStateSession:It is not elegant, but it is the shape the ownership rules force, and it is worth knowing that a real integration ended up here.private func encode( stateIndex: Int, inputs: [String: InferenceFunction.AsyncValue], stateViews: consuming InferenceFunction.AsyncMutableViews, stream: borrowing ComputeStream ) throws -> [String: InferenceFunction.AsyncValue] { guard stateIndex < states.count else { let outputViews = InferenceFunction.AsyncMutableViews() return try function.encode(inputs: inputs, states: stateViews, outputViews: consume outputViews, to: stream) } let state = states[stateIndex] var stateValue = unsafe InferenceFunction.AsyncMutableValue( unsafeBuffer: state.buffer, scalarType: state.descriptor.scalarType, shape: state.descriptor.shape, strides: state.descriptor.preferredStrides, interleaveLayout: state.descriptor.interleaveLayout ) var nextViews = stateViews nextViews.insert(&stateValue, for: state.name) return try encode(stateIndex: stateIndex + 1, inputs: inputs, stateViews: consume nextViews, stream: stream) }
10.4 consume is not optional¶
Both states: and outputViews: are declared consuming. Swift will often insert the consume for
you, but Apple's own package writes it explicitly and even landed a commit to do so:
✅ VERIFIED —
apple/coreai-modelscommit7bd9d32, "Use views in consuming manner, not mutating (#89)".
Write states: consume states, outputViews: consume outputViews. The collection is dead after the
call; treating it as reusable is the mistake this makes impossible.
10.5 What Apple's own engine actually does, end to end¶
The complete decode step from CoreAISequentialEngine is worth reading once as a whole, because it
is the densest correct example of §8, §9 and §10 together:
✅ VERIFIED —
apple/coreai-models,CoreAISequentialEngine.swift. Model contract from its own doc comment: "Expects a.aimodelwith: 2 inputs —input_ids(Int32),position_ids(Int32); 1 output —logits; 2 states —keyCache,valueCache, persistent across steps, updated in-place. KV cache NDArrays start small (256 tokens) and grow dynamically with 2× expansion."var states = InferenceFunction.MutableViews() states.insert(&keyCache, for: keyCacheName) states.insert(&valueCache, for: valueCacheName) var outputViews = InferenceFunction.MutableViews() outputViews.insert(&logitsArray, for: logitsName) _ = try await function.run( inputs: [inputIdsName: inputIdsArray, positionIdsName: positionIds], states: consume states, outputViews: consume outputViews)
Two performance notes from that same file, both Apple-authored source comments (no hardware or build stated — read them as engineering rationale, not benchmarks):
✅ VERIFIED, verbatim: "Reuse pre-allocated
input_idswhen the batch size is unchanged. Steady-state decode keeps batchSize=1 forever, so this avoids theNDArray(descriptor:)+resolvingDynamicDimensionswork on every step — small per call, but compounds over long generations." The file elsewhere quantifies it as "Saves ~50-100 µs/step".✅ VERIFIED, verbatim: "under
-Onone,fillNDArray's(Int) -> LogitsScalarTypeclosure is invoked per element … which made zeroing the KV cache (~14.7M elements for a 32K-context Qwen3) take ~6 seconds perreset()" — which is why that path is a hand-rolled pointer loop. Build optimized. A debug build of Core AI code is not a slow version of the release build; it is a different performance class.
11. The three low-level performance APIs¶
Session 324 lists three, in a single slide, in about forty seconds:
✅ VERIFIED — WWDC26 session 324, lines 175–181, verbatim: "Another area you may want to optimize is removing any overheads in tight inference loops using your model. The Core AI Framework has several APIs to help you here. 1. You can dynamically check the optimal memory layout of NDArray arguments and allocate them with that structure to avoid layout conversions at inference time. 2. You can also pre-allocate output values for the framework to write into, to avoid allocating new output values during inference. 3. And you can also use asynchronous values to efficiently pipeline execution of multiple inference functions together*.
For most use cases, the higher-level inference APIs will get you exactly where you need to be. But when you're optimizing a tight inference loop or integrating a model into a complex compute pipeline, these lower-level APIs are there when you need them."*
The transcript names no API. Each of the three maps onto a concrete symbol, and all three mappings are corroborated by Apple's own shipping Swift — which is why they are ✅ rather than 🟡.
11.1 Optimal memory layout: preferredStrides¶
The mapping: NDArrayDescriptor.resolvingDynamicDimensions(_:) → .preferredStrides →
NDArray(shape:scalarType:strides:) or NDArray(descriptor:).
✅ VERIFIED —
apple/coreai-models,NDArray+Helpers.swift:12-19, verbatim including the doc comment:/// Resolve strides from an NDArrayDescriptor for a given concrete shape. /// /// Uses `NDArrayDescriptor.resolvingDynamicDimensions().preferredStrides` to get /// framework-blessed strides that respect hardware alignment constraints. public func resolvedStrides(descriptor: NDArrayDescriptor, shape: [Int]) throws -> [Int] { let resolved = descriptor.resolvingDynamicDimensions(shape) return resolved.preferredStrides }
What preferredStrides actually is¶
✅ VERIFIED —
/documentation/coreai/ndarraydescriptor/preferredstrides, discussion, verbatim:"During the specialization of an
AIModel, a preferred memory layout for a given ndArray value may be set depending on structure of the model and which compute units it is specialized for. In some cases, this can result in a non-contiguous layout being preferred/required by the backing compute. In such case, you are still able to provideInferenceFunction.runnormal contiguous ndArray values, however it may incur a copy to the preferred layout. As such, this property provides an opportunity for you to optimize performance by creating your source ndArray value with the preferred striding and avoiding that copy."NOTE, verbatim: "Constructing an ndArray with these preferred strides may result in a non-contiguous layout. In such case calling
ndArrayView.contiguousElementson a view of the ndArray will returnnil. If you choose to use the preferred strides, you must read/write the resulting ndArray by dynamically respecting whatever strides are returned."
⚠️ SILENT FAILURE: the layout-conversion copy¶
⚠️ SILENT FAILURE — the copy you never see, on every inference, forever.
This is the defining Core AI performance bug and it has no observable symptom in your code. You allocate
NDArray(shape:scalarType:)— Apple's own hello-world initializer, contiguous row-major, exactly what the descriptor'sshapeandscalarTypesaid. You callrun. It succeeds. The numbers are right. Nothing is logged, nothing throws, nothing appears in the debug gauge as an error.And on every single call, the framework copies your tensor into a different memory layout first, because specialization decided the Neural Engine wants padded or interleaved strides for that argument.
Why it is invisible: the copy is correct. It is not a bug in Core AI; it is the framework doing the right thing with what you gave it. There is no "you could have avoided this" diagnostic anywhere in the API, the docs, or the tooling.
How to detect it: compare
descriptor.preferredStridesagainst the contiguous row-major strides for the same shape. If they differ, you are paying the copy./// Row-major contiguous strides for a shape, in elements. func rowMajorStrides(_ shape: [Int]) -> [Int] { var strides = [Int](repeating: 1, count: shape.count) for axis in stride(from: shape.count - 2, through: 0, by: -1) { strides[axis] = strides[axis + 1] * shape[axis + 1] } return strides } /// Log once at setup. Costs nothing; tells you whether §11.1 is worth doing for this model. func auditLayout(_ fd: InferenceFunctionDescriptor, concrete: [String: [Int]]) { for name in fd.inputNames { guard let v = fd.inputDescriptor(of: name), case .ndArray(let d) = v else { continue } let resolved = d.hasDynamicShape ? d.resolvingDynamicDimensions(concrete[name] ?? d.shape) : d let preferred = resolved.preferredStrides let contiguous = rowMajorStrides(resolved.shape) if preferred != contiguous { print("[CoreAI] input '\(name)': preferred \(preferred) != contiguous \(contiguous) " + "— a contiguous NDArray will be copied on every run.") } if resolved.interleaveLayout != nil { print("[CoreAI] input '\(name)' has an interleave layout: \(resolved.interleaveLayout!)") } } }Safe default if you do nothing: contiguous arrays are correct. This is a throughput bug, not a correctness one. Ship contiguous, measure with the Core AI instrument, and adopt preferred strides only for the arguments the audit above flags — because the cost of adopting them is that
contiguousElementsstarts returningniland every writer you have must grow a stride walk (§8.3).
Apple's own worked example¶
✅ VERIFIED — verbatim from the
preferredStridesdocumentation:guard case .ndArray(let inputDescriptor) = inferenceFunctionDescriptor.inputDescriptor(of: "input") else { throw UnexpectedInferenceValueType() } let preferredStrides = inputDescriptor.preferredStrides var ndArray = NDArray(shape: [theShape], scalarType: .float32, strides: preferredStrides) var view = ndArray.mutableView(as: Float.self) if let contiguousElements = view.contiguousElements { // The preferred strides were a normal contiguous layout } else { // The preferred strides were non-contiguous view.withUnsafeMutablePointer { data, shape, strides in ... logic which respects whatever strides were preferred ... } }Note the branch: preferred strides are often just contiguous, in which case the fast path is still available. Write both branches, take whichever you get.
minimumByteCount, for manual allocation¶
If you are allocating the backing store yourself — a MTLBuffer, an IOSurface — this is how you
size it:
✅ VERIFIED —
minimumByteCountdiscussion, verbatim: "The shape/strides/scalarType of this descriptor are used to compute the addressable byte range of the layout, and the size of that range is returned as the minimum size a backing storage would need to be to contain the ndArray." … "In most cases it is preferred to makeNDArrayinstances which handle creating the allocations for you, but in circumstances where you need to manually handle your allocations, this property can be useful." … "hasDynamicShapemust be false when accessing this property."
Apple's own package uses it exactly that way:
✅ VERIFIED —
1amageek/swift-lm(⚠️ community), state allocation:guard let device = MTLCreateSystemDefaultDevice(), let buffer = device.makeBuffer( length: resolvedDescriptor.minimumByteCount, options: .storageModeShared ) else { throw CoreAIModelAssetError.stateAllocationFailed(name) } memset(buffer.contents(), 0, buffer.length).storageModeSharedis not a preference — it is the documented requirement for every Core AI Metal interop path (§7.7). And note the explicitmemset: nothing zeroes a freshMTLBufferfor you, and a KV cache full of garbage produces plausible-looking wrong output.⚠️ Apple's own
minimumByteCountsample does not compile. The documentation page showsNDArray.RawView(metalBuffer:byteOffset:shape:scalarType:strides:)—shape:beforescalarType:— but the declared order is(metalBuffer:byteOffset:scalarType:shape:strides:interleaveLayout:). The same snippet also writesvar outputs = inferenceFunction.run(inputs: inputs)with notry awaiton anasync throwsmethod. Treat that page's code as illustrative pseudo-code; the prose is reliable.⚠️ A 64-byte floor exists in practice. ✅ VERIFIED —
apple/coreai-modelsPR #62, merged: "Some machine configurations seem to require minimum 64 byte size buffers. This bumps up the new ones from 4 bytes to that minimum." The engine carriesprivate let minimumMPSNDArrayBufferSize = 64with the comment "MPSNDArray enforces 64-byte row-stride alignment". The symptom before the fix was a hard assertion:MPSNDArray.mm:893: failed assertion '[MPSNDArray, initWithBufferImpl:…] Error: buffer is not large enough. Must be 64 bytes'. If you allocate a tiny scalar tensor by hand, floor it at 64 bytes.
11.2 Pre-allocated outputs: the outputViews: parameter¶
The mapping: InferenceFunction.MutableViews passed as outputViews:.
The mechanics are §10 and the trap is §9.4. What is worth adding here is when it is worth doing.
The saving is one allocation per output per call. In a vision app that runs one inference per
camera frame, that is noise. In a token-decode loop at 200 steps/second with a
[1, 1, 151936]-shaped logits tensor, it is not — and Apple's own engine quantifies its
sibling optimisation (reusing the input array) at "~50-100 µs/step" (Apple source comment; no
hardware stated). At 200 steps/s that is 1–2% of wall time, recovered for free.
Rule of thumb: pre-allocate outputs when (a) you call the function in a loop, (b) the output shape is stable across iterations, and (c) you were going to copy the output somewhere anyway. If the shape changes every call, you are re-allocating regardless and the parameter buys you nothing but the §9.4 trap.
⚠️ There is a real counter-case in Apple's own tree. PR #85 (closed unmerged 2026-08-23; previously marked "do not merge") observes: "if we don't use the pre-allocated output view flow, it'll return an
NDArraybacked by the constant directly without making a copy" — the author flagged a performance regression from pre-allocating. So for outputs that alias a model constant (an embedding table, say), letting the framework return its own array can be faster. Measure; don't assume.
11.3 Asynchronous values and ComputeStream¶
The mapping: InferenceFunction.AsyncValue, AsyncMutableValue, AsyncMutableViews,
ComputeStream, and InferenceFunction.encode(inputs:states:outputViews:to:).
The one signature that changes everything¶
✅ VERIFIED —
/documentation/coreai/inferencefunction/encode(inputs:states:outputviews:to:):func encode(inputs: [String : InferenceFunction.AsyncValue], states: consuming InferenceFunction.AsyncMutableViews = AsyncMutableViews(), outputViews: consuming InferenceFunction.AsyncMutableViews = AsyncMutableViews(), to stream: ComputeStream) throws -> [String : InferenceFunction.AsyncValue]It is
throws, notasync throws. Discussion, verbatim: "When this method returns, the compute may still be running onstream. You can pass the returned async values as inputs to subsequentencodecalls to build a pipeline of inferences without waiting for intermediate results, or await them to retrieve the final compute outputs on the CPU."
run is "do the work and give me the answer". encode is "queue the work and give me a receipt".
The receipt is an AsyncValue, and awaiting it is where the async moved to.
The types¶
✅ VERIFIED —
/documentation/coreai/inferencefunction/asyncvalue. Note it is afinal class, andSendable:final class AsyncValue init(_: CVReadOnlyPixelBuffer) init(_: consuming InferenceFunction.AsyncMutableValue) init(_: consuming NDArray) init(unsafeBuffer: consuming any MTLBuffer, byteOffset: Int = 0, scalarType: NDArray.ScalarType, shape: [Int], strides: [Int] = [], interleaveLayout: NDArray.InterleaveLayout? = nil) // ⚠️ no watchOS var kind: InferenceValue.Kind { get } final var ndArray: NDArray? { get async throws } final var pixelBuffer: CVReadOnlyPixelBuffer? { get async throws }Overview, verbatim: "An
AsyncValuecontains an underlyingInferenceValuehowever that value may be actively in-use by some previously dispatched async work, and thus accessing the underlying value below anAsyncValuerequires anawaitto wait for any previous compute writing it to be complete." … "AnAsyncValueis immutable once any previous compute has completed."✅ VERIFIED —
/documentation/coreai/inferencefunction/asyncmutablevalue. Note it is astruct, not a class:struct AsyncMutableValue init(_: consuming CVMutablePixelBuffer) init(_: consuming NDArray) init(descriptor: consuming InferenceValue.Descriptor) // "the descriptor must not have a dynamic shape" init(unsafeBuffer: consuming any MTLBuffer, byteOffset: Int = 0, scalarType: NDArray.ScalarType, shape: [Int], strides: [Int] = [], interleaveLayout: NDArray.InterleaveLayout? = nil) var ndArray: NDArray? { get async throws } var pixelBuffer: CVMutablePixelBuffer? { get async throws }Overview, verbatim (typo Apple's): "When dispatching an
encode(inputs:states:outputViews:to:), mutable values are what is included in the states and output vaiews." … "When encoding a sequence of inferences which each mutate the sameAsyncMutableValue, the framework will insert the necessary synchronization to avoid it being read or written while a previous write is occurring."
That last sentence is the load-bearing one: you do not hand-synchronise a KV cache across pipelined steps. The framework inserts the dependency edges.
✅ VERIFIED —
/documentation/coreai/computestream:final class ComputeStream convenience init() // "Initialize an empty compute stream." init(commandQueue: any MTLCommandQueue) // ⚠️ no watchOS final func currentWorkCompleted() asyncOverview, verbatim (typo Apple's): "A compute stream is what is provided to
encode(inputs:states:outputViews:to:)to encode the work onto the stream. Multiple inferences encoded to the same stream are serialized as needed based on the the values read/written."currentWorkCompleted(): "Waits for all previous work encoded to this stream to be complete." —async, non-throwing, no return.
init(commandQueue:) is the interop door: "You can use this to encode inferences to your own metal
queue."
Apple's pipelining example¶
✅ VERIFIED — verbatim from the
encodedocumentation page. This one does compile (unlike the abbreviated version on theAsyncValuepage, which omits the requiredto:argument and misspells a variable):let computeStream = ComputeStream() let pipelineFunctionOne: InferenceFunction = ... let pipelineFunctionTwo: InferenceFunction = ... let initialInput: NDArray = ... // Run stage one of pipeline and get async value output. let asyncInput = InferenceFunction.AsyncValue(initialInput) let functionOneOutputs = try pipelineFunctionOne.encode(inputs: ["input": asyncInput], to: computeStream) guard let functionOneOutput = functionOneOutputs["output"] else { // Handle unexpected missing output return } // Feed output from function one as an input to function two. // Note that function one may be running the actual compute asynchronously while function two // encodes its inference. let functionTwoOutputs = try pipelineFunctionTwo.encode(inputs: ["input": functionOneOutput], to: computeStream) guard let functionTwoOutput = functionTwoOutputs["output"] else { // Handle unexpected missing output return } // Now both inferences have been encoded guard let finalNDArray = try await functionTwoOutput.ndArray else { // Handle case where output is not an NDArray return }
This is the pattern for a multi-function model. The image_encode → detect handoff in a SAM 3
lite export is precisely stage-one-feeds-stage-two, and doing it with encode means the encoder's
GPU work can still be running while the detector's work is being encoded.
⚠️ SILENT FAILURE: AsyncValue.ndArray copies when the value came from a MTLBuffer¶
⚠️ SILENT COPY — reading
.ndArrayfrom anMTLBuffer-backedAsyncValuegives you a copy, not a view.✅ VERIFIED —
AsyncValue.ndArraydiscussion, verbatim: "If this value was constructed from a provided MTLBuffer directly, then this will return a copy of the data to avoid unsafe aliasing. If aliasing is desired, you can work with the original MTLBuffer directly."The whole point of
init(unsafeBuffer:)was to avoid a copy. Then youawait value.ndArrayto "look at the result" and quietly reintroduce the copy you were avoiding — per call, for the full tensor. Nothing warns. On a[1, 1, 151936]fp16 logits tensor that is ~300 KB per decode step.Safe default: if you built the value from an
MTLBuffer, read theMTLBuffer, not.ndArray. Apple's own pipelined engine never round-trips: it reads the previous step's token straight out ofdecodeOutputBuffers[(step + pipelineDepth - 1) % pipelineDepth].
The unsafe-buffer contract¶
✅ VERIFIED —
AsyncValue.init(unsafeBuffer:)discussion, verbatim: "unsafeBuffermust havesharedstorage mode. Initializing an async value this way requires that you manually ensure the provided metal buffer is not mutated while this value is being used by an inference function." Defaults:byteOffset: Int = 0,strides: [Int] = []("If left empty, they will be computed as contiguous row-major"),interleaveLayout: … = nil.
And in real use, Swift 6's unsafe expression prefix is required at the call site:
✅ VERIFIED —
apple/coreai-models,CoreAIPipelinedEngine.swift:707-800, verbatim:withlet tokenValue = unsafe InferenceFunction.AsyncValue( unsafeBuffer: decodeOutputBuffers[(step + pipelineDepth - 1) % pipelineDepth], byteOffset: 0, scalarType: .int32, shape: tokenShape, strides: tokenStrides ) var keyState = unsafe InferenceFunction.AsyncMutableValue( unsafeBuffer: keyBuffer, byteOffset: 0, scalarType: keyCacheScalarType, shape: keyShape, strides: keyStrides ) var asyncStates = InferenceFunction.AsyncMutableViews() asyncStates.insert(&keyState, for: keyCacheName) asyncStates.insert(&valState, for: valueCacheName) // Encode inference using the public encode() API. // This commits + uses runAfterSyncPoint (no stream wait) — enables true pipelining. let _ = try function.encode( inputs: asyncInputs, states: consume asyncStates, outputViews: consume asyncOutputs, to: computeStream )let computeStream = ComputeStream(commandQueue: pipelineQueue).
When not to do this¶
Apple's own package pays a real price for the pipelined path, and the price list is instructive:
✅ VERIFIED —
apple/coreai-models,CoreAIPipelinedEnginethrows on all of these: - "CoreAI pipelined engine does not support logits (GPU-side sampling). Use a sequential engine for constrained generation or evaluation." - "CoreAI pipelined engine does not supportforcedContinuation." - "Sampling configuration changed mid-generation. Callreset()first."and
supportsLogits == falseon that engine.
Pipelining pushes work onto the GPU and keeps it there, which means intermediate values you used to read on the CPU are no longer conveniently available. That is the trade. For a Core AI LLM this costs you grammar-constrained generation entirely — a first-class architectural constraint covered in Part 4 and Part 1's backend decision table.
Use run until you have a profile that says otherwise. Session 324's own framing —
"For most use cases, the higher-level inference APIs will get you exactly where you need to be" —
is not modesty.
12. Image-typed values, and whose problem orientation is¶
12.1 The type system¶
Core AI has exactly one alternative to NDArray, and it appears when the model author marked a
value as an image at conversion time:
✅ VERIFIED — Apple's integration article, verbatim: "The
NDArraytype represents the input and output tensors from the converted model function at runtime. Values marked as images at conversion time useCVMutablePixelBuffer. Pass your data using the same input names defined at model conversion time."
enum InferenceValue.Kind { case image; case ndArray }
enum InferenceValue.Descriptor { case image(ImageDescriptor); case ndArray(NDArrayDescriptor) }
struct ImageDescriptor: Equatable, Sendable, SendableMetatype
let pixelFormatType: OSType // "The four-character code that identifies the pixel format."
let width: Int
let height: Int
✅ VERIFIED —
/documentation/coreai/imagedescriptor.pixelFormatTypediscussion, verbatim: "Compare this value to thepixelFormatTypeof aCVPixelBuffer."
Three members. There is no colour space, no bytes-per-row, no orientation, no alpha policy. What the framework will tell you about an image argument is: this format code, this width, this height. Everything else is a contract between you and whoever exported the model.
Note the two pixel-buffer types in play, and that they are not CVPixelBuffer:
| Where | Type |
|---|---|
InferenceValue.pixelBuffer, InferenceValue.init(_:), AsyncMutableValue |
CVMutablePixelBuffer |
AsyncValue.init(_:), AsyncValue.pixelBuffer |
CVReadOnlyPixelBuffer |
✅ VERIFIED from the declarations in §9.2 and §11.3. These are the Swift-native pixel-buffer types, and the mutable/read-only split mirrors the
MutableView/Viewsplit onNDArray— same design idea, applied to CoreVideo.
12.2 What Apple's own vision code actually does: not this¶
Here is the finding that should reset your expectations about the image path:
✅ VERIFIED — grepping the entire
swift/Sources/tree ofapple/coreai-models,CVPixelBufferappears zero times. Every vision product — segmentation, object detection, diffusion — takes aCGImage(or aURL/CIImageit immediately converts toCGImage) and hand-builds a Float32NDArraywith Core Graphics and Accelerate. There is noCVPixelBuffer-backed zero-copy path anywhere in that repository.
So Apple ships an image-typed value system in the framework, and Apple's own Swift package does not use it. Two readings, and we cannot distinguish them:
🔴 GAP — is the
CVMutablePixelBufferpath the recommended one for camera input?What is unknown: whether Core AI supports binding an
NDArrayto aCVPixelBufferorIOSurfacefor zero-copy camera input in practice, and why Apple's own vision products don't use the image path.NDArray.RawView.init(ioSurface:…)exists and aCVPixelBufferisIOSurface-backed, so a zero-copy route is at least plausible — but no Apple code or documentation demonstrates it end to end.What would resolve it: an Apple sample project (there are none), a
coreai-torchdoc page showing how a value is marked as an image at conversion time (we could not find one), or an Apple-staff forum answer.Safe default meanwhile: follow Apple's own package, not Apple's own framework. Take a
CGImage, render it into a known bitmap context, normalise with vDSP, and write Float32 into anNDArray. That path is fully worked, ships inapple/coreai-models, and does not depend on the unresolved question. Revisit if you measure the render+copy as a bottleneck.
12.3 The CGImage → tensor recipe, from Apple's package¶
Every decision in this code is deliberate and worth copying:
✅ VERIFIED —
apple/coreai-models,CoreAIShared/Image/ImagePreprocessor.swift:public enum ImageStrategy: String, Codable, Sendable { case stretch case centerCrop = "center_crop" case pad } public struct ImagePreprocessor: Sendable { public let targetSize: CGSize public let mean: (CGFloat, CGFloat, CGFloat) public let std: (CGFloat, CGFloat, CGFloat) public let rescaleFactor: CGFloat public static let gemma3: ImagePreprocessor // 896×896, ImageNet mean/std, rescale 1.0 public static let clip: ImagePreprocessor // 336×336, CLIP mean/std, rescale 1.0 // NHWC RGBA Float32 — returns (Data, width, height) public func preprocess(cgImage: CGImage) throws -> (Data, Int, Int) // Planar CHW Float32 — [3, H, W] flattened public func preprocessCHW(cgImage: CGImage) throws -> [Float] public func preprocessCHW(cgImage: CGImage, strategy: ImageStrategy) throws -> [Float] }
The pixel-format decisions, all ✅ VERIFIED from that file:
- Colour space is hard-pinned to
CGColorSpace.sRGBin both the resize and normalize contexts — not device RGB — so results are display-independent. bitsPerComponent: 8,bytesPerRow: width * 4,bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue— RGBA8 with alpha ignored.ctx.interpolationQuality = .high, with the source's own claim that it "matches PIL BICUBIC closely". This is the PyTorch-parity lever. If your Swift outputs drift from your Python reference and the model is otherwise correct, check this first.- Output is
[H, W, 4]Float32 NHWC with alpha zero-filled, and the doc says: "The caller transposes to[1, 3, H, W](NCHW) before binding to a vision encoder input." - Normalisation is
(pixel * rescale − mean) / std, folded into a single fused multiply-add per channel, with/255.0folded intoscale— sorescaleFactor: 1.0means "map [0,255] → [0,1]".
// ✅ VERIFIED — apple/coreai-models, ImagePreprocessor.swift:251-265, verbatim
let scale = Float(rescaleFactor) / 255.0
let means: [Float] = [Float(mean.0), Float(mean.1), Float(mean.2)]
let stds: [Float] = [Float(std.0), Float(std.1), Float(std.2)]
var channel = [Float](repeating: 0, count: pixelCount)
let n = vDSP_Length(pixelCount)
for c in 0..<3 {
var a = scale / stds[c]
var b = -means[c] / stds[c]
vDSP_vfltu8(rawPixels.advanced(by: c), 4, &channel, 1, n) // UInt8 -> Float, stride 4
vDSP_vsmsa(channel, 1, &a, &b, dstBase.advanced(by: c), 4, n) // out = in*a + b, stride 4
}
var zero: Float = 0
vDSP_vfill(&zero, dstBase.advanced(by: 3), 4, n) // alpha = 0
There is a second, faster implementation in the same repo for the no-per-channel-stats case:
✅ VERIFIED —
CGImageUtils.toNormalizedPlanarRGBhardcodes the diffusion normalisation(pixel / 127.5) − 1.0and does the whole planar conversion in four vDSP calls total, because the de-interleave already writes planar so a singlevDSP_vsmsacovers all3 * pixelCountelements at once. This is the faster of the two and the one to copy when you don't need per-channel mean/std. (⚠️ It usesCGImageAlphaInfo.premultipliedLast, notnoneSkipLast, and itsresizeis square-only — the two files disagree with each other.)
Resize-strategy semantics, exactly as implemented (✅ VERIFIED):
| Strategy | Implementation | Trap |
|---|---|---|
.stretch |
draw into targetW × targetH, filling the rect |
aspect ratio not preserved |
.centerCrop |
shortest edge → target, render, then centered crop | renders twice; negative crop origin when the source is smaller than the target in the long dimension → cropping(to:) returns nil → .renderFailed |
.pad |
longest edge → target, draw centered on a zero canvas | pads with zeros in pixel space, so the padding becomes (0 − mean)/std after normalisation, not tensor-space zero |
12.4 ⚠️ SILENT FAILURE: EXIF orientation is nobody's job, so it is yours¶
⚠️ SILENT FAILURE — the same JPEG produces two differently-oriented tensors depending on which loader you used, and nothing tells you.
✅ VERIFIED — grepping
apple/coreai-models' entire non-LLM Swift tree fororientation|exif|kCGImagePropertyreturns zero handling. And the two entry points in that repository disagree: -ImagePreprocessor.preprocess(imageURL:)goes throughCIImage(contentsOf:), which does apply EXIF orientation. - The CLI tools go throughCGImageSourceCreateImageAtIndex, which does not.So a photo shot in portrait on an iPhone — EXIF orientation 6, pixels stored landscape — is fed to the model rotated 90° by one path and upright by the other. The research pass that found this called it "a real, unfixed inconsistency in the repo."
Why it is silent: a segmentation model handed a sideways image does not throw. It returns masks. They are just wrong, in a way that looks like "the model isn't very good" rather than "the input was rotated". This is the single most expensive debugging session in on-device vision, and the cause is four lines of missing code.
Safe default — normalise orientation at the boundary, once, explicitly:
import ImageIO import CoreImage import UniformTypeIdentifiers /// Load a file into an upright CGImage with EXIF orientation applied. func loadUprightCGImage(at url: URL) -> CGImage? { guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else { return nil } let props = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] let raw = props?[kCGImagePropertyOrientation] as? UInt32 ?? 1 guard raw != 1 else { return cgImage } // already up let ci = CIImage(cgImage: cgImage) .oriented(forExifOrientation: Int32(raw)) return CIContext().createCGImage(ci, from: ci.extent) }For live camera frames the orientation comes from the connection, not from EXIF — read
AVCaptureConnection.videoRotationAngle(or your capture pipeline's equivalent) and normalise before preprocessing. ForPhotosPicker/Transferableloads, the item may or may not be pre-rotated depending on the representation you asked for; assert on it in a test.Write a test with a deliberately EXIF-rotated fixture. It is the only way this stays fixed.
12.5 One more coordinate trap, for completeness¶
Since you are already in vision-land: the output side has a matching problem, and this one is platform-conditional.
⚠️ ✅ VERIFIED —
apple/coreai-models,SegmentationPostprocessor.decodeSegment:// AppKit/macOS uses bottom-left origin, so flip Y for macOS. // UIKit/iOS uses top-left origin matching the model output directly. #if os(macOS) box = CGRect(x: x0 * imageWidth, y: (1.0 - y1) * imageHeight, …) #else box = CGRect(x: x0 * imageWidth, y: y0 * imageHeight, …) #endif
Segment.boxorigin flips on macOS.DetectedObject.boundingBoxdoes not — it is always top-left, on every platform. AndSegmentationVisualization.renderPromptBoxesdemands top-left "regardless of platform", so on macOS you must not feedSegment.boxstraight into it. Two box conventions (XYXY normalized vscxcywhnormalized) and two origin conventions coexist in one Apple repository.Full treatment in Part 16. Mentioned here so that when your masks land in the right place on iPhone and upside-down on Mac, you know it is not your code.
13. ✅ The error-type answer, and how to write a catch block¶
This was the most consequential unknown in the Core AI Swift API, and it blocked something you have to do on day one. It is now settled — by reading the SDK itself.
13.1 The answer, stated precisely¶
✅ VERIFIED (SDK) — in the macOS 27.0 beta interface, the throwing runtime APIs throw untyped errors, and the only public error type in the entire Core AI Swift surface is
CoreAIAsset.AssetError.On 2026-07-29 the shipped module interfaces were dumped from the Xcode 27.0 beta (27A5228h) macOS 27.0 SDK into
notes/sdk-interfaces/. The structural discovery first:CoreAIis an umbrella. TheCoreAImodule is a one-line@_exported public import CoreAIDelegates, andCoreAIDelegatesin turn re-exportsCoreAIAsset,CoreAICommon,CoreAICompilerandCoreAIRuntime(CoreAIDelegates-27.0-macos.swiftinterface:5-8).CoreAICommon,CoreAICompilerandCoreAICachehave empty public Swift surfaces in this beta.With the whole surface in hand, the error question closes:
CoreAIRuntimedeclares no public error type at all. Every throwing API is a plain, untypedthrows/async throws: bothrunoverloads (✅ SDK-verified —CoreAIRuntime-27.0-macos.swiftinterface:96-103),encode(:92-95), and the async value getters onAsyncValue/AsyncMutableValue(:28-36,:60-68).- The loading surface lives in
CoreAIDelegates, and it is untyped too:AIModel.init(contentsOf:options:)andAIModel.specialize(…)areasync throws(✅ SDK-verified —CoreAIDelegates-27.0-macos.swiftinterface:22-26),loadFunction(named:)isthrows -> InferenceFunction?(:119-122), and all four cachedelete*methods plusmodel(for:options:)are plainthrows(:33-43).- The one public error type is
AssetErrorinCoreAIAsset(✅ SDK-verified —CoreAIAsset-27.0-macos.swiftinterface:230-237,Kindat:239-247):public struct AssetError: Error, LocalizedError { // CoreAIAsset-27.0:230-237 public var kind: AssetError.Kind public var debugMessage: String? public init(kind: AssetError.Kind, debugMessage: String?) public var errorDescription: String? { get } } extension AssetError { // CoreAIAsset-27.0:239-247 public enum Kind: Sendable { case unsupportedVersion(String) // "a more recent version of this library generated the asset" case invalidFeatureType(String) case corruptedMetadata // "the asset metadata is corrupted" case invalidName case duplicateName // "a component with that name already exists in the asset" } }Its documentation abstract is "An error that occurs during model asset operations." Every case is about the asset file: metadata, names, versions. Nothing in it describes an inference failure, a specialization failure, a compute-unit failure, or a cache-eviction conflict — and in the macOS 27.0 beta interface, no public type does. There is no public inference, specialization or cache error enum to catch.
Note also that
AssetErrorhas a public initializer, so it is not a sealed system error — app code can construct one. It is a reporting type for asset tooling, not the framework's error taxonomy; the framework does not publish one.What Apple's own documentation does instead of naming a type. Several
- Throws:clauses are malformed in the published docs and render as orphaned Notes with no type attached: - onAIModel.init(contentsOf:options:)andAIModel.specialize(…): "If specializing or loading the model fails." - onAIModelCache.model(for:options:): "If a cache entry was found but the specialized asset failed to load."Those clauses now read as consistent with the interface: there is no type name to print, because the throws are untyped.
What about
AIModelError? ⚠️ Community-reported, from aapple/coreai-modelsGitHub issue thread (2026-07, macOS 27 beta, AOT-compiled.aimodelc): a failed load surfaced asCoreAIDelegates.AIModelError error 3 ← raw AIModel.load invalidCompiledModel ← as re-mapped by llm-runner / LanguageBundleThe interface dump confirms what that breadcrumb implied: something spelled
AIModelErrorexists insideCoreAIDelegates, but it is not present in the module's public interface in the macOS 27.0 beta SDK — it is internal, surfacing only throughNSErrorbridging with a numeric code. You still cannot pattern-match on it, and that is now a verified fact about the beta SDK rather than a documentation hole.What this means for your
catchblocks: matchAssetError(and switch on its fiveKindcases above) for asset operations; everything else — specialization, load, run, cache deletion — must go through a genericcatch, logging theNSErrordomain/code for diagnostics. Branching on "out of memory" vs "compute unit unavailable" vs "cache invalidated" is not possible with the public API in this beta. If a later seed publishes an error taxonomy, this section will be revised; the diagnostic helper in §13.2 is how you would notice first.
13.2 The practice: catch AssetError, then catch broadly, log richly, degrade¶
Because the beta SDK publishes no taxonomy beyond AssetError, correct Core AI error handling has
three properties: it catches everything, it records enough to diagnose later, and it
degrades rather than retrying blindly.
import CoreAI
import Foundation
import os
private let log = Logger(subsystem: "com.example.app", category: "CoreAI")
/// Everything we can learn about an unknown error, without naming its type.
func describeCoreAIError(_ error: any Error) -> String {
let dynamicType = String(reflecting: type(of: error)) // e.g. "CoreAI.AssetError"
let ns = error as NSError
var parts: [String] = [
"type=\(dynamicType)",
"domain=\(ns.domain)",
"code=\(ns.code)",
"localized=\(error.localizedDescription)",
]
if let localized = error as? LocalizedError {
if let d = localized.errorDescription { parts.append("errorDescription=\(d)") }
if let r = localized.failureReason { parts.append("failureReason=\(r)") }
if let s = localized.recoverySuggestion { parts.append("recovery=\(s)") }
}
if let assetError = error as? AssetError { // the ONE type we can name
parts.append("assetKind=\(assetError.kind)")
if let m = assetError.debugMessage { parts.append("debugMessage=\(m)") }
}
if !ns.userInfo.isEmpty {
parts.append("userInfo=\(ns.userInfo.keys.sorted { "\($0)" < "\($1)" })")
}
return parts.joined(separator: " ")
}
String(reflecting: type(of: error)) is the important line. It gives you the fully-qualified
dynamic type of whatever was actually thrown — including non-public types like
CoreAIDelegates.AIModelError that the interface hides (§13.1). Log it: it is your only window
into the internal taxonomy, and the first place a future seed's public error type will show up.
Now the ladder. Note the ordering rule: the specific named type first, the broad catch last —
Swift matches catch clauses in order, so a bare catch above a typed one makes the typed one dead
code.
enum FeatureState {
case ready(InferenceFunction)
case unavailable(reason: String) // degrade: hide or disable the feature
case needsRedownload // the asset itself is bad
}
func prepareFeature(modelURL: URL,
options: SpecializationOptions = .default) async -> FeatureState {
do {
// 1. Cheap probe. Distinct failure domain from specialization.
guard AIModelAsset.isValid(at: modelURL) else {
return .needsRedownload
}
// 2. Cache check, then specialize.
let model: AIModel
if let cached = try AIModelCache.default.model(for: modelURL, options: options) {
model = cached
} else {
model = try await AIModel(contentsOf: modelURL, options: options)
}
// 3. Load the function. `nil` and `throws` mean different things — see §4.
guard let function = try model.loadFunction(named: "main") else {
log.error("No 'main' function. Available: \(model.functionNames, privacy: .public)")
return .needsRedownload
}
return .ready(function)
} catch let error as AssetError {
// The one type Apple documents. Asset-level problems: corrupt metadata,
// an asset written by a newer toolchain, an invalid component name.
log.error("Core AI asset error: \(describeCoreAIError(error), privacy: .public)")
return .needsRedownload
} catch {
// EVERYTHING else — specialization failure, load failure, OOM, the
// non-public AIModelError. We cannot distinguish these today (§13.1).
log.error("Core AI failure: \(describeCoreAIError(error), privacy: .public)")
return .unavailable(reason: error.localizedDescription)
}
}
⚠️ Ordering matters and it is easy to get wrong.
catch let error as AssetErrormust come before the barecatch. An earlier batch of guides in this series shipped a catch ladder in the wrong order; it compiles, it just never runs the specific branch. If you add more typed clauses later, keep the barecatchlast, always.
13.3 Four rules that survive the gap¶
(1) Never try? a Core AI call whose failure you would want to know about. try? collapses
"the name was wrong" and "the device ran out of memory" into nil, and the gap means you cannot
recover the difference afterwards.
(2) Distinguish the nil channels, which are documented. Three APIs return nil with a
precise, verified meaning, and none of them is an error:
| API | nil means |
throws means |
|---|---|---|
loadFunction(named:) |
no function with that name | loading it failed |
functionDescriptor(for:) |
no function with that name | (does not throw) |
AIModelCache.model(for:options:) |
not cached — specialize | a cache entry existed but failed to load |
AIModel.init?(resolvingBookmark:) |
valid bookmark, stale entry | malformed bookmark data |
AIModelCache(appGroup:) |
invalid identifier / entitlement / container | (does not throw) |
✅ VERIFIED — the bookmark row is worth reading twice. From
init(resolvingBookmark:): "If it cannot be resolved due to the specialized asset entry no longer being present nil is returned", and NOTE: "If the bookmark data is malformed due to not being sourced fromAIModel.bookmarkDataan error is thrown."nilis the recoverable case (re-download and re-specialize); the throw is a programming error.
(3) Treat cache-deletion failure as expected, not exceptional. Per §3.4, the reference
documentation says deleting an entry still referenced by a live AIModel throws. Release your
models, then delete, then retry once.
(4) Degrade, don't loop. Because you cannot tell a transient failure from a permanent one, a
retry loop around AIModel.init is a battery bug waiting to happen. Fail the feature, tell the
person, offer a manual retry.
13.4 What the rest of the stack throws, for contrast¶
It is worth knowing that the layer above Core AI has a perfectly good error taxonomy, so if you are
using apple/coreai-models rather than raw Core AI, you are not in this position:
✅ VERIFIED —
apple/coreai-models,ModelBundle.BundleError:.missingMetadata(URL),.malformedMetadata(URL, underlying:),.unsupportedVersion(String),.kindMismatch(expected:got:),.missingField(String),.missingAsset(key:path:),.pointedAtModelAsset(URL). PlusKVCacheError.capacityExceeded,XGrammarError.schemaCompilationFailed(String),ConstrainedGenerationError.{invalidSchema, generationFailed}.
and a community integration defines twenty cases of its own rather than rely on the framework's:
✅ VERIFIED —
1amageek/swift-lm(⚠️ community),CoreAIModelAssetError:.invalidAsset(URL),.functionNotFound(String),.inputNotFound(function:input:),.unexpectedInput(function:input:),.invalidInputShape(function:input:expected:provided:),.invalidInputDataType(function:input:expected:provided:),.stateNotFound(function:state:),.statefulFunctionRequiresStateSession(String),.stateAllocationFailed(String),.unsupportedOutputCount(Int),.contractMismatch(function:message:), and nine more.
Both of them wrap Core AI rather than propagating it. That is the pattern: define your own
error enum at the boundary, put the framework's opaque error in an underlying: any Error
associated value, and never let an un-named error escape into your app's control flow.
14. A complete runner you can paste¶
This puts §3, §4, §6, §8, §9 and §13 together into one type. It is descriptor-driven — it never hardcodes a shape — so it survives a model re-export (§6.1). Every Core AI member it calls is ✅ VERIFIED in the sections above; the assembly is mine and is marked 🟡 RECONSTRUCTED as a whole.
import CoreAI
import Foundation
import os
// MARK: - Span helper (see §7.5 — Span doesn't conform to Sequence)
extension Span where Element == Int {
var product: Int {
var result = 1
for i in 0..<count { result *= self[i] }
return result
}
}
// MARK: - Errors (see §13 — wrap, never propagate an unnamed error)
enum TensorRunnerError: Error, LocalizedError {
case notAModelAsset(URL)
case functionNotFound(requested: String, available: [String])
case functionLoadFailed(name: String, underlying: any Error)
case argumentNotFound(String, available: [String])
case argumentIsImage(String)
case scalarTypeMismatch(String, expected: NDArray.ScalarType, actual: NDArray.ScalarType)
case shapeMismatch(String, expected: [Int], provided: [Int])
case elementCountMismatch(String, expected: Int, provided: Int)
case nonContiguous(String)
case interleavedLayout(String)
case outputMissing(String)
case outputNotATensor(String)
case unsupportedScalarType(NDArray.ScalarType)
case coreAIFailure(stage: String, underlying: any Error)
var errorDescription: String? {
switch self {
case .notAModelAsset(let url):
return "Not a Core AI model asset: \(url.lastPathComponent)"
case .functionNotFound(let requested, let available):
return "No function '\(requested)'. Available: \(available)"
case .functionLoadFailed(let name, let underlying):
return "Failed to load function '\(name)': \(underlying.localizedDescription)"
case .argumentNotFound(let name, let available):
return "No argument '\(name)'. Available: \(available)"
case .argumentIsImage(let name):
return "Argument '\(name)' is image-typed; this runner handles tensors only."
case .scalarTypeMismatch(let name, let expected, let actual):
return "Argument '\(name)' expects \(expected) but got \(actual)."
case .shapeMismatch(let name, let expected, let provided):
return "Argument '\(name)' expects shape \(expected) but got \(provided)."
case .elementCountMismatch(let name, let expected, let provided):
return "Argument '\(name)' needs \(expected) elements; \(provided) provided."
case .nonContiguous(let name):
return "Argument '\(name)' has a non-contiguous layout; use the stride-walking writer."
case .interleavedLayout(let name):
return "Argument '\(name)' has an interleave layout; block strides are not handled here."
case .outputMissing(let name):
return "Output '\(name)' was not returned. Did you pass an outputView for it? (§9.4)"
case .outputNotATensor(let name):
return "Output '\(name)' is an image, not a tensor."
case .unsupportedScalarType(let t):
return "Unsupported scalar type \(t)."
case .coreAIFailure(let stage, let underlying):
return "Core AI failed during \(stage): \(underlying.localizedDescription)"
}
}
}
// MARK: - The runner
/// A descriptor-driven wrapper around one Core AI inference function.
///
/// Deliberately narrow: Float32 tensor in, Float32 tensor out, no states.
/// Extend from here — the descriptor plumbing is the reusable part.
public actor TensorRunner {
private static let log = Logger(subsystem: "com.example.app", category: "CoreAI")
private let model: AIModel
private let function: InferenceFunction
private let descriptor: InferenceFunctionDescriptor
// MARK: Setup
/// Loads and (if necessary) specializes the model, then loads one function.
///
/// - Parameter onSpecializationNeeded: called on the main actor *before* a cold
/// specialization begins. Show explanatory UI here; this can take seconds.
public init(
modelURL: URL,
functionName: String = "main",
options: SpecializationOptions = .default,
onSpecializationNeeded: @Sendable @MainActor () -> Void = {}
) async throws {
// §3.3 step 0 — cheap validity probe, no throw, no await.
guard AIModelAsset.isValid(at: modelURL) else {
throw TensorRunnerError.notAModelAsset(modelURL)
}
// §3.3 steps 2–3 — cache first, then specialize. One `options` value throughout,
// because the cache key is (URL, options). See the §3.3 warning.
let model: AIModel
do {
if let cached = try AIModelCache.default.model(for: modelURL, options: options) {
model = cached
} else {
await onSpecializationNeeded()
model = try await AIModel(contentsOf: modelURL, options: options)
}
} catch {
throw TensorRunnerError.coreAIFailure(stage: "specialization", underlying: error)
}
self.model = model
// §4 — separate the two failure channels.
let loaded: InferenceFunction?
do {
loaded = try model.loadFunction(named: functionName)
} catch {
throw TensorRunnerError.functionLoadFailed(name: functionName, underlying: error)
}
guard let function = loaded else {
throw TensorRunnerError.functionNotFound(requested: functionName,
available: model.functionNames)
}
self.function = function
self.descriptor = function.descriptor
Self.log.info("""
Core AI function '\(functionName, privacy: .public)' ready — \
inputs: \(self.descriptor.inputNames, privacy: .public), \
outputs: \(self.descriptor.outputNames, privacy: .public), \
states: \(self.descriptor.stateNames, privacy: .public)
""")
}
// MARK: Introspection
public var inputNames: [String] { descriptor.inputNames }
public var outputNames: [String] { descriptor.outputNames }
public var stateNames: [String] { descriptor.stateNames }
/// Concrete shape and scalar type for an input, with `-1` for dynamic axes. See §6.
public func inputShape(_ name: String) throws -> (shape: [Int], scalarType: NDArray.ScalarType) {
guard let value = descriptor.inputDescriptor(of: name) else {
throw TensorRunnerError.argumentNotFound(name, available: descriptor.inputNames)
}
guard case .ndArray(let d) = value else {
throw TensorRunnerError.argumentIsImage(name)
}
return (d.shape, d.scalarType)
}
/// Log every input whose preferred layout is not plain contiguous. See §11.1.
public func auditLayouts(concreteShapes: [String: [Int]] = [:]) {
for name in descriptor.inputNames {
guard let v = descriptor.inputDescriptor(of: name),
case .ndArray(let d) = v else { continue }
let resolved = d.hasDynamicShape
? d.resolvingDynamicDimensions(concreteShapes[name] ?? d.shape)
: d
let preferred = resolved.preferredStrides
let contiguous = Self.rowMajorStrides(resolved.shape)
if preferred != contiguous {
Self.log.notice("""
input '\(name, privacy: .public)': preferred strides \
\(preferred, privacy: .public) != contiguous \(contiguous, privacy: .public) \
— a contiguous NDArray will be copied on every run.
""")
}
}
}
// MARK: Inference
/// Run with a single Float32 input and read a single Float32 output.
public func run(
_ values: [Float],
inputName: String,
outputName: String,
concreteShape: [Int]? = nil
) async throws -> (values: [Float], shape: [Int]) {
// --- Build the input, validated against the descriptor (§6, §8) ---
guard let inputValue = descriptor.inputDescriptor(of: inputName) else {
throw TensorRunnerError.argumentNotFound(inputName, available: descriptor.inputNames)
}
guard case .ndArray(let inputDescriptor) = inputValue else {
throw TensorRunnerError.argumentIsImage(inputName)
}
guard inputDescriptor.scalarType == .float32 else {
throw TensorRunnerError.scalarTypeMismatch(inputName,
expected: inputDescriptor.scalarType,
actual: .float32)
}
let resolved: NDArrayDescriptor
if inputDescriptor.hasDynamicShape {
// §6.4 — you MUST resolve before touching preferredStrides/minimumByteCount,
// and before NDArray(descriptor:).
guard let concreteShape else {
throw TensorRunnerError.shapeMismatch(inputName,
expected: inputDescriptor.shape,
provided: [])
}
resolved = inputDescriptor.resolvingDynamicDimensions(concreteShape)
} else {
resolved = inputDescriptor
}
let elementCount = resolved.shape.reduce(1, *)
guard elementCount == values.count else {
throw TensorRunnerError.elementCountMismatch(inputName,
expected: elementCount,
provided: values.count)
}
// Contiguous allocation. Swap for `NDArray(descriptor: resolved)` once
// `auditLayouts` says the preferred strides differ — and then use the
// stride-walking writer from §8.3.
var input = NDArray(shape: resolved.shape, scalarType: .float32)
var writeView = input.mutableView(as: Float.self)
guard writeView.interleaveLayout == nil else {
throw TensorRunnerError.interleavedLayout(inputName)
}
guard var slots = writeView.contiguousElements else {
throw TensorRunnerError.nonContiguous(inputName)
}
for i in 0..<values.count { slots[i] = values[i] }
// --- Run (§8.4). No states, no pre-allocated outputs. ---
var outputs: InferenceFunction.Outputs
do {
outputs = try await function.run(inputs: [inputName: input])
} catch {
throw TensorRunnerError.coreAIFailure(stage: "run", underlying: error)
}
// --- Extract (§9). remove() is destructive; .ndArray is consuming. ---
guard let value = outputs.remove(outputName) else {
throw TensorRunnerError.outputMissing(outputName)
}
guard value.kind == .ndArray else {
throw TensorRunnerError.outputNotATensor(outputName)
}
guard let array = value.ndArray else {
throw TensorRunnerError.outputNotATensor(outputName)
}
// Branch on the ARRAY's own scalar type, never on a cached flag (§7.10).
let shape = array.shape
switch array.scalarType {
case .float32:
return (Self.flatten(array, as: Float.self), shape)
#if !((os(macOS) || targetEnvironment(macCatalyst)) && arch(x86_64))
case .float16:
return (Self.flatten(array, as: Float16.self).map(Float.init), shape)
#endif
default:
throw TensorRunnerError.unsupportedScalarType(array.scalarType)
}
}
// MARK: Helpers
/// `run`'s returned arrays are documented row-major contiguous (§9.3), but we
/// still handle the general case so this is safe to reuse elsewhere.
private static func flatten<T: BitwiseCopyable>(_ array: NDArray, as type: T.Type) -> [T] {
let view = array.view(as: type)
if let elements = view.contiguousElements {
var out = [T]()
out.reserveCapacity(elements.count)
for i in elements.indices { out.append(elements[i]) }
return out
}
return view.withUnsafePointer { ptr, shape, strides in
let total = shape.product
let rank = shape.count
var out = [T]()
out.reserveCapacity(total)
var index = [Int](repeating: 0, count: rank)
for _ in 0..<total {
var offset = 0
for axis in 0..<rank { offset += index[axis] * strides[axis] }
out.append(ptr[offset])
var axis = rank - 1
while axis >= 0 {
index[axis] += 1
if index[axis] < shape[axis] { break }
index[axis] = 0
axis -= 1
}
}
return out
}
}
private static func rowMajorStrides(_ shape: [Int]) -> [Int] {
var strides = [Int](repeating: 1, count: shape.count)
for axis in stride(from: shape.count - 2, through: 0, by: -1) {
strides[axis] = strides[axis + 1] * shape[axis + 1]
}
return strides
}
}
14.1 Using it¶
let runner = try await TensorRunner(
modelURL: Bundle.main.url(forResource: "MyModel", withExtension: "aimodel")!,
functionName: "main"
) {
// Runs on the main actor, only on a cold cache. Show your first-run UI here.
appState.showModelPreparationScreen()
}
await runner.auditLayouts() // one-time §11.1 check
let (logits, shape) = try await runner.run(
features,
inputName: "features",
outputName: "logits"
)
print("logits \(shape): \(logits.prefix(8))")
14.2 Where to extend it¶
| Need | Change |
|---|---|
| States (KV cache) | allocate one NDArray per stateNames entry at init (§10.3), hold them as stored properties, rebuild MutableViews per call |
| Pre-allocated outputs | add an outputViews: argument — and delete the outputs.remove for that name (§9.4) |
| Preferred strides | NDArray(descriptor: resolved) + the stride-walking writer from §8.3 |
| Image inputs | case .image(let d) = inputValue, build a CVMutablePixelBuffer matching d.pixelFormatType/width/height, and normalise orientation first (§12.4) |
| Multi-function pipelines | encode(…to: stream) and AsyncValue (§11.3) instead of run |
| Concurrency | the actor already serialises; for real parallelism drop the actor, keep InferenceFunction shared, and bound the fan-out (§5.2) |
15. Quick reference¶
15.1 The whole runtime API on one screen¶
✅ SDK-verified as a whole (2026-07-29): every declaration in this block was checked against the captured macOS 27.0 beta interfaces (
CoreAIRuntime/CoreAIDelegates/CoreAIAsset— see §16.1).import CoreAIworks becauseCoreAIis an umbrella re-export ofCoreAIDelegates, which re-exports the rest.
import CoreAI // iOS/iPadOS/macOS/Mac Catalyst/tvOS/visionOS/watchOS 27.0+ (Beta)
// ───────── Model ─────────
struct AIModel: Sendable, SendableMetatype
init(contentsOf: URL, options: SpecializationOptions = .default) async throws
init?(resolvingBookmark: Data) throws
func loadFunction(named: String) throws -> InferenceFunction?
func functionDescriptor(for: String) -> InferenceFunctionDescriptor?
var functionNames: [String]
@discardableResult static func specialize(contentsOf:options:cache:cachePolicy:) async throws -> AIModel
var bookmarkData: Data
static var deviceArchitectureName: String
struct AIModelAsset // unspecialized; cannot infer
init(contentsOf: URL) throws // NOT async
static func isValid(at: URL) -> Bool
func summary(includingStatistics: Bool) throws -> AIModelAsset.Summary?
var metadata: AIModelAsset.Metadata
mutating func updateMetadata(_: (inout Metadata) throws -> Void) throws
mutating func removeDerivedArtifacts() throws
// ───────── Function ─────────
struct InferenceFunction: Sendable, SendableMetatype
let descriptor: InferenceFunctionDescriptor
func run(inputs: [String: NDArray],
states: consuming MutableViews = .init(),
outputViews: consuming MutableViews = .init()) async throws -> Outputs
func run(inputs: borrowing Inputs, states: …, outputViews: …) async throws -> Outputs
func encode(inputs: [String: AsyncValue],
states: consuming AsyncMutableViews = .init(),
outputViews: consuming AsyncMutableViews = .init(),
to stream: ComputeStream) throws -> [String: AsyncValue] // NOT async
struct Inputs { init(); mutating func insert(…, for: String) } // 3 overloads
struct Outputs { mutating func remove(_: String) -> InferenceValue?
var count: Int; var names: some Collection<String> }
struct MutableViews { init(); mutating func insert(…, for: String) } // 3 overloads
final class AsyncValue // Sendable; init(NDArray) / (CVReadOnlyPixelBuffer) /
// (unsafeBuffer:…) / (AsyncMutableValue)
// var ndArray: NDArray? { get async throws }
struct AsyncMutableValue // init(NDArray) / (CVMutablePixelBuffer) / (descriptor:) / (unsafeBuffer:…)
struct AsyncMutableViews { init(); mutating func insert(_: inout AsyncMutableValue, for: String) }
struct InferenceFunctionDescriptor: Sendable
var name: String
var inputCount: Int; var inputNames: [String]; func inputDescriptor(of: String) -> InferenceValue.Descriptor?
var outputCount: Int; var outputNames: [String]; func outputDescriptor(of: String) -> InferenceValue.Descriptor?
/* no stateCount */ var stateNames: [String]; func stateDescriptor(of: String) -> InferenceValue.Descriptor?
// ───────── Values ─────────
struct InferenceValue
var kind: Kind // .image | .ndArray
var ndArray: NDArray? // ⚠️ CONSUMING read
var pixelBuffer: CVMutablePixelBuffer?
init(_ pixelBuffer: consuming CVMutablePixelBuffer)
enum Kind { case image, ndArray }
enum Descriptor { case image(ImageDescriptor), ndArray(NDArrayDescriptor) }
struct View; struct MutableView
struct NamedMutableViews { mutating func take(_: String) -> MutableView? } // ⚠️ 2nd take = fatal
protocol ViewRepresentable { func view() -> InferenceValue.View }
protocol MutableViewRepresentable { mutating func mutableView() -> InferenceValue.MutableView }
struct ImageDescriptor: Equatable, Sendable
let pixelFormatType: OSType; let width: Int; let height: Int
// ───────── Arrays ─────────
struct NDArray: Escapable, Sendable, ViewRepresentable, MutableViewRepresentable
init(shape: [Int], scalarType: ScalarType) // contiguous row-major
init(shape: [Int], scalarType: ScalarType, strides: [Int])
init(shape: [Int], scalarType: ScalarType, strides: [Int], interleaveLayout: InterleaveLayout)
init<Scalar: BitwiseCopyable>(scalars: some Sequence, shape: [Int])
init(descriptor: consuming NDArrayDescriptor) // ⚠️ may be non-contiguous
var shape: [Int]; var scalarType: ScalarType; var strides: [Int]
var interleaveLayout: InterleaveLayout?
func view<T: BitwiseCopyable>(as: T.Type = T.self) -> View<T>
mutating func mutableView<T: BitwiseCopyable>(as: T.Type = T.self) -> MutableView<T>
func rawView() -> RawView
mutating func mutableRawView() -> MutableRawView
struct View<Element: BitwiseCopyable> // non-escapable borrow
var isContiguous: Bool; var rank: Int
var shape: Span<Int>; var strides: Span<Int> // ⚠️ Span is NOT a Sequence
var contiguousElements: Span<Element>? // nil when padded/strided/interleaved
subscript<let rank: Int>(scalarAt: InlineArray<rank, Int>) -> Element
func withUnsafePointer<R, E>((UnsafePointer<Element>, Span<Int>, Span<Int>) throws(E) -> R) throws(E) -> R
func slice(at: [any RangeExpression]) -> View<Element> // trailing dims default to .all
var rawView: RawView
struct MutableView<Element: BitwiseCopyable>
var contiguousElements: MutableSpan<Element>?
mutating func copyElements(from: some Sequence<Element>)
mutating func copyElements(fromContentsOf: some Collection<Element>)
func withUnsafeMutablePointer<R, E>((UnsafeMutablePointer<Element>, Span<Int>, Span<Int>) throws(E) -> R) throws(E) -> R
func slice(at:) -> MutableView<Element>
mutating func mutatingSlice(at:) -> MutableView<Element> // ⚠️ use THIS to write through
var view: View<Element>; var mutableRawView: MutableRawView
struct RawView // init(bytes:…) / init(metalBuffer:…) / init(ioSurface:…)
consuming func view<T: BitwiseCopyable>(as: T.Type = T.self) -> View<T> // ⚠️ consuming
struct MutableRawView
func view<T>(as: T.Type) -> MutableView<T> // ⚠️ returns MUTABLE view
struct InterleaveLayout: Equatable, Sendable { init(dimension: Int, factor: Int) }
protocol RangeExpression: Sendable { static var all: _AllRange { get }
func relative(to: Range<Int>) -> Range<Int> }
enum ScalarType: CaseIterable, Hashable, Sendable // 35 cases — see §7.9
struct NDArrayDescriptor: Equatable, Sendable
var shape: [Int] // -1 == dynamic
var scalarType: NDArray.ScalarType; var rank: Int
var hasDynamicShape: Bool; var interleaveLayout: NDArray.InterleaveLayout?
var minimumByteCount: Int // ⚠️ programming error when hasDynamicShape
var preferredStrides: [Int] // ⚠️ same
func resolvingDynamicDimensions(_ newShape: [Int]) -> NDArrayDescriptor
// ───────── Streams & configuration ─────────
final class ComputeStream
convenience init(); init(commandQueue: any MTLCommandQueue) // docs say no watchOS on the
final func currentWorkCompleted() async // latter; SDK declares
// watchOS 27.0 (§16.3). non-throwing
struct SpecializationOptions: Hashable, Sendable // ⚠️ part of the cache key
static let `default`; static let cpuOnly
init(preferredComputeUnitKind: ComputeUnitKind)
var allowedComputeUnitKinds: Set<ComputeUnitKind>
var preferredComputeUnitKind: ComputeUnitKind?
var expectFrequentReshapes: Bool // the only settable property
enum ComputeUnitKind: Hashable, Sendable { case cpu, gpu, neuralEngine
static var availableKinds: Set<ComputeUnitKind> }
final class AIModelCache: Sendable
static let `default`; init?(appGroup: String)
func model(for: URL, options: SpecializationOptions) throws -> AIModel? // never specializes
func deleteEntry(for: URL, options: SpecializationOptions) throws
func deleteEntries(for: URL) throws
func deleteAll() throws
static func deleteEntry(referencedBy bookmark: Data) throws
struct Policy { static let `default`, persistent; init(purgeConditions:) }
// ───────── Errors ─────────
struct AssetError: Error, LocalizedError // ⚠️ ASSET operations only — see §13
enum Kind { case unsupportedVersion(String), invalidFeatureType(String),
corruptedMetadata, invalidName, duplicateName }
// ✅ SDK-verified: the ONLY public error type in the whole Core AI surface
// (CoreAIAsset-27.0-macos.swiftinterface:230-247). Everything else throws untyped.
// (Docs also list Sendable on AssetError; the beta interface does not declare it.)
15.2 nil vs throws vs trap — the cheat sheet¶
| API | nil |
throws |
traps |
|---|---|---|---|
AIModel.loadFunction(named:) |
no such function | load failed | — |
AIModel.functionDescriptor(for:) |
no such function | — | — |
AIModel.init?(resolvingBookmark:) |
stale entry | malformed data | — |
AIModelCache.model(for:options:) |
not cached | entry found but failed to load | — |
AIModelCache(appGroup:) |
bad id / entitlement / container | — | — |
AIModelAsset.summary(_:) |
no program bytecode | asset problem | — |
Outputs.remove(_:) |
absent, or already taken | — | — |
InferenceValue.ndArray |
it's an image | — | — |
View.contiguousElements |
non-contiguous layout | — | — |
NamedMutableViews.take(_:) |
no such name | — | second take |
NDArrayDescriptor.preferredStrides |
— | — | when hasDynamicShape |
NDArrayDescriptor.minimumByteCount |
— | — | when hasDynamicShape |
15.3 Every silent failure in this guide¶
| # | What silently goes wrong | Section |
|---|---|---|
| 1 | A contiguous NDArray gets copied into the preferred layout on every run — correct results, permanent throughput tax, zero diagnostics |
§11.1 |
| 2 | Passing outputViews: for a name removes it from the returned Outputs — existing outputs.remove(name) code starts reporting "missing output" |
§9.4 |
| 3 | Concurrent run calls silently allocate more intermediate buffers; unbounded fan-out works on a Mac and gets jetsammed on a phone |
§5.2 |
| 4 | EXIF orientation is applied by CIImage(contentsOf:) and not by CGImageSourceCreateImageAtIndex — same JPEG, two orientations, no error |
§12.4 |
| 5 | AsyncValue.ndArray returns a copy when the value was built from an MTLBuffer, silently undoing the zero-copy you engineered |
§11.3 |
| 6 | Caching a dtype flag from the input descriptor and reading an output with it reinterprets bits → numeric garbage, no type error | §7.10 |
| 7 | Manual stride arithmetic that ignores interleaveLayout reads the wrong elements; the strides array looks perfectly usable |
§7.8 |
| 8 | Cache-checking with one SpecializationOptions and specializing with another misses the cache forever; both calls succeed |
§3.3 |
| 9 | Segment.box flips origin on macOS while DetectedObject.boundingBox does not |
§12.5 |
| 10 | A fresh MTLBuffer used as a KV cache is not zeroed; garbage state produces plausible wrong output |
§11.1 |
15.4 The ten rules¶
- Never specialize inside an interactive flow. Session 324, line 147, verbatim recommendation.
- Check the cache first, with the same options you will specialize with.
AIModelis free,InferenceFunctionis expensive. Construct both at feature-preparation time, not per call.- Separate
nilfromthrowsonloadFunction. They are different bugs. - Drive everything from the descriptor. Hardcoded shapes break when the model updates
independently of the binary — which is the whole point of the
.aimodelc+ Background Assets delivery model. guard let elements = view.contiguousElements else { … }, always. Never force-unwrap outside a value you got back fromrun.- Copy the
Span.productextension into your project before you need it. - Bound your fan-out.
Sendableis a correctness guarantee, not a memory guarantee. - Build optimized.
-Ononechanges the performance class, not just the constant factor. - Wrap every Core AI error in your own type at the boundary, with
underlying: any Error. The beta SDK publishes no taxonomy beyondAssetError(§13), so an unnamed error must never reach your control flow.
16. Sources and evidence ledger¶
16.1 What was read for this guide¶
SDK module interfaces (captured 2026-07-29, Xcode 27A5228h; recaptured 2026-08-20, beta 5 27A5237l, macOS 27.0 SDK —
now the strongest evidence class in this guide, stronger than doc pages): the shipped
.swiftinterface files for CoreAI (a one-line umbrella re-exporting CoreAIDelegates),
CoreAIDelegates (which re-exports CoreAIAsset, CoreAICommon, CoreAICompiler,
CoreAIRuntime and adds the AIModel loading/caching surface), CoreAIRuntime (1,428 lines —
AIModel, InferenceFunction, InferenceValue, NDArray and all views, NDArrayDescriptor,
ImageDescriptor, ComputeStream), CoreAIAsset (AIModelAsset, AssetError), plus
CoreAICache/CoreAICommon/CoreAICompiler, whose public Swift surfaces are empty in this
beta. All under notes/sdk-interfaces/*-27.0-macos.swiftinterface. Citations in this guide of the
form Module-27.0-macos.swiftinterface:lines refer to these captures.
Apple documentation (harvested 2026-07-27 via sosumi.ai plus Apple's raw DocC JSON API at
developer.apple.com/tutorials/data/documentation/<path>.json, which preserves termList and
table blocks that the Markdown mirror drops):
/documentation/coreai— framework page and the full 312-entry symbol index/documentation/coreai/aimodel,/aimodelasset,/aimodelcache,/inferencefunction,/inferencefunctiondescriptor,/inferencevalue,/imagedescriptor,/computestream,/ndarray,/ndarraydescriptor,/computeunitkind,/specializationoptions,/asseterror— plus roughly 70 individual member pages beneath them- Articles: "Integrating on-device AI models in your app with Core AI", "Managing model specialization and caching", "Compiling Core AI models ahead of time", "Inspecting, debugging and profiling Core AI models", "Monitoring model performance with the debug gauge", "Analyzing model runtime performance with Instruments", "Inspecting Core AI models with Core AI Debugger", "Validating inference correctness against a reference run"
https://developer.apple.com/core-ai-debugger/(system requirements)
WWDC26 transcripts:
- Session 324, "Meet Core AI" (presenter Ben, Core AI team) — 189 lines
- Session 326, "Core AI app features" (presenter Carina, Core AI team) — 216 lines
- Session 325 is referenced once (the SAM 3 split and the 76% figure) and belongs to Part 10.
Apple-authored shipping source — apple/coreai-models (requires macOS/iOS 27.0+, Xcode 27.0+;
products CoreAILM, CoreAIDiffusion, CoreAISegmentation, CoreAISpeech,
CoreAIObjectDetection):
swift/Sources/CoreAIShared/Runtime/{NDArray+Helpers,ModelStructure,ResourceManaging,FileSize}.swiftswift/Sources/CoreAIShared/Image/{ImagePreprocessor,CGImageUtils}.swiftswift/Sources/CoreAILanguageModels/InferenceEngines/{CoreAISequentialEngine,CoreAIPipelinedEngine,CoreAIStaticShapeEngine}.swiftswift/Sources/CoreAIImageSegmenter/{ImageSegmenter,ImageSegmentationEngine,SegmentationPostprocessor}.swiftswift/Sources/CoreAIObjectDetector/*,swift/Sources/CoreAISpeech/SpeechModel.swiftskills/{working-with-coreai,model-authoring,model-compression-exploration}/Package.swift,models/{sam3,qwen3}/README.md- GitHub issues and merged PRs, read live: #62 (64-byte buffer floor), #74
(
resolvingDynamicDimensionsover.shapemutation), #85 (pre-allocated outputs can regress perf), #89 (consumeonMutableViews), plus theAIModelError error 3thread
Apple-authored — apple/coreai-torch (v0.4.1, 2026-07-06): README.md, converter.py,
docs/getting-started/quickstart.ipynb, docs/coreai-core/tutorials/construct-a-graph.ipynb,
docs/api/debugging.md, tests/test_stateful.py.
Community — labelled as such everywhere it is used:
1amageek/swift-lm(single author,0.11.0-alpha.1, HEADdb7a802, 2026-07-18) — one of the very few real third-party Core AI integrations, and the only source in this corpus for the general-N state-view recursion and theCoreAIStateSessionvalidation ladder.
16.2 Apple documentation samples that do not compile¶
Reproduced here so nobody wastes an afternoon on them. All ✅ VERIFIED as broken by reading the declared signatures on the adjacent pages.
| Page | Defect |
|---|---|
NDArrayDescriptor.minimumByteCount |
passes RawView.init arguments out of declared order (shape: before scalarType:) and omits try await on the async throws run |
InferenceFunction.AsyncValue overview |
omits the required to: stream argument on encode, and misspells a variable (embeddingsOutputs vs embeddingOutputs) |
MutableView.copyElements(from:) |
doc references layout.scalarCount, a symbol absent from the public API — an internal-doc leak |
AIModel.init(contentsOf:options:), AIModel.specialize, AIModelCache.model(for:options:) |
- Throws: clauses render as orphaned Notes with no type — consistent, per §13, with the throws being untyped in the SDK |
Also: Apple's own prose contains the typos "vaiews" (AsyncMutableValue overview) and "the the"
(ComputeStream overview). Harmless, but useful confirmation that a quotation is verbatim rather
than paraphrased.
16.3 Open questions — updated 2026-07-29 against the SDK interface dump¶
The macOS 27.0 beta .swiftinterface capture (§16.1) closed four of the ten questions this guide
originally carried. The ledger, updated:
Closed by the interface dump:
- ~~The inference/specialization/cache error taxonomy (§13).~~ Closed: the throws are
untyped;
AssetErroris the only public error type;AIModelErroris not public in the beta SDK. See §13.1. - ~~
NDArray.ScalarType.type(§7.9).~~ Closed: not present in the beta interface — an internal member leaked intoRawView.view(as:)'s documentation. - ~~Typed views for sub-byte and 8-bit-float scalar types (§7.9).~~ Closed: none exist in the beta SDK; the raw-bytes path is the only public route.
- ~~macOS symbol availability.~~ Closed: every declaration in the captured macOS 27.0
interfaces carries
@available(macOS 27.0, …). The symbol pages' missing-macOSmetadata.platformsarrays are a docs-generation bug, as suspected. ⚠️ A related surprise ran the other way: the doc pages markNDArray.RawView.init(metalBuffer:…),AsyncValue.init(unsafeBuffer:…)andComputeStream.init(commandQueue:)unavailable on watchOS, but the captured interface declares all threewatchOS 27.0(CoreAIRuntime-27.0-macos.swiftinterface:26,:40-45,:1034-1041). Metal's own watchOS absence is the practical constraint; treat the docs' watchOS note as advisory.
Still open:
- Whether
CVMutablePixelBuffer/IOSurfacegives a real zero-copy camera path (§12.2).RawView.init(ioSurface:)exists; Apple's own vision package never uses it. - How a value is marked as an image at conversion time. Neither transcript covers it and we
could not find a
coreai-torchpage for it. Blocks writing the image half of §12 with the same confidence as the tensor half. SpecializationOptions.expectFrequentReshapessemantics (§4.3). The interface confirms the spelling, and a 2026-08-20 physical-device probe measuredfalsefor.defaultand.cpuOnly, but Apple still does not document the semantic effect or guarantee those defaults.- ~~Deletion-while-referenced: throws or defers?~~ Closed on iPhone 15 Pro / iOS build
24A5408d(§3.4): the call throws while referenced and succeeds after release, matching the reference pages. Re-run on later seeds for drift. ComputeStreamguidance beyond "serialized as needed" — how many concurrent streams are advisable, and how a stream interacts withrun's implicit one. Undocumented.AIModelAsset.removeDerivedArtifacts()— spelling confirmed (CoreAIAsset-27.0-macos.swiftinterface:19,mutating,throws); still no abstract, no discussion, no known caller.
16.4 Corrections applied while writing¶
Two claims in circulation are corrected in the body rather than repeated:
- "
NDArrayis a non-escapable type." It is not. Apple's conformance list saysEscapable, and it isSendable. The views are the non-escapable part; session 324 says exactly that aboutNDArray.MutableViewand nothing else. See §7.1. - "Splitting a model into functions is a latency optimisation." True, and incomplete. Reading
ModelStructure.swift:70-81shows that the optionalcoreai-modelsloader also uses the function-name set to select its compute-unit preference—a three-function segmenter gets its Neural Engine preference and a single-mainstructure gets its GPU preference. DirectAIModelcallers choose their own options. See §4.3.2
Two known-fabricated claims that appear in third-party Core AI material and are absent from this
guide on purpose: the extensions .coreaimodel and .aiasset (the real ones are .aimodel,
.aimodelc and .aimodelintermediates), and a coreai-torch convert CLI (the CLI is
xcrun coreai-build compile; conversion is a Python API). Part 1 carries the full known-bad-claims
register.
16.5 Where to go next¶
| You want | Go to |
|---|---|
Schedule specialization, manage the cache, ship .aimodelc |
the specialization-and-caching guide in this part |
KV caches, states: as a modelling technique, prefix reuse |
this part's states guide; Part 3 |
Put a Core AI model behind LanguageModelSession |
Part 4 |
Convert a PyTorch model, state_names, dynamic shapes |
Part 8 |
Why ScalarType has 35 cases; palettization and quantization |
Part 9 |
| The Debugger, the gauge, the Instruments template, ANE authoring | Part 10 |
| Vision pipelines, orientation, box conventions end to end | Part 16 |
| Background Assets delivery, first-run UX, OS-update invalidation | Part 15 |
-
Apple’s current
NDArray.ScalarTypereference enumerates the 35 cases in this section: Apple Developer —NDArray.ScalarType. ↩ -
The classifier and preferences are implemented in the optional
apple/coreai-modelspackage’s pinnedModelStructure.swift. Core AI’s.defaultbehavior is documented separately in Managing model specialization and caching. ↩↩