Шесть лет Core ML был единственным ответом на вопрос «как запустить свою модель на айфоне». В iOS 27 у этого вопроса появился второй ответ — Core AI, отдельный фреймворк со своим форматом, своим компилятором и своим отладчиком. Core ML никуда не делся: если у вас дерево решений или табличный feature engineering, идти по-прежнему туда. Core AI — про нейросети и про то, чтобы современные архитектуры ехали на CPU, GPU и Neural Engine без ручного тюнинга под каждый чип.
Разбираю стек по порядку: как модель попадает в проект, что происходит при первой загрузке (и почему она такая долгая), как этим управлять и где я бы притормозил перед продом.
Версии: Core AI доступен с iOS 27, iPadOS 27, macOS 27, tvOS 27, visionOS 27 и watchOS 27. Сигнатуры ниже — из документации Apple по состоянию на конец сентября 2026; API релизный, не бета, но список поддерживаемых платформ у разных частей стека разный, и на этом я отдельно остановлюсь.
Что вообще входит в Core AI#
Фреймворк — только верхушка. Под названием Core AI Apple собрала пять вещей, и путать их дорого:
- Core AI framework — Swift API:
AIModel,InferenceFunction,NDArray,AIModelCache. .aimodel— портируемый формат модели. Работает на всех устройствах Apple, но сам по себе не исполняется.- coreai-torch — PyTorch-расширения: конвертация модели в
.aimodel, экспорт нескольких inference-функций в один артефакт, встроенные оптимизированные операции для attention и нормализации, кастомные ядра Metal 4. - coreai-optimization — квантизация и палетизация с настройкой по слоям.
- coreai-models — каталог готовых к экспорту моделей плюс Swift-пакет с хелперами.
Отдельно стоит Core AI Debugger — приложение для macOS, которое умеет то, чего в Core ML не было никогда: трассировать значения тензоров обратно к исходному коду на Python. Плюс в Xcode появились debug gauge и инструмент Core AI в Instruments.
Первое, на чём спотыкается сборка#
Xcode не умеет собирать проект с .aimodel из коробки. Нужен Metal Toolchain, который по умолчанию не установлен:
% xcodebuild -downloadComponent MetalToolchainИли через Xcode > Settings > Components > Other Components > Metal Toolchain. Без него сборка падает с ошибкой об отсутствующем компиляторе Metal — и по тексту ошибки совершенно неочевидно, что лечится это одной галкой в настройках.
Сам файл добавляется перетаскиванием в Project Navigator; после этого он должен появиться в Compile Sources целевого таргета. Если не появился — модель в бандл не попадёт.
Смотреть модель глазами до того, как писать код#
Выделите .aimodel в навигаторе — откроется вьюер. На вкладке General лежит размер в параметрах и в байтах, метаданные (описание, автор, лицензия, произвольные пары ключ-значение — их можно править прямо там, Xcode сохранит), а главное — точность отдельно для вычислений и отдельно для хранения весов. Там же распределение операций в графе по количеству.
Вкладка Functions — сигнатуры. Имена и типы входов и выходов, и знак вопроса в размерности NDArray там, где она определяется в рантайме. У большинства моделей функция одна.
Эту вкладку стоит открыть до первой строчки кода: половина ошибок интеграции — это несовпадение формы или скалярного типа, и во вьюере они видны за десять секунд.
Загрузка: почему await и почему долго#
import CoreAI
// Специализация модели под текущее устройство и загрузка.
let model = try await AIModel(contentsOf: urlOfModel)
// Загрузка функции из модели.
guard let function = try model.loadFunction(named: "main") else {
// Функция с таким именем не найдена.
}init(contentsOf:) асинхронный не из вежливости. Между .aimodel и работающей моделью лежит специализация — Core AI смотрит на доступные вычислительные блоки конкретного устройства и генерирует исполняемый код под это железо и эту версию ОС. Для крупных моделей это занимает заметное время, и пользователь его увидит.
loadFunction(named:) тоже недешёвый: он готовит ресурсы под конкретную функцию. Бросает исключение при ошибке загрузки и возвращает nil, когда функции с таким именем в модели нет — два разных случая, которые легко схлопнуть в один try? и потом три часа искать причину. Все имена доступны через functionNames.
Одну и ту же inference-функцию можно безопасно звать из разных задач одновременно — это в документации сказано прямо, так что городить свой сериализующий актор поверх не нужно.
Инференс: NDArray и два вида доступа к памяти#
Входы и выходы — это InferenceValue: либо NDArray, либо изображение. Что именно — видно в Functions или через дескриптор в рантайме.
Проверка дескриптора нужна, когда модель прилетает с сервера и может меняться между релизами без пересборки приложения:
let function: InferenceFunction = ...
let functionDescriptor = function.descriptor
guard let valueDescriptor = functionDescriptor.inputDescriptor(of: "input"),
case .ndArray(let arrayDescriptor) = valueDescriptor else {
// Вход не найден или имеет неожиданный тип.
}
guard arrayDescriptor.shape == [3, 4] else {
// Неожиданная форма.
}
guard arrayDescriptor.scalarType == .float32 else {
// Неожиданный скалярный тип.
}Дальше — сам массив. Обратите внимание на разделение доступа: NDArray по умолчанию только для чтения, писать можно через mutableView(as:). Swift следит за этим на этапе компиляции, так что по коду всегда видно, что происходит с памятью.
// Массив нужной формы и типа.
var input = NDArray(shape: [3, 4], scalarType: .float32)
// Мутабельное представление для записи.
var mutableView = input.mutableView(as: Float.self)
guard let elements = mutableView.contiguousElements else {
// Память разложена не непрерывно.
}
writeInputData(into: elements)
// Запуск.
var outputs = try await function.run(inputs: ["input": input])
guard let predictionValue = outputs.remove("prediction") else {
// Выход не найден.
}
guard let prediction = predictionValue.ndArray else {
// Выход другого типа.
}
processOutput(prediction.view())Ключи в inputs — это имена, заданные при конвертации модели, а не что-то, что можно придумать на своей стороне. contiguousElements возвращает nil для непрерывной раскладки — сценарий редкий, но молча ронять на force unwrap там не стоит.
Для изображений вместо NDArray используется CVPixelBuffer, а дескриптор отдаёт ожидаемые ширину, высоту и pixel format; -1 в размерности означает динамику. Собирается вход через InferenceFunction.Inputs() и insert(_:for:).
Кэш специализаций — самая полезная часть API#
По умолчанию AIModel специализирует модель и кладёт результат в кэш: первый запуск платит полную цену, последующие загружают готовое. Всё бы хорошо, но система может удалить кэшированные ассеты при нехватке места — и тогда «последующий» запуск внезапно снова станет первым.
Поэтому проверку кэша перед загрузкой стоит делать всегда — она отвечает на вопрос, надо ли рисовать прогресс:
func loadModel(from modelURL: URL) async throws -> AIModel {
let cache = AIModelCache.default
// Не-nil означает, что специализация уже есть в кэше.
if let model = try cache.model(for: modelURL, options: .default) {
return model
}
// Кэша нет — предупредим человека и специализируем сейчас.
Task { @MainActor in
informUser("Готовим AI-функции. Это займёт какое-то время…")
}
return try await AIModel(contentsOf: modelURL, options: .default)
}cache.model(for:options:) ничего не специализирует — он только отвечает «есть или нет». Если модель скачивается из сети, специализацию можно провести заранее, в удобный момент, через AIModel.specialize(contentsOf:options:): он сохранит ассеты в кэш и вернёт готовую модель, а все последующие инициализации по тому же URL и тем же опциям пойдут уже из кэша.
Политику хранения задаёт cachePolicy. По умолчанию система вправе вычистить ассеты под давлением на storage. .persistent это запрещает — и нужен он в одном конкретном сценарии: когда вы удаляете исходный .aimodel, чтобы не держать на устройстве две копии. На tvOS .persistent недоступен, там локальное хранилище обязано быть вычищаемым.
Если приложений или расширений несколько и модель у них общая — заведите app group и создайте кэш через AIModelCache(appGroup:). Одна специализация на всю группу вместо копии на каждый таргет.
Отдельная тонкость: удалить исходный файл и продолжать пользоваться AIModel(contentsOf:) нельзя — URL исходника и есть ключ, по которому ищется специализация. Для такого случая есть bookmarkData: сохраняете его после специализации и на следующем запуске поднимаете модель через AIModel(resolvingBookmark:), минуя исходник. Закладка может протухнуть после обновления ОС, так что ветка «не нашлось» обязана быть рабочей, а не fatalError.
Опции специализации#
SpecializationOptions.default отдаёт системе выбор комбинации CPU, GPU и Neural Engine под минимальную задержку. Есть .cpuOnly и init(preferredComputeUnitKind:).
Реальный повод отойти от дефолта я вижу один: маленькая модель работает в фоне, и не надо, чтобы она конкурировала за GPU с отрисовкой интерфейса — тогда .cpuOnly оправдан. Во всех остальных случаях дефолт обычно выигрывает, и это стоит мерить, а не предполагать. Набор доступных блоков на разных устройствах разный — сверяйтесь с ComputeUnitKind.
Ещё один флаг стоит знать заранее: expectFrequentReshapes. Для моделей с динамическими формами Core AI по умолчанию оптимизирует функцию под каждую новую форму входа. Для языковой модели, где длина последовательности растёт на токен за шаг, эта оптимизация начинает стоить дороже, чем экономит. Флаг в true переключает на общую динамическую версию функции.
AOT-компиляция: перенести тяжёлое на свой Mac#
Специализацию можно частично сделать на билд-машине. coreai-build компилирует .aimodel в набор .aimodelc — по одному на архитектуру устройства:
% xcrun coreai-build compile MyModel.aimodel --platform iOS --min-deployment-version 27.0 --output compiled/На выходе — файлы вида MyModel.<arch>.aimodelc, где <arch> совпадает с тем, что в рантайме возвращает AIModel.deviceArchitectureName. Каждый скомпилированный ассет работает на любой версии ОС не ниже указанной в --min-deployment-version.
Дальше есть развилка. Класть в бандл все архитектуры — значит возить в приложении несколько копий модели, из которых устройство использует одну. Apple рекомендует хостить .aimodelc у себя и докачивать нужный вариант, определив архитектуру в рантайме:
let arch = AIModel.deviceArchitectureName
let assetName = "MyModel.\(arch).aimodelc"Загружается .aimodelc тем же AIModel(contentsOf:) — код загрузки менять не нужно. Управление скачиванием и обновлениями логично отдать Background Assets.
И честно про границы этой фичи. AOT-компиляция покрывает только устройства с поддержкой Apple Intelligence: iPhone и iPad с A17 Pro и новее, Mac с M1 и новее, Vision Pro с M2. На tvOS и watchOS её нет вообще — при том что сам Core AI там работает. И даже после AOT часть специализации всё равно выполняется на устройстве; сколько именно — зависит от модели и от вычислительных блоков, которые она использует. Формулировка Apple тут аккуратная: «меньше работы», а не «без работы».
Где я бы притормозил#
Три вещи, которые стоит проверить до того, как встраивать Core AI в релизный план.
Первая — неравномерность платформ. Фреймворк заявлен для всех шести ОС, но .persistent нет на tvOS, AOT нет на tvOS и watchOS, а на устройствах без Apple Intelligence нет AOT в принципе. Матрица «фича × платформа» здесь плотнее, чем кажется по странице фреймворка, и собрать её надо до архитектурных решений, а не после.
Вторая — стоимость первого запуска. Специализация происходит на устройстве пользователя, и это не константа: она зависит от модели, от железа и от того, выжила ли предыдущая специализация в кэше. Проектировать UX под «модель уже готова» нельзя — состояние «готовим» придётся нарисовать.
Третья — размер поставки. .aimodel в бандле, .aimodelc по архитектурам, кэш специализаций на диске — это три разные копии одних и тех же весов, и каждая занимает место. Комбинация «скачиваем модель → специализируем → сохраняем bookmark → удаляем исходник» существует ровно потому, что наивная схема раздувает приложение.
Если же модель, которую вы собираетесь везти через Core AI, — языковая, то писать инференс руками через NDArray вам, скорее всего, не придётся вовсе: её можно отдать в LanguageModelSession из Foundation Models и получить привычные промпты, стриминг и структурированный вывод. Как именно это собирается — разбираю отдельно.



