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 · 27B | SGLang · 27B | vLLM · 35B-A3B | SGLang · 35B-A3B |
|---|---|---|---|---|
| Последовательная, ток/с | 148,6 | 140,3 | 310,0 | 298,2 |
| Последовательная + JSON | 145,4 | 129,0 | 304,0 | 251,0 |
| Пропускная (×3), ток/с | 222,6 | 132,5 | 529,8 | 291,1 |
| Пропускная + JSON | 244,4 | 123,7 | 29,7 | 248,3 |
| Генерация, ток/с | 129,3 | 122,5 | 255,9 | 224,0 |
| TTFT, деловой промпт, с | 1,025 | 0,490 | 0,202 | 0,216 |
| Prefill, ток/с | 398 | 832 | 2015 | 1890 |
| Выигрыш кэша префикса | ×2,0 | ×10,3 | ×2,9 | ×4,9 |
| Кэш срабатывает от, токенов | 5264 | 668 | 5266 | 669 |
Первое, что бросается в глаза: 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-A3B | 27B |
|---|---|---|
| Веса всего | 23,4 ГБ | 21,9 ГБ |
| Веса на карту (TP=2) | 11,7 ГБ | 10,24 ГБ |
| MTP-драфт на карту | 1,73 ГБ | 2,82 ГБ |
| Слоёв с полным вниманием | 10 из 40 | 16 из 64 |
| KV-голов / head_dim | 2 / 256 | 4 / 256 |
| Байт на KV-токен на карту | 11 264 | 17 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_ATTN | FLASHINFER (автовыбор) |
|---|---|---|
| Пул KV | 3,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 начинает говорить сразу.
Что получилось на выходе:
| Проверка | Результат |
|---|---|
| Пул KV | 23 731 токен (запас 1,45× к контексту) |
| Декод, короткий промпт | 117,5 ток/с, принято 3,10 из 4 |
| Холодный промпт 13 076 токенов | 8,83 с |
| Тот же префикс повторно | cached 13 056, 0,66 с — в 13 раз быстрее |
| Пик VRAM | 13,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 |
|---|---|---|---|---|
| без MTP | 57,7 | 1,00× | — | 114 688 |
| 2 | 120,3 | 2,09× | 88,8 % | 54 067 |
| 4 | 140,0 | 2,43× | 71,8 % | 37 059 |
| 6 | 141,6 | 2,45× | 60,1 % | 27 610 |
| 8 | 137,9 | 2,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. Тогда станет видно, сколько именно скорости съедает потребительская обвязка, — сейчас это чистое гадание.
Если собираете похожий стенд, посмотрите заодно:
- страница бенчмарков — сырые цифры по всем прогонам, включая эти четыре;
- RTX 5070 Ti против RTX 3090 для LLM — что выбирать из карт и почему 16 ГБ против 24 ГБ важнее процентов скорости;
- vLLM против llama.cpp — сравнение движков на одном железе;
- Qwen 3.6 27B с MTP в llama.cpp — та же спекулятивка, но на третьем движке и с куда более простым запуском;
- сборка сервера под локальные нейросети — если стенда ещё нет.
Разбором запуска локальных моделей, агентов и всей этой кухни я занимаюсь на курсе «Применение искусственного интеллекта ChatGPT для 1С» — там же живёт и голосовой агент, ради которого всё это настраивалось.