Дано: 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.
Это 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-контекст между процессами. Поэтому все шесть фреймворков в бенчмарке я запускал строго по одному — два одновременно просто не сосуществуют на одной железке.
Ниже — что из этого вышло.
Что сравниваем: восемь фреймворков
Вот полный список. Шесть попали в бенчмарк, два отключены — причины ниже.
|
Фреймворк |
Язык |
Главная фича |
В бенчмарке |
|---|---|---|---|
|
Python 3.11 |
Queue-batcher, image gen (Flux), multi-model |
+ |
|
|
Python 3.11+ |
Dual API — OpenAI + Anthropic на одном сервере |
+ |
|
|
Python 3.10+ |
Простота, 1900+ тестов, интеграции (Cursor, Aider) |
+ |
|
|
Python 3.10+ |
vLLM-style, paged KV cache, multimodal |
+ |
|
|
Python |
Tiered KV cache (RAM + SSD), admin dashboard |
— |
|
|
Python 3.10+ |
Fine-tuning VLM, 40+ архитектур |
+ |
|
|
Rust |
Single binary, без Python |
— отключён |
|
|
Zig |
Native, agent mode, без Python |
— отключён |
Сначала визуальный ландшафт — чтобы видеть, кто что умеет без вчитывания в README:
Теперь по каждому коротко.
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-clock |
Самая надёжная, всегда корректна |
|
|
Decode speed (токены / (t_end − t_first)) |
Мусор если сервер не стримит токен-за-токеном |
|
|
Time to first token |
Корректно только при нормальном стриминге |
|
|
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 токенов
Промты специально разные — нужен был диапазон от коротких до болезненно длинных. Вот лестница по токенам:
Задачи тоже разного типа:
|
# |
Промт |
Токены |
Тип |
Что проверяет |
|---|---|---|---|---|
|
1 |
|
176 |
AIME, 1 задача |
Точность, Chain-of-Thought |
|
2 |
|
562 |
GPQA PhD, 4 MCQ |
Научное рассуждение |
|
3 |
|
3 449 |
MMLU-Pro, 34 MCQ |
Широта знаний |
|
4 |
|
5 434 |
SWE-Bench, 48 issues |
Code analysis |
|
5 |
|
1 065 |
creative |
Генерация длинного текста |
|
6 |
|
19 315 |
GPQA extended, 189 MCQ |
Lost in the middle |
|
7 |
|
43 649 |
SWE-Bench extended |
Edge-case stress |
|
8 |
|
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% — это шум между прогонами, между ними разница статистически незначима:
По лидерам уточнение: 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 должен выдавать 2× throughput на двух параллельных запросах. Практика — разная:
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 |
|---|---|---|
|
|
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), стабильность на длинном контексте, честность метрик.
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 ( |
Лучший 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 |
|
|
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_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/