
Как я мерил движки для локальной 27B, а померил свой ноутбук
Восемь инференс-движков на M4 Max, одна модель. По дороге выяснилось, что мой бенчмарк меряет своп, попадания в кеш промптов и что угодно, кроме движков. А выигрывает в итоге вообще не движок.
С чего началось
На диске лежал файл Qwen3.8-27B-GSQ-RCO-IQ3_S-mtp.gguf, 11.28 GiB. Квант от ISTA DASLab, тех самых, что делают GPTQ. У них там своя история: не один тип кванта на всю модель, а свой тип на каждый тензор, подобранный градиентным поиском под бюджет размера. В карточке модели обещают task-lossless на 3.5 битах.
Вопрос был простой: чем это запускать на маке. У меня в системе на тот момент уже стояли llama.cpp, ollama, LM Studio и oMLX. Плюс интернет полон заявок вида “мы в три раза быстрее llama.cpp”.
Железо: M4 Max, 48 GB, macOS 26.5.2. Metal рапортует recommendedMaxWorkingSetSize = 40200.90 MB.
Модель, кстати, не простая. Архитектура qwen35 - гибрид: три слоя линейного внимания (SSM, state_size 128) и каждый четвёртый - полное внимание. 65 слоёв, 27.32B параметров, контекст 262144. Плюс отдельный mmproj на 888 MB - модель мультимодальная. И плюс MTP-голова для спекулятивного декода, она лежит 65-м слоем прямо в файле.
Это важно: гибридная арка новая, и половина форков llama.cpp её просто не знает.
Первый замер, который я потом выбросил
Взял llama.cpp из brew, поднял сервер, прогнал. Прогон - один и тот же запрос, 400 токенов на выход, пять раз, беру медиану.
llama-bench: pp512 238 t/s, tg128 22.62 t/sКрасиво. Потом померил tg400 вместо tg128 - и получил 13.68. На той же машине, тем же бинарём, в ту же минуту.
Дальше пошло веселее. Прогоняю ollama на том же файле - 21.36. Прогоняю ещё раз - 15.63. Ещё пять раз подряд:
13.45 → 9.54 → 8.56 → 9.43 → 9.44Монотонная деградация. Не шум, а именно сползание вниз. Проверяю термотроттлинг:
$ pmset -g therm
Note: No thermal warning level has been recordedНе тепло. Смотрю память:
$ sysctl vm.swapusage
vm.swapusage: total = 17408.00M used = 15949.94M free = 1458.06MСвоп забит под завязку. На машине, кроме моделей, крутилась виртуалка Docker на 11 GB, браузер и прочее хозяйство.
Первый вывод: я мерил не движки. Я мерил, сколько осталось памяти на момент замера.
Дрейф никуда не делся и без Docker
Выключил Docker, свободной памяти стало 81%. Прогнал заново, по пять раз на движок, медиана:
| # | Движок | Модель | Декод, tok/s | Prefill 4k |
|---|---|---|---|---|
| 1 | llama.cpp llama-server | GGUF IQ3_S | 21.10 | 193.1 |
| 3 | oMLX 0.6.4, MTP | MLX oQ3.5e | 42.19 | 365.1 |
| 4 | ollama 0.33.0 | GGUF IQ3_S | 13.14 | 109.8 |
| 5 | LM Studio, llama.cpp 2.34 | GGUF IQ3_S | 10.73 | 119.9 |
| 6 | LM Studio, MLX 1.11 | MLX oQ3.5e | 15.67 | 107.7 |
| 7 | llama.cpp, контроль | GGUF IQ3_S | 14.04 | - |
Седьмая строка - это первая строка, прогнанная ещё раз, в конце серии. Тот же движок, те же настройки, тот же файл.
21.10 в начале серии и 14.04 в конце. Минус 33% за полтора часа.
Внутри одного блока из пяти прогонов разброс маленький, 2-5%. Сползание идёт между блоками. Своп за серию подрос с 4.7 до 5.4 GB.
С этого момента я перестал верить любой разнице меньше чем в полтора раза и начал ставить контрольный замер в конец каждой серии.
Из таблицы, впрочем, кое-что достать можно. llama.cpp быстрее ollama и LM Studio на одном и том же файле - разрыв больше дрейфа. Забавно, что у ollama внутри тот же самый бинарь:
$ ps aux | grep ollama
/opt/homebrew/Cellar/ollama/0.33.0/libexec/lib/ollama/llama-serverЭто обёртка над llama-server с другими дефолтами, а не собственный движок. Ждать от неё другой скорости неоткуда.
Вторая ошибка: я мерил кеш
Уже под конец, разглядывая настройки oMLX, наткнулся:
"cache": {
"enabled": true,
"ssd_cache_dir": "~/.omlx/cache",
"ssd_cache_max_size": "27GB",
"preserve_mid_system_cache": true
}А мой бенчмарк шлёт один и тот же промпт пять раз подряд. То есть прогоны 2-5 вполне могли идти по кешированному префиксу вообще без prefill. Кеш на диске переживает выгрузку модели, так что он собирал попадания и между раундами - на момент обнаружения в ~/.omlx/cache лежало 603 MB.
У llama.cpp ровно то же самое, и я это видел своими глазами в его timings, но прошёл мимо:
{"cache_n": 72, "prompt_n": 4, "prompt_ms": 215.06, ...}Из 76 токенов промпта 72 взяты из кеша. Второй прогон уже не мерил prefill.
Под подозрением в первую очередь те самые 365 tok/s prefill у oMLX, которые так красиво выбивались из общего ряда 110-190.
Лечение: почистить кеш и слать уникальный префикс в каждом запросе.
uniq = f"[req {random.randint(10**9, 10**10)}] " # ломает префиксный кешУ vLLM для этого есть честный флаг --no-enable-prefix-caching.
Кто вообще умеет эту модель
Пока шли замеры, я перебрал всё, что можно поставить на мак. Проверял наличие арки qwen35 в таблице архитектур форка и дату последней активности.
| Движок | Тип | Знает qwen35 | Итог |
|---|---|---|---|
| llama.cpp v0.4.0 | C++ | да | работает |
| ollama 0.33 | Go + ggml | да | это тот же llama-server |
| LM Studio | GGUF + MLX | да | работает |
| oMLX 0.6.4 | MLX | да | работает |
| vllm-metal 0.28 | Python + MLX | да | работает |
| uzu 0.5.26 | Rust | да | работает |
| ik_llama.cpp | C++ форк | да | Metal не развивают |
| koboldcpp | C++ форк | да | отстаёт от апстрима |
| mistral.rs | Rust | да | IQ-кванты не грузит |
| swama | Swift + MLX | - | арку не знает |
| llamafile | C++ форк | - | релиз старше модели |
| SGLang, vLLM CUDA, TGI, TensorRT-LLM | - | - | не про Apple Silicon |
Пара слов про тех, кого вычеркнул.
ik_llama.cpp я собирался собирать всерьёз - это форк автора самих IQ-квантов, у него свои кернелы. Посмотрел коммит-лог, последние 300 коммитов по бэкендам:
| Бэкенд | Коммитов |
|---|---|
| CUDA | 60 |
| CPU / AVX | 49 |
| Metal | 4 |
| Vulkan | 4 |
Единственный Metal-коммит за месяц - metal: initialize encode_async in ggml_backend_metal_init. Это починка, а не оптимизация. Плюс в дереве лежат ggml-metal.m и ggml-metal.metal - старая раскладка апстрима, откуда Metal давно переехал. Работа по Qwen3.5 там есть и свежая, но вся про CUDA. Собирать не стал.
mistral.rs поставился за минуту, сам нашёл mmproj, а потом сказал:
Error: GGUF tensor `token_embd.weight` uses dtype IQ2_S (22);
direct GGUF loading currently supports F32, F16, BF16, Q4_0, Q4_1,
Q5_0, Q5_1, Q8_0, Q8_1, and Q2_K through Q8_KВесь GSQ-RCO построен на IQ-типах. Для него mistral.rs бесполезен в принципе, на K-квантах работал бы.
swama - Swift поверх MLX, ставится одной строчкой brew install swama. Два подарка. Первый: CLI-хелпер падает при первом же запросе, Fatal error: could not load resource bundle: from /Applications/Swama.app/Contents/Helpers/swama_SwamaCore.bundle - бандл лежит в Contents/Resources/, ошибка упаковки. Обошёл запуском самого приложения. Второй: модель не грузится вообще, model.load.failed, config_decode: null. mlx-swift не знает арку qwen3_5.
Заявка на “в три раза быстрее”
Отдельная история - uzu от Mirai. Rust, MIT, активный репозиторий. На сайте:
Qwen3.6-27B на M5 Max: 119.1 tok/s на выход против 25.7 у MLX. Prefill 833.8 против 541.2.
Это не “на десять процентов”. Это другой класс. Проверять обязательно.
Дальше было приключение.
Сначала я скачал их модель с Hugging Face. Скачалась - и повисла на верификации. Файл ровно нужного размера, 14 532 457 936 байт. Проверяю хэш:
lfs.oid с HF: a032b28fe0831aa5030e862a4890f1dbba28e03f0180e112a90a987cab0d10c8
фактический: 48110f24f3237279dfea2784752abec95ddb0f75f395a03d9b210c5f193f6a09Размер совпал, содержимое нет. Виноват Xet-транспорт: качает дедуплицированными чанками, часть записал мусором. Лечится переменной:
HF_HUB_DISABLE_XET=1 huggingface-cli download <repo> --local-dir <dir>Без Xet те же 14.5 GB приехали за 12 минут вместо полутора часов и целыми. Размеру файла верить нельзя, проверяйте shasum -a 256 против lfs.oid из /api/models/<repo>/tree/main.
Дальше выяснилось, что скачанное вообще не подходит. Движок пишет в лог:
[MODEL] File 3 - phase=NotDownloaded, downloaded=0/14532822104 bytesОн ждёт 14 532 822 104, а на HF в main лежит 14 532 457 936. Разница 364 KB - это разные сборки. Качает он не с HF, а со своего CDN, и версия чекпоинта зашита в путь: assets.trymirai.com/models/0.15.0/....
Заодно разобрал формат их кеша. Рядом с каждым файлом лежит сайдкар <файл>.crc:
{"version":1,"crc":"6bfHYA==","file_size":182981,
"modified_unix_seconds":1789015931,"modified_nanos":146958286}Файл без валидного сайдкара движок молча удаляет при следующем обращении - я на это напоролся дважды, прежде чем понял. Поле crc - это CRC32C (Castagnoli), big-endian, в base64. Проверил на config.json: zlib.crc32 даёт dkVs0Q==, CRC32C даёт 6bfHYA==, что совпадает с сайдкаром. Те же хэши лежат в registry.json на каждый файл, так что качать можно чем угодно и собирать сайдкары самому.
И вот наконец замер. Три движка подряд, встык, одна и та же машина:
| Движок | Модель | Декод, tok/s |
|---|---|---|
| oMLX 0.6.4, MTP | MLX oQ3.5e | 33.28 |
| llama.cpp | GGUF IQ3_S | 16.73 |
| uzu 0.5.26 | uzu 0.15.0 | 16.42 |
Ровно на уровне llama.cpp. Никаких 119.
Смотрю статистику каждого прогона - и вот оно:
run1: gen 18.96 prefill 72.2 ttft 0.540s out 400 spec 1.00 tok/passtokens_per_forward_pass = 1.00. Спекулятивного декода нет вообще. Спрашиваю модель напрямую:
is_speculation_capable: False
models_for_speculation(): []Ответ нашёлся в их же блоге, пост от 3 сентября. Схема называется DFlash-Weaver: гибридная черновая модель, дерево токенов вместо цепочки, бюджеты 16-32 токена, верификация специализированными Metal-кернелами под Gated DeltaNet. Звучит красиво. И дальше ключевое: выпущено это пока только для Qwen3.6 27B, а Qwen3.8 и Muse Glimmer заявлены как “coming soon”.
То есть их цифры - это M5 Max, другая модель и спекулятор. У меня M4 Max, Qwen3.8 и спекулятора нет. Паритет с llama.cpp - ожидаемый результат, а не опровержение заявки. Но и подтвердить её на своём железе я не могу, пока они не выпустят голову под 3.8.
Заявка не проверена, а не опровергнута. Это разные вещи, и в блогах их постоянно путают.
vLLM, который съел 38 гигабайт
У vLLM, оказывается, есть официальный бэкенд под Metal - vllm-project/vllm-metal. Апстрим даёт API-сервер, шедулер и paged block manager, mlx_lm даёт слои модели, а сам плагин владеет attention: paged varlen кернел и спекулятивный декод. Qwen3.5/3.6/3.8 в матрице поддержки стоят с галочкой.
Поднялся не сразу. Модель mlx-community/Qwen3.8-27B-4bit мультимодальная, а transformers 5.17 не переваривает её preprocessor_config.json: там image_processor_type: Qwen2VLImageProcessorFast, а суффикс Fast в пятой ветке объявлен устаревшим, и автомаппинг такой класс не находит. Подмена имени не помогает - падает уже на вложенном процессоре. Рабочий обход - каталог из симлинков без файлов процессоров и с текстовым конфигом:
DST=~/models/q38-4bit-patched
mkdir -p $DST && for f in ~/models/mlx-community-Qwen3.8-27B-4bit/*; do ln -sf "$f" $DST/; done
rm $DST/preprocessor_config.json $DST/processor_config.json $DST/config.json
# в config.json: language_model_only=true, architectures=["Qwen3_5ForCausalLM"]Поднялся. И сразу занял +23.78 GiB, а после промпта на 24 тысячи токенов дорос до +38.66 GiB. На 48-гигабайтной машине. Для 15-гигабайтной модели.
Причина - дефолт --gpu-memory-utilization 0.92. Движок печатает свою бухгалтерию сам, и это лучшее, что я видел из диагностики за всю эту историю:
Paged attention memory breakdown: metal_limit=40.20GB, fraction=0.55,
usable_metal=22.11GB, model_memory=15.13GB, overhead=1.16GB,
kv_budget_before_hybrid=5.82GB, hybrid_gdn_state=lazy
KV cache: 5343.5 MB (16 layers, 104 blocks, 784 tokens/block)Попробовал прижать до 0.45 - движок отказался стартовать:
| fraction | usable | модель | оверхед | KV | Ёмкость KV |
|---|---|---|---|---|---|
| 0.45 | 18.09 GB | 15.13 | 1.16 | 1284 MB | 19 600 tok - отказ |
| 0.55 | 22.11 GB | 15.13 | 1.16 | 5343 MB | 81 536 tok - работает |
При 0.45 на KV оставалось 1.80 GB, а на заявленные max-model-len 32768 нужно около 2.15. Реальный минимум для 32k: 15.13 + 1.16 + 2.15 + 0.46 на GDN-состояние = около 18.9 GB. Дефолт отдаёт ему 37 и он их занимает.
Для сравнения llama.cpp на том же контексте:
| ctx | После загрузки | После промпта 24k |
|---|---|---|
| 8 192 | 10.84 GiB | - |
| 32 768 | 13.76 GiB | 14.28 GiB |
| 131 072 | 19.76 GiB | 20.17 GiB |
Весь KV резервируется при старте, дальше рост на полгигабайта от любого промпта.
Накладные сверх весов на 32k: у llama.cpp 2.48 GiB, у vLLM около 3.8 GB. Полтора раза, а не втрое - втрое выглядит только из-за дефолта. Ручки есть: --gpu-memory-utilization, --kv-cache-memory-bytes, --kv-cache-dtype вплоть до turboquant_3bit_nc, --block-size.
Третья ошибка: rss врёт
Первый замер памяти vllm-metal дал мне 0.57 GiB на пятнадцатигигабайтной модели. Смешно.
ps rss для MLX-движков бесполезен: веса живут в MTLHeap, и в rss они не попадают. Для llama.cpp rss честный, потому что ggml использует shared-буферы. Один инструмент, два разных ответа, и ни одного предупреждения.
Пошёл считать системную занятую память, (active + wired + compressed) из vm_stat. Стало не лучше:
после загрузки: +29.82 GiB
промпт 752 tok: +34.35 GiB
промпт 6052 tok: +24.28 GiB
промпт 24052 tok: +30.70 GiBЦифры скачут вверх-вниз внутри одного прогона, потому что счётчик ловит браузер, компрессор и всё остальное хозяйство.
Достоверны оказались ровно два источника: собственная бухгалтерия движка в его логе и rss там, где он не врёт. Всё, что я мерил снаружи, - мусор.
Настоящий вывод
Осталось главное. У oMLX на его собственном кванте oQ3.5e с MTP было 42 tok/s против 21 у llama.cpp. Ровно вдвое. Устойчиво, воспроизводилось в двух разных сериях.
Вопрос: это заслуга рантайма или упаковки?
Скачал mlx-community/Qwen3.8-27B-4bit - обычный MLX-квант того же самого базового Qwen3.8-27B - и скормил его и oMLX, и vllm-metal. Впервые два движка на абсолютно одинаковом файле весов.
| Движок | Веса | Декод, tok/s |
|---|---|---|
| oMLX | oQ3.5e + MTP | 28.75 |
| oMLX | mlx-community 4bit | 17.10 |
| vllm-metal | mlx-community 4bit | 23.76 |
| llama.cpp | GGUF IQ3_S | 16.10 - 21.21 |
Тот же oMLX на стандартных весах теряет треть. Двукратное преимущество давал не движок, а квант плюс MTP-голова.
Первым делом я обрадовался и написал, что vllm-metal на 39% быстрее oMLX. Потом вспомнил про дрейф и перемерил как положено: чередованием A-B-A-B, блоками по три прогона, с проверкой перед каждым блоком, что ни один процесс не держит больше 5 GB, и с уникальными промптами.
| Цикл | oMLX 4bit | vllm-metal 4bit |
|---|---|---|
| 1 | 25.36 | 15.39 |
| 2 | 18.18 | 20.98 |
| 3 | - | 18.18 |
Диапазоны перекрываются полностью. Никакой разницы нет. Мой вывод про 39% был артефактом: тот раунд просто поймал удачное расположение блоков относительно сползания.
Итого. Пять движков, которые вообще запускают эту модель на Metal, лежат в пределах шума друг от друга на равных весах. А реальный выигрыш дают вещи, которые к движку отношения не имеют: формат кванта и спекулятивная голова.
Бонус: у Qwen3.8 дырка в шаблоне
Пока разбирался с параметрами, наткнулся на другое. У Qwen3.8 в chat_template.jinja есть reasoning_effort, по умолчанию xhigh:
{%- set resolved_reasoning_effort = reasoning_effort|default('xhigh') %}
{%- if resolved_reasoning_effort not in ('xhigh', 'medium', 'low') %}
{{- raise_exception('Unexpected reasoning effort ...') }}
{%- endif %}
{%- if resolved_reasoning_effort == 'xhigh' %}
{%- set reasoning_instructions = '...think carefully...' %}
{%- elif resolved_reasoning_effort == 'low' %}
{%- set reasoning_instructions = '...keep your thinking brief...' %}
{%- endif %}Заметили? У medium нет своей ветки. Валидацию он проходит, а инструкцию не получает - reasoning_instructions остаётся пустой строкой.
Проверяем по длине промпта:
reasoning_effort | Токенов промпта | Символов размышлений |
|---|---|---|
xhigh (дефолт) | 63 | 333 |
medium | 21 | 262 |
low | 51 | 179 |
high | HTTP 500 | - |
21 токен против 63 - это ровно отсутствие вставленной инструкции. То есть medium сейчас означает не “средний уровень”, а “без директивы про размышления”. А high шаблон отвергает исключением, хотя llama-server --reasoning-effort в справке бодро предлагает minimal, low, medium, high, xhigh, max - из шести Qwen3.8 переварит три.
Переключается это так:
| Движок | Как |
|---|---|
| llama.cpp | "chat_template_kwargs": {"reasoning_effort": "medium"} на запрос или --reasoning-effort на сервер |
| uzu | .with_reasoning_effort(ReasoningEffort.Medium), причём только на системном сообщении - на пользовательском отвечает Content block 'reasoning_effort' is not supported for role 'user' |
| oMLX | enable_thinking, thinking_budget_tokens в настройках модели |
| ollama | поле think в API |
Что я забрал себе
Ставьте контрольный замер в конец серии. Первый движок и последний движок в вашем списке находятся в разных физических условиях. У меня один и тот же llama.cpp дал 21.10 в начале и 14.04 в конце. Без контрольной строки я бы опубликовал рейтинг, в котором первым идёт тот, кого мерили первым.
Своп важнее движка. Пока vm.swapusage показывает 16 гигабайт занятыми, вы меряете менеджер памяти macOS. Проверять до замера, а не после.
Ваш бенчмарк меряет кеш, если промпт не меняется. Одинаковый запрос пять раз подряд - это один замер prefill и четыре замера кеша. Уникальный префикс в каждом запросе стоит одну строчку, а у vLLM для этого есть отдельный флаг.
Один и тот же инструмент врёт по-разному разным движкам. ps rss честен для llama.cpp и бесполезен для всего, что на MLX. Прежде чем сравнивать числа из одного столбца, проверьте, что они вообще про одно и то же.
Сравнивать движки можно только на одинаковых весах. Иначе вы сравниваете кванты. У меня двукратный разрыв целиком объяснился форматом кванта и MTP-головой, а движки на равных весах оказались неразличимы.
“Не воспроизвелось” и “неправда” - разные вещи. Заявка про 119 tok/s не проверяется на моём железе, потому что нужный компонент для этой модели ещё не выпущен. Это не делает её ложной. Это делает её непроверенной, и написать надо именно так.
Читайте, что движок пишет о себе сам. Лучшая диагностика памяти за всю историю пришла не от моих измерений, а от одной строчки в логе vLLM с полной разбивкой бюджета. Мои собственные замеры снаружи все до одного оказались мусором.
Ах да, что я в итоге запускаю. oMLX со своим oQ-квантом и MTP - потому что он быстрее всех, а не потому, что у него лучший движок. Ровно тот случай, когда правильный ответ на вопрос “чем запускать” оказался ответом на вопрос “что запускать”.