Mamay на Neural Engine: як я запускав українську LLM на iPhone через Core ML
Привіт! Мене звати Микита, я роблю продукти з AI і люблю перевіряти на практиці, наскільки далеко можна зайти з локальними мовними моделями. Цього разу експеримент вийшов довшим, ніж я планував, тож розповідаю все по порядку: від ідеї до цифр на реальному телефоні.
Українська LLM
Я хотів, щоб українськомовна модель працювала на iPhone повністю офлайн, без сервера й без підписки. Чому локальні моделі — це насамперед про приватність і контроль, добре пояснює Юрій Гуц у статті на DOU [15]; він пише про Mac і десктопи, а мене цікавив телефон. MamayLM від INSAIT [1] для цього добре підходить: це дообучена Gemma 3 на 4 млрд параметрів, яка вміщується в телефон. Через MLX і llama.cpp вона в мене вже працювала, обидва варіанти рахують на GPU. Лишався третій шлях — Neural Engine (ANE), окремий прискорювач для нейромереж, який має бути ощадливішим. Я хотів перевірити, чи можна перенести на нього Mamay готовими відкритими інструментами, без власного конвертера, і що це дасть на реальному телефоні.
Варіанти локального запуску на iPhone
Давайте спочатку розглянемо, які взагалі є варіанти запустити мовну модель на iPhone локально. Рахувати її можна на CPU, GPU або Neural Engine, а основних інструментів три: MLX, llama.cpp і Core ML. Кожен з’явився зі своєї причини.
MLX [13] — фреймворк Apple для Apple Silicon. Він рахує на GPU через Metal і бере модель майже як є: ваги з Hugging Face квантуються до ~4 бітів, і цього досить. GPU виконає практично будь-який граф, тому нові архітектури з’являються в MLX швидко. Для дослідників це найзручніший варіант.
llama.cpp [14] — кросплатформний рушій на C/C++ із форматом GGUF. Одна й та сама модель працює на Windows, Linux, Android і Apple, а GGUF-версії моделей часто викладають самі автори; для Mamay така теж є [2]. У моєму застосунку llama.cpp працює за API Foundation Models: застосунок спілкується з моделлю через стандартний LanguageModelSession від Apple, а всередині працює мій власний виконавець на llama.cpp з GGUF-файлом Mamay.
Core ML і Neural Engine влаштовані зовсім інакше. ANE — окремий блок у чипі, створений під нейромережі з мінімальним енергоспоживанням. Універсальним процесором він не є: підтримує обмежений набір операцій, рахує тільки у FP16, хоче фіксовані розміри тензорів і не приймає надто великі графи. Модель під нього доводиться перебудовувати. Навіщо тоді з цим возитися? Через енергію: ANE не займає GPU, потрібний інтерфейсу, і в теорії витрачає менше на ту саму роботу. Для асистента, який постійно під рукою, це важливо.
Як працює ANEMLL
ANEMLL [3], [4] робить саме цю перебудову. Він ріже модель на частини: embeddings, кілька «чанків» із шарами трансформера і LM head. Кожну частину iOS потім компілює під ANE окремо. Ваги стискаються таблицями (LUT): 4 біти для шарів, 6 для вихідного шару. Лінійні шари стають згортками 1×1, KV-кеш стає станом Core ML (MLState), а в кожному файлі лежить кілька функцій: infer для генерації по одному токену і prefill для пакетної обробки промпту. Для Gemma 3 є ще варіанти *_rotate, бо п’ять її шарів із шести бачать лише останні 1024 токени і їхній кеш треба «прокручувати».
Готові збірки Google Gemma 3 4B в ANEMLL уже були [7], [8]. Я перевірив Mamay: 34 шари, розмірність 2560, 8 голів уваги й 4 KV-голови, вікно 1024 — усе як у Gemma 3 4B IT. Токенайзер і шаблон чату збігаються побайтно, префікс ваг language_model.* ANEMLL знімає сам, а vision-частину відкидає. План виглядав простим:
MamayLM (HF) → конвертер Gemma 3 з ANEMLL → Core ML INT4 → Swift-застосунок → ANE
На практиці все виявилося не так просто ще до запуску на телефоні.
Пастки під час конвертації
Позиційне кодування
Глобальні шари Gemma 3 4B (кожен шостий) використовують RoPE з лінійним масштабуванням: у config.json це rope_scaling: {"rope_type": "linear", "factor": 8.0}. ANEMLL читає rope_theta, а rope_scaling просто ігнорує. Я порівняв таблиці cos/sin глобальних шарів з реалізацією в Hugging Face Transformers [11]. Без виправлення максимальна розбіжність сягала 2,0, тобто таблиці просто інші. Після виправлення розбіжностей не лишилося.
Виправлення займає шість рядків: поділити inv_freq на factor для глобального RoPE і не чіпати локальний. Підступність у тому, що модель без нього не ламається явно. На коротких промптах різниця кутів мала, і відповіді виглядають нормально. Помилка стосується всіх збірок Gemma 3 4B в ANEMLL, зокрема офіційних.
Переповнення FP16
Максимум FP16 — 65 504. Gemma 3 навчали в BF16, і сума виходів шарів (residual-потік) у неї росте від шару до шару. Про це писали і Unsloth [10], і автори ANEMLL [5]. Утиліта fp16_compatibility_check з ANEMLL показала, що в Mamay значення виходять за межу вже на
ANEMLL розв’язує це масштабуванням ваг [5]. Embeddings множаться на α, ваги post-norm шарів перетворюються як w' = α·(1 + w) − 1, і завдяки інваріантності RMSNorm до масштабу модель рахує те саме, але в меншому діапазоні. Утиліта порадила α = 0,1797, я взяв 0,125. Це степінь двійки, тож множення в FP16 точне, а пік падає приблизно до 35 000 із запасом.
Прапорець --fp16-scale auto
Прапорець auto визначає α за назвою папки з моделлю, шукаючи підрядки на кшталт gemma-3-4b-it-qat. Для Mamay він нічого не знаходить, друкує попередження і конвертує без масштабування, а на Neural Engine отримуємо NaN. α треба задавати руками.
Дрібниці середовища
ANEMLL розрахований на Python 3.9. На 3.11 знадобилися numpy<2, бо інакше coremltools 9.0 падає з only 0-dimensional arrays can be converted to Python scalars, і transformers<5 для тестового чату. Swift-пакет AnemllCore тягне swift-transformers з гілки main, а мій застосунок закріплює версію 1.3.4, і SwiftPM такі вимоги не поєднує. Я закріпив ту саму версію в ANEMLL, і все зібралося.
Перший запуск на Mac
Коли пастки були обійдені, конвеєр ANEMLL спрацював для Mamay без власного конвертера. Першу збірку я зробив з тими самими параметрами, що й офіційна Google Gemma 3 4B від ANEMLL: контекст 4096, 2 чанки, LUT4/LUT6. Вона займає 3,36 ГБ: 1,3 ГБ embeddings у FP16, два чанки по 788 МБ і 488 МБ вихідного шару. На Mac з M5 Pro модель генерує ~19,5 токена/с на Neural Engine і відповідає зв’язною українською:
Фотосинтез — це процес, за допомогою якого рослини, водорості та деякі бактерії використовують сонячне світло, воду та вуглекислий газ для виробництва глюкози (цукру) та кисню...
На запитання «як тебе звати?» вона відповідає «ChatGPT», але так само відповідає і
Запуск на iPhone
У свій застосунок MamayChat я додав третій рушій, Core ML ANE, поруч із MLX і Foundation Models, і бенчмарк, що міряє час завантаження, TTFT, швидкість, пам’ять і температурний стан так само, як для інших рушіїв. На iPhone 16 Pro з iOS 27.0 embeddings завантажувалися, а на першому ж чанку лог видавав:
E5RT: MILCompilerForANE error: failed to compile ANE model using ANEF.
Error=_ANECompiler : ANECCompile() FAILED (11)
Одразу після цього Core ML показував іншу помилку, «functionName must be nil unless the model type is ML Program», яка вела зовсім не туди. Це вторинний наслідок: коли компіляція під ANE не вдається, Core ML пробує завантажити модель інакше. Причину треба шукати за першим рядком.
Я довго перебирав гіпотези:
| Що перевіряли | Результат |
|---|---|
| Моя збірка (2 чанки, як офіційна) | падає |
| Статичний запис у KV-кеш замість динамічного | падає |
| Офіційна збірка Google від ANEMLL, контекст 4096 | падає |
| Та сама офіційна збірка, контекст 1024 | падає |
| Перезавантаження iPhone | не допомогло |
| 4 чанки по ~400 МБ | частково: перший чанк без *_rotate-функцій компілюється |
| Контроль: Gemma 3 270M від ANEMLL [9] | працює, ~120 токенів/с |
| Єдиний KV-кеш (обхід ANEMLL для M1/A14 [6]), 4 чанки | перший чанк проходить, другий ні |
| Єдиний кеш, 8 чанків по ~200 МБ | падав навіть перший чанк |
Останній рядок розвалив гіпотезу про розмір: менший шматок падав там, де більший годиною раніше компілювався. Отже, результат залежав від стану телефона, а не від моделі. Тому читати цю таблицю як вирок конкретним налаштуванням не варто.
Першу причину я знайшов в лозі одного з прогонів:
BNNS Graph Compile: failed to preallocate file with error: No space left on device for path: .../Library/Caches/.../com.apple.e5rt.e5bundlecache E5RT: [MIL FileWriter]: Unknown error occured while writing data to the file. (11)
Компілятор ANE пише на диск багато тимчасових даних. У діагностичному звіті iOS служба ANECompilerService записала 1,07 ГБ за чотири хвилини. Місця на телефоні було мало весь час, а кожна невдала спроба ще й лишала запасні CPU-версії блоків, яких у кеші застосунку назбиралося на 1,25 ГБ. Маленькій 270M вистачало й залишків. Після скидання телефона, коли вільного місця стало вдосталь, усі частини Mamay 4B вперше скомпілювалися на ANE. Але до відповіді моделі було ще далеко: місце виявилося лише першою з кількох причин.
Щойно модель завантажилася, iOS убила процес сигналом SIGKILL: com.apple.developer.kernel.increased-memory-limit [12], після нього запас пам’яті під час завантаження зріс до 5,7 ГБ. Ще один фактор — перегрів. Кожна компіляція 4B навантажує телефон на кілька хвилин, після серії спроб iPhone переходив у стан serious, і мій застосунок сам відмовлявся генерувати.
Остання перешкода з’явилася вже тоді, коли модель відповідала на короткі запитання. На промптах, довших за 64 токени (розмір пакета prefill), генерація падала з Feature update_mask is required but not specified. Prefill в ANEMLL буває динамічний (запис у кеш через зріз зі змінним зсувом) і статичний (через додаткову маску update_mask). На iPhone динамічний не скомпілювався жодного разу з трьох спроб, а статичний компілювався. Swift-рантайм ANEMLL передавав маску лише монолітним моделям, а чанковим ні. Виправлення вийшло невелике: виділяти буфер маски для будь-якої моделі, якій він потрібен, і передавати його в пакетний prefill чанків. Після цього промпт на 89 токенів пройшов і на Mac, і на iPhone.
Кілька дрібниць, які теж забрали час. xcrun devicectl device copy to може без помилки лишити на телефоні файл нульового розміру, якщо закінчилося місце, тож розміри після копіювання треба перевіряти. Видаляти файли в контейнері застосунку devicectl не вміє, тож почистити їх можна лише перевстановленням застосунку. А кожна спроба з компіляцією — це від двох до десяти хвилин і гарячий телефон.
Результати на iPhone
Робоча конфігурація: контекст 1024, 4 чанки, єдиний KV-кеш, статичний prefill, LUT4/LUT6, α = 0,125, виправлений AnemllCore і entitlement на пам’ять. Цифри нижче — окремі запуски на iPhone 16 Pro, а не медіани.
| Сценарій | Промпт, токенів | Завантаження | TTFT | Генерація, токенів/с | Пік пам’яті |
|---|---|---|---|---|---|
| Тест «як тебе звати?» | 21 | ~2,8 с | 2,9 ГБ | ||
| Тест, 64 символи | 30 | 244 с (два збої компіляції) | 3,4 с | 16,4 | 2,9 ГБ |
| Тест, 256 символів | 89 | 168 с | 4,6 с | 16,0 | 4,2 ГБ |
| Застосунок, частковий кеш | 11 | 24 с | 1,3 с | ~17 | 3,7 ГБ |
| Застосунок, наступне повідомлення | 37 | — | 2,5 с | 17,8 | 2,5 ГБ |
Штучний інтелект (ШІ) — це підрозділ інформатики, що займається розробкою інтелектуальних агентів, які...
Довге завантаження — це компіляція. У тестах xcodebuild щоразу перевстановлює застосунок, тож модель компілюється з нуля, на
Під кожною відповіддю застосунок показує рядок статистики у форматі ANEMLL, наприклад 19.9 t/s, TTFT: 660.1ms (19.7 t/s, 13 tokens), 53 tokens.
Приклад роботи, швидкість ± в нормі, проте модель не дуже розумна
Енергоспоживання
Енергія — головний аргумент на користь ANE, тож без заміру стаття була б неповною. Я скористався Power Profiler з Instruments [17], і вийшло це не з першого разу. Запис на самому телефоні (Налаштування → Розробник → Performance Trace) щоразу закінчувався повідомленням «error processing trace»; схожі скарги є на форумах Apple [18]. xctrace з командного рядка записував трейс, але таблиці потужності в ньому лишалися порожніми, навіть після імпорту сирого .atrc з потрібним шаблоном. Запрацював лише запис з інтерфейсу Instruments, з iPhone, підключеним по Wi-Fi, щоб він не заряджався від кабелю.
Навантаження для всіх рушіїв було однакове: п’ять разів поспіль прохання «напиши розповідь приблизно на 200 слів про осінній Київ», щоразу на свіжому рушії, щоб історія діалогу не накопичувалася. Відповіді впиралися в ліміт 256 токенів. Power Profiler щосекунди пише потужність усієї системи у відсотках заряду на годину. Скрипт scripts/power_from_trace.py інтегрує її лише у вікнах генерації, без завантаження моделі.
| Рушій | Потужність | Генерація, токенів/с | Заряд на 1000 токенів | ≈ Дж на токен |
|---|---|---|---|---|
| Core ML (ANE) | 46,9 %/год (~6,5 Вт) | ~18 | 0,84 % | ~0,42 |
| MLX (GPU) | 66,5 %/год (~9,2 Вт) | ~21,8 | 0,89 % | ~0,44 |
| llama.cpp (Foundation Models) | 76,7 %/год (~10,6 Вт) | — | 1,16 %* | ~0,58 |
Вати й джоулі перераховано через ємність батареї iPhone 16 Pro (~13,9 Вт·год), тож це орієнтовні значення. *Для llama.cpp кількість токенів я не рахую, тому взяв 256 на відповідь: усі відповіді впиралися в ліміт.
ANE справді тримає генерацію на меншій потужності: на 30% нижче за MLX і на 40% нижче за llama.cpp, а телефон увесь прогін лишався в тепловому стані nominal. Але на токен енергії йде майже стільки ж, як у MLX, лише на 5% менше, бо ANE генерує повільніше, і екран із рештою системи працюють довше. Це по одному запису на рушій, для всієї системи з екраном на яскравості 56%. Окремої колонки для Neural Engine у Power Profiler немає, тож його роботу видно лише в загальній потужності.
Чому рушії поводяться по-різному
| MLX (GPU) | llama.cpp | Core ML (ANE) | |
|---|---|---|---|
| Завантаження | ~2 с | ~170 с уперше, ~24 с з кешем | |
| Генерація | ~21—22 токени/с | — | |
| Потужність | ~9,2 Вт | ~10,6 Вт | ~6,5 Вт |
| Пік пам’яті | ~2,4 ГБ | ~2,6 ГБ | |
| Контекст | 2048 | 2048 | 1024 |
Я очікував, що ANE виграє всюди, тож ця таблиця мене здивувала. Пояснень кілька. По-перше, під ANE модель довелося перебудувати, і звідси обмеження на кшталт контексту 1024. По-друге, стиснення різне: MLX і GGUF тримають усі ваги приблизно в 4 бітах --lut1 мав би зменшити це приблизно на гігабайт, але я його не пробував. По-третє, GPU отримує готові ядра, а граф для ANE iOS компілює на самому пристрої, і для моделі на 4B це хвилини.
І найважливіше: швидкість генерації впирається в пропускну здатність пам’яті, а не в обчислювальну потужність. На кожен новий токен треба прочитати всі ваги моделі, а пам’ять у GPU й ANE на iPhone спільна. Тож на генерації тексту ANE не може бути в рази швидшим за GPU, а з «товстішими» embeddings виходить навіть трохи повільнішим. Сильні сторони ANE — менша потужність і нагрів, вільний для інтерфейсу GPU і пакетні обчислення: на Mac prefill на ANE йшов ~69—75 токенів/с проти ~21 токена/с генерації.
Як повторити
Найпростіше взяти готову збірку з Hugging Face [19]. Якщо хочете сконвертувати самі, ось збірка, яка працює на iPhone 16 Pro з iOS 27.0:
./anemll/utils/convert_model.sh \ —model ./MamayLM-Gemma-3-4B-IT-v1.0 \ —output ./mamay-ane \ —context 1024 —batch 64 \ —lut2 4,4 —lut3 6,4 \ —chunk 4 —prefix gemma3 \ —fp16-scale 0.125 \ —single-cache —static-prefill-slice
Окрім цього знадобиться ANEMLL з виправленням RoPE, AnemllCore з передаванням update_mask для чанкових моделей, Python 3.11 з numpy<2 і transformers<5, entitlement increased-memory-limit у застосунку і кілька гігабайтів вільного місця на телефоні для першої компіляції. На iPhone копіюються тільки *.mlmodelc, meta.yaml і файли токенайзера, разом ~3,3 ГБ.
Обмеження варто назвати прямо. Усе перевірено на одному iPhone 16 Pro, 15 Pro у мене не було. Цифри — окремі запуски, а енергію я виміряв одним записом на рушій. Перша компіляція під ANE нестабільна, а контекст у ANE-збірці вдвічі коротший, ніж у MLX і llama.cpp.
Що далі
Кілька речей я свідомо не робив. TTFT можна помітно скоротити: зараз пакетом обробляються лише повні блоки по 64 токени, а залишок іде по одному, приблизно по 0,1 секунди на токен. З робочою update_mask неповний пакет можна прогнати так само, як це вже роблять монолітні моделі ANEMLL, і для коротких промптів TTFT мав би впасти з
Виправлення RoPE і update_mask я планую запропонувати авторам ANEMLL. Core
Висновки
Отже, що маємо в сухому залишку.
Українська LLM на 4 мільярди параметрів живе в кишені. Без інтернету, без сервера, без підписки: просто Neural Engine в iPhone і
Хочете покрутити самі — збірка вже лежить на Hugging Face [19]. Завантажили, закинули в застосунок, і воно працює.
Neural Engine таки економить батарею, але не так, як обіцяє маркетинг. Телефон споживає на третину менше енергії за секунду і майже не гріється, проте токени видає повільніше. Тож на одну відповідь заряду йде приблизно стільки ж, скільки з GPU. Чесний розмін: прохолодний телефон замість швидкості.
Власний конвертер писати не довелося, open source вивіз. Щоправда, в ANEMLL довелося полагодити дві помилки, на які наступив би кожен, хто переносить Gemma 3 4B на Neural Engine. Тож рецепт тепер підійде й для інших моделей на базі Gemma 3 4B, не лише для української.
А головним босом цього рівня виявилася не модель, а сам iPhone. Перший запуск компілює модель кілька хвилин, хоче вільного місця й пам’яті і може просто впасти. І все це ховається за одним і тим самим загадковим (11). Зате коли цей бос пройдений, модель стартує за
Тепер питання до вас. Що для вас важливіше в локальній моделі на телефоні: швидкість відповіді чи холодний iPhone і довша батарея? І чи готові ви один раз почекати кілька хвилин на першому запуску заради асистента, який потім працює без інтернету? Пишіть у коментарях, бо від цього залежить, куди копати далі: у швидший prefill і довший контекст чи в ще меншу модель.
Наступні експерименти з локальними моделями й AI-розробкою викладаю в телеграм-каналі «Микита про Vibe-Engineering».
Про інструменти. Під час роботи мені допомагали AI-асистенти Claude Code і Codex: з ними я швидше розбирався в коді ANEMLL, писав допоміжні скрипти й розбирав логи компілятора. Постановка експерименту, запуски на iPhone, рішення й висновки — мої.
Джерела
- INSAIT. MamayLM-Gemma-3-4B-IT-v1.0 — картка моделі
- INSAIT. MamayLM-Gemma-3-4B-IT-v1.0-GGUF
- ANEMLL — репозиторій
- ANEMLL — сайт і документація
- ANEMLL. Gemma3 FP16/ANE Scaling Guide
- ANEMLL. Release Notes 0.3.5 (Gemma 3, SWA, single-cache, обмеження M1/A14)
- ANEMLL. Google Gemma 3 4B QAT INT4, Core ML, контекст 4096
- ANEMLL. Google Gemma 3 4B QAT INT4, Core ML, контекст 1024
- ANEMLL. Google Gemma 3 270M, Core ML, контекст 4096
- Unsloth. Блог про Gemma 3, зокрема переповнення активацій у FP16
- Hugging Face Transformers — реалізація RoPE, зокрема лінійного масштабування (
modeling_rope_utils.py) і Gemma 3 - Apple Developer. Increased Memory Limit entitlement
- MLX Swift LM
- llama.cpp
- Юрій Гуц. «Як запускати відкриті GenAI-моделі локально. Генеруємо переказ
8-годинного відео Валерія Маркуса». DOU, 20.01.2025 - Google. Gemma Terms of Use
- Apple. WWDC25: Profile and optimize power usage in your app
- Apple Developer Forums. (iOS 26) PowerProfiler trace file cannot be opened
- MamayLM-Gemma-3-4B-IT для Apple Neural Engine (Core ML), контекст 1024
6 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарівgithub.com/jointwait/OnBoard-AI-iOS
apps.apple.com/...onboard/id6772760068?l=uk
Круто!
Стаття скоріш про показати технічні особливості, аніж бізнесову частину.
Бачу прикладну історію — мед сфера.
Доречі, Gemma 4 на 2 млрд параметрів з LiteRT гарно працює і на iPhone 13pro.
Обгорнув Gemma 4 E4B за допомогою фреймворку LiteRT-LM в простий UI
Мда, я так розумію — ера статей від людей закінчилася.
Михайло вітаю! стаття справді редагувалась за допомогою ШІ, проте не є його продуктом.