Бенчмарк восьми локальных LLM-серверов на Mac
Как я запускал 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.
Это 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 на одном сервере
Python 3.10+
Простота, 1900+ тестов, интеграции (Cursor, Aider)
Python 3.10+
vLLM-style, paged KV cache, multimodal
Tiered KV cache (RAM + SSD), admin dashboard
Python 3.10+
Fine-tuning VLM, 40+ архитектур
Single binary, без Python
- отключён
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
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 window32768 токенов. Большие промты получают 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. Архитектура линейная:
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-лоадер:
Кроме wall-clock метрик, в CSV летятrequest_start_offset(насколько рассинхронизировались старты) иoverlap_ratio(доля времени, когда оба запроса были активны). Речь о настоящем параллелизме, а не о том, что оба запроса прогнались, но не одновременно.
Что считаем и насколько надёжно:
Что измеряет
Надёжность
wall_tps_p50
Медиана токенов/с по wall-clock
Самая надёжная, всегда корректна
gen_tps_p50
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 для фикса регрессии на батчинге.
Запуск - три строки:
Результаты пишутся вdata_test/results/NNN_iterXX_YYYYMMDD_HHMMSS/- полный CSV, логи серверов, ответы моделей в.md, копия конфига, снимок окружения.
Восемь промтов: от AIME до 52k токенов
Промты специально разные - нужен был диапазон от коротких до болезненно длинных. Вот лестница по токенам:
Задачи тоже разного типа:
Что проверяет
100_aime.md
AIME, 1 задача
Точность, Chain-of-Thought
500_gpqa.md
GPQA PhD, 4 MCQ
Научное рассуждение
2000_mmlu-pro.md
MMLU-Pro, 34 MCQ
Широта знаний
5000_swe-bench.md
SWE-Bench, 48 issues
Code analysis
long_story_15000.md
Генерация длинного текста
15000_gpqa.md
GPQA extended, 189 MCQ
Lost in the middle
40000_swe-bench.md
SWE-Bench extended
Edge-case stress
30000_mmlu-pro.md
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 - кто откликается первым
Комментарий
Быстрый старт, медленный decode
mlx-omni-server
Быстрый старт + быстрый decode
mlx-openai-server
Чуть дольше старт, есть prompt cache
Нет стриминга → TTFT = total time
Длинный первый чанк
Медленный 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 у него speedup0.849- это регрессия, два параллельных запроса выполняютсямедленнееодного.
1 (default)
single wall_tps
double wall_tps
Разгадка простая: 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.zigattention-матрица материализуется целиком: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:
Первый чанк - пустой, с ролью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:
18GB active - это ровно размер нашей 4-bit модели в unified memory. Но процессapp_inferenceумер. Кто это держит?
Subprocessomlx serveпродолжал жить. Parent умер, но subprocess перешёл в init (PID 1) и продолжил работать - держал 35B модель в памяти, занимал~20GB RAM.
Стоп. 64GB − 20GB (зомби) = 44GB свободно. А новая модель + KV cache ≈ 45GB. OOM.
Пришлось сделать явную проверку после каждого прогона:
В харнесс добавил post-run cleanup, который убивает любые subprocess, в пути к которым естьframeworks/. Мораль:35B модель буквально «занимает» треть памяти Mac.Ни один README про это не предупреждает, а на 64GB это критично.
История 4 - omlx: hardcoded ctx window 32768
Это та самая проблема, из-за которой omlx в таблице имеет6/8 OKвместо8/8. Два больших промта возвращают HTTP 400:
Казалось бы - ctx window это конфиг, должен быть CLI-флаг. Смотрю:
- никаких--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:
А ожидал...4bit. Фикс - явныйmodel_aliasв конфиге, чтобы autodetect не работал:
В 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(--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 serve
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-ответами лежит публично. Запуск:
Результат:data_test/results/NNN_iterXX_YYYYMMDD_HHMMSS/- полный CSV per request, серверные логи, ответы моделей, снимок окружения. Хотите auto-tune (harness сам удвоитmax_tokensпри truncation и выключит падающий фреймворк):
Если повторите с другими промтами или моделью - интересно посмотреть числа. Комментарии открыты.
Спасибо что прочитали.Если было полезно - поставьте плюс, это подскажет Хабру, что такие long-read бенчмарки нужны. Следующая статья - про Rust-сервер MLX-inferene сделанный через клод. Если хотите про что-то конкретное (llama.cpp сравнение? batch >2? квантизация 8bit vs 4bit на качество?) - напишите в комментариях.