- BrainTools - https://www.braintools.ru -

Как я запускал 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 [1]

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

Сразу сниму главный вопрос – «а почему не llama.cpp?» llama.cpp отличный и универсальный, но на Apple Silicon MLX стабильно быстрее на 10-30%, умеет настоящий continuous batching из коробки и хранит модели в нативном формате под unified memory – без промежуточной конвертации GGUF. Статья именно про MLX-экосистему: там внезапно оказалось восемь серверов, и между ними реальная разница, которая тянет на отдельный разбор. Сравнение с llama.cpp – тема отдельной статьи, и я её не избегаю, просто не смешиваю.

Зачем мне локальная 35B – три причины:

  • Privacy. В работу прилетают договоры, ТЗ, переписки с клиентами – это нельзя просто скормить в ChatGPT или Claude. Локальная модель обрабатывает всё без утечек: снимает ФИО, счета, контакты и возвращает чистый текст.

  • Coding-агенты и open-code. Claude и GPT по подписке хороши, пока агент не гоняет задачи в цикле по восемь часов – тогда токены превращаются в кофейные зёрна. Все современные open-source тулы для AI-кодинга – OpenCode, Aider, Claude Code – умеют подключаться к любому OpenAI-совместимому endpoint. Ставишь base_url: http://mac.local:8000/v1 и свой API-ключ – агент крутится на уже оплаченном железе, без телеметрии и rate-limit’ов. На работе я разрабатываю агентные системы, и мне постоянно нужно гонять свежие компактные LLM: с февраля ежедневным инструментом был GLM 4.7 Flash на 4090, теперь примеряю Qwen 3.5 35B на Mac.

  • Нет сетевого RTT. 35B в 4-бит на M2 Max отвечает живее многих облачных API с очередью – просто потому что нет раунд-трипа через интернет. И запускать серьёзную модель на машине без отдельной видеокарты – это до сих пор ощущается как магия.

Если коротко: три фреймворка идут ноздря в ноздрю на single-user, но стоит пустить два параллельных запроса – и четверо из шести откатываются в очередь, один выходит в 2.17× speedup, а ещё один вообще деградирует в 0.85×, пока не дашь ему --workers 2. По ходу всплыли квадратичный attention в 2026 году, фантомные 14000 tokens/sec из-за одной строчки в SSE-парсере и зомби-процесс на 20GB RAM, которого нет ни в одном README.

Три фреймворка в пределах 2% - но это только single user

Три фреймворка в пределах 2% – но это только single user

Это single-user. С батчингом картина переворачивается – но до неё доберёмся через пятнадцать минут чтения.


Зачем вообще всё это

Хотелось простого. Mac – как локальный LLM-сервер. Сверху LiteLLM-гейтвей, дальше VPS с белым IP – чтобы дёргать Qwen по API как Open-AI compatiable из интернета, несколько ключей на несколько устройств. Требования короткие: OpenAI-совместимый endpoint (/v1/chat/completions), нормальный батчинг под нескольких пользователей, стабильность.

Первое, что попробовал – mlx-vlm. Это библиотека для vision-моделей, но в ней есть серверный режим. Запустил, получил 15–25 tps, половина запросов падает, LiteLLM коннектит, но под нагрузкой сервер просто отваливается. Ясно стало одно: это не готовый сервер. Нужен другой.

Альтернатива mlx-vlm – MLX-экосистема целиком. Про выбор MLX вместо llama.cpp я уже сказал в начале, не повторяюсь; добавлю только живой источник – нашёл Reddit тред на r/LocalLLaMA [2], где народ меряет 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 – привычная боль [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-контекст между процессами. Поэтому все шесть фреймворков в бенчмарке я запускал строго по одному – два одновременно просто не сосуществуют на одной железке.

Ниже – что из этого вышло.


Что сравниваем: восемь фреймворков

Вот полный список. Шесть попали в бенчмарк, два отключены – причины ниже.

Фреймворк

Язык

Главная фича

В бенчмарке

mlx-openai-server [5]

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:

Ландшафт: 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 [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_tps_p50

Медиана токенов/с по wall-clock

Самая надёжная, всегда корректна

gen_tps_p50

Decode speed (токены / (t_end − t_first))

Мусор если сервер не стримит токен-за-токеном

ttft_p50

Time to first token

Корректно только при нормальном стриминге

speedup

Batching-эффективность

Надёжна – считается из wall_tps

Почему везде медиана, а не среднее? Восемь промтов от 100 до 53000 токенов – это экстремальный разброс. Среднее даст перевес длинным: один 40k-промт с 110 секундами total-time утопит восемь коротких в статистике. Медиана показывает типичный запрос.

И ещё – пять итераций, не одна. Iter01 был baseline. Iter02 добавил max_tokens=2048 вместо 1024 и явный model_alias для mlx-omni (история с подменой модели – ниже). Iter03 и iter04 – повторы iter02 для проверки воспроизводимости. Iter05 – добавлен флаг --workers 2 к mlx-omni для фикса регрессии на батчинге.

Запуск – три строки:

cd app_inference
uv run -m app_inference run --config config/iteration_05.yaml

Результаты пишутся в data_test/results/NNN_iterXX_YYYYMMDD_HHMMSS/ – полный CSV, логи серверов, ответы моделей в .md, копия конфига, снимок окружения.


Восемь промтов: от AIME до 52k токенов

Промты специально разные – нужен был диапазон от коротких до болезненно длинных. Вот лестница по токенам:

Лестница нагрузки: от 176 до 52 247 токенов

Лестница нагрузки: от 176 до 52 247 токенов

Задачи тоже разного типа:

#

Промт

Токены

Тип

Что проверяет

1

100_aime.md

176

AIME, 1 задача

Точность, Chain-of-Thought

2

500_gpqa.md

562

GPQA PhD, 4 MCQ

Научное рассуждение

3

2000_mmlu-pro.md

3 449

MMLU-Pro, 34 MCQ

Широта знаний

4

5000_swe-bench.md

5 434

SWE-Bench, 48 issues

Code analysis

5

long_story_15000.md

1 065

creative

Генерация длинного текста

6

15000_gpqa.md

19 315

GPQA extended, 189 MCQ

Lost in the middle

7

40000_swe-bench.md

43 649

SWE-Bench extended

Edge-case stress

8

30000_mmlu-pro.md

52 247

MMLU-Pro extended, 521 MCQ

Long context, предел

Почти все промты на русском. Намеренно: Qwen 3.5 хорошо говорит по-русски, и это мой реальный use case. Только long_story_15000.md на английском – фэнтези-новелла про картографа Maren Vale в сеттинге Hollow Tides, 10 глав, 10-14k слов – проверяет длинную связную генерацию, а не retrieval.

Для каждого промта я отдельно сгенерировал gold-ответ той же моделью на неограниченном бюджете – чтобы не сравнивать просто «сервер вернул 200 OK», а выборочно сверять осмысленность. Это стало важным позже, при разборе аномалий: длина и наличие ответа – не то же самое, что корректный ответ.

Для double_batch подобрал четыре пары: «короткий + длинный». Например, 500_gpqa (562 tok) в пару с 15000_gpqa (19315 tok). Это проверяет, что происходит, когда один клиент тянет ручку с большим prefill, а второй ждёт свой быстрый ответ.


Single user: кто быстрее на одиночных запросах

Главная таблица – wall_tps_p50 из лучшей итерации каждого фреймворка. Три лидера в пределах 2% – это шум между прогонами, между ними разница статистически незначима:

Single user: кто быстрее генерирует

Single user: кто быстрее генерирует

По лидерам уточнение: mlx-omni-server (64 tps) и mlx-openai-server (63 tps) показывают честный gen_tps около 75 tokens/sec – это реальная decode-скорость на Apple Silicon для 4-битной 35B MoE. Rapid-MLX в этой же группе по wall_tps (62.9), но он не стримит – отдаёт ответ одним куском, поэтому у него TTFT = 36с (это полное время ответа, а не задержка до первого токена). Для терминального чат-клиента это обычно окей, для интерактивного UI – проблема.

Ниже – странная динамика: vllm-mlx (56 tps) и omlx (51 tps) проседают, хотя декодят тем же mlx-lm под капотом. Про vllm-mlx вся история в gen_tps = 14909 – это не decode-скорость, это баг (разбираю в следующем разделе). У omlx – два из восьми промтов упали с HTTP 400 из-за жёстко зашитого ctx window 32k. Остальные шесть он отдаёт нормально, но с медленным prefill.

mlx-vlm (36 tps) – медленнее всех, но стабилен. Это библиотека VLM с серверным режимом, не production-сервер – используется когда нужен 40+ архитектур VLM или fine-tuning, не для продакшн-хостинга.

Как менялось по итерациям

Пять прогонов подряд. Три верхних фреймворка стабильны ±2% между итерациями, что само по себе хороший сигнал воспроизводимости. Исключение – +42% прыжок mlx-omni-server между iter01 и iter02:

Стабильность по итерациям

Стабильность по итерациям

45 → 63.7 tps. Без рефакторинга, без апдейта библиотек, на тех же промтах, на той же машине. Что произошло – во второй части, где про баги.

TTFT – кто откликается первым

Фреймворк

TTFT p50

TTFT p95

Комментарий

mlx-vlm

7.2 с

90.5 с

Быстрый старт, медленный decode

mlx-omni-server

7.3 с

93.2 с

Быстрый старт + быстрый decode

mlx-openai-server

9.7 с

91.2 с

Чуть дольше старт, есть prompt cache

Rapid-MLX

36.0 с

128.9 с

Нет стриминга → TTFT = total time

omlx

38.7 с

44.9 с

Длинный первый чанк

vllm-mlx

43.3 с

131.9 с

Медленный prefill

Важный нюанс: у Rapid-MLX и omlx TTFT завышен не потому что они медленные, а потому что они не стримят токены по одному – отдают буфером. Для пользователя это значит: запрос «висит» до конца, потом падает ответ целиком. В чате это ощущается как «подвис».

Если latency важна (интерактивный UI, автокомплит), смотреть на mlx-omni-server или mlx-openai-server.


Batch: а что если пустить два запроса одновременно

Вот где всё становится интересно. Идеальный batcher должен выдавать throughput на двух параллельных запросах. Практика – разная:

Batch: кто реально параллелит 2 запроса

Batch: кто реально параллелит 2 запроса

mlx-openai-server – 2.17×. Единственный настоящий batcher в экосистеме MLX. Double wall_tps (71.7) выше single wall_tps (62.6) – то есть два клиента одновременно дают больше общего throughput, чем один клиент подряд. Это ключевой маркер continuous batching: несколько sequences делят один forward pass, GPU используется эффективнее. Механизм – внутренняя очередь запросов + on-line merge в decode loop.

Дальше – три фреймворка в зоне 1.6-1.8×, которые я про себя назвал partial batching:

  • vllm-mlx (1.79×) – скорее всего, срабатывает prefix sharing в paged KV cache (второй запрос видит закэшированный prefill первого) + pipelining (prefill одного параллельно с decode другого)

  • mlx-vlm (1.72×) – pipelined, без общего forward pass

  • omlx (1.64×) – partial batching через continuous batcher, но менее эффективно

У всех троих double wall_tps ≈ single wall_tps (или даже ниже). Это значит: два запроса обрабатываются одновременно по времени, но общий throughput не растёт – просто меньше пустых слотов у GPU.

Rapid-MLX (1.13×) – sequential queue. Два запроса просто становятся в очередь: пока первый генерирует, второй ждёт. Формально speedup чуть выше 1.0 из-за того, что второй стартует раньше, чем первый финиширует (прогрев общий), но это не параллелизм.

mlx-omni-server (1.13×) – отдельная история. В iter01-iter04 у него speedup 0.849 – это регрессия, два параллельных запроса выполняются медленнее одного.

iter01-04

iter05

--workers

1 (default)

2

single wall_tps

64.01

63.99

double wall_tps

23.39

29.41

speedup

0.849

1.132

Разгадка простая: FastAPI + uvicorn с --workers 1 сериализует оба запроса в один event loop, GPU переключается между ними без реального параллелизма, но с overhead на переключение. Один флаг --workers 2 – и два воркера делят GPU fair-share. Не batching, а time-sharing, но хотя бы без регрессии.

Вывод простой: если нужно обслуживать нескольких пользователей – выбор один, mlx-openai-server. Остальные будут ставить в очередь или делить GPU пополам.


Пять историй про баги

Это самая интересная часть. В бенчмарке всплыло пять разных классов проблем, о которых нет ни в одном README. Три из них – настоящие ловушки, которые портят метрики, если не знать про них заранее.

Три аномалии из прогона

Три аномалии из прогона

История 1 – mlx-serve: квадратичный attention в 2026 году

Казалось бы, в 2026 году все LLM-серверы используют flash attention. Flash attention – это алгоритм, который не материализует полную матрицу Q · Kᵀ в памяти, а считает attention кусками с O(N) потреблением памяти вместо O(N²). Он есть в каждой библиотеке – PyTorch, JAX, MLX.

В mlx-serve – нет. Я залез в исходники на Zig: в src/transformer.zig attention-матрица материализуется целиком: heads × seq² × 4 bytes (float32).

Для нашей Qwen 3.5 35B на промте 30000_mmlu-pro.md (52247 токенов):

  • 8 KV-голов, seq = 52247

  • Attention-матрица на один слой: 8 × 52247² × 4 ≈ 87 GB

  • KV-cache на все 64 слоя – ещё ~80 GB

  • Итого: ~170 GB на 64GB машине → гарантированный [METAL] Insufficient Memory

В src/server.zig:420 есть функция checkAttentionMemory(), которая решает квадратное уравнение от доступной RAM и режет контекст. На 64GB Mac она выдаёт потолок 19383 токенов. Это не лимит железа – это следствие наивной реализации attention, которая просто не успела получить flash-оптимизацию.

То есть три наших промта – 15000_gpqa (19838 tok), 40000_swe-bench (44914 tok) и 30000_mmlu-pro (53269 tok) – mlx-serve физически не возьмёт без переписывания transformer.zig. Поэтому он отключён от бенчмарка.

Обход через --ctx-size 65536 не работает: флаг обходит pre-flight check, но реальный attention eval всё равно падает в Metal OOM и убивает процесс.

Урок для читателя: если ваш нативный LLM-сервер написан «с нуля», а не обёртка над mlx-lm – проверьте, использует ли он mlx.fast.scaled_dot_product_attention. Если нет – потолок контекста будет проблемой.

История 2 – vllm-mlx: фантомный tps 14000

В iter01 я смотрю в CSV vllm-mlx и вижу: gen_tps_p50 = 14909. Для 35B модели на consumer Mac это невозможно – реалистичный максимум в районе 80-100 tok/s. Первая мысль: мой парсинг багнут.

Полез в raw SSE-лог сервера. Вот что приходит от vllm-mlx:

data: {"choices":[{"delta":{"role":"assistant"},"index":0}]}
[90 секунд тишины]
data: {"choices":[{"delta":{"content":"...<полный ответ 2048 токенов>..."}}]}
data: [DONE]

Первый чанк – пустой, с ролью assistant. Потом 90 секунд тишины – сервер генерирует за кулисами. Потом весь ответ приходит одним SSE-чанком в самом конце.

Харнесс видит это так:

  • t_first = момент пустого чанка (почти мгновенно – это просто role assignment)

  • t_end = момент прихода data-чанка с 2048 токенами

  • generation_time = t_end − t_first ≈ 0.07 секунды

  • gen_tps = 2048 / 0.07 ≈ 14900

Метрика математически [14] корректна, а по смыслу – мусор.

Хорошая новость: wall_tps (полное время от отправки запроса до конца ответа) остаётся верным – 1024 / 90 ≈ 50 tps. Это и есть настоящая скорость vllm-mlx.

И TTFT тоже корректен – пустой первый чанк приходит после реального prefill.

Урок: gen_tps нельзя сравнивать между фреймворками без проверки формата streaming. Если сервер отдаёт всё пакетом в конце – вы мерите не decode-скорость, а задержку сети. Всегда проверять сырой SSE-лог хотя бы одного запроса.

История 3 – зомби-процесс на 20GB RAM

Реальный кейс из середины бенчмарка. Запустил iterate на шести фреймворках, пошёл пить кофе. Вернулся – смотрю: omlx идёт уже полчаса на одном промте. Что-то явно залипло. Нажал Ctrl-C.

Основной процесс app_inference умер. Terminal вернул prompt. Иду запускать следующий прогон.

Следующий фреймворк стартует, пытается загрузить модель – [METAL] Insufficient Memory. Странно – память должна быть свободна. Смотрю vm_stat:

Pages free: 512 MB
Pages wired down: 12 GB
Pages active: 18 GB    ← ???

18GB active – это ровно размер нашей 4-bit модели в unified memory. Но процесс app_inference умер. Кто это держит?

ps aux | grep -E "frameworks/(omlx|higgs|vllm-mlx|mlx-serve)"
user 12345  omlx serve --model ...

Subprocess omlx serve продолжал жить. Parent умер, но subprocess перешёл в init (PID 1) и продолжил работать – держал 35B модель в памяти, занимал ~20GB RAM.

Стоп. 64GB − 20GB (зомби) = 44GB свободно. А новая модель + KV cache ≈ 45GB. OOM.

Пришлось сделать явную проверку после каждого прогона:

ps aux | grep -E "frameworks/(omlx|higgs|vllm-mlx|mlx-serve|mlx-openai|mlx-omni|Rapid-MLX)" | grep -v grep
# если что-то нашлось - kill <pid>, дождаться vm_stat

В харнесс добавил post-run cleanup, который убивает любые subprocess, в пути к которым есть frameworks/. Мораль: 35B модель буквально «занимает» треть памяти Mac. Ни один README про это не предупреждает, а на 64GB это критично.

История 4 – omlx: hardcoded ctx window 32768

Это та самая проблема, из-за которой omlx в таблице имеет 6/8 OK вместо 8/8. Два больших промта возвращают HTTP 400:

{
  "error": {
    "message": "Prompt too long: 52247 tokens exceeds max context window of 32768 tokens"
  }
}

Казалось бы – ctx window это конфиг, должен быть CLI-флаг. Смотрю:

omlx serve --help
  • никаких --ctx-size, --max-ctx, --context-window. Лимит зашит либо в конфиге модели, либо в коде сервера. Без патча обойти нельзя.

Почему остальные берут эти промты? Потому что они основаны на mlx-lm, который читает max_position_embeddings из конфига модели и дальше не проверяет – просто пробует генерировать. Качество на длинных контекстах может деградировать, но технически ответ вы получите. omlx же делает explicit check и отвечает 400.

Если ваш workload включает длинные промты (>32k) – omlx не подходит, пока не добавят флаг.

История 5 – mlx-omni-server: autodetect подменяет модель

Возвращаемся к тому самому прыжку 45 → 63.7 tps между iter01 и iter02. В iter01 харнесс вызывает GET /v1/models для автоопределения model_id. mlx-omni-server возвращает первую модель из кэша ~/.lmstudio/models/. А в кэше у меня оказалась не только Qwen3.5-35B-A3B-4bit, но и Qwen3.5-35B-A3B-**8bit** (оставалась от предыдущих экспериментов).

Харнесс записал в конфиг 8bit и отправлял все запросы на неё. 8-битная версия весит ~40GB вместо 20GB и работает на 42% медленнее на Apple Silicon.

Обнаружил случайно – глянул server.log:

[INFO] Loaded model: mlx-community/Qwen3.5-35B-A3B-8bit

А ожидал ...4bit. Фикс – явный model_alias в конфиге, чтобы autodetect не работал:

mlx-omni-server:
  model_alias: mlx-community/Qwen3.5-35B-A3B-4bit

В iter02 – 45 → 63.7 tps. Метрики прыгнули не из-за оптимизации, а потому что я наконец тестировал правильную модель.

Урок скучный, но важный: всегда проверяйте, какую модель реально загрузил сервер. Autodetect в MLX-серверах часто берёт «первую подходящую» из кэша LM Studio. Если там лежат несколько версий – можете тестировать не то, что думаете.


Выводы: что выбрать

Сворачиваю всё в одну картинку. Пять осей, нормализованные 0…1: скорость одиночных запросов, коэффициент батчинга, отзывчивость (обратный TTFT), стабильность на длинном контексте, честность метрик.

Scorecard: пять осей, один победитель

Scorecard: пять осей, один победитель

Overall winner – mlx-openai-server. Не потому что он быстрее всех на single (mlx-omni чуть впереди – 64 vs 63), а потому что он единственный, кто реально батчит (2.17× вместо 1.1-1.8 у остальных), не обрезает промты (8/8 vs 6/8 у omlx), честно стримит (реальный gen_tps 75, а не фантомные 15000), и стабилен (±0.2% между четырьмя повторами).

Но «один победитель на все сценарии» – это неправда. Вот честная таблица:

Сценарий

Выбор

Почему

Несколько пользователей (LiteLLM/gateway)

mlx-openai-server

Единственный настоящий batcher (2.17×)

Один пользователь, latency важна

mlx-omni-server (--workers 2)

Лучший TTFT (7.3с) + top single tps (64)

Research / честные метрики

mlx-openai-server или mlx-omni-server

Корректные TTFT, gen_tps, wall_tps

Длинный контекст (>32k токенов)

любой кроме omlx и mlx-serve

omlx – ctx 32k, mlx-serve – OOM

Максимальная простота запуска

Rapid-MLX

rapid-mlx serve <model> и готово

Dual API (OpenAI + Anthropic)

mlx-omni-server

Единственный с Anthropic endpoint

Без Python runtime

пока никто

Ждать higgs + qwen3_5_moe, или ждать flash attention в mlx-serve

Чего не хватает в этом бенчмарке – честно: я не тестировал batch >2 (реальный multi-user это 4-8 параллельных), не сравнивал с llama.cpp (сознательно, статья про MLX-экосистему), не делал автоматической оценки качества ответов (все фреймворки используют одну модель, текст одинаковый – разница только в том, доходит ли ответ до конца или обрезается по max_tokens).


Бонус: а что если убрать Python

В процессе стало видно очевидное. Python-серверы хорошие, но тяжёлые: torch, transformers, ffmpeg, 2.5GB зависимостей, GIL, холодный старт 10+ секунд. Rust-сервер higgssingle binary, 30MB, стартует мгновенно – но не поддерживает qwen3_5_moe. Zig-сервер mlx-serve – быстрый, но квадратичный attention.

Нехватка очевидна: single binary на Rust + MLX, с поддержкой Qwen 3.5 MoE, с настоящим continuous batching. Я начал делать форк higgs с портированием ключевой логики из mlx-openai-server – prompt cache (prefix-trie + LRU), request queue на tokio mpsc, архитектура qwen3_5_moe в mlx-rs, tool/reasoning parser.

Это отдельная история на 7-10 недель. Когда будут цифры – напишу вторую статью, Rust vs Python для LLM inference - реальный бенчмарк. А пока – вот этот.


Собрать эксперимент у себя

Всё воспроизводимо. Мой репозиторий [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

www.BrainTools.ru

Rambler's Top100