- BrainTools - https://www.braintools.ru -
Дано: 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 [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.
Это 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], где народ меряет 2× разницу на Qwen 3.5 35B. Важнее другое: MLX-серверов оказалось много. Половину я узнал, только когда начал копать.
Я решил не гадать по README, а просто проверить все на одинаковых данных. Написал харнесс на Python, который запускает сервер как subprocess, ждёт healthcheck на /v1/models, прогоняет восемь промтов в single-режиме, потом те же пары в двойном режиме через asyncio-барьер, собирает CSV и убивает процесс. Следующий фреймворк. И так шесть раз подряд, пять итераций.
Если фоном у вас 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 – привычная боль [3] через cudaMemcpy. На M-series CPU и GPU делят один пул памяти [4]. 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 |
+ |
|
|
mlx-omni-server [6] |
Python 3.11+ |
Dual API – OpenAI + Anthropic на одном сервере |
+ |
|
Rapid-MLX [7] |
Python 3.10+ |
Простота, 1900+ тестов, интеграции (Cursor, Aider) |
+ |
|
vllm-mlx [8] |
Python 3.10+ |
vLLM-style, paged KV cache, multimodal |
+ |
|
omlx [9] |
Python |
Tiered KV cache (RAM + SSD), admin dashboard |
– |
|
mlx-vlm [10] |
Python 3.10+ |
Fine-tuning VLM, 40+ архитектур |
+ |
|
higgs [11] |
Rust |
Single binary, без Python |
– отключён |
|
mlx-serve [12] |
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 и восемью встроенными инструментами. Причина отключения – отдельный праздник, в разделе про аномалии подробно.
Модель одна на всех – mlx-community/Qwen3.5-35B-A3B-4bit [13]. 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_inference
uv run -m app_inference run --config config/iteration_05.yaml
Результаты пишутся в data_test/results/NNN_iterXX_YYYYMMDD_HHMMSS/ – полный CSV, логи серверов, ответы моделей в .md, копия конфига, снимок окружения.
Промты специально разные – нужен был диапазон от коротких до болезненно длинных. Вот лестница по токенам:
Задачи тоже разного типа:
|
# |
Промт |
Токены |
Тип |
Что проверяет |
|---|---|---|---|---|
|
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, а второй ждёт свой быстрый ответ.
Главная таблица – 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 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.
Вот где всё становится интересно. Идеальный 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. Три из них – настоящие ловушки, которые портят метрики, если не знать про них заранее.
Казалось бы, в 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. Если нет – потолок контекста будет проблемой.
В 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
Метрика математически [14] корректна, а по смыслу – мусор.
Хорошая новость: wall_tps (полное время от отправки запроса до конца ответа) остаётся верным – 1024 / 90 ≈ 50 tps. Это и есть настоящая скорость vllm-mlx.
И TTFT тоже корректен – пустой первый чанк приходит после реального prefill.
Урок: gen_tps нельзя сравнивать между фреймворками без проверки формата streaming. Если сервер отдаёт всё пакетом в конце – вы мерите не decode-скорость, а задержку сети. Всегда проверять сырой SSE-лог хотя бы одного запроса.
Реальный кейс из середины бенчмарка. Запустил 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 это критично.
Это та самая проблема, из-за которой 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 не подходит, пока не добавят флаг.
Возвращаемся к тому самому прыжку 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-серверы хорошие, но тяжёлые: 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 - реальный бенчмарк. А пока – вот этот.
Всё воспроизводимо. Мой репозиторий [1] с харнессом, конфигами итераций, промтами и 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
Источник [15]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/28989
URLs in this post:
[1] https://github.com/yaruslove/qwen3.5-bench-8-mlx-server-mac: https://github.com/yaruslove/qwen3.5-bench-8-mlx-server-mac
[2] Reddit тред на r/LocalLLaMA: https://www.reddit.com/r/LocalLLaMA/comments/1rezq19/qwen3535b_on_apple_silicon_how_i_got_2x_faster/
[3] боль: http://www.braintools.ru/article/9901
[4] памяти: http://www.braintools.ru/article/4140
[5] mlx-openai-server: https://github.com/cubist38/mlx-openai-server
[6] mlx-omni-server: https://github.com/madroidmaq/mlx-omni-server
[7] Rapid-MLX: https://github.com/raullenchai/Rapid-MLX
[8] vllm-mlx: https://github.com/waybarrios/vllm-mlx
[9] omlx: https://github.com/jundot/omlx
[10] mlx-vlm: https://github.com/Blaizzy/mlx-vlm
[11] higgs: https://github.com/panbanda/higgs
[12] mlx-serve: https://github.com/ddalcu/mlx-serve
[13] mlx-community/Qwen3.5-35B-A3B-4bit: https://huggingface.co/mlx-community/Qwen3.5-35B-A3B-4bit
[14] математически: http://www.braintools.ru/article/7620
[15] Источник: https://habr.com/ru/articles/1024880/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1024880
Нажмите здесь для печати.