Трюк Карпаты сработал: локальная LLM на Mac M5 выдает 117 токенов в секунду

Как open-source движок Uzu от Mirai обходит потолок пропускной способности памяти на Apple M5 и разгоняет Qwen3.5 9B с 22 до 117 токенов в секунду: квантизация, спекулятивный декодинг, Weaver и бенчмарк против llama.cpp и MLX.

В 2023 году Андрей Карпаты объяснил, почему локальные языковые модели работают медленно. Когда модель генерирует один поток текста на вашем компьютере, чип почти все время ждет, пока веса доедут из памяти, а вычислительные блоки простаивают.

Через несколько недель он описал обходной путь. Проверить несколько токенов за один проход стоит примерно столько же, сколько проверить один. Значит, маленькая черновая модель может угадывать текст наперед, а основная модель проверит все догадки разом.

Первое наблюдение до сих пор задает потолок. Базовый чип Apple M5 прокачивает 153 ГБ данных в секунду, а Qwen3.5 9B в 4-битной квантизации весит около 5,2 ГБ. Если за проход получается один токен, выше 29 токенов в секунду этот чип не прыгнет. llama.cpp и MLX упираются прямо в эту линию: 22 и 25 токенов в секунду на одном и том же промпте с кодом.

Запас скрыт во втором наблюдении. Только локальные движки вроде llama.cpp в режиме MTP угадывают всего три или четыре токена за раз. Uzu, open-source движок инференса от компании Mirai, заходит на Apple Silicon гораздо дальше. Он строит черновик до 16 токенов вперед, следит, чтобы догадки были согласованы между собой, и проверяет их за один проход на ядрах, написанных специально под M5. На том же M5, с той же моделью и тем же промптом Uzu выдал от 92 до 117 токенов в секунду.

Ниже разберем, откуда берется потолок, как Uzu его обходит, и сравним движок с llama.cpp и MLX с помощью клиента, который можно запустить на своих промптах.

Почему генерация с batch size 1 упирается в память

Инференс LLM состоит из двух фаз. На префилле модель параллельно обрабатывает токены промпта. На декодинге она пишет ответ по одному токену, и каждый новый токен требует еще одного прохода модели. Во время прохода рантайм читает блоки весов и считает оценки для следующего токена.

При batch size 1 проход продвигает только одну последовательность, и арифметики в нем мало по сравнению с объемом данных. Чтобы получить один токен, чип один раз прогоняет все веса модели из памяти в GPU и делает примерно одно умножение со сложением на каждый вес. GPU заканчивает считать раньше, чем память подвезет следующий блок, поэтому скорость определяет пропускная способность памяти.

Отсюда жесткий предел: 153 ÷ 5,2 ≈ 29 токенов в секунду, как бы быстро ни считал GPU. Реальные движки работают чуть медленнее, потому что на каждом проходе читают еще и кэш предыдущих токенов.

Стандартный способ ускориться здесь это квантизация: веса становятся меньше, и за проход читается меньше байтов. Но цикл остается прежним, основная модель все так же запускается заново ради каждого токена. Квантизацией сегодня никого не удивишь, поэтому интереснее посмотреть, что еще делает Uzu.

Два рычага Uzu: меньше байтов и больше токенов за проход

Uzu работает с двумя величинами в цикле декодинга. Он уменьшает объем данных, который передается за проход, и увеличивает число токенов, которые проход принимает. За первое отвечает квантизация, за второе спекулятивный декодинг.

Mirai публикует для одной базовой модели несколько оптимизированных чекпоинтов и помечает их по размеру. M (Medium) использует 4-битную асимметричную целочисленную квантизацию, L (Large) использует 8-битную симметричную. Архитектура модели при этом одна и та же, отличается только упаковка весов. Medium экономит память, Large сохраняет больше бит на вес. В разборе квантизации от Mirai описано, как команда совмещает пост-тренировочную квантизацию с дистилляцией с учетом квантизации.

Целочисленный формат выбран намеренно. В отличие от старых GPU Apple, M5 выдает максимальную производительность именно в int8. Поэтому Uzu на лету квантизирует до 8 бит и активации, то есть промежуточные значения, которые передаются между слоями. Когда обе стороны матричного умножения хранятся как целые числа, проверка черновика идет по аппаратно ускоренному int8-пути M5.

У низкой точности есть своя проблема. При групповой квантизации все значения группы делят один масштаб, и он подстраивается под самое большое по модулю значение. Если в группе есть выброс, остальным значениям достается меньше целочисленных уровней, и ошибка округления растет.

Uzu лечит это блочно-диагональным случайным преобразованием Адамара перед квантизацией. Матрица Адамара состоит только из +1 и -1 с общим множителем. Умножение блока весов на нее перемешивает каждое значение по всем позициям, и один крупный выброс превращается в несколько умеренных. Случайность дают переключения знаков перед смешиванием, а блочность означает, что каждый блок из 32 значений смешивается отдельно. Преобразование точно обратимо, поэтому, кроме округления, в вычислениях модели ничего не меняется.

Размер блока в 32 элемента совпадает с SIMD-шириной GPU Apple: одна инструкция выполняется сразу на 32 потоках, и одна группа потоков обрабатывает один блок. Блок преобразования и группа квантизации при этом управляют разными вещами. Первый определяет, какие значения смешивает матрица Адамара, вторая определяет, какие значения делят параметры квантизации, и их размеры совпадать не обязаны.

Несколько токенов за один проход модели

Спекулятивный декодинг ставит рядом с основной моделью маленькую черновую. Черновик дешево предлагает будущие токены, основная модель проверяет их все вместе, принимает корректный префикс и продолжает с первого отвергнутого токена.

Допустим, черновик предложил пять токенов, а основная модель согласилась с первыми четырьмя. Один проверочный проход продвинул текст на пять токенов: четыре принятых плюс собственный токен основной модели на пятой позиции. Обычному декодингу на тот же текст понадобилось бы пять проходов. Итоговый вывод по-прежнему контролирует основная модель, и при корректной процедуре принятия распределение ее ответов сохраняется.

Проверка требует больше вычислений, чем генерация одного токена. Зато при локальном инференсе с batch size 1 вычислительные блоки и так простаивают, пока веса едут из памяти, и спекулятивный декодинг тратит эту свободную мощность на сокращение числа проходов. Цена зависит от доли принятых токенов: длинный черновик дает большой батч на проверку, но и больше шансов, что ранняя ошибка обнулит все следующие токены.

Многие системы угадывают три или четыре токена. Типичный пример это multi-token prediction (MTP), где дополнительные головы, обученные прямо внутри модели, предсказывают несколько следующих токенов. Uzu на M5 использует бюджет от 16 до 32 токенов, а чекпоинт Qwen3.5 9B поставляется с деревом на 16 токенов для чипов серии M5.

Такие крупные батчи нужны, чтобы загрузить работой Neural Accelerators M5. Это матричные блоки внутри каждого ядра GPU, и они окупаются только на умножениях с большим числом строк. Проверка 16 кандидатов сразу дает им 16 строк работы вместо одной.

Почему длинный параллельный черновик разваливается

Быстрый параллельный драфтер предсказывает несколько будущих позиций одновременно. Каждая позиция видит контекст, но не видит токены, выбранные на предыдущих позициях черновика.

Возьмем промпт, который заканчивается на «She went to the store to buy». Позиции по отдельности могут выбрать «a», «few», «of» и «milk». Каждый токен выглядит правдоподобно, а вместе получается «a few of milk». Ранняя ошибка отбрасывает остаток цепочки, поэтому чем длиннее параллельный черновик, тем хуже он принимается.

Эту слабость закрывает Weaver из статьи Trees from Marginals. Это авторегрессионный адаптер на 56,7 млн параметров, который выбирает из коротких списков кандидатов и не проецирует на весь словарь. Параллельный драфтер DFlash за один заход предсказывает вероятных кандидатов для каждой позиции, а Weaver выбирает из них последовательно, и каждый выбор учитывает предыдущие. Пример превращается в «a gallon of milk».

Weaver умеет держать несколько согласованных веток, например «a gallon of milk» и «a bottle of juice». Основная модель проверяет дерево целиком и принимает самый длинный корректный путь. Форма дерева подстраивается под промпт: предсказуемый код позволяет вырастить длинную узкую ветку, а свободный текст требует коротких веток с большим числом вариантов.

Авторы статьи сообщают о приросте на 24,7% относительно оптимизированного DFlash и об ускорении в 4,37 раза по сравнению с обычным авторегрессионным декодингом. Эти эксперименты шли на CUDA и SGLang, поэтому переносить цифры на Uzu напрямую нельзя. Uzu применяет ту же идею драфтинга в своем рантайме для Apple, и остается задача быстро проверять деревья на основной модели.

Проверка дерева без отката состояния

Часть слоев модели хранит текущее внутреннее состояние. Каждый новый токен читает его и обновляет. Пока последовательность одна, рантайм просто применяет обновления по порядку.

Дерево черновика порождает несколько возможных продолжений, и каждая ветка обновила бы состояние по-своему. Если менять живое состояние при проверке одной ветки, перед следующей его приходится восстанавливать. На большом дереве это превращается в лишние операции с памятью и повторную работу.

В Qwen3.5 и Qwen3.6 такие слои называются Gated DeltaNet. Слой внимания хранит растущий кэш всех прошлых токенов, а Gated DeltaNet сжимает все увиденное в состояние фиксированного размера, которое меняет каждый принятый токен. В Qwen3.5 9B таких слоев 24 из 32.

Uzu разделяет проверку ветки и фиксацию ее состояния. Во время проверки он считает все значения, нужные каждой ветке, и не трогает живое состояние. Затем основная модель выбирает принятый путь, и обновления применяются только для принятых токенов. В заметке о реализации Uzu это называется rollback-free tree verification. Metal-ядра считают выходы DeltaNet для всего дерева за один проход без пошаговой рекурсии по каждой ветке, а состояние фиксируется только вдоль выбранного пути, так что откатывать нечего.

Без этого выигрыш бы таял. Спекулятивный декодинг выгоден, только пока проверка дешевле обычной потокенной генерации, а постоянное восстановление состояния съедало бы часть ускорения. Weaver вместе с проверкой без отката позволяют Uzu предлагать длинные последовательности токенов и проверять их без лишней работы.

Цифры на базовом M5 и в бенчмарках Mirai

Автор оригинального разбора прогнал Qwen3.5 9B тремя способами на MacBook Pro с базовым M5 и 16 ГБ общей памяти: llama.cpp без спекулятивного декодинга, llama.cpp со встроенными MTP-головами на 3 токена вперед и Uzu с DFlash и Weaver. Во всех случаях использовались 4-битные веса и один промпт: «Write a python function to merge two sorted lists».

У Mirai есть и опубликованные бенчмарки скорости вывода, скорости чтения промпта, памяти и качества квантизации. Спекулятивный результат сняли на Qwen3.6 27B Mirai-M: чекпоинт занимает 15,6 ГБ, замер делали на M5 Max со 128 ГБ памяти. Uzu оказался быстрее MTPLX в 2,1 раза, llama.cpp в 3,8 раза и MLX в 4,4 раза. Без спекуляции Uzu выдает 35,54 токена в секунду, со спекуляцией 114, то есть спекулятивный путь добавляет 3,2 раза.

Скорость сильно зависит от типа задачи. В коде и математике много предсказуемых продолжений, а в свободном диалоге драфтеру сложнее угадывать. Поэтому у спекулятивного декодинга нет одной цифры на модель, все определяет нагрузка.

Как запустить модель локально

Быстрее всего проверить Uzu через командную строку. CLI ставится из Homebrew и при первом запуске скачивает выбранную модель.

brew install mirai

mirai --model trymirai/Qwen3.5-9B-M \
  -m "Write a python function to merge two sorted lists"

Запуск на Mirai CLI 0.6.1, MacBook Pro с базовым M5 и 16 ГБ памяти под macOS 26.6.2 после ответа вывел такую статистику:

time to first token: 0.12 s
prefill speed: 161.11 t/s
generation speed: 98.10 t/s
tokens per forward pass: 8.39 t/f
memory used: 5.77 GB
total energy: 114.90 J
input energy per token: 0.11 J/tok
output energy per token: 0.16 J/tok
duration: 7.36 s

Time to first token показывает, сколько ждать первый токен ответа, и почти все это время уходит на чтение промпта. Prefill speed это скорость чтения промпта в токенах в секунду. Промпт здесь всего около 20 токенов, поэтому цифра мало о чем говорит и на длинных промптах растет. Generation speed это скорость записи ответа, та самая фаза декодинга, которую ограничивает память. Tokens per forward pass показывает, сколько токенов в среднем дал один проход 9B-модели, при обычном декодинге это ровно 1,00. Memory used это занятая общая память: в основном веса модели, плюс драфтер и кэш.

Значение 8,39 t/f и показывает спекулятивный путь в работе. Каждый проход 9B-модели в среднем выдал около 8 токенов, поэтому генерация доходит до 98 tok/s на чипе с потолком около 29. Между запусками скорость немного гуляла: от 92 до 117 tok/s при 7,5 до 9,6 токена за проход.

Спекуляция включается под конкретный чип. В конфигурации этого чекпоинта перечислены M5, M5 Pro и M5 Max, поэтому на Mac с M1 по M4 та же команда отработает без драфтера и покажет 1,00 t/f.

Uzu как локальный OpenAI-совместимый сервер

Uzu умеет поднимать HTTP-эндпоинт, совместимый с OpenAI. Модель по-прежнему работает на вашем Mac, а запрос ходит только через локальный loopback-интерфейс. Сервер запускается в отдельном терминале:

mirai server --model trymirai/Qwen3.5-9B-M --port 8000 --no-prefix-cache

Сервер загружает один локальный чекпоинт и слушает 127.0.0.1, обращаться к нему могут процессы на том же Mac. Это удобно для приложения, которое уже общается по протоколу OpenAI chat: достаточно направить OpenAI-клиент на http://127.0.0.1:8000/v1.

По умолчанию Uzu хранит префиксный кэш, то есть уже обработанное начало прошлых промптов, и повторный промпт пропускает большую часть префилла. Флаг –no-prefix-cache отключает кэш, чтобы каждый тестовый запрос проходил полный путь и повторные замеры оставались сопоставимыми.

Сравниваем Uzu, MLX и llama.cpp одним клиентом

На том же базовом M5, когда каждый движок работал в одиночку, llama.cpp и MLX остались под пределом 29 токенов в секунду, как и положено при одном токене за проход. Uzu писал примерно в 3,7 раза быстрее MLX. Uzu, MLX LM и llama.cpp умеют поднимать OpenAI-совместимые чат-эндпоинты, поэтому все три можно проверить одним скриптом на своих промптах.

Uzu запускается с 4-битным Medium-чекпоинтом от Mirai:

mirai server --model trymirai/Qwen3.5-9B-M --port 8000

MLX LM запускается с 4-битной MLX-конвертацией той же базовой модели. Команда uv tool update-shell добавляет каталог с mlx_lm.server в PATH, после нее откройте новый терминал и запустите сервер:

brew install uv
uv tool install mlx-lm
uv tool update-shell

mlx_lm.server --model mlx-community/Qwen3.5-9B-4bit --port 8001

llama.cpp запускается с GGUF-чекпоинтом Q4_K_S, который Mirai использовала в своем сравнении:

brew install llama.cpp
llama-server --version

llama-server \
  -hf unsloth/Qwen3.5-9B-MTP-GGUF:Q4_K_S \
  --alias qwen3.5-9b-q4-k-s \
  -ngl 99 -fa on -c 8192 -np 1 \
  --port 8002

Флаг -c 8192 не дает llama.cpp зарезервировать полный контекст модели в 262K токенов, который на Mac с 16 ГБ не поместится рядом с весами. -np 1 запускает один слот. Чтобы включить MTP-драфтинг, добавьте –spec-type draft-mtp –spec-draft-n-max 3, для MTP обязателен -np 1.

Каждый сервер при первом старте скачивает свой чекпоинт. Запускайте их по одному, чтобы в общей памяти во время замера лежала только одна копия Qwen3.5 9B. Учтите, что все три чекпоинта сделаны из одной Qwen3.5 9B, но формат и рецепт квантизации у них разные: Mirai-M у Uzu, собственная 4-битная конвертация у MLX и Q4_K_S у llama.cpp. Поэтому сравниваются полные движки в том виде, в каком их запустит разработчик, без изоляции отдельных ядер.

Клиент ниже сохраните как compare_client.py. Он использует только стандартную библиотеку Python и отправляет один и тот же детерминированный промпт в любой OpenAI-совместимый локальный эндпоинт. Скрипт отключает thinking, чтобы каждый движок отвечал сразу, просит у сервера статистику токенов и выводит время до первого токена, общее время и скорость декодинга. Uzu дополнительно сообщает, сколько проходов основной модели он сделал, поэтому для него печатается и число токенов за проход.

import json, os, sys, time, urllib.request

PROMPT = "Write a Python function that merges two sorted lists."

body = json.dumps({
    "model": os.environ["MODEL"],
    "messages": [{"role": "user", "content": PROMPT}],
    "temperature": 0,
    "max_tokens": 1024,
    "stream": True,
    "stream_options": {"include_usage": True},
    "chat_template_kwargs": {"enable_thinking": False},
}).encode()
request = urllib.request.Request(os.environ["ENDPOINT"], body, {"Content-Type": "application/json"})

start = time.perf_counter()
first = last = None
usage = {}

with urllib.request.urlopen(request, timeout=600) as response:
    for line in response:
        line = line.decode().strip()
        if not line.startswith("data:"):
            continue
        data = line[5:].strip()
        if data == "[DONE]":
            break
        event = json.loads(data)
        usage = event.get("usage") or usage
        for choice in event.get("choices") or []:
            delta = choice.get("delta") or {}
            text = delta.get("content") or ""
            if text or delta.get("reasoning_content") or delta.get("reasoning"):
                last = time.perf_counter()
                first = first or last
            print(text, end="", flush=True)

if first is None:
    sys.exit("No tokens received. Check the server log.")

tokens = usage.get("completion_tokens", 0)
print(f"\n\ntime to first token: {first - start:.2f} s")
print(f"end-to-end time: {time.perf_counter() - start:.2f} s")
if tokens > 1 and last > first:
    print(f"decode speed: {(tokens - 1) / (last - first):.1f} tok/s")
if usage.get("spec_verify_ct"):
    print(f"tokens per pass: {tokens / usage['spec_verify_ct']:.2f}")

Для каждого движка поднимите сервер в первом терминале, а клиент запустите из второго, подставив нужный порт и модель:

ENDPOINT=http://127.0.0.1:8000/v1/chat/completions \
MODEL=trymirai/Qwen3.5-9B-M \
python3 compare_client.py

ENDPOINT=http://127.0.0.1:8001/v1/chat/completions \
MODEL=mlx-community/Qwen3.5-9B-4bit \
python3 compare_client.py

ENDPOINT=http://127.0.0.1:8002/v1/chat/completions \
MODEL=qwen3.5-9b-q4-k-s \
python3 compare_client.py

Клиент запускайте дважды: первый прогон включает прогрев, его выбрасываем и оставляем второй. Затем останавливаем сервер через Ctrl-C и переходим к следующему движку. Неизменными должны оставаться Mac и его режим питания, промпт и шаблон чата, лимит выходных токенов, температура и настройки сэмплирования, число прогревов и число замеров.

Скорость в клиенте включает HTTP-стриминг, поэтому она будет чуть ниже, чем в логах самих движков. Сравнивайте клиентские цифры между собой и не смешивайте их с выводом CLI. В Mirai CLI нет флага, который отключает спекуляцию, так что вклад DFlash-Weaver отдельно не измерить. Ближе всего к этому сравнение llama.cpp с MTP и без него.

Итог

Загрузить квантизированный чекпоинт несложно. Трудность в том, чтобы генерация оставалась быстрой, когда каждый запрос идет с batch size 1 на устройстве пользователя. Uzu решает эту задачу сразу на нескольких уровнях. Квантизация режет трафик памяти, Metal-ядра заточены под железо Apple, DFlash-Weaver предлагает длинные согласованные черновики, а проверка без отката состояния не тратит время на восстановление рекуррентных слоев. На базовом M5 эта связка подняла 4-битную 9B-модель с 22 токенов в секунду на обычном llama.cpp до 92 и выше на Uzu с тем же промптом.

Доля принятых токенов меняется от задачи к задаче, а на результат влияют железо, качество квантизации, длина контекста и нагрев. Проверять стоит на промптах своего продукта и мерить качество вместе с задержкой. В репозитории Uzu на GitHub лежат движок, локальный сервер, инструкции по сборке и биндинги для Rust, Swift, Python и TypeScript.

git clone https://github.com/trymirai/uzu.git

Источник: разбор Akshay Pachaar в X.

Поделиться:

Ответить

Ваш адрес email не будет опубликован. Обязательные поля помечены *