Как я запускал Qwen 3.5 на Mac: бенчмарк 8 локальных LLM-серверов. Кто быстрее?. Big Data.. Big Data. llm.. Big Data. llm. Mac.. Big Data. llm. Mac. mlx.. Big Data. llm. Mac. mlx. python.. Big Data. llm. Mac. mlx. python. qwen3.5.. Big Data. llm. Mac. mlx. python. qwen3.5. Qwen3.5-35B-A3B.. Big Data. llm. Mac. mlx. python. qwen3.5. Qwen3.5-35B-A3B. искусственный интеллект.

Дано: 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

Как я запускал Qwen 3.5 на Mac: бенчмарк 8 локальных LLM-серверов. Кто быстрее? - 1

Сразу сниму главный вопрос – «а почему не 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-сервер higgssingle 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 на качество?) – напишите в комментариях.

Автор: kitbit

Источник