Всем привет, с вами Низамов Илья. Спекулятивное декодирование — самый дешёвый способ ускорить локальную модель: ничего не квантуешь заново, качество ответа не трогаешь, просто добавляешь флаг. Методов спекуляции в vLLM целое семейство — от совсем дешёвого ngram, который обходится вообще без нейросети, до подключения отдельной модели-черновика. Я сравниваю два: MTP — основной режим для Qwen 3.8 27B, его голова едет прямо в чекпойнте модели, — и DFlash2, новый и пока экспериментальный, с отдельной черновой моделью из z-lab/Qwen3.8-27B-DFlash2. Оба прогнал на своём стенде из двух RTX 5070 Ti в кванте NVFP4 и сравнил на одинаковом потолке контекста.
Короткий ответ: DFlash2 быстрее на 25 %, и это честные 142.0 против 113.4 токенов в секунду. Но чтобы получить эти 25 %, пришлось урезать контекст с 168912 токенов до 8240 — в двадцать раз. На карте с 16 GB черновая модель на четыре миллиарда параметров в bf16 просто не оставляет места под KV-кэш.
Стенд тот же, что и в статье про пять конфигураций Qwen 3.8 27B: две RTX 5070 Ti по 16 GB (полезных 15.47 GiB на карту), Ryzen 5 3600, 76 GB оперативной памяти, Ubuntu 26.04, драйвер 595.71.05. Модель — unsloth/Qwen3.8-27B-NVFP4, тензорный параллелизм на две карты, KV-кэш в fp8.
Что такое спекулятивное декодирование и чем методы различаются
Обычная генерация расточительна: ради одного токена модель на 27 миллиардов параметров читает все свои веса из видеопамяти. Упирается всё именно в это чтение, поэтому проверить сразу восемь токенов стоит почти столько же, сколько выдать один. Отсюда и приём: дешёвый черновик пишет несколько токенов вперёд, большая модель проверяет их одним проходом и оставляет совпавший префикс. Разберите цикл по шагам и подвигайте ползунок — вся экономика метода видна на нём:
Различаются методы тем, кто и как пишет черновик. Два сравниваемых здесь расходятся принципиально.
MTP (multi-token prediction) — это голова, приделанная к самой модели. Один слой поверх последнего скрытого состояния, никакого отдельного KV-кэша, вес на карту 0.40 GiB. Она обучена предсказывать следующий токен и делает это хорошо: одна позиция принимается в 78–92 % случаев. Проблема начинается, когда голову просят написать блок подлиннее: она применяется несколько раз подряд к собственному же выходу, ошибка накапливается, и вторая позиция принимается уже в 18–46 %.
Как MTP ведёт себя на предыдущем поколении модели, я показывал в разборе Qwen 3.6 27B с MTP — там она удваивала скорость на llama.cpp.
DFlash2 идёт другим путём: рядом с большой моделью ставится маленькая самостоятельная, и пишет она сразу блок целиком.
Чем DFlash2 отличается от MTP?
Черновик — не голова, а полноценная модель со своей архитектурой DFlash2DraftModel. Вот что говорит её config.json:
num_hidden_layers = 5
hidden_size = 5120
intermediate_size = 17408
vocab_size = 248320
sliding_attention window = 2048
dflash_config.block_size = 8
dflash_config.selector_rank = 256
dflash_config.selector_top_k = 16
dflash_config.target_layer_ids = [5, 19, 33, 47, 61]Четыре вещи отсюда объясняют и её силу, и её цену.
Она читает таргет вглубь, а не только с конца. target_layer_ids — это пять уровней большой модели, с которых черновик забирает скрытые состояния. MTP видит только выход последнего слоя, то есть уже сжатую до одного вектора картину. DFlash2 получает срез на пяти глубинах — сигнала больше, и предсказание держится дальше по блоку.
Она пишет блок, а не токен за токеном. block_size 8 означает семь черновых токенов за один заход (седьмой — это num_speculative_tokens, то есть block_size − 1). Позиции не строятся рекурсивно одна на другой, как у многократно применённой головы MTP, — блок формируется сразу.
Кандидаты отбираются низкоранговым селектором. Словарь на 248320 токенов, честно считать по нему семь распределений было бы дороже самой экономии. selector_rank 256 и selector_top_k 16 — это узкое место, через которое пропускают по шестнадцать кандидатов на позицию. Так блок из семи токенов и остаётся дешёвым.
Ей не нужен весь контекст. sliding_attention с окном 2048: черновику хватает хвоста, он не обязан помнить всю переписку.
Расплата за всё это — размер. Пять слоёв по 5120, свои embed_tokens, свой lm_head на 248320 строк: чекпойнт весит 3.58 GiB, и он не квантован, это чистый bf16 рядом с четырёхбитным таргетом. Плюс собственный KV-кэш на пять слоёв. В логе это видно вторым предупреждением Add 4 padding layers, may waste at most 25.00% KV cache memory — у таргета своё, на 4.17 %.
Замеры: 142 против 113 токенов в секунду
Чтобы сравнение было честным, MTP я запустил не в его лучшей конфигурации, а в тех же условиях, в которых способен работать DFlash2: те же семь черновых токенов, тот же потолок контекста 8192, тот же квант, то же железо. Замеры — бенчмарком по методике версии 4, три прогона в серии, один запрос за раз.
| DFlash2, 7 токенов | MTP, 7 токенов | Разница | |
|---|---|---|---|
| Скорость одного запроса | 141.96 ток/сек | 113.38 ток/сек | +25.2 % |
| То же со строгим форматом (JSON-схема) | 109.83 ток/сек | 94.00 ток/сек | +16.8 % |
| Цена строгого формата | −22.6 % | −17.1 % | |
| Потолок контекста в замере | 8192 | 8192 | |
| Идентификатор прогона | 20260830-130806 | 20260830-132751 |
Двадцать пять процентов — это ощутимо: на ответе в тысячу токенов разница между двумя методами составляет примерно 1.8 секунды, 7.0 против 8.8. В чате с человеком за пультом такое видно глазом.
Одна оговорка про сборку движка, и её надо назвать прямо: версии vLLM в двух прогонах разные. DFlash2 в релиз ещё не приехал, метод живёт в пулл-реквесте, поэтому черновая модель крутилась на сборке 0.26.1rc1.dev1045+g3406ec1da из PR 52816, а MTP — на релизной 0.27.1. Часть разницы теоретически может приходиться на сам движок. Проверить это можно будет, когда dflash доедет до релиза; отдельного смысла гонять MTP на PR-ветке я не увидел.
Про строгий формат стоит сказать отдельно, потому что цифра неприятная. Ответ по JSON-схеме стоит DFlash2 почти 23 % скорости против 17 % у MTP — и это логично. Грамматика запрещает часть токенов на каждом шаге, а значит черновик, который эту грамматику не знает, промахивается чаще. Чем длиннее блок он пишет, тем больше выбрасывается. Так что если вся ваша нагрузка — это вызовы инструментов и структурированные ответы, преимущество DFlash2 сжимается почти вдвое.
Приёмка по позициям: где DFlash2 действительно сильнее
Скорость — это следствие, а причина видна в метриках спекуляции. vLLM печатает их прямо в лог:
SpecDecoding metrics: Mean acceptance length: 5.22, Avg draft acceptance rate: 60.3%
Per-position acceptance rate: 0.878, 0.755, 0.673, 0.571, 0.510, 0.469, 0.367
Accepted: 207 tokens, Drafted: 343 tokensЧитается это так. Средняя длина принятого блока — 5.22 токена: за один проход большой модели получается в пять с лишним раз больше текста, чем без спекуляции. Приёмка по позициям падает плавно, и даже седьмой токен берётся в 36.7 % случаев. У головы MTP на второй позиции уже 18–46 %, а третью и дальше в этих прогонах отдельно не мерили — но приёмка идёт цепочкой, и выше второй позиции хвост быть не может по построению.
Важно, что приёмка идёт цепочкой: позиция засчитывается только если приняты все, что стоят перед ней. Поэтому обвал в середине обесценивает весь хвост — и поэтому у MTP длинный блок почти не окупается, а у DFlash2 окупается.
И тут же оговорка, которую я повторю столько раз, сколько понадобится: доля принятых токенов не годится для проверки исправности сборки. Она меряет предсказуемость текста, а не его правильность. В прошлый раз на сломанной связке четырёхбитного кэша со спекуляцией acceptance был выше, чем на исправной, — модель уверенно печатала мусор. Проверять сборку нужно текстом ответа.
Цена черновика в гигабайтах
Теперь про то, за что всё это платится. Строка Model loading took в логе — общий вес того, что легло на карту:
| Без спекуляции | MTP, 1 токен | DFlash2, 7 токенов | |
|---|---|---|---|
Веса на карту (Model loading took) | 10.24 GiB | 10.64 GiB | 12.45 GiB |
| Прибавка за спекуляцию | — | +0.40 GiB | +2.21 GiB |
Осталось под KV (Available KV cache memory) | 3.31 GiB | 2.84 GiB | 0.96 GiB |
| Максимум контекста | 262144 | 168912 | 8240 |
Черновик DFlash2 дороже головы MTP в 5.5 раза по весам — и это ещё не всё, он вдобавок держит собственный кэш на пять слоёв. В сумме под контекст остаётся меньше гигабайта, и потолок падает до 8240 токенов. Ещё одна цифра из лога, которую легко пропустить: Maximum concurrency for 8240 tokens per request: 1.04x. Машина тянет ровно один запрос. Ни о каком обслуживании нескольких пользователей речи уже нет.
Итоговые команды запуска
DFlash2. Отдельное окружение обязательно — метода dflash в релизных сборках vLLM пока нет:
uv venv --python 3.12 .venv-dflash
source .venv-dflash/bin/activate
pip install -U "vllm @ git+https://github.com/vllm-project/vllm.git@refs/pull/52816/head"PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True CUDA_DEVICE_ORDER=PCI_BUS_ID \
CUDA_VISIBLE_DEVICES=1,2 PYTHONUNBUFFERED=1 \
vllm serve unsloth/Qwen3.8-27B-NVFP4 --host 0.0.0.0 --port 8000 \
--tensor-parallel-size 2 \
--language-model-only --mamba-ssm-cache-dtype bfloat16 \
--max-num-seqs 2 --max-num-batched-tokens 1024 \
--kv-cache-dtype fp8_e4m3 --gpu-memory-utilization 0.965 \
--kv-cache-memory-bytes 640000000 \
--reasoning-parser qwen3 --enable-auto-tool-choice --tool-call-parser qwen3_coder \
--enable-prompt-tokens-details \
--speculative-config '{"method": "dflash", "model": "incoai/Qwen3.8-27B-DFlash2", "num_speculative_tokens": 7}' \
--max-model-len 8192 \
--chat-template /home/ilya/ai/qwen38-nvfp4-fixed.jinjaДва флага здесь неочевидны, и оба появились не сразу. --mamba-ssm-cache-dtype bfloat16 — единственный рычаг, который в этой связке реально дал память: доступный KV поднялся с 0.72 до 0.96 GiB, а заодно размер страницы внимания упал с 1648 до 880 токенов, что на кэше меньше гигабайта существенно само по себе. --kv-cache-memory-bytes 640000000 ставится не чтобы выжать больше кэша, а наоборот — чтобы отдать часть памяти обратно рантайму: без него KV-кэш выделялся успешно, а потом не влезал захват CUDA-графов.
MTP в тех же условиях — обычное релизное окружение, флагов заметно меньше:
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True CUDA_DEVICE_ORDER=PCI_BUS_ID \
CUDA_VISIBLE_DEVICES=1,2 \
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 --mamba-ssm-cache-dtype bfloat16 \
--max-num-seqs 2 --max-num-batched-tokens 1024 \
--kv-cache-dtype fp8_e4m3 \
--speculative-config '{"method": "mtp", "num_speculative_tokens": 7}' \
--reasoning-parser qwen3 --enable-auto-tool-choice --tool-call-parser qwen3_coder \
--enable-prompt-tokens-details --max-model-len 8192 \
--chat-template /home/ilya/ai/qwen38-nvfp4-fixed.jinjaПро --chat-template в обеих командах: встроенный jinja-шаблон Qwen 3.8 отвечает 400 на системное сообщение в середине диалога, а без этого не живёт ни один агент. Разбор поломки и исправленный шаблон — в статье про запуск Claude Code на локальной модели, файл там один на оба движка.
Кому подойдёт DFlash2, а кому лучше MTP?
Считаем по-честному. DFlash2 даёт 142.0 токена в секунду на восьми тысячах контекста. MTP с одним черновым токеном даёт 84 токена в секунду на 168912 токенах контекста. Двадцать пять процентов скорости против двадцатикратного контекста — размен, в котором на этом железе выигрывает MTP почти при любой задаче.
Почти — но не при любой. DFlash2 стоит брать, если одновременно верно всё следующее: диалоги короткие и укладываются в восемь тысяч токенов; пользователь один; ответы длинные, потому что именно на длине ответа спекуляция и отбивается; строгий формат не используется или используется редко. Это, например, генерация текста в чате с человеком за пультом. А вот агент внутри редактора кода — противоположный случай по всем четырём пунктам сразу.
Что могло бы поменять расклад:
- Квантованный черновик. 2.21 GiB на карту в bf16 против примерно 0.6 GiB в fp8 или nvfp4 вернули бы порядка 1.6 GiB под кэш — это примерно втрое-вчетверо по контексту. Готового квантованного
Qwen3.8-27B-DFlash2пока нет, и это главный рычаг. - Меньше черновых токенов. Буферы спекуляции масштабируются от произведения размера пачки на
1 + num_speculative_tokens. Три токена вместо семи сэкономили бы часть буферов ценой приёмки. Я не замерял. - Четырёхбитный KV-кэш. Даёт почти двукратный выигрыш по контексту, но со спекуляцией MTP он молча генерировал мусор. С dflash не проверено, и проверять надо текстом ответа, а не долей принятых токенов — на сломанной связке она была выше, чем на исправной.
- Карта побольше. На 24 GB черновик в 2.21 GiB перестаёт быть проблемой, и весь этот разбор теряет смысл: там DFlash2 просто выигрывает.
Оба прогона лежат в общем разделе бенчмарков рядом с остальными конфигурациями этого же стенда, так что сравнивать можно построчно. Соседние замеры на том же железе — в статьях про пять конфигураций Qwen 3.8 27B и про Qwen 3.8 Flash Next на llama.cpp; про выбор самого движка — в сравнении vLLM и llama.cpp, про выбор карт — в RTX 5070 Ti против RTX 3090.
Что осталось непроверенным
Честный список, чтобы никого не вводить в заблуждение:
- Влияние версии движка. DFlash2 гонялся на PR-сборке, MTP — на релизе. Какая доля из 25 % приходится на сам метод, а какая на изменения между версиями, я не знаю.
- Длинный промпт. Контекста хватает на восемь тысяч токенов, поэтому деградацию скорости по длине на DFlash2 замерить не на чем.
- Конкурентные запросы. При параллельности 1.04 их просто нет.
- Длинная генерация. Проверял на ответах в пределах нескольких сотен токенов.
- Минимальный безопасный
--kv-cache-memory-bytes. 640000000 подобрано с запасом, где проходит настоящая граница — не выяснял.
Локальная модель сама по себе — это ещё не результат, а фундамент. Польза появляется, когда она встроена в рабочий процесс: читает документы, отвечает клиентам, заполняет данные в учётной системе. Как довести дело до работающей интеграции — в курсе «Применение искусственного интеллекта ChatGPT для 1С»: от локального движка до агента внутри вашей 1С.