Дано: MacBook Pro 16" M2 Max, 64GB unified memory, задача - гонять Qwen 3.5 35B moe локально как inference-сервер. Серверов для MLX - штук восемь, и каждый в README обещает «blazing fast». Я взял все, написал автоматический бенчмарк на восьми реальных задачах, прогнал пять итераций - и получил результаты, которые меня удивили.

гит моего бенча: https://github.com/yaruslove/qwen3.5-bench-8-mlx-server-mac

Сразу сниму главный вопрос - «а почему не llama.cpp?» llama.cpp отличный и универсальный, но на Apple Silicon MLX стабильно быстрее на 10-30%, умеет настоящий continuous batching из коробки и хранит модели в нативном формате под unified memory - без промежуточной конвертации GGUF. Статья именно про MLX-экосистему: там внезапно оказалось восемь серверов, и между ними реальная разница, которая тянет на отдельный разбор. Сравнение с llama.cpp - тема отдельной статьи, и я её не избегаю, просто не смешиваю.

Зачем мне локальная 35B - три причины:

  • Privacy. В работу прилетают договоры, ТЗ, переписки с клиентами - это нельзя просто скормить в ChatGPT или Claude. Локальная модель обрабатывает всё без утечек: снимает ФИО, счета, контакты и возвращает чистый текст.

  • Coding-агенты и open-code. Claude и GPT по подписке хороши, пока агент не гоняет задачи в цикле по восемь часов - тогда токены превращаются в кофейные зёрна. Все современные open-source тулы для AI-кодинга - OpenCode, Aider, Claude Code - умеют подключаться к любому OpenAI-совместимому endpoint. Ставишь base_url: http://mac.local:8000/v1 и свой API-ключ - агент крутится на уже оплаченном железе, без телеметрии и rate-limit’ов. На работе я разрабатываю агентные системы, и мне постоянно нужно гонять свежие компактные LLM: с февраля ежедневным инструментом был GLM 4.7 Flash на 4090, теперь примеряю Qwen 3.5 35B на Mac.

  • Нет сетевого RTT. 35B в 4-бит на M2 Max отвечает живее многих облачных API с очередью - просто потому что нет раунд-трипа через интернет. И запускать серьёзную модель на машине без отдельной видеокарты - это до сих пор ощущается как магия.

Если коротко: три фреймворка идут ноздря в ноздрю на single-user, но стоит пустить два параллельных запроса - и четверо из шести откатываются в очередь, один выходит в 2.17× speedup, а ещё один вообще деградирует в 0.85×, пока не дашь ему --workers 2. По ходу всплыли квадратичный attention в 2026 году, фантомные 14000 tokens/sec из-за одной строчки в SSE-парсере и зомби-процесс на 20GB RAM, которого нет ни в одном README.

Три фреймворка в пределах 2% - но это только single user
Три фреймворка в пределах 2% - но это только single user

Это single-user. С батчингом картина переворачивается - но до неё доберёмся через пятнадцать минут чтения.


Зачем вообще всё это

Хотелось простого. Mac - как локальный LLM-сервер. Сверху LiteLLM-гейтвей, дальше VPS с белым IP - чтобы дёргать Qwen по API как Open-AI compatiable из интернета, несколько ключей на несколько устройств. Требования короткие: OpenAI-совместимый endpoint (/v1/chat/completions), нормальный батчинг под нескольких пользователей, стабильность.

Первое, что попробовал - mlx-vlm. Это библиотека для vision-моделей, но в ней есть серверный режим. Запустил, получил 15–25 tps, половина запросов падает, LiteLLM коннектит, но под нагрузкой сервер просто отваливается. Ясно стало одно: это не готовый сервер. Нужен другой.

Альтернатива mlx-vlm - MLX-экосистема целиком. Про выбор MLX вместо llama.cpp я уже сказал в начале, не повторяюсь; добавлю только живой источник - нашёл Reddit тред на r/LocalLLaMA, где народ меряет 2× разницу на Qwen 3.5 35B. Важнее другое: MLX-серверов оказалось много. Половину я узнал, только когда начал копать.

Я решил не гадать по README, а просто проверить все на одинаковых данных. Написал харнесс на Python, который запускает сервер как subprocess, ждёт healthcheck на /v1/models, прогоняет восемь промтов в single-режиме, потом те же пары в двойном режиме через asyncio-барьер, собирает CSV и убивает процесс. Следующий фреймворк. И так шесть раз подряд, пять итераций.

Короткая шпаргалка почему на новых мак можно инференсить: что такое MLX, если вы с NVIDIA

Если фоном у вас CUDA и PyTorch - вот быстрые соответствия для мира Apple Silicon:

  • Metal - это Apple-ский CUDA. GPU-API чипа M-series, на нём идут все matmul и attention. Аналог CUDA Toolkit.

  • MLX - это Apple-ский PyTorch + CUDA runtime в одном лице. Фреймворк Apple для ML, который компилируется напрямую в Metal. Вокруг него экосистема: mlx-lm для LLM (аналог HuggingFace Transformers), mlx.fast - оптимизированные операции, включая flash attention (аналог cuDNN).

  • Unified memory - ключевое отличие от NVIDIA. На RTX у вас 24GB VRAM и 64GB RAM отдельно, копирование весов из RAM в VRAM - привычная боль через cudaMemcpy. На M-series CPU и GPU делят один пул памяти. 35B-модель в 20GB лежит один раз и одинаково доступна обоим - никаких копий.

Почему GPU вообще быстрее CPU на LLM? Генерация одного токена - это прогон входного вектора через десятки слоёв матричных умножений. У CPU десятки больших ядер с кешем, у GPU - тысячи простых ядер, которые жрут одну и ту же операцию параллельно. Одно скалярное умножение CPU сделает быстрее; батч из миллионов - GPU бьёт CPU в десятки раз. M2 Max даёт ~400 GB/s memory bandwidth - этого хватает на реалтайм-декод 35B модели со скоростью 50-80 токенов в секунду. На CPU той же модели вы бы ждали ответа в 10-20 раз дольше.

Практический нюанс: Metal не шарит GPU-контекст между процессами. Поэтому все шесть фреймворков в бенчмарке я запускал строго по одному - два одновременно просто не сосуществуют на одной железке.

Ниже - что из этого вышло.


Что сравниваем: восемь фреймворков

Вот полный список. Шесть попали в бенчмарк, два отключены - причины ниже.

Фреймворк

Язык

Главная фича

В бенчмарке

mlx-openai-server

Python 3.11

Queue-batcher, image gen (Flux), multi-model

+

mlx-omni-server

Python 3.11+

Dual API - OpenAI + Anthropic на одном сервере

+

Rapid-MLX

Python 3.10+

Простота, 1900+ тестов, интеграции (Cursor, Aider)

+

vllm-mlx

Python 3.10+

vLLM-style, paged KV cache, multimodal

+

omlx

Python

Tiered KV cache (RAM + SSD), admin dashboard

-

mlx-vlm

Python 3.10+

Fine-tuning VLM, 40+ архитектур

+

higgs

Rust

Single binary, без Python

- отключён

mlx-serve

Zig

Native, agent mode, без Python

- отключён

Сначала визуальный ландшафт - чтобы видеть, кто что умеет без вчитывания в README:

Ландшафт: 8 фреймворков × 6 фич
Ландшафт: 8 фреймворков × 6 фич

Теперь по каждому коротко.

mlx-openai-server - drop-in замена OpenAI API. Из интересного: очередь запросов с настоящим continuous batching (сразу спойлер - единственный, кто реально параллелит), speculative decoding для ускорения, multi-model через YAML, structured output через outlines. Минус - жёстко требует Python 3.11 и тащит torchvision + ffmpeg в зависимостях.

mlx-omni-server - единственный с двойным API: /v1/* в стиле OpenAI и /anthropic/v1/* для Claude-совместимых клиентов. Плюс TTS/STT и эмбеддинги. Нюанс с батчингом - ниже целая история.

Rapid-MLX - философия «запусти одной командой»: rapid-mlx serve <model>. 1900+ тестов в репе, интеграции с Cursor, Claude Code, Aider, Open WebUI, LibreChat. Минус - не стримит чанки токен-за-токеном, отдаёт весь ответ одним куском. Из-за этого gen_tps харнесс не мерит (показывает 0), а TTFT у него равен полному времени ответа.

vllm-mlx - vLLM-style inference, адаптированный под Apple Silicon. Paged KV cache с prefix sharing, мультимодальность (text + image + video + audio), Anthropic Messages API. Большой минус - тащит torch на 2.5GB и содержит феерический баг в SSE streaming, из-за которого показывает фантомные 14000 tokens/sec. Про это отдельно.

omlx - интересный подход: tiered KV cache, где горячая часть в RAM, холодная - на SSD в safetensors. Multi-model с LRU-выталкиванием, веб-дашборд, tool calling + MCP. Requires macOS 15 (Sequoia). Критичная проблема - hardcoded ctx window 32768 токенов. Большие промты получают HTTP 400.

mlx-vlm - изначально библиотека для Vision Language Models (включая fine-tuning), а не сервер. Поддерживает 40+ архитектур. Серверный режим есть, но относительно медленный, и на очень длинных промтах (30k+ токенов) уходит в pathological prefill slowdown - я видел 31 минуту на prefill 52k токенов, причём скорость циклически скачет 790 → 3.5 → 790 → 3.5 tok/s, как будто GC срабатывает каждые пару секунд.

higgs (Rust) - отключён. Протестровал но не до конца. Единственный Rust-сервер: single binary, zero Python runtime, TUI-дашборд, structured output на json_schema с 100% compliance. В заявленных цифрах - 755 tok/s на 8 concurrent. Причина отключения обидная: в registry есть qwen3, qwen3_moe и qwen3_next - но нет qwen3_5_moe. Нашу модель просто не загрузит. Когда появится поддержка - вернём в бой.

mlx-serve (Zig) - отключён. Нативный Zig, zero Python, MLX Core macOS-приложение с agent mode и восемью встроенными инструментами. Причина отключения - отдельный праздник, в разделе про аномалии подробно.


Как я мерил: бенчмарк-харнесс на Python

Модель одна на всех - mlx-community/Qwen3.5-35B-A3B-4bit. MoE с 3B активных параметров из 35B, 4-bit квантизация, в RAM занимает ~20GB. Выбор не случайный: помещается в 64GB с запасом под KV cache, нативный MLX-формат, поддерживается всеми шестью фреймворками.

Железо - Apple Mac M-series, 64GB unified memory. Все фреймворки делят одну GPU (Metal), запускаются строго по очереди: один за раз.

Харнесс называется app_inference, это ~700 строк Python на httpx, pyyaml, rich, psutil. Архитектура линейная:

YAML config → Runner → Launcher → Healthcheck → Scenarios → Metrics → Summary → Analyze

Launcher запускает subprocess фреймворка, перенаправляет stdout в server.log, ждёт /v1/models (healthcheck каждые 2 секунды, таймаут 600с), потом гасит SIGTERM → 15с → SIGKILL.

Client делает POST /v1/chat/completions с stream: true, парсит SSE и фиксирует три момента: когда отправил запрос (t_start), когда пришёл первый токен (t_first - отсюда TTFT), когда закончилась генерация (t_end).

Scenarios прогоняют два режима. run_single - последовательно, 8 промтов один за другим. run_double_batch - два промта одновременно через asyncio-лоадер:

gate = asyncio.Event()
task_a = create_task(chat_stream(..., start_gate=gate))
task_b = create_task(chat_stream(..., start_gate=gate))
await asyncio.sleep(0.05)   # оба дошли до барьера
gate.set()                   # отпускаем одновременно
res_a, res_b = await gather(task_a, task_b)

Кроме wall-clock метрик, в CSV летят request_start_offset (насколько рассинхронизировались старты) и overlap_ratio (доля времени, когда оба запроса были активны). Речь о настоящем параллелизме, а не о том, что оба запроса прогнались, но не одновременно.

Что считаем и насколько надёжно:

Метрика

Что измеряет

Надёжность

wall_tps_p50

Медиана токенов/с по wall-clock

Самая надёжная, всегда корректна

gen_tps_p50

Decode speed (токены / (t_end − t_first))

Мусор если сервер не стримит токен-за-токеном

ttft_p50

Time to first token

Корректно только при нормальном стриминге

speedup

Batching-эффективность

Надёжна - считается из wall_tps

Почему везде медиана, а не среднее? Восемь промтов от 100 до 53000 токенов - это экстремальный разброс. Среднее даст перевес длинным: один 40k-промт с 110 секундами total-time утопит восемь коротких в статистике. Медиана показывает типичный запрос.

И ещё - пять итераций, не одна. Iter01 был baseline. Iter02 добавил max_tokens=2048 вместо 1024 и явный model_alias для mlx-omni (история с подменой модели - ниже). Iter03 и iter04 - повторы iter02 для проверки воспроизводимости. Iter05 - добавлен флаг --workers 2 к mlx-omni для фикса регрессии на батчинге.

Запуск - три строки:

cd app_inference
uv run -m app_inference run --config config/iteration_05.yaml

Результаты пишутся в data_test/results/NNN_iterXX_YYYYMMDD_HHMMSS/ - полный CSV, логи серверов, ответы моделей в .md, копия конфига, снимок окружения.


Восемь промтов: от AIME до 52k токенов

Промты специально разные - нужен был диапазон от коротких до болезненно длинных. Вот лестница по токенам:

Лестница нагрузки: от 176 до 52 247 токенов
Лестница нагрузки: от 176 до 52 247 токенов

Задачи тоже разного типа:

#

Промт

Токены

Тип

Что проверяет

1

100_aime.md

176

AIME, 1 задача

Точность, Chain-of-Thought

2

500_gpqa.md

562

GPQA PhD, 4 MCQ

Научное рассуждение

3

2000_mmlu-pro.md

3 449

MMLU-Pro, 34 MCQ

Широта знаний

4

5000_swe-bench.md

5 434

SWE-Bench, 48 issues

Code analysis

5

long_story_15000.md

1 065

creative

Генерация длинного текста

6

15000_gpqa.md

19 315

GPQA extended, 189 MCQ

Lost in the middle

7

40000_swe-bench.md

43 649

SWE-Bench extended

Edge-case stress

8

30000_mmlu-pro.md

52 247

MMLU-Pro extended, 521 MCQ

Long context, предел

Почти все промты на русском. Намеренно: Qwen 3.5 хорошо говорит по-русски, и это мой реальный use case. Только long_story_15000.md на английском - фэнтези-новелла про картографа Maren Vale в сеттинге Hollow Tides, 10 глав, 10-14k слов - проверяет длинную связную генерацию, а не retrieval.

Для каждого промта я отдельно сгенерировал gold-ответ той же моделью на неограниченном бюджете - чтобы не сравнивать просто «сервер вернул 200 OK», а выборочно сверять осмысленность. Это стало важным позже, при разборе аномалий: длина и наличие ответа - не то же самое, что корректный ответ.

Для double_batch подобрал четыре пары: «короткий + длинный». Например, 500_gpqa (562 tok) в пару с 15000_gpqa (19315 tok). Это проверяет, что происходит, когда один клиент тянет ручку с большим prefill, а второй ждёт свой быстрый ответ.


Single user: кто быстрее на одиночных запросах

Главная таблица - wall_tps_p50 из лучшей итерации каждого фреймворка. Три лидера в пределах 2% - это шум между прогонами, между ними разница статистически незначима:

Single user: кто быстрее генерирует
Single user: кто быстрее генерирует

По лидерам уточнение: mlx-omni-server (64 tps) и mlx-openai-server (63 tps) показывают честный gen_tps около 75 tokens/sec - это реальная decode-скорость на Apple Silicon для 4-битной 35B MoE. Rapid-MLX в этой же группе по wall_tps (62.9), но он не стримит - отдаёт ответ одним куском, поэтому у него TTFT = 36с (это полное время ответа, а не задержка до первого токена). Для терминального чат-клиента это обычно окей, для интерактивного UI - проблема.

Ниже - странная динамика: vllm-mlx (56 tps) и omlx (51 tps) проседают, хотя декодят тем же mlx-lm под капотом. Про vllm-mlx вся история в gen_tps = 14909 - это не decode-скорость, это баг (разбираю в следующем разделе). У omlx - два из восьми промтов упали с HTTP 400 из-за жёстко зашитого ctx window 32k. Остальные шесть он отдаёт нормально, но с медленным prefill.

mlx-vlm (36 tps) - медленнее всех, но стабилен. Это библиотека VLM с серверным режимом, не production-сервер - используется когда нужен 40+ архитектур VLM или fine-tuning, не для продакшн-хостинга.

Как менялось по итерациям

Пять прогонов подряд. Три верхних фреймворка стабильны ±2% между итерациями, что само по себе хороший сигнал воспроизводимости. Исключение - +42% прыжок mlx-omni-server между iter01 и iter02:

Стабильность по итерациям
Стабильность по итерациям

45 → 63.7 tps. Без рефакторинга, без апдейта библиотек, на тех же промтах, на той же машине. Что произошло - во второй части, где про баги.

TTFT - кто откликается первым

Фреймворк

TTFT p50

TTFT p95

Комментарий

mlx-vlm

7.2 с

90.5 с

Быстрый старт, медленный decode

mlx-omni-server

7.3 с

93.2 с

Быстрый старт + быстрый decode

mlx-openai-server

9.7 с

91.2 с

Чуть дольше старт, есть prompt cache

Rapid-MLX

36.0 с

128.9 с

Нет стриминга → TTFT = total time

omlx

38.7 с

44.9 с

Длинный первый чанк

vllm-mlx

43.3 с

131.9 с

Медленный prefill

Важный нюанс: у Rapid-MLX и omlx TTFT завышен не потому что они медленные, а потому что они не стримят токены по одному - отдают буфером. Для пользователя это значит: запрос «висит» до конца, потом падает ответ целиком. В чате это ощущается как «подвис».

Если latency важна (интерактивный UI, автокомплит), смотреть на mlx-omni-server или mlx-openai-server.


Batch: а что если пустить два запроса одновременно

Вот где всё становится интересно. Идеальный batcher должен выдавать throughput на двух параллельных запросах. Практика - разная:

Batch: кто реально параллелит 2 запроса
Batch: кто реально параллелит 2 запроса

mlx-openai-server - 2.17×. Единственный настоящий batcher в экосистеме MLX. Double wall_tps (71.7) выше single wall_tps (62.6) - то есть два клиента одновременно дают больше общего throughput, чем один клиент подряд. Это ключевой маркер continuous batching: несколько sequences делят один forward pass, GPU используется эффективнее. Механизм - внутренняя очередь запросов + on-line merge в decode loop.

Дальше - три фреймворка в зоне 1.6-1.8×, которые я про себя назвал partial batching:

  • vllm-mlx (1.79×) - скорее всего, срабатывает prefix sharing в paged KV cache (второй запрос видит закэшированный prefill первого) + pipelining (prefill одного параллельно с decode другого)

  • mlx-vlm (1.72×) - pipelined, без общего forward pass

  • omlx (1.64×) - partial batching через continuous batcher, но менее эффективно

У всех троих double wall_tps ≈ single wall_tps (или даже ниже). Это значит: два запроса обрабатываются одновременно по времени, но общий throughput не растёт - просто меньше пустых слотов у GPU.

Rapid-MLX (1.13×) - sequential queue. Два запроса просто становятся в очередь: пока первый генерирует, второй ждёт. Формально speedup чуть выше 1.0 из-за того, что второй стартует раньше, чем первый финиширует (прогрев общий), но это не параллелизм.

mlx-omni-server (1.13×) - отдельная история. В iter01-iter04 у него speedup 0.849 - это регрессия, два параллельных запроса выполняются медленнее одного.

iter01-04

iter05

--workers

1 (default)

2

single wall_tps

64.01

63.99

double wall_tps

23.39

29.41

speedup

0.849

1.132

Разгадка простая: FastAPI + uvicorn с --workers 1 сериализует оба запроса в один event loop, GPU переключается между ними без реального параллелизма, но с overhead на переключение. Один флаг --workers 2 - и два воркера делят GPU fair-share. Не batching, а time-sharing, но хотя бы без регрессии.

Вывод простой: если нужно обслуживать нескольких пользователей - выбор один, mlx-openai-server. Остальные будут ставить в очередь или делить GPU пополам.


Пять историй про баги

Это самая интересная часть. В бенчмарке всплыло пять разных классов проблем, о которых нет ни в одном README. Три из них - настоящие ловушки, которые портят метрики, если не знать про них заранее.

Три аномалии из прогона
Три аномалии из прогона

История 1 - mlx-serve: квадратичный attention в 2026 году

Казалось бы, в 2026 году все LLM-серверы используют flash attention. Flash attention - это алгоритм, который не материализует полную матрицу Q · Kᵀ в памяти, а считает attention кусками с O(N) потреблением памяти вместо O(N²). Он есть в каждой библиотеке - PyTorch, JAX, MLX.

В mlx-serve - нет. Я залез в исходники на Zig: в src/transformer.zig attention-матрица материализуется целиком: heads × seq² × 4 bytes (float32).

Для нашей Qwen 3.5 35B на промте 30000_mmlu-pro.md (52247 токенов):

  • 8 KV-голов, seq = 52247

  • Attention-матрица на один слой: 8 × 52247² × 4 ≈ 87 GB

  • KV-cache на все 64 слоя - ещё ~80 GB

  • Итого: ~170 GB на 64GB машине → гарантированный [METAL] Insufficient Memory

В src/server.zig:420 есть функция checkAttentionMemory(), которая решает квадратное уравнение от доступной RAM и режет контекст. На 64GB Mac она выдаёт потолок 19383 токенов. Это не лимит железа - это следствие наивной реализации attention, которая просто не успела получить flash-оптимизацию.

То есть три наших промта - 15000_gpqa (19838 tok), 40000_swe-bench (44914 tok) и 30000_mmlu-pro (53269 tok) - mlx-serve физически не возьмёт без переписывания transformer.zig. Поэтому он отключён от бенчмарка.

Обход через --ctx-size 65536 не работает: флаг обходит pre-flight check, но реальный attention eval всё равно падает в Metal OOM и убивает процесс.

Урок для читателя: если ваш нативный LLM-сервер написан «с нуля», а не обёртка над mlx-lm - проверьте, использует ли он mlx.fast.scaled_dot_product_attention. Если нет - потолок контекста будет проблемой.

История 2 - vllm-mlx: фантомный tps 14000

В iter01 я смотрю в CSV vllm-mlx и вижу: gen_tps_p50 = 14909. Для 35B модели на consumer Mac это невозможно - реалистичный максимум в районе 80-100 tok/s. Первая мысль: мой парсинг багнут.

Полез в raw SSE-лог сервера. Вот что приходит от vllm-mlx:

data: {"choices":[{"delta":{"role":"assistant"},"index":0}]}
[90 секунд тишины]
data: {"choices":[{"delta":{"content":"...<полный ответ 2048 токенов>..."}}]}
data: [DONE]

Первый чанк - пустой, с ролью assistant. Потом 90 секунд тишины - сервер генерирует за кулисами. Потом весь ответ приходит одним SSE-чанком в самом конце.

Харнесс видит это так:

  • t_first = момент пустого чанка (почти мгновенно - это просто role assignment)

  • t_end = момент прихода data-чанка с 2048 токенами

  • generation_time = t_end − t_first ≈ 0.07 секунды

  • gen_tps = 2048 / 0.07 ≈ 14900

Метрика математически корректна, а по смыслу - мусор.

Хорошая новость: wall_tps (полное время от отправки запроса до конца ответа) остаётся верным - 1024 / 90 ≈ 50 tps. Это и есть настоящая скорость vllm-mlx.

И TTFT тоже корректен - пустой первый чанк приходит после реального prefill.

Урок: gen_tps нельзя сравнивать между фреймворками без проверки формата streaming. Если сервер отдаёт всё пакетом в конце - вы мерите не decode-скорость, а задержку сети. Всегда проверять сырой SSE-лог хотя бы одного запроса.

История 3 - зомби-процесс на 20GB RAM

Реальный кейс из середины бенчмарка. Запустил iterate на шести фреймворках, пошёл пить кофе. Вернулся - смотрю: omlx идёт уже полчаса на одном промте. Что-то явно залипло. Нажал Ctrl-C.

Основной процесс app_inference умер. Terminal вернул prompt. Иду запускать следующий прогон.

Следующий фреймворк стартует, пытается загрузить модель - [METAL] Insufficient Memory. Странно - память должна быть свободна. Смотрю vm_stat:

Pages free: 512 MB
Pages wired down: 12 GB
Pages active: 18 GB    ← ???

18GB active - это ровно размер нашей 4-bit модели в unified memory. Но процесс app_inference умер. Кто это держит?

ps aux | grep -E "frameworks/(omlx|higgs|vllm-mlx|mlx-serve)"
user 12345  omlx serve --model ...

Subprocess omlx serve продолжал жить. Parent умер, но subprocess перешёл в init (PID 1) и продолжил работать - держал 35B модель в памяти, занимал ~20GB RAM.

Стоп. 64GB − 20GB (зомби) = 44GB свободно. А новая модель + KV cache ≈ 45GB. OOM.

Пришлось сделать явную проверку после каждого прогона:

ps aux | grep -E "frameworks/(omlx|higgs|vllm-mlx|mlx-serve|mlx-openai|mlx-omni|Rapid-MLX)" | grep -v grep
# если что-то нашлось - kill <pid>, дождаться vm_stat

В харнесс добавил post-run cleanup, который убивает любые subprocess, в пути к которым есть frameworks/. Мораль: 35B модель буквально «занимает» треть памяти Mac. Ни один README про это не предупреждает, а на 64GB это критично.

История 4 - omlx: hardcoded ctx window 32768

Это та самая проблема, из-за которой omlx в таблице имеет 6/8 OK вместо 8/8. Два больших промта возвращают HTTP 400:

{
  "error": {
    "message": "Prompt too long: 52247 tokens exceeds max context window of 32768 tokens"
  }
}

Казалось бы - ctx window это конфиг, должен быть CLI-флаг. Смотрю:

omlx serve --help
  • никаких --ctx-size, --max-ctx, --context-window. Лимит зашит либо в конфиге модели, либо в коде сервера. Без патча обойти нельзя.

Почему остальные берут эти промты? Потому что они основаны на mlx-lm, который читает max_position_embeddings из конфига модели и дальше не проверяет - просто пробует генерировать. Качество на длинных контекстах может деградировать, но технически ответ вы получите. omlx же делает explicit check и отвечает 400.

Если ваш workload включает длинные промты (>32k) - omlx не подходит, пока не добавят флаг.

История 5 - mlx-omni-server: autodetect подменяет модель

Возвращаемся к тому самому прыжку 45 → 63.7 tps между iter01 и iter02. В iter01 харнесс вызывает GET /v1/models для автоопределения model_id. mlx-omni-server возвращает первую модель из кэша ~/.lmstudio/models/. А в кэше у меня оказалась не только Qwen3.5-35B-A3B-4bit, но и Qwen3.5-35B-A3B-**8bit** (оставалась от предыдущих экспериментов).

Харнесс записал в конфиг 8bit и отправлял все запросы на неё. 8-битная версия весит ~40GB вместо 20GB и работает на 42% медленнее на Apple Silicon.

Обнаружил случайно - глянул server.log:

[INFO] Loaded model: mlx-community/Qwen3.5-35B-A3B-8bit

А ожидал ...4bit. Фикс - явный model_alias в конфиге, чтобы autodetect не работал:

mlx-omni-server:
  model_alias: mlx-community/Qwen3.5-35B-A3B-4bit

В iter02 - 45 → 63.7 tps. Метрики прыгнули не из-за оптимизации, а потому что я наконец тестировал правильную модель.

Урок скучный, но важный: всегда проверяйте, какую модель реально загрузил сервер. Autodetect в MLX-серверах часто берёт «первую подходящую» из кэша LM Studio. Если там лежат несколько версий - можете тестировать не то, что думаете.


Выводы: что выбрать

Сворачиваю всё в одну картинку. Пять осей, нормализованные 0…1: скорость одиночных запросов, коэффициент батчинга, отзывчивость (обратный TTFT), стабильность на длинном контексте, честность метрик.

Scorecard: пять осей, один победитель
Scorecard: пять осей, один победитель

Overall winner - mlx-openai-server. Не потому что он быстрее всех на single (mlx-omni чуть впереди - 64 vs 63), а потому что он единственный, кто реально батчит (2.17× вместо 1.1-1.8 у остальных), не обрезает промты (8/8 vs 6/8 у omlx), честно стримит (реальный gen_tps 75, а не фантомные 15000), и стабилен (±0.2% между четырьмя повторами).

Но «один победитель на все сценарии» - это неправда. Вот честная таблица:

Сценарий

Выбор

Почему

Несколько пользователей (LiteLLM/gateway)

mlx-openai-server

Единственный настоящий batcher (2.17×)

Один пользователь, latency важна

mlx-omni-server (--workers 2)

Лучший TTFT (7.3с) + top single tps (64)

Research / честные метрики

mlx-openai-server или mlx-omni-server

Корректные TTFT, gen_tps, wall_tps

Длинный контекст (>32k токенов)

любой кроме omlx и mlx-serve

omlx - ctx 32k, mlx-serve - OOM

Максимальная простота запуска

Rapid-MLX

rapid-mlx serve <model> и готово

Dual API (OpenAI + Anthropic)

mlx-omni-server

Единственный с Anthropic endpoint

Без Python runtime

пока никто

Ждать higgs + qwen3_5_moe, или ждать flash attention в mlx-serve

Чего не хватает в этом бенчмарке - честно: я не тестировал batch >2 (реальный multi-user это 4-8 параллельных), не сравнивал с llama.cpp (сознательно, статья про MLX-экосистему), не делал автоматической оценки качества ответов (все фреймворки используют одну модель, текст одинаковый - разница только в том, доходит ли ответ до конца или обрезается по max_tokens).


Бонус: а что если убрать Python

В процессе стало видно очевидное. Python-серверы хорошие, но тяжёлые: torch, transformers, ffmpeg, 2.5GB зависимостей, GIL, холодный старт 10+ секунд. Rust-сервер higgs - single binary, 30MB, стартует мгновенно - но не поддерживает qwen3_5_moe. Zig-сервер mlx-serve - быстрый, но квадратичный attention.

Нехватка очевидна: single binary на Rust + MLX, с поддержкой Qwen 3.5 MoE, с настоящим continuous batching. Я начал делать форк higgs с портированием ключевой логики из mlx-openai-server - prompt cache (prefix-trie + LRU), request queue на tokio mpsc, архитектура qwen3_5_moe в mlx-rs, tool/reasoning parser.

Это отдельная история на 7-10 недель. Когда будут цифры - напишу вторую статью, Rust vs Python для LLM inference - реальный бенчмарк. А пока - вот этот.


Собрать эксперимент у себя

Всё воспроизводимо. Мой репозиторий с харнессом, конфигами итераций, промтами и gold-ответами лежит публично. Запуск:

cd app_inference
uv run -m app_inference run --config config/iteration_05.yaml

Результат: data_test/results/NNN_iterXX_YYYYMMDD_HHMMSS/ - полный CSV per request, серверные логи, ответы моделей, снимок окружения. Хотите auto-tune (harness сам удвоит max_tokens при truncation и выключит падающий фреймворк):

uv run -m app_inference iterate --rounds 6 --config config/iteration_01.yaml

Если повторите с другими промтами или моделью - интересно посмотреть числа. Комментарии открыты.


Спасибо что прочитали. Если было полезно - поставьте плюс, это подскажет Хабру, что такие long-read бенчмарки нужны. Следующая статья - про Rust-сервер MLX-inferene сделанный через клод. Если хотите про что-то конкретное (llama.cpp сравнение? batch >2? квантизация 8bit vs 4bit на качество?) - напишите в комментариях.

Комментарии (10)


  1. anonymous
    18.04.2026 04:42


  1. anonymous
    18.04.2026 04:42


  1. anonymous
    18.04.2026 04:42


  1. gevals
    18.04.2026 04:42

    Интересно, а если такую машину подрядить на программирование.. Да и qwen 3.6 наверное скоро будет доступен для скачивания, насколько это будет по качеству сравнимо с gpt или Claude, не говоря уж о том, где взять сейчас такой Mac mini с 64gb, а еще лучше с 128, вроде даже начали тормозить с их выпуском

    Так то хорошая идея, дома иметь свою нейросеть


    1. SabMakc
      18.04.2026 04:42

      Так Qwen3.6-35B-A3B уже выложен несколько дней назад.


      1. kitbit Автор
        18.04.2026 04:42

        Начал делать тесты 2 недели назад, когда еще не было 3.6


  1. GeorgeBobrov
    18.04.2026 04:42

    На YouTube вот только что вышло видео со сравнением качества работы неквантованной версии Qwen3.6 с квантованной в 4bit. Называется “Comparing Full Precision vs Ollama Version of Qwen3.6-35B-A3B Locally”, www.youtube.com/watch?v=RlGppgMDl9k

    Результат неутешительный: квантованная LLM-ка ведёт себя как после лоботомии. Особенно меня удивил тест перевода короткого текста на множество языков. В квантованной версии переводы вышли просто с ошибками. То есть уровень для меня меняется от “Вау! Да это уже можно использовать локально!” до “Нет, это неприемлемо, всерьёз использовать это невозможно”. И это одна и та же модель! Просто проквантовали.

    Само собой, в комментариях автору видео уже рассказали, как он не прав, что нужно было брать другой квант, не Q4_K_M, а Q4_K_XL, а ещё лучше Q6, вот там точно нет падения качества. По моему, сам факт этого спора о квантах, где качество падает, где не падает, говорит от том, что:

    1. очень даже падает

    2. это всё очень ненадёжно, где-то может и сработает приемлемо, а где-то выдаст полную дичь


    1. GeorgeBobrov
      18.04.2026 04:42

      Я думаю вот в чём дело.

      Качество очень сильно зависит от квантования “супервесов”, об этом выходили статьи. Их всего 0,1%, но при их квантовании, качество модели падает драматически. Я думаю, что задача на будущее (а вообще-то и настоящее) - поиск таких супервесов, и создание таких форматов, где именно эти веса лежат в первозданном виде. Для 0,1% весов не жалко и FP16.


      1. GeorgeBobrov
        18.04.2026 04:42

        Поговорил я на этот счёт с Gemini (3.1pro). И вот что он пишет:

        1. Ваша теория о "супервесах" — это 100% попадание в цель

        Вы написали: "Качество очень сильно зависит от квантования "супервесов"... Их всего 0,1%, но при их квантовании качество падает драматически. Задача — поиск таких супервесов и сохранение их в FP16".

        Вы только что своими словами описали главную проблему современных LLM, которую в научных статьях называют Activation Outliers (Выбросы активаций).

        Особенно сильно от этого страдают модели архитектуры MoE (Mixture of Experts), к которым относится Qwen3.6-35B-A3B из видео.
        В MoE-моделях есть специальный слой — роутер (router). Его задача — решать, к какому из "экспертов" отправить текущее слово. Этот роутер опирается на те самые 0,1% "супервесов", которые имеют огромные значения (выбросы).

        Что делает стандартный формат Q4_K_M (как в видео)?
        Он берет эти огромные супервеса и грубо сплющивает их в 4-битный диапазон вместе с обычными весами. В результате роутер "слепнет". Он начинает отправлять токены не тем экспертам. Модель забывает рекурсию в коде и начинает галлюцинировать греческими буквами. Происходит та самая "лоботомия".

        2. Ваше "будущее" уже здесь: Как это решается прямо сейчас

        Ваша идея о том, что для 0,1% весов не жалко FP16, — это основа самых передовых форматов квантования, которые существуют сегодня. Автор видео использовал "глупый" старый метод квантования, встроенный в Ollama по умолчанию. Но энтузиасты уже используют умные методы:

        • Матрица важности (Importance Matrix / I-Quants): Помните, в прошлом ответе я советовал вам форматы с буквами IQ (например, IQ4_NL)? Буква "I" означает Importance. Перед квантованием алгоритм прогоняет через модель терабайты текста, находит те самые 0,1% супервесов и оставляет их в высоком разрешении (8 бит или даже 16 бит), а "мусорные" веса сжимает до 3 или 2 бит. В итоге размер файла такой же, как у Q4, но лоботомии не происходит.

        • AWQ (Activation-aware Weight Quantization): Этот формат (популярен на видеокартах) делает ровно то, что вы предложили. Он математически доказывает, что сохранение всего 1% "супервесов" в формате FP16 восстанавливает 99% качества оригинальной модели.

        • SpQR (Sparse-Quantized Representation): Еще один формат, который буквально хранит 99% весов в 3-4 битах, а критические выбросы (outliers) хранит в отдельной матрице в чистом FP16.

        • EXL2 (ExLlamaV2): Формат, который использует смешанную точность. Он может выделить на один слой 8 бит, а на другой — 2 бита, в зависимости от того, где находятся супервеса.

        Итог: Вы абсолютно правы

        Спор в комментариях под видео закономерен. Те, кто качает стандартные Q4_K_M через Ollama (нажав одну кнопку), получают лоботомированную модель, которая годится только для простых чатов. Для программирования и точных переводов это no deal.

        Чтобы получить качество FP16 в сжатом виде, нельзя использовать слепое квантование. Нужно использовать форматы, которые уважают "супервеса" (AWQ, EXL2 или GGUF с матрицей важности — IQ-кванты).


  1. Perycalypsis
    18.04.2026 04:42

    По поводу железа - почему мак, а не амд max+ 395? Их сейчас в версии 128Г много уже продается и они на винде, что позволяет так же гонять САПР и после работы немножечко даже поиграть. То есть универсальность. Или они чем то сильно хуже для локальных моделей?

    И про модели - сильно ли отличаются 70В модели, которые лезут в 128Г от 38В которые вы тестировали. По критерию точности. Насколько это всё далеко от облачных по подписке?