Qwen 3.8 27B локально: пять конфигураций на двух RTX 5070 Ti

Qwen 3.8 27B локально: 5 конфигураций на двух RTX 5070 Ti
Видео: Qwen 3.8 27B локально: 5 конфигураций на двух RTX 5070 Ti

Всем привет, с вами Низамов Илья. Модель unsloth/Qwen3.8-27B-NVFP4 я гоняю локально в vLLM на двух RTX 5070 Ti по 16 GB, и вопрос был простой: сколько контекста удастся получить и чем за это платить. Замеры я снимал не руками, а собственным инструментом с единой базой прогонов: пять конфигураций запуска, шесть прогонов, одиннадцать тестов на каждый, одна методика, один файл с результатами.

Всё, что ниже, пересобрано на замерах от 18 августа 2026 года по четвёртой версии методики — предыдущая редакция статьи считала часть метрик иначе, и цифры в ней устарели целиком.

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

Железо стенда

Начну с железа, и подробно. Без него любая цифра ниже читается неправильно: «56 токенов в секунду» ничего не значит, пока неизвестно, во что упирается конкретно эта машина. Всё, что в таблицах, — не по памяти, а из паспорта прогона: инструмент снимает конфигурацию сам, через lscpu, dmidecode, SPD модулей, /sys/bus/pci и nvidia-smi.

Процессор, плата, память

ПроцессорAMD Ryzen 5 3600, 6 ядер / 12 потоков, до 4208 MHz, один сокет, один NUMA-узел
Материнская платаASUS ROG STRIX X570-F GAMING, rev X.0x
BIOSAmerican Megatrends 5044 от 04.01.2026
Оперативная память78032 MB, четыре слота из четырёх заняты
Модули2 × G.Skill F4-3600C18-32GVK по 32 GB (двухранговые) + 2 × Patriot «4000 C19 Series» по 8 GB (одноранговые)
РежимDDR4, 3266 MT/s на всех четырёх модулях
Таймингине прочитаны, и это честнее выдумки: 3266 MT/s нет ни в одном профиле SPD (у G.Skill — JEDEC 2666 и XMP 3600, у Patriot — JEDEC 2133, XMP 4000 и 3866), то есть частота и задержки набраны в BIOS вручную
Пропускная способность ОЗУ (замер)чтение 42.0 GB/s, копирование 39.1 GB/s, запись 26.0 GB/s
Операционная системаUbuntu 26.04 LTS, ядро Linux 7.0.0-29-generic
Драйвер и CUDANVIDIA 595.71.05, CUDA 13.2

Две детали, которые видно только в паспорте.

Память собрана из двух разных комплектов. 64 GB G.Skill плюс 16 GB Patriot — и именно поэтому ни один XMP-профиль не включён: на смешанном наборе плата не вытянула ни 3600, ни 3866, частоту пришлось задавать руками. 3266 MT/s — это компромисс, а не паспорт. Инструмент такой случай не маскирует: он пишет «таких профилей в SPD нет» вместо того, чтобы подставить красивые «3600 CL18».

Контроллер памяти выбирается вторым потоком. Развёртка по числу потоков дала на копировании 35.6 → 39.1 → 37.2 → 35.7 → 34.6 GB/s на 1, 2, 4, 8 и 12 потоках. То есть шести ядрам 2019 года хватает двух, чтобы упереться в память, а дальше потоки только мешают друг другу. Для наших цифр это почти неважно — веса целиком лежат в VRAM, в системную память ничего не выгружается, — но процессор здесь самое старое звено, и он обслуживает планировщик vLLM и семплер llama.cpp.

Видеокарты и шина PCIe

КартаVRAMЛимит мощностиVBIOSСлотЛинк под нагрузкой
RTX 3090 (MSI)24576 MB370 W94.02.42.00.F1PCIe 4.0 x4Gen4 x4 — упирается в плату
RTX 5070 Ti (ASUS)16303 MB300 W98.03.58.40.B6PCIe 4.0 x8Gen4 x8 — карта умеет Gen5, хост нет
RTX 5070 Ti (ASUS)16303 MB300 W98.03.58.00.78PCIe 4.0 x8Gen4 x8 — то же самое

NVLink нет ни у одной пары. И вот это — главный факт о стенде, к которому я вернусь в разделе про третью карту:

  • 3090 сидит в слоте, разведённом на четыре линии. Не карта такая, а плата: slot_max_width у этого слота равен 4, и райзером это не лечится. Инструмент помечает такую карту «упирается в плату».
  • 5070 Ti работают на Gen4, хотя обе умеют Gen5 — потолок ставит платформа X570.
  • Линк снимается под нагрузкой, а не на простое. В простое карта уходит в Gen1 ради энергосбережения, и снимок «до прогона» показал бы «PCIe 1.0 x8» на исправной машине. Фоновый наблюдатель опрашивает драйвер раз в две секунды и держит максимум.
  • У двух 5070 Ti разные версии VBIOS. На результаты это не влияло, но карты не идентичны, и в паспорте это видно.

Между сериями замеров машина пересобиралась: в прогонах vLLM инструмент видел две 5070 Ti на адресах 0000:08:00.0 и 0000:09:00.0, в прогонах llama.cpp — три карты на 04, 09 и 0a, с 3090 первой по шине. Отсюда CUDA_VISIBLE_DEVICES=0,1 в одних командах и 0,1,2 в других.

Сколько остаётся на KV-кэш

Чекпойнт NVFP4 занимает 21.81 GiB. При тензорном параллелизме на две карты на каждую приходится около 11 GiB весов, а полезного объёма у карты 15.47 GiB. Остаётся примерно 3 GiB на всё остальное: KV-кэш, буферы активаций, графы CUDA.

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

Что показывают датчики под нагрузкой

ПрогонКартыПотребление в конце прогонаТемператураЗагрузка GPU
vLLM, две карты5070 Ti + 5070 Ti145–167 W из 30055–58 °C82–100 %
llama.cpp, три карты3090 + 2 × 5070 Ti206 / 82 / 124 W57–63 °C21 / 14 / 17 %

Два числа отсюда пригодятся дальше. Первое: под vLLM карты берут около половины разрешённой мощности. Батч единица — это не про вычисления, это про чтение весов из памяти, и кремний скучает. Второе: под llama.cpp на трёх картах загрузка 14–21 %, то есть карты большую часть времени ждут друг друга.

Как снимались замеры

Все шесть прогонов сняты одним инструментом, батчем 1, с фиксированным зерном и анти-кэш-маркером в начале каждого промпта. Определения важны: половина расхождений между обзорами в интернете — это разные формулы под одним словом.

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

  • Чтение запроса (префилл). Прежде чем написать первое слово, модель молча читает весь запрос: вопрос, приложенные файлы, историю переписки. Всё это время экран пустой. Метрика — сколько текста она успевает прочитать за секунду.
  • Пауза до первого слова (TTFT). Столько проходит между нажатием Enter и первой буквой ответа. На коротком вопросе это незаметно, на большом документе из этого состоит всё ожидание.
  • Скорость ответа (генерация) и чистая скорость печати (декодирование). Первая считает всё время запроса вместе с паузой — это то, что чувствует человек. Вторая — только сам набор текста, без ожидания.
  • Подтверждённый контекст. Движок соглашается принять сколько-то токенов — это цифра из настроек запуска. Проверяем делом: прячем в длинный текст фразу, которой модель знать не может, и просим найти. Прячем трижды — в начале, в середине и в конце. Подтверждённый контекст — самая большая длина, где нашлись все три.
  • Экономия на повторах (кэш префикса). Начало диалога модель уже читала, и второй раз может его не перечитывать. Метрика — во сколько раз повторный запрос быстрее холодного и с какой длины эта экономия вообще включается.
  • Исправность сборки. Не «умная ли модель», а не поглупела ли она от сжатия и не сломана ли обвязка: отвечает ли строго по заданной форме, решает ли задачи с заранее известным ответом, не мешает ли языки в одном ответе, приходит ли на один и тот же вопрос дважды один и тот же текст.
  • Агентская траектория. Агент не задаёт один вопрос: он читает историю и на каждом шаге выбирает инструмент. Прогоняем 26 записанных заранее ходов и смотрим, сколько раз инструмент выбран верно и сколько длится обычный шаг.

А теперь формально — как каждая метрика считается:

Что меряемКак считается
Скорость ответа (генерация), ток/свыходные токены / полное время запроса, включая ожидание первого токена — то, что чувствует пользователь
Чистая скорость печати (декодирование), ток/свыходные токены / (полное время − TTFT) — чистая скорость выдачи
Пауза до первого слова (TTFT)время до первого токена потока; отдельно на короткой реплике и на деловом промпте ~450–500 токенов
Чтение запроса (префилл), ток/снаклон прямой t(N) = a + N/s по лестнице промптов 512 / 2048 / 8192 / 16384 токена, метод наименьших квадратов, по три повтора на точку, в зачёт минимум
Постоянные потери на запрос (накладные), ссвободный член a той же прямой — «сколько стоит дотянуться до движка»
Подтверждённый контекстнаибольшая длина, на которой модель нашла «иголку» сразу на трёх глубинах (10 %, 50 %, 90 %)
Падение скорости по длинево сколько раз падает скорость на каждое удвоение длины промпта
Экономия на повторах (кэш префикса)TTFT холодного запроса / TTFT повторного, плюс подтверждение по cached_tokens и счётчику vllm:prefix_cache_hits_total
Исправность сборкичетыре блока с равным весом: structured output и шаблон чата, 25 задач с проверяемым ответом, языковая целостность, побайтовая повторяемость
Агентская траектория26 фиксированных ходов, 25 схем инструментов в каждом запросе, история растёт до 28720 токенов

Три оговорки, без которых цифры ниже читаются неправильно.

Про префилл. Самый очевидный способ — prompt_tokens / TTFT — занижает результат в разы. В TTFT кроме собственно обработки входа сидят HTTP-запрос, постановка в слот, токенизация, генерация первого токена и флаш чанка; на llama.cpp эта постоянная составляющая занимала до 86 % измеренного времени. Поэтому считается не отношение, а скорость роста: строится прямая по четырём длинам промпта, наклон даёт префилл, свободный член — накладные расходы. Не легли на прямую (R² ниже 0.95) — число не публикуется вовсе. У всех шести прогонов R² вышел не ниже 0.9993, так что здесь публикуется всё.

Про падение скорости по длине. Раньше это было отношение первой точки лестницы к последней — и такое число нельзя сравнивать между конфигурациями с разным потолком контекста: у одной лестница проходит пять удвоений, у другой два, и «1.05 против 1.84» читается как «стало лучше», а означает «мерили короче». Теперь падение приведено к одному удвоению длины. Это единственная форма, в которой конфигурации с потолком 65536 и 262144 сравнимы напрямую.

Про подтверждённый контекст. Это верхняя точка лестницы, на которой иголка нашлась на всех трёх глубинах, а верхнюю точку ставит потолок движка. Сравнивать «248380 у первой конфигурации против 61533 у второй» бессмысленно — сравнивать надо долю подтверждённого от заявленного и общее начало лестницы, которое у всех прогонов побайтово одинаковое.

Шесть прогонов одной таблицей

Чтобы дальше было понятно, о чём речь, — весь набор сразу. Разбор каждой конфигурации ниже.

К1К2К3К4К5a, по слоямК5b, по тензорам
ДвижокvLLM 0.27.1vLLM 0.27.1vLLM 0.27.1vLLM 0.27.1llama.cpp b10446llama.cpp b10446
Карты2 × 5070 Ti2 × 5070 Ti2 × 5070 Ti2 × 5070 Ti3090 + 2 × 5070 Ti3090 + 2 × 5070 Ti
KV-кэшturboquant 4 битаfp8_e4m3fp8_e4m3 + mamba bf16fp8_e4m3q8_0q8_0
Угадывание наперёд (MTP)нетесть, ×4есть, ×4нетесть, ×4 (draft-mtp)есть, ×4 (draft-mtp)
Visionвыключенна местевыключенвыключен
Заявленный контекст26214465536131072170912262144262144
Подтверждённый24838061533123848123848123848123848
Скорость ответа, ток/с54.7998.59100.7355.4346.8562.33
Чистая скорость печати, ток/с55.67101.24102.5756.3049.7468.49
Пауза до первого слова, с0.0910.0600.0570.0960.4760.549
Чтение запроса, ток/с350633623140336021181177
Постоянные потери на запрос, с−0.006+0.004−0.009−0.016+0.417+0.385
Чтение медленнее, на удвоение длины×1.19×1.13×1.17×1.16×1.19×1.08
Печать медленнее, на удвоение длины×1.20×1.01×1.02×1.02×1.06×1.07
Экономия на повторах включиласьс 5256 ток.с 5256 ток.с 2856 ток.с 1656 ток.с 656 ток.с 656 ток.
Исправность сборки100 %100 %100 %100 %100 %100 %
Верный выбор инструмента88.5 %88.5 %96.2 %84.6 %92.3 %88.5 %
Обычный шаг агента, с2.221.771.522.022.832.94

Одна строка требует пояснения сразу: исправность сборки 100 % у всех шести. В прошлой редакции статьи у vLLM было 90–95 %, и весь провал держался на одном сценарии — системном сообщении не первым в списке. Теперь все четыре конфигурации vLLM запускаются с --chat-template /home/ilya/ai/qwen38-nvfp4-fixed.jinja, и провал исчез. Это ровно та правка, до которой в прошлый раз я дошёл рассуждением, а теперь она подтверждена замером.

Конфигурация 1: предел — 262144 токена ценой скорости

Полный нативный контекст модели на двух картах по 16 GB. Достаётся экстремальным квантованием KV-кэша, и, забегая вперёд, в постоянной работе я её не держу — но как демонстрация предела она показательна.

source unsloth-nvfp4-env/bin/activate

PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True \
CUDA_DEVICE_ORDER=PCI_BUS_ID CUDA_VISIBLE_DEVICES=0,1 \
vllm serve unsloth/Qwen3.8-27B-NVFP4 --host 0.0.0.0 --port 8000 \
  --tensor-parallel-size 2 --gpu-memory-utilization 0.965 \
  --kv-cache-dtype turboquant_4bit_nc --language-model-only \
  --kv-cache-memory-bytes 2400000000 \
  --max-num-seqs 8 --max-num-batched-tokens 1024 \
  --reasoning-parser qwen3 --enable-auto-tool-choice --tool-call-parser qwen3_coder \
  --enable-prefix-caching --max-model-len 262144 --enable-prompt-tokens-details \
  --chat-template /home/ilya/ai/qwen38-nvfp4-fixed.jinja
Что меряемЗначение
Заявленный контекст262144
Подтверждённый контекст248380 — иголка найдена на всех трёх глубинах
Скорость ответа / чистая печать54.79 / 55.67 ток/с
Свободный ответ / ответ строго по форме54.17 / 51.27 ток/с
Пауза до первого слова: короткая реплика / деловой промпт0.091 / 0.148 с
Чтение запроса3506 ток/с (накладные −0.006 с, R² 0.9997)
Экономия на повторахвключилась только с 5256 токенов, медианный выигрыш ×0.98
Чтение / печать медленнее на удвоение длины×1.19 / ×1.20
Исправность сборки100 %
Агент: верный инструмент / экономия на повторах / рост паузы / обычный шаг88.5 % / 83.2 % / ×0.54 / 2.22 с

Главная новость этого прогона: 248380 токенов подтверждены замером. Иголка — факт, которого нет в весах, — вставлена на глубины 10 %, 50 % и 90 % от четверти миллиона токенов, и модель нашла её во всех трёх случаях. То есть 262144 в конфиге — не декорация, контекст действительно живой по всей длине.

Плата за это видна ровно там же. На 248380 токенах время до первого токена — 149.9 с, а скорость выдачи падает до 13.05 ток/с против 55.4 на коротком промпте. Полный холодный запрос с ответом в 300 токенов на такой длине идёт почти три минуты, и 87 % этого времени — чтение входа.

Разбор параметров

--gpu-memory-utilization 0.965. Стандартные 0.95 оставляют на столе примерно четверть гигабайта. Выше 0.9689 подняться физически нельзя: свободно на карте 14.99 из 15.47 GiB. Значение 0.98 не стартует вообще, а подсказки самой vLLM в логе («увеличьте до 0.9841», в другом прогоне — до 0.9991) невыполнимы: они считают от полного объёма карты, а не от свободного.

--language-model-only. Модель мультимодальная: помимо 64 текстовых слоёв в ней 27 блоков vision-энкодера. Флаг убирает их: веса на карту падают с 10.67 до 10.24 GiB, доступный KV растёт с 2.64 до 3.31 GiB, потолок контекста — на 26 %. Прибавка к KV больше экономии на весах, потому что vision перестаёт участвовать ещё и в профилировании пиковых активаций.

--kv-cache-dtype turboquant_4bit_nc. Главный рычаг и главный компромисс. TurboQuant — это post-training квантование KV-кэша от Google Research: поворот вектора преобразованием Адамара, дальше квантование ключей по Ллойду-Максу и значений равномерно. Дообучения не требует, в vLLM 0.27.1 это готовый флаг с четырьмя пресетами.

Что стоит знать до включения:

  • Заявленные «в шесть раз меньше памяти» считаются от FP16, а у нас KV уже в FP8. Честная дельта к текущему состоянию: k8v4 даёт ×1.32, 4bit_nc — ×1.95, k3v4_nc — ×2.23, 3bit_nc — ×2.59.
  • Качество платное, и цифры лежат в самом коде vLLM: рост перплексии +1.17 % у k8v4, +2.71 % у 4bit_nc, +10.63 % у k3v4_nc и +20.59 % у 3bit_nc. Последний пресет на гибридных моделях лучше не трогать вовсе — там нет защиты граничных слоёв.
  • Сжимаются только 16 слоёв из 64: состояния mamba-слоёв TurboQuant не трогает. Поэтому отдача растёт вместе с длиной контекста — на 8k экономия около 31 % памяти на запрос, на 32k уже 43 %.
  • Реализация в vLLM неполная: вторая ступень (QJL, однобитный остаток) выброшена намеренно — в комментарии к коду сказано, что несколько независимых групп нашли ухудшение качества внимания.

--kv-cache-memory-bytes 2400000000. Самый неочевидный флаг: он не добавляет контекста, он возвращает память рантайму. Если отдать TurboQuant всё, что есть, сервер стартует, отвечает на короткие запросы и выглядит здоровым — а потом приходит длинный промпт, и движок умирает:

File ".../vllm/v1/attention/backends/turboquant_attn.py", line 857, in _continuation_prefill
torch.OutOfMemoryError: CUDA out of memory. Tried to allocate 204.00 MiB.
GPU 0 has a total capacity of 15.47 GiB of which 202.06 MiB is free.
...
vllm.v1.engine.exceptions.EngineDeadError

Двухсот четырёх мегабайт не хватило при двухстах двух свободных. При поэтапном префилле TurboQuant распаковывает уже сохранённый префикс, и временный буфер под это растёт вместе с длиной префикса. Профилировщик памяти его не видит: профилирование идёт с крошечным KV-кэшем, где префикса ещё нет. 2.4 GB — чуть больше, чем нужно на 262144 токена, остальное сознательно оставлено движку.

Чем эта конфигурация расплачивается

Число 262144 в конфиге настоящее, но полезным его назвать трудно, и ломаются три вещи.

Печатает всё медленнее по мере роста промпта — сильнее всех в наборе. На коротком вопросе это 55 токенов в секунду, к 124 тысячам остаётся 21, к 248 тысячам — 13. Каждое удвоение длины отнимает пятую часть скорости (×1.20 — худший результат набора). Честный fp8 на тех же удвоениях не теряет почти ничего (×1.02): у него на 124 тысячах всё ещё 51 токен в секунду. На графике это видно нагляднее, чем в цифрах: на коротком вопросе две конфигурации неразличимы, расходятся они только на длине.

Экономии на повторах почти нет. Обычно модель не перечитывает начало диалога, которое уже видела, и повторный запрос приходит быстрее. Здесь это включается только с 5256 токенов: на всём, что короче, второй запрос идёт ровно столько же, сколько первый. Причина в устройстве кэша: при четырёхбитном хранении он кладётся кусками по 3072 токена, и короткий префикс в такой кусок целиком не набирается. Плюс пул KV урезан вручную до 2.4 GB. Отсюда и «×0.98» в сводной таблице — это медиана по четырём длинам, из которых сработала одна.

В агентской сессии это стоит времени. Переиспользование кэша 83.2 % — не худшее в наборе, но медиана хода 2.22 с при том, что декодирование у К1 такое же, как у К4, а у К4 медиана 2.02 с.

Как вообще добраться до такого контекста

Отправная точка — 70997 токенов в пуле KV на штатных настройках. Путь до полных 262144 занял четыре флага, и один из них не добавил ни токена: он в лестнице вообще не за этим. Ниже — та же лестница по шагам:

Отдельно про методику поиска потолка. Число GPU KV cache size в логе — не потолок контекста, оно показывает расклад для текущей длины и ошибается в полтора раза. Правильный способ: задать заведомо недостижимый --max-model-len и прочитать из ValueError строку estimated maximum model length — один запуск вместо бинарного поиска. Именно так получены некруглые 170912 в конфигурации 4.

Конфигурация 2: 65536 токенов, зато модель целиком (vision на месте)

Честный восьмибитный кэш, спекулятивное декодирование, увеличенный батч предзаполнения и единственная конфигурация, где модель поднимается целиком, вместе с vision-частью. Платим контекстом: 65536 токенов.

PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True \
CUDA_DEVICE_ORDER=PCI_BUS_ID CUDA_VISIBLE_DEVICES=0,1 PYTHONUNBUFFERED=1 \
vllm serve unsloth/Qwen3.8-27B-NVFP4 --host 0.0.0.0 --port 8000 \
  --tensor-parallel-size 2 --max-num-seqs 8 --max-num-batched-tokens 4096 \
  --kv-cache-dtype fp8_e4m3 --gpu-memory-utilization 0.965 \
  --reasoning-parser qwen3 --enable-auto-tool-choice --tool-call-parser qwen3_coder \
  --enable-prefix-caching \
  --speculative-config '{"method": "mtp", "num_speculative_tokens": 4}' \
  --enable-prompt-tokens-details --max-model-len 65536 \
  --chat-template /home/ilya/ai/qwen38-nvfp4-fixed.jinja
Что меряемЗначение
Заявленный контекст65536
Подтверждённый контекст61533 — весь заявленный, иголка найдена на трёх глубинах
Скорость ответа / чистая печать98.59 / 101.24 ток/с
Свободный ответ / ответ строго по форме119.16 / 96.01 ток/с
Пауза до первого слова: короткая реплика / деловой промпт0.060 / 0.171 с
Чтение запроса3362 ток/с (накладные +0.004 с)
Экономия на повторахс 5256 токенов, медиана ×0.97
Чтение / печать медленнее на удвоение длины×1.13 / ×1.01
Исправность сборки100 %
Агент: верный инструмент / экономия на повторах / рост паузы / обычный шаг88.5 % / 74.0 % / ×1.04 / 1.77 с

Здесь важно, что лестница длин дошла до конца заявленного потолка: 61533 токена — это верхняя ступень, которую заказывали как 65536, и стог по построению выходит на 6 % короче цели. В прошлой редакции статьи эта конфигурация обрывалась на 15370 токенах, и я честно писал «сравнивать нельзя». Обрывалась она из-за отсечки «не выше 90 % потолка» в старой методике: потолок сам оказался точкой сетки, и точка вылетала целиком. Теперь отсечка считается по замеренному расширению стога, и верхние две трети контекста наконец измерены.

Разбор параметров

--max-num-batched-tokens 4096. Этому флагу принято приписывать кратный рост префилла. На замере это не подтверждается: 3362 ток/с против 3360 у конфигурации 4 с батчем 1024 — разница в шестую долю процента. Впечатление кратного роста берётся из формулы prompt_tokens / TTFT: большой батч меняет в основном постоянную составляющую, а она в этой формуле и сидит.

Vision остаётся. Здесь нет --language-model-only, и это стоит примерно 26 % контекста — но модель поднимается со всеми возможностями. Если картинки нужны, платить приходится именно контекстом, других вариантов на 32 GB VRAM нет.

Цена за отказ от --language-model-only видна в кэше. Переиспользование кэша в агентской сессии 74.0 % — худшее из шести прогонов, и это единственный прогон, где TTFT по сессии вырос (×1.04), то есть каждый следующий ход давался движку не легче, а тяжелее. Кэш префикса включается только с 5256 токенов, как у К1, хотя KV здесь честный восьмибитный.

Конфигурация 3: модель угадывает наперёд (MTP), +82 % к скорости

Та же спекуляция, но vision убран, а состояние mamba-слоёв переведено в bf16. Контекст — 131072. Это лучший прогон набора почти по всем метрикам, которые чувствуются руками.

PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True \
CUDA_DEVICE_ORDER=PCI_BUS_ID CUDA_VISIBLE_DEVICES=0,1 \
vllm serve unsloth/Qwen3.8-27B-NVFP4 --host 0.0.0.0 --port 8000 \
  --tensor-parallel-size 2 --gpu-memory-utilization 0.965 \
  --language-model-only --max-num-seqs 8 --max-num-batched-tokens 1024 \
  --kv-cache-dtype fp8_e4m3 --mamba-ssm-cache-dtype bfloat16 \
  --speculative-config '{"method": "mtp", "num_speculative_tokens": 4}' \
  --reasoning-parser qwen3 --enable-auto-tool-choice --tool-call-parser qwen3_coder \
  --enable-prefix-caching --enable-prompt-tokens-details --max-model-len 131072 \
  --chat-template /home/ilya/ai/qwen38-nvfp4-fixed.jinja
Что меряемЗначение
Заявленный контекст131072
Подтверждённый контекст123848 — весь заявленный
Скорость ответа / чистая печать100.73 / 102.57 ток/с
Свободный ответ / ответ строго по форме122.09 / 99.67 ток/с
Пауза до первого слова: короткая реплика / деловой промпт0.057 / 0.171 с
Чтение запроса3140 ток/с (накладные −0.009 с)
Экономия на повторахс 2856 токенов, до ×4.66 на 5256 токенах
Чтение / печать медленнее на удвоение длины×1.17 / ×1.02
Исправность сборки100 %
Агент: верный инструмент / экономия на повторах / рост паузы / обычный шаг96.2 % / 82.4 % / ×0.93 / 1.52 с

Разбор параметров

--speculative-config '{"method": "mtp", "num_speculative_tokens": 4}'. Простыми словами: рядом с большой моделью работает маленькая быстрая голова, которая пишет несколько слов наперёд, а большая модель проверяет их все разом, за один проход. Угадала — эти слова уже готовы, не угадала — лишний черновик выбрасывается. В чекпойнте эта голова лежит готовой (model_mtp.safetensors, 811 MB), отдельно её качать не надо.

Замер против ближайшего соседа — конфигурации 4, отличающейся отсутствием MTP и --mamba-ssm-cache-dtype:

Что меряемК3, угадывает наперёдК4, не угадываетразница
Скорость ответа100.73 ток/с55.43 ток/с+82 %
Чистая скорость печати102.57 ток/с56.30 ток/с+82 %
Свободный ответ122.09 ток/с55.00 ток/с+122 %
Пауза до первого слова0.057 с0.096 с−41 %
Обычный шаг агента1.52 с2.02 с−25 %
Чтение запроса3140 ток/с3360 ток/с−6.5 %
Заявленный контекст131072170912−23 %

Восемьдесят процентов к скорости выдачи за 6.5 % скорости чтения — размен, который на этом железе выглядит однозначным. Дальше по тексту будет граница, где он перестаёт работать, но лежит она сильно правее, чем кажется.

Сколько черновых токенов брать — вопрос не такой простой, как кажется. Слой MTP в модели один, и при num_speculative_tokens > 1 он прогоняется несколько раз подряд, о чём vLLM честно предупреждает в логе. Второй черновой токен проходит куда реже первого — но итоговая скорость от этого не падает: за один шаг движка выдаётся больше токенов. С четырьмя черновыми токенами против двух в прошлой редакции замеров генерация выросла с 92 до 101 ток/с.

Плата — память: MTP добавляет 0.40 GiB весов (черновой слой лежит в bf16, он в списке исключений квантователя) и забирает 0.47 GiB доступного KV, плюс выравнивание слоёв — в логе это Add 3 padding layers, may waste at most 6.25% KV cache memory.

--mamba-ssm-cache-dtype bfloat16. Модель гибридная: из 64 слоёв только 16 — полноценное внимание, остальные 48 — linear attention (GDN/mamba) с состоянием фиксированного размера вместо растущего KV. Флаг переводит это состояние в bf16. Со спекулятивным декодированием он даёт около +3 % контекста — втрое больше, чем без него: с MTP состояние свёрточной части крупнее, и его сжатие приносит больше. Заодно вдвое падает размер блока внимания — и это видно в кэше префикса: у К3 он включается с 2856 токенов, а не с 5256, как у К1 и К2.

131072 — это круглое число, а не потолок. В отличие от 170912 у конфигурации 4, здесь я не искал предел, а взял степень двойки: со спекуляцией потолок ниже, чем без неё, и упираться в него на каждый запуск незачем.

Конфигурация 4: максимум без сжатия кэша — 170912 токенов

Убираем спекуляцию и смотрим, до какой длины дотягивается честный fp8-кэш.

PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True \
CUDA_DEVICE_ORDER=PCI_BUS_ID CUDA_VISIBLE_DEVICES=0,1 \
vllm serve unsloth/Qwen3.8-27B-NVFP4 --host 0.0.0.0 --port 8000 \
  --tensor-parallel-size 2 --gpu-memory-utilization 0.965 \
  --language-model-only --max-num-seqs 8 --max-num-batched-tokens 1024 \
  --kv-cache-dtype fp8_e4m3 \
  --reasoning-parser qwen3 --enable-auto-tool-choice --tool-call-parser qwen3_coder \
  --enable-prefix-caching --enable-prompt-tokens-details --max-model-len 170912 \
  --chat-template /home/ilya/ai/qwen38-nvfp4-fixed.jinja
Что меряемЗначение
Заявленный контекст170912
Подтверждённый контекст123848 — верхняя точка лестницы, а не предел конфигурации
Скорость ответа / чистая печать55.43 / 56.30 ток/с
Свободный ответ / ответ строго по форме55.00 / 51.11 ток/с
Пауза до первого слова: короткая реплика / деловой промпт0.096 / 0.134 с
Чтение запроса3360 ток/с (накладные −0.016 с)
Экономия на повторахс 1656 токенов, до ×7.86 на 5256 токенах
Чтение / печать медленнее на удвоение длины×1.16 / ×1.02
Исправность сборки100 %
Агент: верный инструмент / экономия на повторах / рост паузы / обычный шаг84.6 % / 88.1 % / ×0.76 / 2.02 с

Про подтверждённый контекст здесь надо читать внимательно. Лестница длин идёт степенями двойки: 1024, 4096, 16384, 65536, 131072, 262144. Следующая после 131072 ступень — 262144 — в заявленные 170912 не влезает, поэтому лестница остановилась, и 123848 — это «до сих пор дошли и всё ещё находили иголку», а не «дальше слепнет». Доля подтверждённого от заявленного у К4 самая низкая в наборе (72 %) ровно по этой причине, а не из-за модели.

Зато у К4 лучший в семействе vLLM кэш префикса: он включается уже с 1656 токенов и даёт до ×7.86. Блок кэша при честном fp8 и без спекуляции вышел 1568 токенов — вчетверо меньше, чем при четырёхбитном хранении, — и короткие префиксы в него попадают.

170912 — не круглое число, потому что его назвала сама vLLM: заявляешь заведомо недостижимую длину, читаешь estimated maximum model length из отказа и ставишь ровно её. Округлять вверх нельзя, и вот почему.

Ловушка: запрос чуть короче лимита виснет навсегда

Запрос с промптом 166465 токенов при --max-model-len 167776 (то есть внутри лимита) завис. Не упал, не вернул ошибку — завис: двадцать минут, ни одной новой строки в логе, Running: 0 reqs, Waiting: 0 reqs, загрузка GPU ноль. Сервер при этом живой, короткие запросы обрабатывает.

Арифметика такая: пул 167776 токенов — это ровно 107 блоков по 1568. Блок номер ноль vLLM резервирует как null-block, значит реально распределяемых 106 блоков, то есть 166208 токенов. Запрос на 166865 требует 107 блоков и не может быть запланирован никогда, а планировщик не считает это ошибкой — он просто ждёт.

Лечение: выравнивать --max-model-len по границе блока, отняв один блок. Тогда негабаритный запрос получает честный HTTP 400 мгновенно.

Почему нельзя взять и контекст, и скорость сразу

Коротко: четырёхбитное сжатие кэша ради длины и угадывание наперёд ради скорости вместе не собираются — берём либо длину, либо скорость.

Напрашивается очевидное: включить TurboQuant ради контекста и MTP ради скорости. Связка стартует, отдаёт полные 262144 токена и показывает высокую долю принятых черновых токенов. И генерирует мусор.

Воспроизводится детерминированно: две независимые конфигурации, четыре промпта, температура ноль. Контроли на месте — FP8 со спекуляцией отвечает нормально, четыре бита без спекуляции отвечают нормально. На полном контексте эта же связка убивает движок: CUDA error: an illegal memory access в сэмплере отбраковки, оба воркера, HTTP 500.

Причина нашлась в коде: у бэкенда TurboQuant стоит supports_spec_as_decode=False, поэтому порог переупорядочивания батча остаётся равным единице. Шаг спекуляции приходит с несколькими токенами в запросе, не проходит порог «это декод» и уходит в ветку префилла — с синтетическими длинами последовательностей. Формально код отрабатывает, считает неверно. Первые 3–6 токенов всегда правильные: самый первый шаг идёт вообще без черновика.

И главная ловушка: доля принятых драфт-токенов эту поломку не детектирует. На сломанной связке она равна 93.6 % против 78.4 % у исправной — модель печатает повторяющийся мусор, а мусор черновая голова предсказывает идеально.

Корректность спекулятивного декодирования проверяется чтением текста, а не метрикой. Именно поэтому в наборе выше нет конфигурации «четыре бита плюс MTP»: она собирается, но её результаты нельзя публиковать.

Конфигурация 5: спасёт ли третья видеокарта?

vLLM не умеет делить эту модель на три карты: тензорный параллелизм требует, чтобы число KV-голов делилось на размер группы, а у неё их четыре — допустимы 2 и 4, но не 3. Плюс 3090 не умеет NVFP4. Значит, llama.cpp и GGUF-сборка unsloth/Qwen3.8-27B-GGUF в кванте UD-Q8_K_XL — флаг -hf скачает её с Hugging Face сам.

Здесь два прогона: они отличаются ровно одним флагом — способом деления модели между картами.

CUDA_VISIBLE_DEVICES=0,1,2 ./build/bin/llama-server \
  -hf unsloth/Qwen3.8-27B-GGUF:UD-Q8_K_XL \
  --host 0.0.0.0 --port 8000 \
  --ctx-size 262144 --parallel 1 \
  --cache-type-k q8_0 --cache-type-v q8_0 --flash-attn on \
  --spec-draft-n-max 4 --spec-type draft-mtp \
  --temp 0.7 --top-p 0.8 --top-k 20 --presence-penalty 1.5 --min-p 0.00 \
  --reasoning off --split-mode layer \
  --batch-size 2048 --ubatch-size 512 --jinja \
  --chat-template-file /home/ilya/ai/qwen38-fixed.jinja

Второй прогон — та же команда с --split-mode tensor.

Что меряемПо слоям (--split-mode layer)Каждый слой на все карты (--split-mode tensor)
Скорость ответа46.85 ток/с62.33 ток/с
Чистая скорость печати49.74 ток/с68.49 ток/с
Свободный ответ / ответ по форме51.88 / 52.8970.91 / 75.49
Пауза до первого слова0.476 с0.549 с
Чтение запроса2118 ток/с1177 ток/с
Постоянные потери на запрос0.417 с0.385 с
Экономия на повторах×9.12 с 656 токенов×10.56 с 656 токенов
Чтение / печать медленнее на удвоение длины×1.19 / ×1.06×1.08 / ×1.07
Исправность сборки100 %100 %
Агент: верный инструмент / экономия на повторах / обычный шаг92.3 % / 93.9 % / 2.83 с88.5 % / 93.9 % / 2.94 с

Сразу оговорка про квант. В имени файла стоит UD-Q8_K_XL, а сам движок в /props называет model_ftype как Q4_K - Medium. Это динамический квант unsloth со смешанной разрядностью слоёв, и заголовок GGUF отражает базовый тип, а не фактическую раскладку. Поэтому называть эту сборку «восьмибитной» я не берусь — по паспорту движка она четырёхбитная с восьмибитными вставками. На исправность сборки это не повлияло: 100 % по всем четырём блокам.

Что дают эти флаги

--split-mode layer против --split-mode tensor. Это два способа поделить модель между картами: либо каждой карте достаётся своя пачка слоёв, либо каждый слой считают все карты сразу. Здесь и есть ответ на вопрос про третью карту, и упирается он в те самые четыре линии PCIe.

  • Тензорное деление заставляет все три карты работать над каждым токеном — суммарной пропускной способности памяти становится больше, и декодирование вырастает на 38 % (68.49 против 49.74 ток/с). Но за каждый слой платится обменом между картами, а один из участников сидит на Gen4 x4. На префилле, где через шину едут не отдельные токены, а целые батчи активаций, это обрушивает скорость на 44 %: 1177 против 2118 ток/с.
  • Послойное деление гоняет по шине только активации на границах слоёв — префилл остаётся вдвое выше, но карты работают по очереди, и декодирование проседает.

Ни один из режимов не обгоняет vLLM на двух картах: 2118 и 1177 ток/с префилла против 3360 у конфигурации 4. Разница в 1.6–2.9 раза.

--cache-type-k q8_0 --cache-type-v q8_0. Восьмибитный KV-кэш — аналог fp8 у vLLM, но квантование здесь блочное. На 262144 токена без него памяти не хватило бы даже втроём. Под нагрузкой заняли 19.0 / 14.7 / 15.4 GB — почти 50 GB VRAM на модель с полным контекстом.

--chat-template-file. Свой шаблон чата, и это не мелочь: со встроенным в GGUF шаблоном сервер отвечает пятисоткой на каждом рабочем запросе агента. Разбор — в отдельной статье, ссылка ниже.

Накладные расходы 0.4 с. Вот что видно только на регрессионной методике: свободный член прямой у llama.cpp равен +0.39…+0.42 с, у vLLM — минус шесть миллисекунд, то есть ноль. Почти полсекунды уходит ещё до того, как движок начал считать промпт. Для чата это незаметно, для агента, который делает десятки коротких ходов подряд, — это полсекунды на каждом ходу, и в медиане хода (2.83–2.94 против 1.52 у К3) видно ровно это.

Вывод по третьей карте

Третья карта решает вопрос памяти: 262144 токена контекста без всякой экзотики вроде четырёхбитного KV, и лучший в наборе кэш префикса — он включается уже с 656 токенов и даёт до ×19 на длинном префиксе.

Скорости она не добавляет. Слот на четыре линии — жёсткий потолок, и он бьёт ровно по той метрике, которая для агента важнее всего. Загрузка карт под нагрузкой 14–21 % — это и есть картинка «ждём шину», а не «считаем».

Контекст под нагрузкой: что происходит на длинном промпте

Здесь самое интересное. Все цифры выше сняты на коротких промптах, а весь смысл большого контекста в том, что промпты бывают длинными. Лестница длин показывает, во что превращаются заявленные токены.

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

Пауза до первого слова по длине промпта, секунды:

Длина промптаК1, 4 битаК2, MTP 65kК3, MTP 131kК4, fp8171kК5a, по слоямК5b, по тензорам
9980.280.300.290.281.381.66
38711.071.101.171.092.534.11
153704.454.634.924.617.9813.80
6153322.0725.8425.5223.3636.7758.38
12384853.7067.2760.2196.42135.28
248380149.94

Скорость чтения запроса на отрезке между соседними точками, ток/с:

ОтрезокК1К2К3К4К5a, по слоямК5b, по тензорам
до 3871363535613280355025151177
до 15370339732643065326921101186
до 61533262121762241246216031036
до 1238481970149316911045810
до 2483801294

Скорость выдачи токенов на той же длине, ток/с:

Длина промптаК1К2К3К4К5a, по слоямК5b, по тензорам
99855.489.890.156.738.755.6
1537046.593.882.855.836.352.0
6153330.883.174.253.629.247.7
12384821.276.651.126.235.9
24838013.0

Четыре вывода из этих таблиц.

Первый: чтение запроса замедляется у всех, и это не аномалия конкретной сборки. На каждое удвоение длины скорость обработки входа теряет 8–19 %. Внимание квадратично по длине, и хотя у этой модели полноценное внимание всего в 16 слоях из 64, на такой длине оно всё равно берёт своё.

Второй: на длинном промпте всё время уходит на чтение запроса. Если считать холодный запрос как «пауза до первого слова плюс 300 токенов ответа», то доля чтения растёт так:

Длина промптаК1К3К4К5b, по тензорам
387116 %26 %17 %41 %
1537041 %58 %46 %71 %
6153369 %86 %81 %90 %
12384879 %94 %91 %94 %
24838087 %

Начиная примерно с шестидесяти тысяч токенов скорость генерации перестаёт влиять на впечатление от системы. Влияет только префилл.

Третий: угадывание наперёд окупается длиной ответа, а не длиной промпта. В прошлой редакции я писал, что спекуляция перестаёт окупаться примерно на 60 тысячах токенов. Это была неполная правда: всё зависит от того, сколько модель напишет в ответ. Считается это в лоб — MTP проигрывает на TTFT и выигрывает на каждом токене выдачи:

Длина промптаПроигрыш на паузеВыигрыш на каждом токенеОтвет, с которого угадывание выгодно
153700.31 с5.8 мсот 54 токенов
615332.16 с5.2 мсот 420 токенов
1238487.06 с6.5 мсот 1080 токенов

То есть даже на промпте в 124 тысячи токенов спекуляция окупается, если ответ длиннее тысячи токенов, — а у агента, который пишет патч или разбор, так и бывает. Проигрывает MTP только в сценарии «залил огромный документ и получил абзац в ответ».

Четвёртый: угадывание наперёд держит скорость печати по всей длине. Падение печати на удвоение: ×1.01 у К2, ×1.02 у К3 и К4 против ×1.20 у К1. У К3 на 124 тысячах токенов чистая скорость печати 76.6 ток/с — выше, чем у К4 на тысяче токенов. Спекулятивное декодирование не просто ускоряет старт, оно вытягивает хвост.

Цена строгого формата: ответ по форме стоит 19 % скорости

Отдельная находка, которой не было в прошлой редакции. Последовательный тест гоняется дважды: со свободным ответом и с принуждением по JSON-схеме (response_format со strict: true). Разница — это цена грамматики, и она резко разная у конфигураций со спекуляцией и без неё:

КонфигурацияСвободный ответОтвет строго по формеЦена
К1, 4 бита, без MTP54.1751.27−5.4 %
К4, fp8, без MTP55.0051.11−7.1 %
К2, MTP119.1696.01−19.4 %
К3, MTP122.0999.67−18.4 %
К5a, llama.cpp по слоям51.8852.89+1.9 %
К5b, llama.cpp по тензорам70.9175.49+6.5 %

Логика простая. Когда ответ обязан лечь в заданный формат, движок на каждом шаге запрещает часть вариантов — иначе схема сломается. Быстрая черновая голова про эти запреты не знает и чаще промахивается мимо разрешённого: принятых черновых токенов меньше, выигрыш от угадывания тает. Без спекуляции грамматика стоит те самые 5–7 %, которые обычно и называют «ценой structured output».

Практический вывод для агентов: если ваш клиент гоняет всё через строгие схемы, реальная разница между конфигурацией с MTP и без неё не восемьдесят процентов, а около девяноста пяти — но всё равно в пользу MTP.

У llama.cpp цифры со схемой выше, чем без неё. Это не магия грамматики, а короткие ответы: под схемой модель пишет плотнее и упирается в потолок в 512 токенов иначе. Разница в пределах разброса между запусками, читать её как «схема ускоряет» не надо.

Экономия на повторах: почему кэш префикса у vLLM включается поздно

Длина префиксаК1К2К3К4К5a, по слоямК5b, по тензорам
656×0.97×0.98×0.98×0.97×5.79×5.38
1656×0.98×0.95×0.96×4.29×7.93×9.01
2856×0.99×0.97×2.20×2.07×10.31×12.10
5256×2.27×2.41×4.66×7.86×13.87×18.98

Число меньше единицы означает, что повторный запрос пришёл не быстрее холодного, то есть кэш не сработал вовсе. И вот тут видно то, о чём обычно не пишут: у vLLM кэш префикса работает блоками, и чем сильнее сжат KV, тем крупнее блок. Замеренный квант cached_tokens вышел такой: 1568 токенов у К4, около 1700 у К3, около 3100 у К1 и К2. Короткий системный промпт в такой блок не попадает целиком — и не кэшируется вообще.

Для агента это важнее, чем кажется. Типичный системный промпт клиента — 400–2000 токенов, и на конфигурации с четырёхбитным KV он не переиспользуется ни разу. У llama.cpp с --parallel 1 кэш устроен иначе и ловит префикс с 656 токенов, отсюда ×5.8 уже на первой точке.

Медианный выигрыш по всем четырём точкам — та самая цифра «×0.98» в сводной таблице — поэтому и вводит в заблуждение. Смотреть надо на длину, с которой кэш вообще включился.

Исправность сборки: не поглупела ли модель

Модель ужата почти вчетверо, и главный страх при этом один: не ушли ли вместе с размером мозги. Отдельный тест проверяет не «умная ли модель», а не сломана ли сборка: отвечает ли строго по заданной форме (structured output и шаблон чата), решает ли 25 задач с заранее известным ответом, не мешает ли языки в одном ответе и приходит ли на три одинаковых запроса один и тот же текст побайтово.

КонфигурацияИтогStructured outputЗадачиЯзыкПовторяемость
К1, 4 бита100 %100 %100 %100 %100 %
К2, MTP 65k100 %100 %100 %100 %100 %
К3, MTP 131k100 %100 %100 %100 %100 %
К4, fp8171k100 %100 %100 %100 %100 %
К5a, llama.cpp по слоям100 %100 %100 %100 %100 %
К5b, llama.cpp по тензорам100 %100 %100 %100 %100 %

В прошлой редакции у vLLM здесь было 90–95 %, и весь провал держался на одном сценарии: system-сообщение не первым в списке, роли user → system → user, ответ HTTP 400. Виноват был встроенный в чекпойнт jinja-шаблон Qwen3.8, который бросает raise_exception('System message must be at the beginning').

Теперь все четыре конфигурации vLLM запускаются с --chat-template, и сценарий проходит. Заодно исчезли редкие провалы схемы саммари, которые я в прошлый раз принял за поведение квантованной модели: они шли из того же клиента, а не из квантования.

Это, пожалуй, главный практический вывод статьи, и стоит он одного флага: никакая конфигурация не работает без исправленного шаблона чата. Клиенты кладут системное сообщение в середину диалога регулярно, и каждый такой запрос без правки отвечает четырёхсоткой. Разбор самой поломки — в статье Как запустить Claude Code локально на llama.cpp и Qwen 3.8 27B.

Готовый шаблон лежит в архиве под статьёй — кнопка «Материалы к статье» внизу страницы. Там же launch-configs.sh со всеми шестью командами запуска и замерами в комментариях, чтобы не собирать их по тексту. Файл один и тот же для NVFP4 в vLLM и для GGUF в llama.cpp, отличается только имя флага: --chat-template против --chat-template-file.

Второе: разваливающейся квантизации на этом наборе не видно ни у кого, включая четырёхбитный KV. Задачи с проверяемым ответом, языковая целостность и побайтовая повторяемость — сотня у всех шести. Качество здесь ломает не квант, а конфигурация.

Агентская траектория: 26 ходов подряд

Двадцать шесть фиксированных ходов, двадцать пять схем инструментов в каждом запросе, история растёт до 28720 токенов. Модель не управляет ходом теста — история записана заранее, ответ идёт только на вердикт. Это ближе всего к тому, как сборку нагружает реальный агент. Шесть из двадцати пяти схем — приманки: их не ждёт ни один ход, и взятая приманка однозначно означает ошибку, а не сбившийся порядок.

КонфигурацияВерный выбор инструментаПереиспользование кэшаРост паузы по сессииОбычный шаг
К1, 4 бита88.5 %83.2 %×0.542.22 с
К2, MTP 65k88.5 %74.0 %×1.041.77 с
К3, MTP 131k96.2 %82.4 %×0.931.52 с
К4, fp8171k84.6 %88.1 %×0.762.02 с
К5a, по слоям92.3 %93.9 %×0.642.83 с
К5b, по тензорам88.5 %93.9 %×0.462.94 с

Проценты верных вызовов читать надо осторожно: 26 ходов, один ход — это 3.85 %. Разница между 84.6 и 96.2 % — три хода. Поэтому полезнее не проценты, а карта провалов:

ХодЧто не такК1К2К3К4К5a, по слоямК5b, по тензорам
7вместо правки файла — чтение файла
13то же самое
17вызов оборвался на потолке в 640 токенов, JSON не разобрался✗¹
20взята приманка deploy_service вместо run_command

¹ у К1 на семнадцатом ходу другая ошибка: вызвано чтение файла вместо записи.

Отсюда три вещи. Тринадцатый ход провалили все шесть — это поведение модели, а не свойство конфигурации, и списывать его на флаги запуска нельзя. Приманку взяли три конфигурации vLLM из четырёх — все, кроме К3; на llama.cpp она не сработала ни разу. И обрыв по потолку ответа — пограничный случай самого теста, а не поломка сборки.

Чистый результат: К3 прошла всё, кроме общего тринадцатого хода — 25 из 26.

Рост TTFT меньше единицы — не ошибка. С работающим кэшем первый ход платит за холодный префилл целиком, а следующие дочитывают только новый кусок. Чем меньше число, тем лучше кэш делает свою работу. Единственное значение выше единицы — у К2, и это тот же прогон, где кэш префикса включился позже всех и переиспользование вышло 74 %.

Переиспользование кэша у llama.cpp выше всех — 93.9 % против 74–88 % у vLLM. Но медиана времени хода у него всё равно худшая: 2.8–2.9 с против 1.52 у К3. Полсекунды постоянных накладных на каждом ходу никаким кэшем не отыграть.

Как подключить агента к этим конфигурациям

Любой клиент, который умеет ходить в Anthropic API, подставляется переменными окружения. Для vLLM-конфигураций все три роли моделей указывают на одну и ту же локальную модель:

ANTHROPIC_BASE_URL=http://10.10.1.32:8000 \
ANTHROPIC_API_KEY=dummy ANTHROPIC_AUTH_TOKEN=dummy \
ANTHROPIC_DEFAULT_OPUS_MODEL=unsloth/Qwen3.8-27B-NVFP4 \
ANTHROPIC_DEFAULT_SONNET_MODEL=unsloth/Qwen3.8-27B-NVFP4 \
ANTHROPIC_DEFAULT_HAIKU_MODEL=unsloth/Qwen3.8-27B-NVFP4 \
claude

Ключи нужны любые непустые — сервер их не проверяет, но клиент без них не стартует.

Обратите внимание на --parallel 1 и урезанные батчи: сборка настроена под одного пользователя. Со стороны vLLM тому же служат --max-num-seqs 8 и небольшой --max-num-batched-tokens, а --tool-call-parser qwen3_coder и --reasoning-parser qwen3 разбирают вызовы инструментов и блоки рассуждений в формат, который клиент понимает. Флаг --enable-prompt-tokens-details нужен, чтобы движок вообще отдавал cached_tokens в usage — без него вы не увидите, работает ли кэш префикса, и метрика переиспользования останется пустой.

Ещё одна мелочь, которую легко пропустить: min_p и logit_bias со спекулятивным декодированием не работают — vLLM предупреждает об этом в логе.

Где узкое место

Если свести шесть прогонов в один ответ — узких мест четыре, и они в таком порядке.

1. Чтение запроса на длинном контексте. Это главное. Не печать ответа. На 124 тысячах токенов холодный запрос с ответом в 300 токенов занимает от 66 до 144 секунд, и от 79 до 94 % этого времени — чтение входа. Скорость префилла теряет 8–19 % на каждое удвоение длины у всех шести прогонов без исключения. Любая оптимизация, которая ускоряет выдачу и замедляет предзаполнение, на длинном промпте работает против вас — а на коротком, наоборот, окупается мгновенно.

Лечится это не флагами, а архитектурой работы: кэшем префикса, стабильным началом промпта, инкрементальными ходами вместо одного гигантского запроса. У агента с работающим кэшем префилл платится один раз, у сценария «залил документ — спросил» — каждый раз заново.

2. Скорость памяти видеокарты на печати ответа — и она наполовину лечится бесплатно. Одинаковое железо, одинаковые веса, разница только в одном флаге: 55–56 ток/с без MTP и 99–101 с MTP. Восемьдесят процентов разницы означают, что декодирование упиралось не в вычисления, а в чтение весов из VRAM — классическая стена батча единица. Косвенное подтверждение — датчики: карты берут 145–167 W из разрешённых 300.

Спекулятивное декодирование эту стену обходит: за один проход по весам проверяется несколько токенов сразу. Стоит это 6.5 % префилла и около 0.9 GiB памяти. Запускать эту модель без MTP — значит отдавать почти половину скорости даром.

3. VRAM: 32 GB на две карты, из них около 6 GB на всё, кроме весов. Это первопричина всей статьи. Не будь этого ограничения, не было бы ни четырёхбитного KV, ни выбора между vision и контекстом, ни ручной подкрутки --kv-cache-memory-bytes. Все пять конфигураций — это пять способов поделить три гигабайта на карту.

4. Шина PCIe, а конкретно — слот на четыре линии. Это то, что убивает идею «доложить третью карту». Тензорное деление между тремя картами даёт +38 % к декодированию и −44 % к префиллу, потому что каждый слой платит обменом через Gen4 x4. Послойное деление префилл спасает, но тогда карты работают по очереди — загрузка 14–21 %. Ни один из вариантов не догоняет две карты в vLLM: 2118 и 1177 против 3360 ток/с. Здесь помогла бы платформа с нормальной разводкой линий, а не ещё одна видеокарта.

Чего в списке узких мест нет:

  • Качества квантования. Исправность сборки 100 % у всех шести прогонов, включая четыре бита на KV. Задачи с проверяемым ответом, язык и побайтовая повторяемость — сотня везде.
  • Шаблона чата. Он ломал всё в прошлой редакции и чинится одним флагом.
  • Процессора. Ryzen 5 3600 — самое старое звено сборки, но веса лежат в VRAM, и в замерах его вклад виден только косвенно, через накладные расходы движка.

Что я выбрал под какие задачи

Ежедневная работа с кодом и агентом — конфигурация 3. MTP, fp8-кэш, mamba в bf16, --language-model-only, 131072 токена. Она выигрывает у соседей по всему, что чувствуется руками: выдача 102.6 ток/с против 56, время до первого токена 0.057 с, медианный ход агента 1.52 с — на 25 % быстрее ближайшего, и лучшая точность вызова инструментов (25 ходов из 26). Контекста 131 тысяча токенов, и на практике агент столько не набирает: он работает серией запросов с кэшем префикса, а не одним гигантским промптом. Это дефолт, с которого стоит начинать.

Картинки и мультимодальность — конфигурация 2. Единственная, где модель поднимается целиком. Скорость почти та же (98.6 против 100.7), контекста вдвое меньше, кэш префикса включается позже и переиспользуется хуже. Брать её имеет смысл ровно тогда, когда нужен vision; в остальных случаях конфигурация 3 её перекрывает по всем осям.

Один большой документ на 130–250 тысяч токенов — конфигурация 1. Единственная, где замером подтверждены 248380 токенов: иголка найдена на всех трёх глубинах четверти миллиона. Но это пакетный режим, а не интерактивный: 150 секунд до первого токена, 13 ток/с выдачи, кэш префикса не работает на промптах короче 5 тысяч токенов. Поставили задачу — ушли пить чай.

Конфигурация 4 — запасной вариант, а не рабочий. По префиллу она равна третьей, по скорости выдачи вдвое медленнее, по точности вызова инструментов худшая в наборе. Смысл в ней остаётся в двух случаях: если MTP в вашей сборке ведёт себя нестабильно, и если нужен честный контекст между 131 и 171 тысячей токенов на восьмибитном KV. Ещё у неё лучший среди vLLM кэш префикса — включается с 1656 токенов, — и это единственная метрика, где она обходит третью.

Полные 262144 токена без экзотики — llama.cpp на трёх картах. Если NVFP4 не вариант (старые карты), или в машине уже стоит 3090, которую жалко не использовать. Из двух режимов деления: layer — когда важнее время до ответа (префилл вдвое выше), tensor — когда важнее скорость печати (декодирование на 38 % выше). Для агента я бы не брал ни тот, ни другой: полсекунды постоянных накладных на каждом ходу превращаются в полторы минуты на сотне ходов.

И общая рекомендация вне зависимости от выбора, из двух пунктов:

  1. Запускайте с починенным шаблоном чата--chat-template у vLLM, --chat-template-file у llama.cpp. Без него ломается не скорость, а работоспособность.
  2. Включайте MTP везде, где он не конфликтует с квантованием KV. Плюс 80 % к выдаче за 6.5 % префилла — лучший размен во всей статье.

Что осталось непроверенным

  • Перплексию четырёхбитного KV не мерил: только тесты исправности сборки и поиск иголки. Цифра +2.71 % взята из кода vLLM, а не из моего замера.
  • Лестница контекста у К5 остановилась на 123848 токенах не из-за движка, а из-за двадцатиминутного бюджета теста: точке 262144 требовалось ещё 14–20 минут. Что там с иголкой на полной длине в llama.cpp, я не знаю.
  • Конфигурацию «четыре бита плюс MTP» я не публикую сознательно: она собирается и печатает мусор, разбор — в разделе про спекулятивное декодирование.
  • Все замеры сняты батчем 1, параллельные тесты пропущены методикой как бессмысленные при parallel = 1. Под несколькими одновременными запросами выигрыш спекулятивного декодирования обычно тает — на этом стенде не проверял.
  • Квант GGUF движок называет Q4_K - Medium при имени файла UD-Q8_K_XL. Реальную раскладку разрядностей по слоям я не разбирал.

Про соседние стенды я писал отдельно: Qwen 3.6 27B и 35B-A3B в NVFP4 — сравнение vLLM и SGLang на этих же картах, vLLM vs llama.cpp — что выбрать под задачу, RTX 5070 Ti vs RTX 3090 — стоит ли брать новое поколение, и GPU-сервер для ИИ дома — как собиралась сама машина. Замеры скоростей на разных картах лежат в бенчмарках.

Если хотите собрать такой же стенд и не наступить на те же грабли — про выбор карт, локальные модели и сборку агентов вокруг них на реальных задачах бизнеса я разбираю на курсе ИИ для 1С: там же и про то, как посадить локальную модель в рабочий контур, а не в демо.

Материалы к статье

qwen38-nvfp4-template.zip · 7,9 KB

Скачать

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