haiodo.blog
Как я мерил движки для локальной 27B, а померил свой ноутбук

Как я мерил движки для локальной 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/sPrefill 4k
1llama.cpp llama-serverGGUF IQ3_S21.10193.1
3oMLX 0.6.4, MTPMLX oQ3.5e42.19365.1
4ollama 0.33.0GGUF IQ3_S13.14109.8
5LM Studio, llama.cpp 2.34GGUF IQ3_S10.73119.9
6LM Studio, MLX 1.11MLX oQ3.5e15.67107.7
7llama.cpp, контрольGGUF IQ3_S14.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.0C++даработает
ollama 0.33Go + ggmlдаэто тот же llama-server
LM StudioGGUF + MLXдаработает
oMLX 0.6.4MLXдаработает
vllm-metal 0.28Python + MLXдаработает
uzu 0.5.26Rustдаработает
ik_llama.cppC++ форкдаMetal не развивают
koboldcppC++ форкдаотстаёт от апстрима
mistral.rsRustдаIQ-кванты не грузит
swamaSwift + MLX-арку не знает
llamafileC++ форк-релиз старше модели
SGLang, vLLM CUDA, TGI, TensorRT-LLM--не про Apple Silicon

Пара слов про тех, кого вычеркнул.

ik_llama.cpp я собирался собирать всерьёз - это форк автора самих IQ-квантов, у него свои кернелы. Посмотрел коммит-лог, последние 300 коммитов по бэкендам:

БэкендКоммитов
CUDA60
CPU / AVX49
Metal4
Vulkan4

Единственный 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, MTPMLX oQ3.5e33.28
llama.cppGGUF IQ3_S16.73
uzu 0.5.26uzu 0.15.016.42

Ровно на уровне llama.cpp. Никаких 119.

Смотрю статистику каждого прогона - и вот оно:

run1: gen 18.96  prefill 72.2  ttft 0.540s  out 400  spec 1.00 tok/pass

tokens_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 - движок отказался стартовать:

fractionusableмодельоверхедKVЁмкость KV
0.4518.09 GB15.131.161284 MB19 600 tok - отказ
0.5522.11 GB15.131.165343 MB81 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 19210.84 GiB-
32 76813.76 GiB14.28 GiB
131 07219.76 GiB20.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
oMLXoQ3.5e + MTP28.75
oMLXmlx-community 4bit17.10
vllm-metalmlx-community 4bit23.76
llama.cppGGUF IQ3_S16.10 - 21.21

Тот же oMLX на стандартных весах теряет треть. Двукратное преимущество давал не движок, а квант плюс MTP-голова.

Первым делом я обрадовался и написал, что vllm-metal на 39% быстрее oMLX. Потом вспомнил про дрейф и перемерил как положено: чередованием A-B-A-B, блоками по три прогона, с проверкой перед каждым блоком, что ни один процесс не держит больше 5 GB, и с уникальными промптами.

ЦиклoMLX 4bitvllm-metal 4bit
125.3615.39
218.1820.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 (дефолт)63333
medium21262
low51179
highHTTP 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'
oMLXenable_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 - потому что он быстрее всех, а не потому, что у него лучший движок. Ровно тот случай, когда правильный ответ на вопрос “чем запускать” оказался ответом на вопрос “что запускать”.