Всем привет! Меня зовут Никита Нагорнов, я iOS‑разработчик в компании «Исходный Код». В этой статье разберем, как запускать локальные LLM на iPhone.
Поговорим про:
-
Архитектуру Apple Silicon и Unified Memory — почему не можем использовать всю доступную память;
-
Память, квантование и KV Cache — где приложения начинают падать и как это отловить;
-
Core ML и MLX — чем отличаются, где какой быстрее и почему нельзя всё запускать на нейронном процессоре;
-
Температуру и троттлинг — что происходит с телефоном на длинной сессии;
-
Гибридный пайплайн — как взять сильные стороны обоих фреймворков и получить стабильную генерацию. Это практический разбор с примерами кода, замерами на Llama 3 1B и выводами, когда локальная модель оправдана, а когда лучше использовать серверные модели.
TLDR (спойлеры!)
Локальные модели необходимо использовать только для узких мест, где нужна гарантированная приватность данных и офлайн режим. Корпоративные офлайн‑инструменты и медицинские сервисы делают это базовым требованием. И не следует забывать про юнит‑экономику, ведь проще чтобы работал чип, который пользователь уже купил. Во всех остальных случаях он не сравнится с полноценной моделью в облаке.
Итак, поехали
Эпоха облачных моделей и дешевого API избаловала индустрию безлимитными ресурсами. А что же делать, если по какой‑то причине необходимо воспользоваться только подручными средствами? В данном случае поговорим про Iphone. Запуск LLM на телефоне требует решения задач с памятью, пропускной способностью, скоростью и даже троттлингом. Я собрал этот материал как практический ориентир, который будет полезен если вы пойдете этим путем и расскажу вам о некоторых нюансах.
Архитектура Apple Silicon
Для начала разберем архитектуру Iphone. Большинство людей привыкли к классическому варианту как на ПК, где оперативная и видеопамять находятся отдельно друг от друга. Получается так, что модель будет лежать в RAM, а после большой объем памяти копируется в VRAM через шину PCIe.
Но архитектура у чипа Apple другая. У них общий пул памяти Unified Memory, которую используют сразу все: CPU, GPU и Apple Neural Engine (ANE). С одной стороны это снижает издержки на копирование данных, но возникает другая скрытая проблема. Все компоненты системы жестко конкурируют за один и тот же кусок объединенной памяти. Фреймворки, UI, декодирование медиа, веса модели и кэш живут вместе и постоянно конкурируют за эту память.
Разработчику важно понимать роли процессоров. Их всего 3.
-
CPU: это по сути мозг устройства, который управляет общей системой. Он хорошо подходит для логики приложения и оркестрации, но медленный для многопоточности и решения матриц.
-
GPU: идеален для параллельных вычислений, он обрабатывает множество задач одновременно. Тяжелые математические вычисления, 3D. Он также дает высокую скорость генерации токенов, но ценой очень быстрого нагрева корпуса.
-
ANE: создан специально для ИИ и работы с нейросетями. Используется для обработок фото, FaceId, Siri. Он обеспечивает лучшую энергоэффективность для матричных вычислений. Однако разработчикам нет прямого низкоуровневого доступа к ANE, все запросы маршрутизируются через Core ML. Кроме того есть ряд ограничений к слоям неподдерживаемые слои нейросети автоматически откатываются обратно на GPU или CPU.
Виды памяти
Разработчики часто делают ошибку, оценивая физические 8 или 12 ГБ в айфонах как доступный бюджет. Но мы уже знаем, что этой памятью пользуемся не только мы и реальная доступная квота будет в разы меньше. Для грубой оценки можно пользоваться правилом 50%, просто делим общую доступную память и получим примерный лимит для нашего приложения.
Лайфхак: можно добавить
com.apple.developer.kernel.increased-memory-limitв нашinfo.plistи получить еще ~500мб памяти.
Объединенная память девайса делится на три типа.
-
Clean Memory: сюда входит код приложения, любые системные фреймворки и файлы отображенные в памяти. Система эту память любит, потому что в любой момент времени может при нехватке ресурсов память освободить и заново прочитать.
-
Dirty Memory: здесь помещается все то, что приложение создает в рантайме. Декодированные структуры, вычисления, буферы потоков, объекты swift и др. Эту память неоткуда восстановить (по крайней мере без затрат процессоров), потому что ее просто нет на диске. В большинстве случаев все утечки происходят именно тут и часто приложения крашится из‑за этого.
-
Compressed Memory: когда Dirty Memory становится слишком много, то система пытается сжать эту память, чтобы дать приложению еще немного времени. По сути это последнее предупреждение перед тем, как приложение будет остановлено.
Для отслеживания роста потребления памяти система iOS использует специальный демон Jetsam. Он не дает времени на аккуратное закрытие приложения или сохранения каких‑то данных. Jetsam мгновенно отправляет сигнал SIGKILL 9 и закрывает приложение, не используя никаких делегатов. Поэтому особо важно следить за потреблением памяти в таких приложениях.
Веса и квантование
Итак, мы поняли, что у нас есть очень жесткий лимит по памяти. А теперь давайте посмотрим на саму нейросеть. Существует базовый закон «физики» языковых моделей: нейросети при генерации даже одного единственного слова необходимо прогнать данные через все свои параметры. Это означает, что мы не можем загрузить модель лишь наполовину в память, нам необходимо держать ее полностью.
Возьмем, например, распространенный тип небольшой модели: пусть у нее будет 8 миллиардов параметров в формате Float16, которая занимает около 16 ГБ. Запустить такой объем на iPhone физически невозможно. Поэтому обязательным для нас будет использование квантования.
Мы берем параметры модели, которые изначально были записаны с высокой точностью — 16 бит (FP16). И грубо говоря округляем их до 4 бит (INT4). Модель при этом станет чуть глупее, но существуют способы минимизировать эти потери. (ссылка на статью).
При работе с нейросетью и большим потреблением памяти не стоит забывать и про простые вещи и оптимизировать их.
Fun‑факт: Немногие знают, что если фотография на жестком диске занимает пару мегабайт, то когда вы ее загрузите в память, то она будет весить в разы больше. Алгоритмы Хаффмана и косинусное преобразование отлично ужимают данные, но процессор не может выводить сжатую картинку, ему нужен bitmap. Размер фото можно узнать по формуле
Обычное 4K‑фото, которое весило 1 мегабайт на диске, превращается в 33 мегабайта чистейшей Dirty‑памяти. Поэтому используйте даунсемплинг картинок, особенно загруженных пользователем.
Проблема KV Cache
Помимо хранения модели, нам необходимо хранить еще KV кэш. Веса модели статичны, но вот контекст нет. Если бы мы каждый раз пересчитывали Attention по всей истории диалога с нуля, то сложность стала бы O(N²). Для этого мы считаем матрицы ключей (Key) и значений (Value) один раз и кладем их в кэш. А когда нужен новый токен, то достаем их оттуда, что дает нам линейную скорость. Однако KV кэш находится также в объединенной памяти и растет с каждым сгенерированным словом. И чем длиннее будет ваш диалог с нейросетью, тем больше мы будем забивать память контекстом. И если мы не будем следить за ним, то опять придет наш любимый демон Jetsam.
V Cache живет в оперативной памяти и растет с каждым сгенерированным словом. Чем длиннее ваш диалог с нейросетью, тем больше мы забиваем RAM не весами модели, а именно контекстом. В итоге, даже если сама модель компактно лежит в 4.5 гигабайтах, длинный диалог аккуратно приведет нас к кому? Правильно. К демону Jetsam.
Но мы уже знаем, что мы квантовали веса модели чтобы сэкономить место, тоже самое мы можем сделать с KV кэшем. (подробнее тут)
Подход Core ML
Итак, мы разобрались с памятью и моделями. Теперь давайте попробуем запустить простейшую модель в Xcode. Берем для теста небольшую модельку LLama 3 1B.
import CoreML
let config = MLModelConfiguration()
config.computeUnits = .all
let model = try Llama3_1B(configuration: config)
let input = Llama3_1BInput(input_ids: targetTokenIds)
if let output = try? model.prediction(input: input) {
let logits = output.token_probabilities
}
Флаг computeUnits выступает только рекомендацией. Планировщик Core ML сам распределяет слои модели в момент инициализации. Он сканирует ваш граф вычислений и сам решает, какие слои куда отправить. Спойлер: часто не так, как вы думаете. Он учитывает форму тензора, поддерживание операции и даже текущую загрузку системы. Планировщик часто решает, что ему проще и надежнее будет выполнить слой на GPU или даже на CPU чем пытаться запустить ее на ANE.
Из‑за таких переключений возникает Context Switch, когда скорость генерации новых токенов будет даже меньше, чем если бы изначально запускали на CPU/GPU.
Более того, CoreML нигде не пишет, что выполняет сейчас на ANE. Первый способ через инструмент Performance Report в Xcode. Он показывает статическое распределение операций еще до запуска, но делает это в идеальных условиях без учета нагрузки системы. Однако, если даже тут вы не попали в ANE, то во время реальных тестов точно не сможете.
Второй способ — через Time Profiler, нужно поставить процесс на паузу и искать поток с названием ANE. Если он есть, то CoreML, вероятно, отдал хотя бы часть работы на нейронный движок.
Подход MLX
Фреймворк MLX лучше справляется с меняющимся контекстом авторегрессионного цикла. По сути MLX мало чем отличается от pytorch, например. У него вся работа строится вокруг одного главного типа данных — MLXArray.
let inputIds = MLXArray(tokenIds)
let logits = model.call(inputIds)
let lastToken = logits[-1]
let nextTokenId = MLX.argmax(lastToken, axis: -1)
MLX.eval(nextTokenId)
let id = nextTokenId.item(Int32.self)
Когда мы написали вызов модели, сделали срез, посчитали argmax, то на самом деле физически ничего не произошло, ноль вычислений. Все потому что MLX использует ленивые вычисления (Lazy Evaluation) и все предыдущие строчки кода просто строили граф зависимостей. И только после явного вызова MLX.eval(), весь этот скомпилированный граф отправляется на GPU и выполняется за один заход, позволяя сделать оптимизации и сэкономить ресурсы.
Сравнение фреймворков
Попробуем как‑нибудь сравнить эти 2 подхода. Для чистоты эксперимента не стал брать тяжелых трансформеров, взял базу — многослойный перцептрон с размерностью 1024×4096. По сути измерив чистую скорость матричного умножения.
На графике видно, что на обычных статических задачах CoreML быстрее. Он выполняет инференс почти мгновенно. Все дело в AOT‑компиляции (Ahead‑of‑Time, компилируем еще до момента запуска) и нативной поддержке ANE.
Но что будет если взять и проверить на локальном чат боте. Тут результаты противоположные. Более того, если хотим запускать на ANE, то мы должны зафиксировать длину промта в CoreML и если, например, пришел запрос больше — то следует пересобирать модель, либо городить костыли. А вот MLX работает как pytorch: ему не важна длина контекста, он адаптирует граф вычислений на лету.
Температура и троттлинг
Получается, что можно брать и запускать все на MLX? Он и быстрее и удобнее. Однако тут нас ждет один большой нюанс. У Iphone нет активного охлаждения, это означает, что при длительных нагрузках очень быстро нагревается телефон и включается троттлинг. Система урезает напряжение на GPU и пропускную способность. По сути снизится скорость генерации практически до 0, UI начнет пропускать кадры, либо вообще приведет к закрытию приложения.
Fun‑факт: Многие могут подумать, что телефон включает троттлинг, чтобы спасти процессор от расплавления. Это миф! Кристаллам Apple Silicon абсолютно комфортно работать при внутренней температуре в 85–95 градусов. (Вспомним macbook Apple Neo где используется чип с телефона). Тогда почему включается троттлинг? Все потому, чтобы не спасти …. ваши руки! Стандарты запрещают нагревать устройства выше 43–45 градусов при контакте с кожей человека.
Тепловое состояние устройства можно отследить через ProcessInfo.thermalState. При критическом нагреве мы должны искусственно замедлять генерацию. Это даст время корпусу остыть, а нам прогнозировано влиять на перфоманс приложения.
let thermalState = ProcessInfo.processInfo.thermalState
if thermalState == .serious || thermalState == .critical {
Thread.sleep(forTimeInterval: 0.1)
}
Гибридный пайплайн
Практичным инженерным компромиссом может стать комбинация двух подходов. Статичные операции следует отправлять через Core ML на энергоэффективном ANE.
Динамическую генерацию через MLX на мощном GPU.
Что же выбрать? С одной стороны у нас Core ML. У него Ahead‑of‑Time компиляция, он заранее знает граф и отправляет его на энергоэффективный ANE.
С другой стороны MLX. Just‑in‑Time компиляция, ленивые вычисления, но очень ресурсоемкий.
А что, если взять лучшее от обоих миров? Зачем выбирать один фреймворк, если у нас есть Единая память? Мы можем разделить задачу:
-
Статику (обработку изображения, голоса или даже огромного промпта) мы отдаем Core ML на ANE.
-
Динамику (пошаговую генерацию токенов) — мы запускаем на GPU через MLX.
Тест модели Llama 3 1B INT4 на iPhone 15 Pro показал такие результаты.
|
Архитектура |
Time to First Token |
Скорость генерации |
Поведение на длинной сессии |
|
MLX (GPU) |
120 ms |
65–75 token/s |
Быстрый нагрев, ранний троттлинг |
|
Core ML (ANE) |
45 ms |
30–40 token/s |
Ограничен фиксированным окном |
|
Гибрид |
70 ms |
65–75 token/s |
Стабильный фреймрейт, долгий запас |
Главный урок
Оцените ваши задачи и попробуйте сделать гибридный подход: использовать CoreML + MLX. Не пытайтесь заменить локальной моделью огромные модели в облаке, попробуйте комбинировать. Используйте локальные модели только для узких мест, где критична приватность или оффлайн‑доступность.
Автор: neoron


