Продолжаем человеческий ANE reverse engineering. В этот раз разберёмся, чем таким интересным можно накормить компилятор, чтобы получить желаемый результат — Qwen, запущенный без GPU и CPU.

Ищем примеры MIL

Реверсить бинарник компилятора целиком не хотелось. Примеры, доступные в сети, довольно однообразны, и все копируют друг друга — все проекты, так или иначе, ссылаются на исследование Maderix Claude. Где же ещё искать примеры под чип Apple, если не в системе Apple?

find /System/Library -name ‘*.mil’ -type f

И вот у нас в руках 163 (у вас может получиться другое число) файла с примерами MIL‑кода. Валидного. Правда, с оговоркой — валидный он как CoreML MIL (на который, кстати, есть какой‑никакой референс). Не все из этих файлов удастся скомпилировать и запустить под ANE.

Анализируем находки

  • Dynamic shapes: в первую очередь я полез искать что‑то вроде ? — классическое обозначение «я не знаю какой shape тут будет». И нашёл — работает это через аннотацию FlexibleShapeInformation. Аннотация содержит DefaultShapes и RangeDims/EnumeratedShapes — вернёмся сюда чуть позже.

  • Новые типы данных: как «языковые» (tuple, list, dict), так и для данных (pixel_buffer, tensor_buffer, state<T>).

  • Operation set: конечно, много операций. Много того, что можно сделать с вашими данными. С примерами валидных аргументов, конечно же.

  • Multi‑function kernels: оказывается, main — условность. Функция (программа, давайте называть её так, дальше это сделает вещи проще) может называться как угодно. И их может быть несколько в одном файле. Уменьшаем overhead компиляции ещё сильнее (но это не точно).

Фильтруем полезное

Проверять все 163 файла руками — зачем тратить 2 часа на то, что можно автоматизировать за неделю 10 минут? У нас уже есть (с предыдущей статьи) обёртка на rust, которая берёт MIL и собирает/запускает его. Правда, тут же возникают нюансы:

  1. Во многих моделях, помимо MIL, лежат файлы с весами (blobfile), которые надо аккуратно разложить во временные директории перед сборкой.

  2. В некоторых MIL эти файлы ещё и находятся на уровень выше, из‑за чего пути к этим файлам содержат /../ — надо пропатчить MIL и убрать эту историю.

В общем, за 10 минут не уложился, но всё таки написал example к ane-bridge — который так и назвал, ane‑compile, потом ещё пригодится. Путь к mil на входе, с остальным разберётся сам. Вызываем через find -exec, и идём наливать кофе.

Светлое фильтрованное

Для начала, чего точно нет ни в одном файле, собранном под ANE:

  • scatter*, slice_update — собрать тензор из оригинального и обновлённой части обычным способом не получится.

  • write_state, read_state — stateful‑модели в ANE не запускаем.

  • RangeDims — диапазонные динамические размеры не поддерживаются (правда, есть EnumeratedShapes).

Идея запустить LLM на ANE без CPU начала казаться сложнее чем в начале — как же обновлять KV‑кэш, если нельзя записать новые значения в их место?

База

— Товарищ полковник, машина не заводится!
Фигня, поехали, потом заведешь!

Писать MIL руками — дело неблагородное. Даже зная, что можно писать. Компилятор придирчивый, подсветки нет, autocompletion нет, блокнот — не наш путь. Разбираться, почему отвалилась компиляция очередного шедевра инженерной мысли — нет уж, спасибо. Строим Builder. Поначалу, была мысль построить полноценный графовый, но остановился на линейном — просто очередная операция дописывается в конец программы, предыдущие можно референсить по названию. Входы задаём явно, выходы указываем завершающим вызовом Builder. Получается почти красиво:

let program = ProgramBuilder::new()
  .build_info(HashMap::from([("ane_fn_name".to_owned(), "qwen_out".to_owned())]))

  .func("qwen_out_b1")
  .input("w_model_norm", TensorType::new(TensorDtype::Fp16, &[hidden_size])?)
  .input("w_lm_head", TensorType::new(TensorDtype::Fp16, &[self.config.vocab_size, hidden_size])?)
  .input("i_hidden", TensorType::new(TensorDtype::Fp16, &[hidden_size])?)
  .rms_norm("i_x_norm", "i_hidden", "w_model_norm", rms_eps, 0, nwp1)?
  .matmul("logits", "i_x_norm", "w_lm_head", false, true)?
  .done(["logits"])

  .done();

Kernel::compile("qwen_out", &self.bridge, &program.mil(), &[])?

Программы, индексы, и все‑все‑все

Программ (именно так в терминологии Apple называются выполняемые куски кода внутри MIL) может быть несколько. У каждой — свои входы, свои выходы. Мы их как‑то обозначили, дали названия, типы, и отдали компилятору. А потом нам надо запустить конкретную программу, дав ей все необходимые буферы входов и выходов.

Ранние эксперименты показали, что компилятор не особо хочет сохранять порядок того, что мы наобъявляли. Что‑то сортирует по алфавиту, что‑то по размеру, что‑то просто как хочет. Но ведь так не бывает — должна быть какая‑то система? И да, она есть. Если у загруженной ANEModel спросить modelAttributes — она вернёт очень подробную структуру, в которой будут указаны все номера программ, все айдишники буферов, соответствующие им названия, размеры, и кучу других метаданных. Так что убираем хардкод и пишем честный парсер — с этого момента можем по названию передавать и получать что угодно, без регистрации и смс.

Загружаем веса

Тут было бы всё просто, если бы не одно но: ANE считает только в fp16. На вход ему можно скормить много чего — fp32, int32, да даже int8, но тогда надо конвертировать данные в самом ANE. А формат bf16, в котором распространяются почти все модели на huggingface, вообще не поддерживается. Хорошо, что есть крейт half — который позволяет удобно работать с fp16/bf16 прямо в Rust. Ещё одной прослойкой меньше — в предыдущих работах данные преимущественно ходили в fp32, с постоянными cast туда‑обратно. Однако, для загрузки весов, готового метода bf16-to-fp16 нет — не беда, берём спецификацию Qwen и просим написать сниппет. Упрощаем валидацию (nan/inf в весах нас не интересуют), подключаем rayon, и грузим тензоры с диска потоком (memmap2, safetensors, ноль лишних аллокаций).

Первый блин

Начать я решил с Qwen3-0.6B — весит немного, работает шустро, математика простая. Референс на numr был написан за час — с переписыванием кода между языками у меня редко возникали проблемы. Модель запустилась на CPU, и уверенно начала писать ответы. Baseline был пройден — наивная реализация, без кэша, без оптимизаций. Пора было переписывать на ANE — блок за блоком. Не без приключений — ну как же иначе.

Я уже говорил, что ANE считает только в fp16? С первого же слоя данные «поплыли» — и виновником оказался rms_norm. Обычно его считают в fp32 (принудительным cast), но нам эта опция недоступна. Благо, тот же Qwen быстро придумал вспомнил трюк, который всё исправил: ввести scale, что‑то вынести за скобки, что‑то перегруппировать, и в итоге получить математически корректный результат в переменных низкой размерности. Прогнал тесты между CPU fp32 и преобразованным вариантом на ANE — точность совпала до mae=1e-5 (max=1e-4). Модель начала отвечать более вразумительно.

Вторая подстава возникла с embedding: компилятор ANE отказывался давать мне gather. Пора было ковырнуть, могу ли я достать более адекватные сообщения об ошибках — и нашёл проект ANETools. В нём вызов ANECCompile реализован напрямую, и вынесено большое количество настроек — интересовал меня DebugMask.

В этот момент я подумал — а почему бы не собирать бинарники напрямую, а не через _ANEClient? А вот потому что. Скомпилированная модель не загружалась — и в Console (стандартная утилита macOS, собирающая вообще все логи со всех уголков системы) нашлись упоминания codesign. Тут пришло понимание, что загружать неподписанные бинарники в ANE просто так не получится. Чем их подписывать? А чёрт его знает, но ANEClient через XPC вызывает внутренние механизмы Apple, и оно работает, а как говорится, работает — не трогай. Тем не менее, ANECCompile позволял увидеть отладочную информацию — а она нужна ровно в тот момент, когда что‑то пошло не так. Именно туда (в обработку ошибки компиляции) я и добавил вызов in‑process сборки — с дебагом, с логами, с удобствами.

А с логами пришло и понимание: Unsupported tensor data type: int32. Как покажут дальнейшие исследования, int32 можно использовать только для shape/size, и в около‑константных операциях типа concat (для тех же shape). Но для индексов он не подходит. В меньший тип данных (uint16) не влезут индексы токенов — словарь сильно больше. Поэтому embedding остался на CPU (пока).

Ладно, отстанем от «одноразовых» операций (sampling я тоже оставил на CPU, так как воевать с argmax/topk желания не было) — что там в середине? А середина ушла на ANE целиком — matmul и rms_norm делали своё дело, slice_by_size честно резал тензоры, concat склеивал обратно. Attention я сразу делал на цепочке matmul — быстрый тест подтвердил показания Claude о том, что attn_mask игнорируется. В целом — всё работало. Пора было идти в рейд на финального босса.

Key‑Value Cache

Окей, делаем шаг назад. Нарезаем наш program прямо посередине слоя — между QKV projection и собственно Attention. Временные буферы проблемой не являются. Проверяем — всё ещё работает.

Ограничиваем вычисления: теперь мы считаем не все токены, а ровно один. Переписываем input, все остальные размеры пересчитываются на лету (спасибо builder!), проверяем — опа, упало. Что упало? Размер буфера не совпал. Так узнаём, что последний dimension тензора имеет stride кратный 32. Для входов и выходов это важно — если мы говорим, что тензор [256, 1] — в памяти он должен быть как [256, 32]. Расточительно по байтам и неудобно читать/писать данные с CPU, поэтому фиксируем это в своей биологической памяти и докидываем reshape где надо.

Теперь нам надо забирать «новые» K/V из первой программы, писать в наш «кэш» (который был нашим временным буфером между ядрами), и запускать оставшуюся часть. Окей, всё ещё работает, но не прикольно — как убрать из этой цепочки CPU?

Наивное решение — slice_update. У нас есть оригинал (старый кэш), новые данные, и диапазон который надо заменить — всё сходится? Нет, эта операция не поддерживается. Cannot support standalone slice_update — что бы ни значил этот standalone. Декомпиляция ANECompiler показала, что этой ошибкой завершается любая попытка парсинга этого оператора. Примеры из системы показывают, что этой операцией надо пользоваться в связке со state<T>, read_state, write_state — но данные операции тоже не компилируются в ANE.

Чуть более замороченно — scatter. Есть оригинал, есть update, индексы собрать не проблема — и снова мимо, Unsupported MIL operation “scatter”.

Спустя несколько минут медитации на список операций из документации, в голову приходит безумная идея. У нас есть оригинал. У нас есть select, позволяющий выбирать между A и B в зависимости от tensor<bool, [...]> cond. И у нас есть gather, позволяющий набрать данных из другого тензора. Набираем из «обновлений» по индексам, чтобы получить тензор «полной» длины — вне обновляемого диапазона «забираем» нулевой индекс, в целом нам всё равно что там будет. Обновляемый диапазон маскируем, и делаем select между полным (старым) кэшем, и новыми данными. Эффективно? Ну, выглядит так себе, но надеемся на внутренний оптимизатор. Будет ли работать? Сейчас узнаем... да!

It's alive

Критический инсайт по результатам: в попытке собрать данную цепочку вызовов обратно, выяснилось, что 4 мелких вызова отрабатывают быстрее, чем один fused mega‑kernel. То же самое справедливо для multi‑program kernel: попытка свести все методы одной модели в один вызов компилятора замедлила inference в разы. Вишенкой на торте стал механизм enumerated shapes: он, по факту, разворачивается на этапе компилятора в несколько независимых программ, и наследует проблему замедления от multi‑program. То‑есть — «можно, а зачем но не нужно».

В остальном — у меня был пайплайн из CPU‑embed, full‑ANE‑layers (28 слоёв для Qwen3-0.6B), по 4 вызова на слой, и CPU‑sampler в конце. В сочетании с builder, это дало возможность без переписывания всего сделать chunked‑prefill, ускорив TTFT. Всё было хорошо... Кроме ~112 вызовов ANE на каждый токен. Ну, то‑есть, overhead. По результатам профилирования через Instruments (заботливо показывающего использование ANE), было выяснено, что больше половины времени не работает никто. ANE закончил предыдущий вызов, через XPC моему процессу летит ответ «готово», тот возвращается в поток, где я запускаю новый вызов, и тот через XPC летит обратно в сервис, который занимается ANE, и дальше в ядро. Долго. Медленно. Не нравится.

Chaining — больше не опция

Изначально я хотел посвятить этой теме отдельную статью (с разносом того, куда опять понесло ЫЫ), но потом понял, что в этом нет никакого смысла — тема достаточно короткая. Всё как в прошлой статье — нейронка увидела знакомое слово, подумала что «вот оно», и пошла ковырять то, что на самом деле не нужно.

_ANEChainingRequest. Очевидно, что он занимается «связкой» нескольких вызовов вместе. Но правда ли он тут нужен? Возможно. Но исследования в этой области завели нейронку в тупик. Очевидно, почему — её in‑memory path был фундаментально несовместим с тем, что на вход ожидают методы вокруг chaining. Замена на обычную ANEModel (которая уже была сделана в предыдущей серии) дала толчок вперёд, реверс функций показал, что надо положить в остальные параметры, а подробные логи дали понимание порядка вызовов. Через некоторое время все методы возвращали «ок»... Но код не работал. Системные логи рассказывали о каких‑то необработанных событиях и assert в низкоуровневом драйвере.

В этот момент я решил поискать вызовы этого метода в системе. Кто‑то (CoreML? CoreAI?) должен был работать с ANE эффективно. Но... нет. Использований этого класса в системе не нашлось (как и в случае с ANEInMemoryModel). Зато нашлось кое‑что другое. Ответ всё это время был у меня в руках, и был этим ответом... completionHandler. Это обычная практика для «универсальных» методов — хочешь синхронный — просто вызывай, хочешь асинхронный — дай callback и жди пока его позовут.

Такой callback был у самого обычного ANERequest — того самого, который использовался с самого первого исследования. Задание ему «какого‑то» блока сразу сломало всё — код стал полностью асинхронным, и даже повесил macOS — пришлось перезагружать ноут принудительно. Вставив в обработчик мьютекс для синхронизации (в дальнейшем заменённый на atomic‑wait), и добавив вызов sync, я получил желаемый результат — теперь работа для ANE отправлялась в очередь подряд, а в месте, где мне нужен был её результат, CPU вставал ждать. График использования ANE в Instruments стал плотнее, а использование CPU — заметно меньше. Можно ли улучшить этот результат? Естественно, сейчас overhead составляет около 10–15%, но это в 3–4 раза лучше, чем было в синхронном варианте. Но для этого надо решить проблему с «нативным» chaining — если она, конечно, решается.

Что дальше?

Qwen3-0.6B — это, конечно, здорово. Но хотелось что‑то поумнее. 4B и 8B тоже запускаются и работают — правда, медленно. Всё‑таки, 16 гигабайт матриц в 3W питания ворочать тяжело.

Семейство Qwen3.5 — это уже сильно лучше. Кроме повышения качества ответов (модель «новее» и предсказуемо «умнее» при тех же размерах), там в 3 из 4 слоях задействованы более эффективные Gated DeltaNet — что означает рекуррентную обработку, константное время и память при любом контексте. Но тут меня ждали новые подставы от ANE: depthwise convolution из коробки не заработал. Новые трюки для эффективной обработки — WIP.

Следующий уровень — квантизация. Если 8B ещё использовать комфортно (16GB RAM + контекст), то модели уровня 14–32B уже в адекватное количество RAM не влезают. А хочется Qwen3.8-27B, ещё и с MTP, и со всем фаршем... Ну и MoE, куда же без Qwen3.6-35B-A3B.

Превращение данного фреймворка в универсальный комбайн пока под сомнением. Training? Конечно, можно пробросить binding в python, где посчитать градиенты и оптимизаторы (всё, конечно же, асинхронно и без CPU). И, скорее всего, оно будет работать. Но мой интерес в другой области.

Обёртка данного фреймворка в OpenAI‑совместимый API — вот то, куда я точно буду это развивать. Подключение к любому клиенту, будь то редактор кода или автономный агент.

Дай потыкать

После обширного рефакторинга, я всё же готов показать код: GitHub. Наработки по Qwen3.5, OpenAI API, и квантизации будут появляться по мере стабилизации. Pull Requests с тестами, бенчмарками, оптимизациями и другими полезностями — you are welcome.