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

от автора

Дано: 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_inferenceuv 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 MBPages wired down: 12 GBPages 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_inferenceuv 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 на качество?) — напишите в комментариях.

ссылка на оригинал статьи https://habr.com/ru/articles/1024880/