Qwen 3.6 27B и 35B-A3B в NVFP4: запуск на двух RTX 5070 Ti через vLLM и SGLang

NVIDIA оптимизирует модели под своё железо, и квантование NVFP4 даёт очень приличную скорость при достойном качестве. Сегодня я не буду мерить, насколько просаживается качество, — разговор о другом: получится ли вообще запустить Qwen 3.6 в NVFP4 локально на двух потребительских видеокартах RTX 5070 Ti по 16 ГБ. Берём две официальные сборки от NVIDIA — nvidia/Qwen3.6-27B-NVFP4 и nvidia/Qwen3.6-35B-A3B-NVFP4 на Hugging Face, — поднимаем их в vLLM 0.26.0 и SGLang 0.5.16, и разбираем каждый флаг запуска: что он делает и почему в этой конфигурации стоит именно такое значение.

Спойлер по итогу: обе модели поднимаются, обе выдают больше сотни токенов в секунду, но цена вопроса — контекст. И «более лёгкая» 27B на этих картах оказывается тяжелее, чем 35B-A3B. Ниже — почему.

Сразу оговорюсь: это не продакшн-сборка

Я собираю realtime голосового агента для своего курса по ИИ, и мне важны вполне конкретные вещи: время до первого токена, скорость генерации и кэширование промпта. Всё остальное принесено в жертву. Контекст ограничен 32k, и то это впритык — пришлось порезать другие характеристики и настроить связку в основном на однопоточную обработку.

Железо тоже не серверное:

  • Ryzen 5 3600, 64 ГБ G.Skill 3600, потребительская материнская плата;
  • RTX 3090 в PCIe x4, RTX 5070 Ti в x8, вторая RTX 5070 Ti в x2;
  • NVLink между картами нет, P2P между ними не работает — все обмены идут через PCIe.

3090 в тестах не участвует: NVFP4 она не поддерживает, да и многие современные вещи уже тоже. Думаю, в ИИ-сборках эти карты ждёт судьба Tesla V100 — сначала «народный выбор», потом «а на нём половина ядер не собирается». Она переедет в домашний компьютер и, скорее всего, доживёт там до старости.

Что хотелось бы улучшить: в идеале модель должна влезать в одну карту. Надеюсь дорасти до RTX PRO 6000 96 ГБ — вот это прямо идеал для таких моделей, но не дешёвый. Потом планирую добавить ещё две 5070 Ti: тогда VRAM хватит и на полный контекст, и на параллелизм.

И отдельно — тем, кто сейчас пишет в комментарии, что запускает эту же модель на полном контексте с тысячей токенов в секунду. Пишите, пожалуйста, сразу параметры запуска, инференс и точную ссылку на модель. Запуск через ollama и LM Studio не в счёт. Если у вас Qwen 3.6 завелась на 3060 с 8 ГБ, то это либо не та модель, либо квант уровня Q2, либо половина слоёв уехала в оперативку. Ждём пруфы.

NVFP4 — и почему в этом чекпойнте он не сплошной

Первое, что стоит понять про nvidia/Qwen3.6-*-NVFP4: это не чистый 4-битный квант. В config.json лежит quant_algo = MIXED_PRECISION, и квантование размазано по слоям неравномерно.

Для 27B раскладка такая (401 запись в quantized_layers):

  • W4A16_NVFP4, group_size 16 — 193 слоя: все mlp.gate/up/down_proj и lm_head;
  • FP8 — 208 слоёв: linear_attn.in_proj_qkv/in_proj_z/out_proj и self_attn.q/k/v/o_proj;
  • MTP-модуль в ignore — он вообще не квантован.

Из этого следуют два практических вывода.

Первый: это weight-only четыре бита, а не W4A4. Активации остаются в bfloat16, 4 бита живут только в весах. Потребительский Blackwell (SM 12.0) нативного FP4-умножения не умеет, поэтому веса распаковываются ядрами Marlin. Движок честно об этом предупреждает:

WARNING [marlin.py:34] Your GPU does not have native support for FP4 computation
but FP4 quantization is being used. Weight-only FP4 compression will be used
leveraging the Marlin kernel.

Второй: флаг --quantization тут почти ни на что не влияет. И vLLM, и SGLang сами определяют формат по чекпойнту и переписывают значение на modelopt_mixed. Причём делают это молча и до проверки совместимости с CLI: вы напишете modelopt_fp4, движок стартует без единого предупреждения, а внутри всё равно будет modelopt_mixed. Держите это в голове, когда сравниваете свою команду с чужой.

Архитектура у обеих моделей гибридная, и это важнее квантования. У 27B — 64 слоя, из них только 16 с полным вниманием, остальные 48 — линейные GDN-слои с mamba-подобным состоянием. У 35B-A3B — 40 слоёв, из них 10 с полным вниманием, плюс MoE на 256 экспертов с 8 активными. Дальше вы увидите, что половина странностей в бюджете памяти растёт именно отсюда.

Сколько токенов в секунду выдаёт Qwen 3.6 на двух RTX 5070 Ti?

Все четыре конфигурации прогнаны одним и тем же скриптом, результаты лежат на странице бенчмарков — прогоны 33–36. Методика единая: три последовательных запроса, три параллельных, отдельно замер латентности со стримингом и отдельно проверка кэша префикса. Каждый режим — в двух вариантах: обычная генерация и генерация со структурированным выводом (SO), когда модель обязана вернуть строгий JSON по схеме.

МетрикаvLLM · 27BSGLang · 27BvLLM · 35B-A3BSGLang · 35B-A3B
Последовательная, ток/с148,6140,3310,0298,2
Последовательная + JSON145,4129,0304,0251,0
Пропускная (×3), ток/с222,6132,5529,8291,1
Пропускная + JSON244,4123,729,7248,3
Генерация, ток/с129,3122,5255,9224,0
TTFT, деловой промпт, с1,0250,4900,2020,216
Prefill, ток/с39883220151890
Выигрыш кэша префикса×2,0×10,3×2,9×4,9
Кэш срабатывает от, токенов52646685266669

Первое, что бросается в глаза: MoE-версия быстрее плотной вдвое. 35B-A3B держит 35 миллиардов весов, но считает на каждом токене только около трёх — отсюда 310 ток/с против 148. Если вам нужна просто скорость и качество MoE устраивает, брать 27B незачем.

Второе — колонка «пропускная + JSON» у vLLM с 35B-A3B: 529,8 → 29,7 ток/с. Это не опечатка. Структурированный вывод на MoE в этой связке разваливается почти в двадцать раз. Для чата такое незаметно, а для модели внутри интеграции, которая обязана возвращать JSON по схеме, это приговор конфигурации. У SGLang на той же модели просадка нормальная: 291 → 248.

Третье, и для меня главное — две последние строки. vLLM переиспользует кэш префикса только начиная с ~5,3 тысяч токенов общего начала. SGLang — с 668 токенов, и выигрыш там десятикратный. Системный промпт голосового агента почти всегда короче пяти тысяч токенов, то есть у vLLM он просто не кэшируется. Об этом ниже отдельно.

Про метрику «декодирование после TTFT», которая тоже есть на странице бенчмарков: доверять ей на этих прогонах не стоит. Движок отдаёт поток чанками неравномерно, короткий ответ прилетает одним куском, и деление количества токенов на «время после первого токена» даёт красивые, но бессмысленные тысячи. Честная цифра скорости — строка «Генерация», она считается от начала запроса.

Почему Qwen 3.6 27B оставляет меньше контекста, чем 35B-A3B?

Это главное открытие всей затеи, и оно контринтуитивное. Логика «модель меньше — значит места под контекст больше» здесь не работает.

Что сравниваем35B-A3B27B
Веса всего23,4 ГБ21,9 ГБ
Веса на карту (TP=2)11,7 ГБ10,24 ГБ
MTP-драфт на карту1,73 ГБ2,82 ГБ
Слоёв с полным вниманием10 из 4016 из 64
KV-голов / head_dim2 / 2564 / 256
Байт на KV-токен на карту11 26417 408
Пул KV на практике (с MTP)44 399 токенов23 731 токен

27B выигрывает полтора гигабайта на весах — и тут же проигрывает 1,1 ГБ на MTP-драфте и в 1,55 раза на цене каждого KV-токена. Причин ровно три.

Драфт-модель спекулятивки не квантуется. MTP-слой лежит внутри того же чекпойнта, но движок обнуляет для него quant_config и грузит embed_tokens с lm_head в bfloat16. При словаре в 248 320 токенов и hidden 5120 это 2 × (248320 × 5120 × 2 Б) / 2 = 2,54 ГБ плюс 0,28 ГБ на сам слой. У 35B hidden всего 2048 — отсюда 1,73 ГБ вместо 2,82. В пересчёте на контекст 2,82 ГБ — это примерно 170 тысяч токенов KV, которых вы не увидите.

KV-токен у 27B дороже. Вдвое больше слоёв с полным вниманием и вдвое больше KV-голов. Спасает только то, что у 27B в чекпойнте есть kv_cache_scheme и KV автоматически ложится в FP8 — без этого было бы 33,8 КБ на токен и пул около 11 тысяч. У 35B такой секции нет, поэтому там FP8 приходится включать флагом руками.

Состояние GDN-слоёв у 27B в 2,6 раза тяжелее. 48 линейных слоёв по 48 value-голов дают ~42 МБ на один слот против ~16 МБ у 35B. При пяти слотах плюс промежуточный кэш спекулятивки это ещё около 0,3 ГБ, отъеденных прямо у пула.

Складываем: у 35B под KV остаётся 0,47 ГБ при цене 11 264 Б за токен — это 44 399 токенов. У 27B остаётся 0,40 ГБ при цене 17 408 Б — это 22 747 токенов. Вот и вся разница.

И отсюда — самая противная ловушка этой связки. Если взять рабочую команду от 35B и запустить её как есть на 27B, сервер поднимется без единой ошибки и отрапортует context_len = 32768. Пул при этом будет 5 692 токена. --context-length нигде не сверяется с размером пула — вы упрётесь в это только на первом длинном промпте, в проде, в самый неподходящий момент. Всегда читайте в логе фактический размер пула, а не то, что вы попросили.

Разбор параметров запуска

Дальше — четыре рабочие команды и объяснение каждого флага. Виджет ниже даёт то же самое в интерактиве: выбираете конфигурацию, кликаете по флагу, читаете, что он делает и почему здесь именно такое значение.

Общее для всех четырёх команд

CUDA_DEVICE_ORDER=PCI_BUS_ID
CUDA_VISIBLE_DEVICES=1,2
FLASHINFER_DISABLE_VERSION_CHECK=1

CUDA_DEVICE_ORDER=PCI_BUS_ID нумерует карты так же, как их видит материнская плата. По умолчанию CUDA сортирует устройства по своей оценке мощности, и порядок плавает от запуска к запуску — в системе с тремя разными картами это значит, что однажды 1,2 выберет не то, что вы имели в виду. Цена ошибки высокая: в связку попадёт 3090, которая NVFP4 не поддерживает.

CUDA_VISIBLE_DEVICES=1,2 оставляет процессу только две 5070 Ti. Смешивать поколения в тензорном параллелизме бессмысленно: связка выравнивается по слабейшей карте.

FLASHINFER_DISABLE_VERSION_CHECK=1 — обход, а не решение. В сборке разъехались flashinfer 0.6.14 и flashinfer-cubin 0.6.12, и запуск падал ещё в коммуникаторе, до всякого выбора attention-бэкенда. Правильно — выровнять версии пакетов; на скорость расхождение не влияет.

Ещё одна деталь, на которой я потерял вечер: если вызывать vllm/sglang бинарником из .venv/bin, не активировав venv, каталог .venv/bin не попадает в PATH. Дальше flashinfer пытается собрать ядра под SM120 и падает с FileNotFoundError: 'ninja' — при том, что ninja установлен. Активируйте окружение или добавляйте PATH=/path/.venv/bin:$PATH в команду.

vLLM · Qwen3.6-27B-NVFP4

vllm serve nvidia/Qwen3.6-27B-NVFP4 \
  --max-model-len 32768 \
  --tensor-parallel-size 2 \
  --gpu-memory-utilization 0.95 \
  --attention-backend TRITON_ATTN \
  --max-num-seqs 8 \
  --max-num-batched-tokens 2048 \
  --speculative-config '{"method":"mtp","num_speculative_tokens":4,"attention_backend":"TRITON_ATTN"}' \
  --reasoning-parser qwen3 \
  --enable-auto-tool-choice --tool-call-parser qwen3_coder \
  --enable-prefix-caching \
  --limit-mm-per-prompt '{"image":0,"video":0}' \
  --host 0.0.0.0 --port 8000

--tensor-parallel-size 2. Не оптимизация, а условие запуска: 21,9 ГБ весов в 16 ГБ одной карты не помещаются. При TP=2 на карту приходится около 10 ГБ. Обратная сторона — все all-reduce идут по PCIe через PYNCCL: custom all-reduce отключается сам с сообщением «your platform lacks GPU P2P capability», а SymmMem не поддерживает SM 12.0.

--gpu-memory-utilization 0.95. На 0,9 сервер стабильно падал в OOM ещё на профилировании: веса ~9,97 ГБ, пик профилирования 14,57 ГБ из 15,47 ГБ доступных — под KV не оставалось ничего. 0,95 — проверенное рабочее значение; выше поднимать не стоит, остаток нужен на фрагментацию аллокатора.

--attention-backend TRITON_ATTN. Здесь я иду против рекомендации документации, где приоритет FLASHINFER > FLASH_ATTN > TRITON_ATTN, а Triton позиционируется как запасной вариант. На 16 ГБ память дороже скорости:

Что меряемTRITON_ATTNFLASHINFER (автовыбор)
Пул KV3,68 ГБ / 140 174 токена3,35 ГБ / 127 431 токен
Память под CUDA-графы0,11 ГБ0,52 ГБ
Полный старт~7 мин 20 с4 мин 05 с
Латентность ~300 токенов6,5 с5,0 с

FlashInfer стартует почти вдвое быстрее и отвечает быстрее на 20 %. Но он же съедает 0,4 ГБ лишних на графы, а это 13 тысяч токенов контекста. Если бы памяти было в достатке, выбор был бы обратным — в issue #37242 репозитория vLLM на RTX 5090 сообщают о +8 % в пользу flashinfer, но там нет дефицита VRAM.

--max-num-seqs 8 и --max-num-batched-tokens 2048 — это одна настройка, а не две. Я проверял их по отдельности:

  • только --max-num-batched-tokens 2048 — OOM, побайтово тот же самый;
  • только --max-num-seqs 8 — доходит до конца профилирования (пул 3,95 ГБ), но падает позже, в flashinfer_autotune;
  • вместе — работает.

Главный рычаг здесь --max-num-seqs: он роняет максимальный размер захватываемого CUDA-графа с 512 до 16, и список cudagraph_capture_sizes сжимается с 51 значения до [1,2,4,8,16]. Кстати, популярный совет «отключите мультимодальность, vision-энкодер съедает память» на этой модели не работает: --limit-mm-per-prompt в одиночку даёт ровно тот же OOM.

--speculative-config. Разбор MTP — ниже отдельным разделом, здесь про синтаксис. Вложенный "attention_backend":"TRITON_ATTN" обязателен, и это неочевидно: --attention-backend применяется только к целевой модели. Драфт образует отдельную attention-группу, бэкенд для неё выбирается автоматически, и в логе вы увидите бодрое Using FLASHINFER attention backend. Сервер поднимется штатно и умрёт на первом же инференс-запросе:

File ".../vllm/v1/attention/backends/flashinfer.py", line 956, in _get_workspace_buffer
torch.OutOfMemoryError: CUDA out of memory. Tried to allocate 394.00 MiB.

Причина — FlashInfer выделяет workspace-буфер лениво, при первом префилле. Профилирование памяти на старте его не видит, gpu-memory-utilization его не учитывает.

--enable-prefix-caching. В vLLM по умолчанию выключено — многие об этом не знают и меряют движок без кэша. Включать нужно всегда, если у запросов есть общее начало.

--limit-mm-per-prompt '{"image":0,"video":0}'. Qwen 3.6 мультимодальная, но голосовому агенту картинки не нужны. Выигрыш ощутимый: веса 9,97 → 9,54 ГБ, пик активаций 1,47 → 1,26 ГБ, пул KV +22 % (114 688 → 140 174 токена). В логе появится running in text-only mode.

--reasoning-parser qwen3. На скорость не влияет — я проверял специально: разброс 2 %, при восьми параллельных запросах цифры совпали с точностью 0,01 %, а генерация оказалась побайтово одинаковой. Парсер просто перекладывает текст из content в отдельное поле. Нужен он для другого: чтобы клиент не показывал пользователю поток размышлений. В стриминге vLLM 0.26 это поле delta.reasoning, а не reasoning_content — на этом ломаются клиенты, написанные под старые версии.

Отдельно про сам reasoning: на простом вопросе модель тратит на размышления 200–370 токенов, а на нетривиальном рассуждения разрастаются до нескольких тысяч символов. При max_tokens: 1024 четыре промпта из пяти в моём прогоне упёрлись в лимит, не выйдя из рассуждений: finish_reason: length, content пустой, 17 секунд впустую. Бюджет токенов для этой модели закладывайте с большим запасом.

--enable-auto-tool-choice и --tool-call-parser qwen3_coder. Без первого сервер принимает описания функций, но никогда не возвращает tool_calls. Без второго вызов инструмента приедет клиенту куском текста внутри content: модель отдаёт его своим синтаксисом, а не готовым JSON OpenAI.

vLLM · Qwen3.6-35B-A3B-NVFP4 — что меняется у MoE

vllm serve nvidia/Qwen3.6-35B-A3B-NVFP4 \
  --max-model-len 32768 \
  --tensor-parallel-size 2 \
  --gpu-memory-utilization 0.95 \
  --attention-backend TRITON_ATTN \
  --kv-cache-dtype fp8 \
  --moe-backend marlin \
  --max-num-seqs 8 \
  --max-num-batched-tokens 4096 \
  --speculative-config '{"method":"mtp","num_speculative_tokens":3,"attention_backend":"TRITON_ATTN","moe_backend":"triton"}' \
  --reasoning-parser qwen3 \
  --enable-auto-tool-choice --tool-call-parser qwen3_coder \
  --enable-prefix-caching --enable-chunked-prefill \
  --limit-mm-per-prompt '{"image":0,"video":0}' \
  --host 0.0.0.0 --port 8000

Отличия от 27B — ровно пять, и каждое имеет причину.

--kv-cache-dtype fp8. Вот это принципиально. В чекпойнте 35B нет секции kv_cache_scheme, поэтому по умолчанию KV лёг бы в bfloat16 и стоил бы вдвое дороже. У 27B тот же FP8 включается автоматически из чекпойнта, и там этот флаг лишний. Побочный эффект одинаков для обеих моделей: калиброванных множителей k_scale/v_scale в весах нет, движок ставит 1.0 и честно предупреждает об этом. На длинном контексте это потенциальная потеря точности; выключается ценой удвоения KV-кэша.

--moe-backend marlin. Ядра для MoE-слоёв. У плотной 27B этот флаг — пустышка, MoE-слоёв там нет вообще, как и толку от VLLM_MOE_FORCE_MARLIN=1.

--max-num-batched-tokens 4096 — вдвое больше, чем у 27B. MoE активирует лишь часть весов, память под активации дешевле, и более крупный чанк префилла окупается. На 27B то же значение возвращало OOM.

num_speculative_tokens: 3 вместо 4. У MoE каждый лишний спекулятивный шаг тянет за собой маршрутизацию экспертов, и шаг стоит дороже, чем у плотной модели. Плюс внутри конфига появляется "moe_backend":"triton": драфт считается отдельно, и Marlin ему не подходит.

--enable-chunked-prefill. У 27B включён по умолчанию, здесь задан явно. Режет длинный префилл на куски и перемежает его с декодом, чтобы один тридцатитысячный промпт не заморозил очередь целиком.

SGLang · Qwen3.6-27B-NVFP4 — итоговая конфигурация

Это то, на чём я в итоге остановился для голосового агента.

CUDA_DEVICE_ORDER=PCI_BUS_ID CUDA_VISIBLE_DEVICES=1,2 \
FLASHINFER_DISABLE_VERSION_CHECK=1 PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True \
PATH=/home/ilya/ai/sglang/.venv/bin:$PATH \
python3 -m sglang.launch_server --model-path nvidia/Qwen3.6-27B-NVFP4 \
  --host 0.0.0.0 --port 8000 --tp-size 2 \
  --quantization modelopt_mixed --language-only \
  --attention-backend flashinfer --linear-attn-backend triton --mamba-ssm-dtype bfloat16 \
  --speculative-algorithm NEXTN --speculative-draft-model-path nvidia/Qwen3.6-27B-NVFP4 \
  --speculative-num-steps 3 --speculative-eagle-topk 1 --speculative-num-draft-tokens 4 \
  --context-length 16384 --max-running-requests 1 --max-mamba-cache-size 5 \
  --cuda-graph-max-bs-decode 1 --chunked-prefill-size 2048 --mem-fraction-static 0.995 \
  --reasoning-parser qwen3 --tool-call-parser qwen3_coder \
  --enable-metrics --enable-cache-report --stream-interval 1

PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True. Растягиваемые сегменты вместо фиксированных блоков дают +17 % к пулу KV буквально бесплатно: профилировщик видит после загрузки весов 2,21 ГБ свободных вместо 1,96 ГБ. На 16 ГБ каждая сотня мегабайт превращается в тысячи токенов контекста.

--quantization modelopt_mixed. Автодетект вернёт то же самое, но задать явно стоит — хотя бы чтобы следующий человек, читающий команду, понимал, с чем имеет дело.

--language-only — аналог --limit-mm-per-prompt у vLLM. 27 блоков и 333 тензора vision-башни экономят около 0,8 ГБ на карту.

--attention-backend flashinfer — здесь выбор обратный vLLM, и это не непоследовательность. В SGLang бюджет памяти управляется явно: --mem-fraction-static, --max-mamba-cache-size, --cuda-graph-max-bs-decode. Раз память режется руками, экономить ещё и на attention-бэкенде незачем — берём тот, что быстрее.

--linear-attn-backend triton — а вот это не вкусовщина, а жёсткое требование. Только triton поддерживает стратегию extra_buffer для radix-кэша mamba-состояний. Без неё кэш префикса на гибридной модели не работает вообще, а ради него всё и затевалось.

--mamba-ssm-dtype bfloat16. 48 линейных слоёв из 64 — это половина архитектуры, а не деталь. bfloat16 — компромисс между точностью состояния и его размером; один слот стоит ~42 МБ на карту.

--speculative-algorithm NEXTN + --speculative-draft-model-path на тот же репозиторий. То же самое, что method: mtp в vLLM, только под другим именем. Отдельная маленькая драфт-модель не нужна: MTP-слой лежит внутри основного чекпойнта (mtp_num_hidden_layers = 1, 15 тензоров mtp.*).

--speculative-num-steps 3, --speculative-eagle-topk 1, --speculative-num-draft-tokens 4. Три шага — граница, за которой acceptance начинает валиться быстрее, чем растёт выигрыш: MTP-слой один, и при каждом следующем прогоне качество черновика падает. eagle-topk 1 означает линейную цепочку вместо дерева черновиков — дерево окупается, когда GPU недогружена, а здесь она упирается в пропускную способность памяти. Четыре черновых токена принимаются в среднем на 3,10 — для плотной 27B это очень хорошо.

Попытка сэкономить память на черновиках оказалась худшим обменом из всех проверенных: урезание до двух токенов освободило 957 токенов пула и уронило скорость со 117 до 84,9 ток/с.

--context-length 16384. Сознательно ниже размера пула (23 731 токен). Запас в 1,45× нужен radix-кэшу — иначе одна длинная сессия займёт пул целиком, и кэш начнёт вытеснять сам себя. Плюс запросы длиннее лимита отбиваются честным HTTP 400 на входе, а не падают в середине префилла.

--max-running-requests 1 и --max-mamba-cache-size 5. Эти два флага связаны формулой, о которой стоит знать: при стратегии extra_buffer действует правило max_running_requests = max_mamba_cache_size / 5. Пятёрка — минимум, при котором обслуживается хотя бы один запрос. Множитель 5 берётся из ping-pong буфера, которым кэш снимает снимки GDN-состояния (база 3 плюс 2 за буфер).

--cuda-graph-max-bs-decode 1 — прямое следствие: графы под батчи 2, 4, 8 никогда не пригодятся, а память под них резервируется на старте.

--chunked-prefill-size 2048. На размер пула не влияет вообще — проверено, 2048 против 4096 дают одинаковые 6 659 токенов. Но 4096 медленнее: 14,9 с против 14,1 с на промпте в 18,8 тысяч токенов.

--mem-fraction-static 0.995. Значение выглядит безумным и работает только со включённым MTP: драфт занимает память до профилирования, и движок видит честный остаток. Без MTP на 0,995 сервер падает в OOM ещё на старте — пул сайзится по 4,78 ГБ «свободных», которые на деле держат временные буферы загрузчика. Без спекулятивки берите 0,88–0,90.

--enable-metrics и --enable-cache-report. Без них подбор параметров превращается в гадание: первый отдаёт метрики Prometheus (настоящий acceptance rate, заполненность пула), второй кладёт в ответ cached_tokens. Именно так и проверялось, что кэш работает.

--stream-interval 1. По умолчанию SGLang копит чанки и отдаёт группами. Для текста это нормально, для синтеза речи — рваная подача. Единица чуть увеличивает накладные расходы, зато TTS начинает говорить сразу.

Что получилось на выходе:

ПроверкаРезультат
Пул KV23 731 токен (запас 1,45× к контексту)
Декод, короткий промпт117,5 ток/с, принято 3,10 из 4
Холодный промпт 13 076 токенов8,83 с
Тот же префикс повторноcached 13 056, 0,66 с — в 13 раз быстрее
Пик VRAM13,1 ГБ из 16,3 ГБ на карту
Запрос длиннее 16 384честный HTTP 400

Гранулярность кэша префикса — 256 токенов: снимки GDN-состояния делаются раз в 256 позиций, поэтому из 13 077 токенов закэшировалось ровно 13 056 = 51 × 256.

SGLang · Qwen3.6-35B-A3B-NVFP4

python3 -m sglang.launch_server --model-path nvidia/Qwen3.6-35B-A3B-NVFP4 \
  --host 0.0.0.0 --port 8000 --tp-size 2 --context-length 32768 \
  --language-only --quantization modelopt_fp4 \
  --moe-runner-backend flashinfer_cutlass --fp4-gemm-backend marlin \
  --attention-backend flashinfer --linear-attn-backend triton --mamba-ssm-dtype bfloat16 \
  --speculative-algorithm NEXTN --speculative-draft-model-path nvidia/Qwen3.6-35B-A3B-NVFP4 \
  --speculative-num-steps 3 --speculative-eagle-topk 1 --speculative-num-draft-tokens 4 \
  --chunked-prefill-size 2048 --max-running-requests 1 --cuda-graph-max-bs-decode 1 \
  --mem-fraction-static 0.92 \
  --reasoning-parser qwen3 --tool-call-parser qwen3_coder \
  --enable-metrics --enable-cache-report --stream-interval 1

Здесь интереснее то, чего в команде нет и что в ней не работает.

--context-length 32768 — обеспечен арифметикой. Пул выходит 44 399 токенов, то есть реально больше заявленного контекста. Именно этим 35B-A3B отличается от 27B, а вовсе не набором флагов.

--quantization modelopt_fp4 — пустышка. Молча перезаписывается на modelopt_mixed, потому что чекпойнт 35B тоже MIXED_PRECISION. Я оставил флаг как есть, чтобы команда совпадала с прогоном на странице бенчмарков, но знайте: он ничего не делает.

--fp4-gemm-backend marlin — тоже пустышка, причём на обеих моделях. Слои W4A16 жёстко идут через Marlin в обход селектора, а слоёв W4A4 в чекпойнте нет вообще.

--moe-runner-backend flashinfer_cutlass — а вот он работает. Это единственный флаг, который у 35B имеет смысл, а у 27B является заглушкой: 256 экспертов против отсутствия MoE-слоёв как класса.

--mem-fraction-static 0.92 вместо 0,995. Гнаться за последними процентами незачем: пул и так 44 399 токенов, а риск OOM на старте растёт быстрее выигрыша.

--max-mamba-cache-size не задан. Здесь пригодился бы --mamba-full-memory-ratio, но учтите: если задан явный --max-mamba-cache-size, то ratio не читается вообще — это разные ветки кода, и явный размер идёт первым.

MTP: сколько черновых токенов брать?

Спекулятивное декодирование на встроенном MTP-слое — то, ради чего эти чекпойнты вообще имеет смысл брать у NVIDIA. Идея простая: маленький слой предсказывает несколько будущих токенов сразу, основная модель проверяет их одним проходом. Совпало — токены достались бесплатно, не совпало — откат к обычной генерации.

На vLLM с 27B я снял всю кривую:

Черновых токеновСкорость, ток/сУскорениеПринято черновиковПул KV
без MTP57,71,00×114 688
2120,32,09×88,8 %54 067
4140,02,43×71,8 %37 059
6141,62,45×60,1 %27 610
8137,92,39×50,6 %22 784

Читается это так.

Оптимум N = 4…6 — это плато, а не пик. Разница между 140,0 и 141,6 в пределах шума прогона, а на восьми идёт откат.

Acceptance падает монотонно и быстро, потому что MTP-слой в чекпойнте всего один. При N > 1 движок гоняет его несколько раз подряд, и качество черновика деградирует с каждой позицией. Видно по позиционным метрикам при N = 8: позиция 0 принимается 1950 раз, позиция 3 — уже 1207. vLLM предупреждает об этом прямо в логе.

Цена по памяти нелинейная. Веса растут на 0,40 ГБ, а пул падает со 114 688 до 22 784 токенов. Дело не только в гигабайтах: под спекуляцию резервируются слоты, и cudagraph_capture_sizes растёт с [1,2,4,8,16] до полусотни значений.

Под параллельной нагрузкой MTP при больших N вреден. При восьми одновременных запросах N = 2 даёт 358 ток/с — это лучше базы в 294. А N = 4/6/8 дают 235/287/222, то есть хуже, чем вообще без спекулятивки. Логика понятная: при заполненном батче GPU и так загружена, и черновики тратят вычисления впустую.

Практический вывод: один пользователь — берите 4, много пользователей — берите 2 либо выключайте MTP совсем. N = 2 единственный, кто выигрывает в обоих режимах.

На SGLang с той же 27B картина такая же по смыслу: 116,8 против 54,8 ток/с, ровно 2,13× при accept length 3,1 из 4. Для плотной модели это отличный результат — драфт-слой один, а таргет упирается в пропускную способность памяти с 10,24 ГБ весов на карту.

Грабли, на которых я потерял время

Протухший AOT-кэш torch.compile. Два запуска упали не по памяти, а с загадочным AttributeError: 'NoneType' object has no attribute 'size' внутри qwen3_next.py. Причина: ключ кэша ~/.cache/vllm/torch_compile_cache/torch_aot_compile/ не учитывает limit_mm_per_prompt. Прямое доказательство — запуск в text-only и запуск в мультимодальном режиме сгенерировали одинаковый хэш 3b42589cda0a…, и артефакт, скомпилированный в одном режиме, загрузился в другом. Лечится rm -rf ~/.cache/vllm/torch_compile_cache/torch_aot_compile/; чистить надо при каждом переключении режима.

Осиротевшие воркеры держат GPU. pkill -f "vllm serve" не убивает воркеров — они называются VLLM::Worker_TPn. Процесс продолжал держать 15 830 МиБ уже после того, как pgrep ничего не находил, и следующий старт падал с ложным CUDA error: out of memory. Бить нужно по VLLM::, добивать по nvidia-smi --query-compute-apps и дожидаться фактического освобождения памяти, а не просто отсутствия процесса в списке.

Первый прогон бенчмарка непригоден. Triton компилирует часть ядер при первом инференсе, и warmup движка их не покрывает. Расхождение между холодным и прогретым прогоном доходило до 57 %, а TTFT в холодном завышен втрое-вчетверо. Всегда делайте два прохода и засчитывайте второй.

Ужать KV до четырёх бит не выйдет. --kv-cache-dtype nvfp4 в SGLang — тупик по всем трём дорогам: с flashinfer сразу AssertionError (KV4 требует triton, torch_native, flex_attention или trtllm_mha), с triton сервер стартует и пул растёт до 36 395 токенов, но первый же форвард падает с KeyError: 'float4_e2m1fn_x2', а trtllm_mha требует SM 100, у нас SM 120.

Отключать radix-кэш ради контекста невыгодно. Это даёт +1 915 токенов пула при той же скорости, но убивает кэш префикса. Замер на двух запросах с общим началом в 6,9 тысяч токенов: с кэшем повтор занял 0,44 с, без кэша — 4,64 с. Обмен почти всегда проигрышный.

Autotuner молча откатывается. Даже в рабочей конфигурации flashinfer семь раз подряд пишет [Autotuner]: OOM detected, falling back to default tactic. Это значит, что GEMM-ядра работают не на оптимальных настройках — упираемся в память даже там, где вроде бы всё поднялось.

Что выбрать для голосового агента — vLLM или SGLang?

По сырой скорости vLLM выигрывает: 148,6 против 140,3 ток/с последовательно и 222,6 против 132,5 на пропускной. Если бы я строил сервис под поток запросов, разговор бы на этом закончился.

Но у realtime голосового агента метрики другие. Пользователь задал вопрос — и ждёт. Ждёт он не «пропускную способность», а первый токен, после которого начнёт говорить синтез речи. И вот здесь:

  • TTFT на деловом промпте: 0,49 с против 1,025 с — SGLang вдвое быстрее;
  • prefill 832 против 398 ток/с — вход обрабатывается вдвое быстрее;
  • кэш префикса срабатывает от 668 токенов против 5264 — и это решающее.

Последний пункт стоит развернуть. У агента есть системный промпт: роль, инструменты, правила. Он идёт в начале каждого запроса и не меняется. Если движок умеет переиспользовать его KV, то на втором и всех последующих обращениях префилл системного промпта стоит нуль. У vLLM порог в 5,3 тысячи токенов означает, что нормальный системный промпт на пару тысяч токенов не кэшируется никогда — вы платите за него полную цену на каждом запросе. У SGLang порог 668 токенов, и выигрыш по TTFT — десятикратный.

Плюс --stream-interval 1: поток идёт по токену, TTS начинает произносить фразу, не дожидаясь пачки. Мелочь, которая на слух ощущается сильнее, чем двадцать процентов пропускной способности.

Так что вывод у меня частный, а не универсальный: под поток запросов — vLLM, под низкую задержку одному пользователю с постоянным системным промптом — SGLang. Если кто-то знает, как дотюнить vLLM до тех же показателей по кэшу префикса, — пишите, мне правда интересно.

Что дальше

Ближайшие планы по железу: добавить ещё две 5070 Ti, чтобы VRAM хватило и на полный контекст, и на параллелизм. Дальше — переезд на нормальную серверную платформу, где карты не будут сидеть в x2 и x4. Тогда станет видно, сколько именно скорости съедает потребительская обвязка, — сейчас это чистое гадание.

Если собираете похожий стенд, посмотрите заодно:

Разбором запуска локальных моделей, агентов и всей этой кухни я занимаюсь на курсе «Применение искусственного интеллекта ChatGPT для 1С» — там же живёт и голосовой агент, ради которого всё это настраивалось.

Частые вопросы