← На главную

Опубликовано

Проверяем спекулятивное декодирование на одной RTX 4060: четыре метода, ноль API

Автор: Гусев Николай [портфолио]

Обновлено

Темы: llm, benchmarks, inference, local-models

Автор: Гусев Николай

Недавно подписчик из канала TKBK API подогнал мне на своём сервисе tkbk.online жирный исследовательский лимит - за что ему отдельное спасибо. Кстати, вот реферальная ссылка TKBK :)

Это продолжение истории «Почему при доступности фронтирных LLM их использование не всегда оправдано». Там GPT-6.1 Sol на пульте моего стенда устроил индуцированный СДВГ, топил за древний DSpark и переиграл сам себя. Статья кончалась строкой «Спойлер: я с ним еще закончил!». Заканчиваю. Без API, на своём железе, силами агента-исполнителя без единого LLM в исполнительном контуре.

Серия целиком: часть 2 - DFlash, граница применимости, часть 3 - EAGLE-3, acceptance 0.17, часть 4 - 96K в 8 ГБ, PFlash и Lucebox.

Спойлер: из четырёх методов один работает стабильно, второй - только на подходящем классе задач, третий даёт скромный выигрыш, а четвёртый на этом стенде не дал выигрыша.

Стенд и правила

Железо - домашняя классика: RTX 4060 на 8 ГБ VRAM, llama.cpp b11598, модели Qwen3-4B и Qwen3.5-4B в Q4_K_M и Q8_0. Никакой магии, всё воспроизводимо.

Правила замера, выработанные ещё в первой части на шишках:

  1. Greedy-генерация, seed фиксирован, корпус заморожен до старта. Один и тот же прогон должен давать один и тот же результат.
  2. Метрика - корректных ответов в минуту, а не токенов в секунду. Спекуляция, которая ускоряет токены и ломает ответы, мне не нужна.
  3. Acceptance-счётчики из сервера: сколько драфт-токенов предложено и сколько принято. Без них непонятно, работает спекуляция или просто ест VRAM.
  4. Бейзлайн снимается в той же сессии, что и метод. Между сессиями в фоне может жить что угодно.
  5. Медианы по повторам. Среднее по одному прогону - не результат, а анекдот.

Четыре метода одной таблицей

МетодЗаявлениеМой замерВердикт
Нативный MTPбесплатно из голов модели+40% серийно, +10% под нагрузкой, acceptance 0.81работает
DFlash 22-3.4× на детерминированных задачах1.8× на коде, 0.25× на документахработает с оговоркой
EAGLE-3acceptance 0.80-0.88acceptance 0.03-0.09не работает
PFlash (Lucebox)6-10× на prefill1.2-1.8× end-to-endработает скромно

Под нагрузкой я понимаю параллельную подачу в 4 слота - типовой сценарий агента, когда несколько сессий долбят одну модель.

По каждой строке есть отдельная статья серии с полными таблицами и границами применимости. Здесь - только каркас.

MTP: единственный чистый выигрыш

У Qwen3.5 есть нативные MTP-головы, и llama.cpp умеет их читать прямо из GGUF - без внешнего драфтера. Результат: с 33.0 до 46.2 корректных задач в минуту серийно, с 45.5 до 50.2 под нагрузкой, acceptance 0.81 на всём корпусе.

MTP, или multi-token prediction, добавляет к основной модели несколько голов, которые пытаются предсказать следующие токены. На каждом шаге таргет сначала получает обычное скрытое состояние, затем MTP-головы предлагают небольшой хвост. Таргет проверяет этот хвост за один проход. Принятые токены экономят отдельные вызовы полного forward pass; первый несовпавший токен становится новой точкой продолжения.

Схема выглядит так:

основная модель:  x_t -> hidden_t -> token_t
                                  |
                                  +-> MTP head 1 -> token_t+1
                                  +-> MTP head 2 -> token_t+2
                                  +-> MTP head 3 -> token_t+3

verify: target проверяет хвост слева направо
        [ok][ok][ok][reject] -> принимаем три токена

Это не внешний draft-модельный контур. У MTP нет отдельной маленькой модели, которую нужно подбирать по домену, и нет дополнительной загрузки второго GGUF. Поэтому на консумерской карте он особенно интересен: VRAM уходит на один target, а не на пару target плюс draft. Цена метода - дополнительные головы должны быть совместимы с конкретной ревизией модели и поддержаны движком.

В моём запуске это проверялось парой mtp-off против mtp-on на одном и том же файле Qwen3.5-4B-MTP-Q4_K_M.gguf. Включение происходило флагом --spec-type draft-mtp, а не магическим изменением prompt. Минимальный smoke на Windows можно повторить так:

$server = "C:\path\to\llama-server.exe"
$model = "C:\path\to\Qwen3.5-4B-MTP-Q4_K_M.gguf"
$common = @(
  "-m", $model, "--alias", "subject",
  "-c", "8192", "-np", "1", "-ngl", "99", "-fa", "on", "--fit", "off",
  "--jinja", "--reasoning-format", "none", "--no-warmup",
  "--host", "127.0.0.1", "--port", "8611"
)

# baseline
& $server @common

# после остановки baseline: MTP
& $server @common "--spec-type" "draft-mtp"

Для автоматизированного сравнения я использовал скрипт spec_r0_smoke_mtp.py: он запускает две arms, отправляет одинаковый greedy-запрос через /v1/chat/completions, сохраняет timings и пишет сырые логи в measurements/spec-r0-smoke/. В публичную статью я не прячу смысл команды за ссылкой на несуществующий benchmark.py: если нужен именно автоматический раннер, его можно взять из материалов проекта или прогнать этот короткий smoke руками. Скрипт не скачивает модель и не содержит бинарник сервера.

Почему MTP не ломается на документах

Это ключ к тому, почему метод оказался единственным рабочим из четырёх. Головы MTP обучаются вместе с основной моделью и по определению повторяют её распределение, поэтому у них нет доменного разрыва, который убивает DFlash. Драфт AtomicChat был обучен на чате, поэтому на документах он предлагал правдоподобное, но неверное продолжение, и acceptance падал вдвое (см. вторую статью). Головы MTP не могут «врать по домену», потому что это та же самая модель, проекции из её же скрытых состояний. На коде и на документах они смотрят в один и тот же target, поэтому +40% серийно и на том, и на другом.

Размер выигрыша задаётся простой арифметикой, но с оговоркой. Блок принимается целиком с вероятностью около 0.81, то есть это block acceptance rate, доля полностью принятых предложений. Такой блок экономит отдельные forward-проходы для своих токенов, но target всё делает проход для каждого отвергнутого блока. Поэтому идеальный ускорение драфта мутирует: часть токенов летит бесплатно, часть снова проходит через target. Именно поэтому энд-ту-энд +40% серийно, а не обещанные производителем голов два-три раза. На бумаге 0.81 × длина блока даёт приличный множитель, на карте этот множитель съедается тем, что target на 4B сам по себе не быстрый.

Поэтому MTP и называется чистым выигрышем. Второй GGUF не грузится, домен не подбирается, головы это маленькие проекции, их VRAM на карте незаметен. На 4B основной forward определяет bottleneck, а головы его не замедляют. Единственное требование: чтобы головы были в конкретном GGUF, а не просто в названии семейства. Обычный Qwen3.5-4B без MTP-варианта флагом не оживёт, это отдельная версия весов.

Что показывать и как читать smoke

В measurements/spec-r0-smoke/ лежат два файла: off.json и on.json с таймингами по задачам и средней пропускной способностью. Разница в средних это и есть выигрыш серии. Разница в разбросе по задачам это то, что скрывает усреднение: на некоторых коротких задачах MTP даёт ноль, потому что блок из трёх токенов не успевает сложиться, и там метод только добавляет overhead. Поэтому я не верю одной цифре «+40%», я смотрю на распределение, и там, где разброс съедает выигрыш, MTP на этом стенде не применяю.

Что я бы рекомендовал для консумерского железа. Это рекомендация по вместимости и сценариям, не отдельный замер этой статьи:

  • 12 ГБ: Qwen3 8B, Gemma 3 12B или близкие 7-12B модели в Q4_K_M; для длинного KV оставить запас, не забивать VRAM весами до последнего мегабайта;
  • 16 ГБ: Qwen3-Coder 14B, Mistral Small 3.1 24B в агрессивном кванте или MoE-класс Qwen3-30B-A3B, если устраивает компромисс по качеству и движок поддерживает модель;
  • 24 ГБ: Qwen3-Coder 30B-A3B, Mistral Small 3.1 24B и другие 20-32B модели в Q4/Q5; здесь уже можно оставлять заметный запас под KV и параллельные слоты;
  • для MTP смотреть не на размер модели, а на конкретный GGUF с MTP-головами. В этой серии измерена именно Qwen3.5-4B-MTP-Q4_K_M; обычные Qwen3-4B и Qwen3.5-4B без MTP-варианта не превращаются в MTP от одного флага;
  • для EAGLE-3 и DFlash действует то же правило: target, голова или draft должны быть обучены под совместимую ревизию. Название семейства в имени файла недостаточно;
  • для 12 ГБ я бы не начинал с большой плотной 20-30B модели: низкий квант и offload легко превращают benchmark GPU в тест PCIe.

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

Остальные три - teaser

DFlash 2 на детерминированных задачах даёт 1.8× и серийно, и под нагрузкой. На документах и общем chatter - ускорение 0.55× серийно и 0.25× под нагрузкой: метод работает в минус. А ещё у него обнаружился коллапс с забавной структурой: короткие задачи - acceptance не трогается, длинные NL-задачи под нагрузкой - acceptance падает вдвое. Граница проведена, механизм - открытый вопрос. Разбор - вторая статья серии.

EAGLE-3 не работает при любой квантизации таргета: Q4_K_M и Q8_0 дают одинаковый acceptance 0.17-0.18. И это не наша криворукость - независимое воспроизведение на Kaggle T4 получило замедление 0.95× на русскоязычном домене при заявленных 2.5× на английском коде. Домен решает. Третья статья серии.

PFlash - сжатие prefill из research-демона Lucebox, которого в природе под Windows не существовало. Собрали, запатчили, замерили: до 5.3× внутри рантайма на 96K контекста, 1.84× end-to-end против обычного llama.cpp. Не 6-10× из блога - те числа сняты на 33B и RTX 3090. Четвёртая статья серии - производственный роман про сборку.

Урок пилота, который стоит дороже всех цифр

Первый замер DFlash на коде дал 1.6× на двух повторах. Я почти пошёл писать выводы. Полная серия - 24 повтора на всех конфигурациях - дала 1.09-1.15× вместо 1.6×. Двух повторов мало всегда. Не «почти всегда» - всегда.

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

Что в сухом остатке

Для 8 ГБ-стенда формула на сегодня: MTP включён всегда, DFlash - роутером на детерминированный класс задач, PFlash - для длинных контекстов, EAGLE-3 - нет. Это не рецепт для всех: одна карта, одно семейство моделей, одна версия движка. Но методология переноса - цикл «заявление, затем замер на своём железе за вечер» - переносится на любое железо.

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

Как читать эти цифры

Спекулятивное декодирование легко превратить в рекламную таблицу. Достаточно выбрать длинную генерацию, удачный домен и вывести tokens/s. В реальном сервисе важнее другое: сколько полезных ответов система выдаёт за минуту, сколько запросов она обслуживает одновременно и не меняется ли качество при переходе от одного слота к четырём.

Поэтому мы сохраняли итоговый ratio вместе с низкоуровневыми счётчиками. У DFlash, например, одна строка результата выглядела примерно так:

{
  "trial": "dflash-n4-r3",
  "wall_s": 6.387,
  "correct": 11,
  "correct_per_minute": 103.3,
  "draft_n": 4500,
  "draft_n_accepted": 3649,
  "acceptance": 0.8108,
  "prompt_tokens": 3337
}

draft_n_accepted / draft_n - не самостоятельная скорость. Это диагностический сигнал: почему метод ускорился или почему начал вредить. Если acceptance падает, надо смотреть, компенсирует ли его размер принятого блока и сколько стоит проверка.

Почему четыре повтора, а не один

Первый запуск нужен, чтобы обнаружить очевидный слом: неправильный GGUF, несовместимый флаг, пустой стрим или переполнение контекста. Но первый запуск почти никогда нельзя превращать в публикационный результат. На Windows есть загрузка модели, прогрев CUDA, кэш файлов, фоновые процессы и случайные задержки планировщика.

В матрице каждая ячейка получила несколько повторов, а итоговая таблица использовала медиану. Если один прогон выбивался, он оставался в сырых данных, а не исчезал в красивом среднем. Отдельно сохранялись ошибки: unsupported не превращался в ноль, а упавшая модель не превращалась в «метод медленный».

Что доказал этот стенд, а чего не доказал

Мы доказали карту применимости для одной RTX 4060, конкретных GGUF, конкретной версии llama.cpp и конкретных корпусов. Мы не доказали рейтинг методов для всех GPU и моделей. Это не формальная слабость эксперимента, а его контракт: результат должен быть воспроизводимым, а не универсальным по риторике.

Связь с работами на arXiv тоже проверяли не по совпадению слов. AgentSpec обсуждает деградацию speculative decoding при росте batch и динамический бюджет; это совпадает с нашим наблюдением на doc-корпусе, но не доказывает тождественный внутренний механизм. PARD-2 предлагает оптимизировать длину непрерывно принятого фрагмента, а не только token accuracy; это объясняет, почему высокая точность отдельных предложений не гарантирует выигрыша.

Именно поэтому следующие три статьи не являются рекламными карточками: отдельно разберём хорошие условия DFlash, доменную зависимость EAGLE-3 и prefill с длинным контекстом. А в четвёртой, последней части соберём теоретические ветки, которые на RTX 4060 нельзя честно закрыть измерением: тренировку собственной EAGLE-3 головы, MLX-оптимизации и перенос на Ryzen AI Max+ 395 с ROCm. Каждая ветка получила свой статус: измерено, спроектировано или отложено до подходящего железа. Скрипты владеют матрицей эксперимента сами: LLM в исполнительном контуре нет, агент ничего не решает после старта.