← На главную

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

От 96K до следующей лаборатории: Lucebox, EAGLE-3, MLX и ROCm

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

Обновлено

Темы: llm, inference, long-context, windows

Автор: Гусев Николай. Серия про спекулятивное декодирование на 8 ГБ: часть 1, часть 2 - DFlash, часть 3 - EAGLE-3.

PFlash - сжатие prefill из research-демона Lucebox. Вендорские заявления: 6-10× на префилле. Забавная деталь: репозиторий исследовательский, Linux-only, релизов нет. Готовой Windows-сборки в ходе работы я не нашёл - поэтому собрал демон из исходников. Эта часть серии начинается с измеренного результата, а заканчивается тем, что я бы ещё проверил на другом классе железа.

Сначала про память: миф о «не влезает»

Общепринятая мудрость: длинный контекст на 8 ГБ невозможен. Замер это опроверг - для Qwen3.5. У модели гибридное внимание (Gated DeltaNet), KV весит ~35 КБ на токен в f16 - вчетверо компактнее плотных трансформеров.

КонфигурацияКонтекстPrefillVRAM пик
KV f1664K27.9 с5765 МиБ
KV q8_064K29.3 с5025 МиБ
KV q4_096K51.0 с4954 МиБ
KV q4_0 с выгрузкой в RAM96K51.2 с4946 МиБ

64K влезает даже с f16, 96K - с квантованным KV. Выгрузка в RAM не понадобилась вплоть до 96K - буфер и так помещался. Точность NIAH 4/4 во всех ячейках, включая q4_0 на 96K. Узкое место - не VRAM, а время prefill: 2000 токенов в секунду на моей карте. И именно тут живёт PFlash.

Что такое prefill и почему его вообще надо сжимать

При длинном запросе модель сначала читает весь вход - это prefill. Только после него начинается decode, который пользователь воспринимает как печать ответа. На коротком prompt decode доминирует и ускорение prefill почти не видно. На 96K токенов чтение входа занимает десятки секунд, поэтому отдельный выигрыш на этой фазе становится end-to-end выигрышем.

Упрощённая схема:

prefill:  [system][history][document][question] -> KV cache
                                                |
                                                v
decode:   answer token 1 -> token 2 -> token 3 ...

PFlash оптимизирует именно первую стрелку. Это не магический ускоритель каждой генерации и не замена MTP/DFlash/EAGLE для decode. В длинном контексте эти слои могут сочетаться концептуально: PFlash обрабатывает вход, а выбранный decode-метод обслуживает ответ. В нашем замере R3b-2 сознательно фиксировались плечи AR и PFLASH, чтобы не смешивать эффекты.

Как строился NIAH-корпус

Для каждой длины генерировался длинный нейтральный filler и один needle - специальная строка с ключом и числом. В конце запроса модель должна была вернуть только найденное значение. Критерий был машинным: правильный magic number или ошибка.

{
  "id": 2,
  "prompt_tokens": 98302,
  "needle": "The special magic jcxxyrpi number is 4625423.",
  "expected": "4625423"
}

Корпус имел точки 16K, 32K, 64K и 96K. Для каждой точки было несколько разных needles, чтобы один удачный пример не выглядел как доказательство. На 96K prompt template добавлял служебные токены, поэтому контекстный предел выставлялся с запасом: --max-ctx 100352.

Сборка несуществующего

Сборка не лежит в GitHub как бинарный релиз: test_dflash.exe и CUDA DLL занимают сотни мегабайт, а часть зависимостей привязана к локальной версии CUDA. Исходники Lucebox лежат в vendor/lucebox/ материалов проекта - сам движок в git не заливается, почему и куда смотреть, разобрано ниже. Там же журнал сборки и патчи. Для Windows я использовал CMake, Ninja, CUDA Toolkit и Visual Studio Build Tools.

Минимальный путь для Linux/WSL2, близкий к upstream-инструкции:

cd vendor/lucebox
cmake -B server/build -S server -G Ninja \
  -DCMAKE_BUILD_TYPE=Release \
  -DCMAKE_CUDA_ARCHITECTURES=89
cmake --build server/build --target test_dflash -j

Для сервера:

cmake --build server/build --target luce_server -j
./server/build/luce_server --help

Для PFlash-ветки отдельная инструкция лежит в vendor/lucebox/optimizations/pflash/README.md, общая — в vendor/lucebox/server/README.md. Обе ссылки ведут на репозиторий Lucebox целиком; сам движок — сторонний код (~130 МБ зависимостей, cublasLt-библиотеки), поэтому в наш репозиторий он не заливается и лежит на диске в vendor/lucebox/ (это зафиксировано в vendor/SOURCES.md). На Windows к их инструкциям добавляются наши конкретные поправки: WriteFile вместо CRT _write, forward-slash для временных путей, совместимый output.weight и правки заголовков CCCL. Они перечислены в журнале lucebox-build.log.

После сборки бинарник запускается только с подходящей парой target/draft; обычный Qwen3.5 GGUF не является автоматически совместимым Lucebox DFlash-драфтом. Это ограничение важно сохранить: собрать движок - не значит получить рабочий DFlash на любой модели.

Кривая: чем длиннее, тем лучше

NIAH-корпус, плечи AR (обычный префилл) и PFLASH (сжатие), медианы по 4 задачам:

КонтекстARPFLASHУскорениеК upstream
16K9.5 с4.4 с2.2×1.22×
32K24.6 с8.0 с3.1×1.47×
64K72 с16.7 с4.3×1.67×
96K148.1 с27.7 с5.34×1.84×

Два сюжета в этой таблице.

Первый: выигрыш растёт быстрее линейного - сжатие префилла съедает именно ту часть, которая масштабируется квадратично. На 96K PFlash внутри рантайма ускоряет в 5.3 раза.

Второй: до 64K демон проигрывал обычному llama.cpp на AR-плече (их граф не оптимизирован под sm_89 - мою карту), и PFlash выигрывал только за счёт сжатия. На 96K сжатие перевесило: 27.7 секунды PFlash против 51 секунды upstream AR - PFlash обгоняет обычный инференс напрямую, 1.84× end-to-end. Точность NIAH 100% во всех точках, ответы совпадают с upstream до символа.

Что бы я еще проверил на другом классе железа

На этом месте важно не притворяться, что эксперимент закончен там, где закончилась VRAM. Я бы еще проверил три ветки, для которых уже можно описать методику, критерии и ожидаемый практический смысл, но нельзя честно выдать результат на текущем стенде.

1. Собственная голова EAGLE-3

Наш отрицательный результат относится к доступной голове AngelSlim и нашей связке Qwen3-4B. Q4 и Q8 дали почти одинаковый acceptance, поэтому простое объяснение «всё сломала квантизация» не прошло. Рабочая гипотеза - голова не совпадает с ревизией таргета или интерфейсом скрытых состояний. Гипотеза не превращается в факт от того, что красиво звучит.

Проверка требует обучить голову на тех же весах, токенизаторе и шаблоне, которые используются при инференсе:

target revision + exact tokenizer + exact chat template
                         |
                 hidden-state capture
                         |
                    EAGLE-3 head
                         |
                  GGUF conversion
                         |
             acceptance smoke + speed matrix

Практический пайплайн такой:

  1. Зафиксировать ревизию Qwen3-4B, GGUF-хэш, токенизатор и enable_thinking=false.
  2. Собрать 50-100 тысяч диалогов и задач с тем же шаблоном, который будет на сервере.
  3. Прогнать таргет и сохранить fused features нескольких глубин.
  4. Обучить EAGLE-3 голову на непрерывном принятии, а не только на token-level accuracy. Здесь полезна связь с PARD-2: для speculative decoding важна длина принимаемого участка.
  5. Конвертировать голову в GGUF, проверить размерности и прогнать frozen smoke.
  6. Сравнить BF16/Q8/Q4 таргеты на одинаковом корпусе, затем отдельно проверить русский NL, код и JSON.

Gate заранее формальный: голова должна показать acceptance не ниже 0.5 на контрольном корпусе и дать положительный correct/min против baseline. Если она выигрывает только на обучающем домене, это не универсальный фикс, а доменная голова.

На RTX 4060 я этого не проверял: таргет, голова, активации и батч обучения упираются в 8 ГБ. Теоретически можно разнести части по RAM и ждать, но это уже другой эксперимент с неизвестной скоростью и не основание обещать результат. На другом классе GPU я бы сначала повторил эту проверку с запасом по памяти, а не объявлял ветку выполненной по одному удачному запуску.

2. MLX: не переносить CUDA-выводы на Apple Silicon

Вторая ветка - возможный запуск на Mac M3 Ultra. MLX - не «ещё один llama.cpp». У Apple Silicon единая память, Metal-граф и другие точки насыщения. На RTX 4060 мы видели, что batch может резко менять acceptance DFlash на естественно-языковых задачах. На MLX я бы проверил, что именно делает сервер: настоящий continuous batching, очередь, time-sharing или смесь этих режимов.

План сравнения:

server: mlx-openai-server / mlx_lm.server / llama-server
model:  one fixed MLX quant
load:   single, batch 2, 4, 6, 8
input:  1K, 4K, 16K, 64K, 100K
modes:  baseline, native MTP, speculative if supported
metrics: TTFT, ITL, aggregate tok/s, per-stream tok/s,
         correct/min, memory, acceptance, fairness

На каждом сервере сначала проверяется, какая модель действительно загружена. В чужом MLX-бенчмарке уже был показательный инцидент: автодетект подменил 4-битную модель на 8-битную из кэша, и скачок производительности выглядел как оптимизация. Поэтому model_alias, хэш и server log являются частью результата, а не служебной мелочью.

В предварительном плане я бы сравнил mlx-openai-server с mlx_lm.server и llama-server: первый интересен заявлениями о continuous batching и speculative decoding, второй нельзя заранее объявлять плохим, а третий нужен как OpenAI-совместимый контрольный путь. Риск contamination KV-cache при большом числе параллельных запросов я бы проверял на нашем корпусе, а не повторял как легенду.

Особенно важен fairness-тест: короткий запрос, пришедший одновременно с 100K документом, не должен ждать весь prefill длинного соседа без измерения этого хвоста. Поэтому в матрице нужны сдвиги стартов, запросы разной длины и per-stream TTFT, а не только суммарный wall throughput.

Теоретическое ожидание простое: unified memory снимает часть ограничений вместимости, но не отменяет стоимость чтения KV и prefill. Если модель и контекст помещаются, это ещё не означает, что latency годится для пользователя. Именно это на RTX 4060 мы уже увидели в другом виде: 96K влезает, но upstream prefill занимает 51 секунду.

3. Ryzen AI Max+ 395 и ROCm

Третья ветка - возможная проверка альтернативного узла на AMD Ryzen AI Max+ 395 / Strix Halo с Radeon 8060S и общей памятью 96-128 ГБ. Его нельзя описывать как «RTX 4060, только быстрее». Это APU с unified memory, другой GPU-бэкенд, другая зрелость kernels и другой профиль копирования.

Зачем я бы его проверил:

  • большой объём общей памяти для моделей и KV без отдельного CUDA-узла;
  • проверка длинного контекста в 4-6 битах на 64-256K;
  • возможность держать несколько моделей и не выгружать их между запросами;
  • потенциально более дешёвый узел для data-parallel фермы;
  • ROCm-стенд для проверки, какие CUDA-ориентированные методы реально портируются.

Минимальная матрица переноса:

hardware: Ryzen AI Max+ 395 / Radeon 8060S / 96 or 128 GB
backend:  ROCm version pinned in manifest
runtime:  llama.cpp HIP/ROCm, vLLM/ROCm if compatible
models:   same Qwen family, same quant where possible
context:  16K, 32K, 64K, 96K, 128K, 256K
load:     single, batch 2, 4, 8

Сначала я бы снял baseline без спекуляции. Затем отдельно проверил MTP, если конкретный runtime умеет native heads. DFlash, EAGLE-3 и PFlash пошли бы отдельными ветками только после compatibility gate. Если Lucebox требует CUDA-специфичные ядра, перенос на ROCm не считался бы «почти готовым»: это новый backend и новая серия проверок.

Критерии выбора AMD-узла:

  1. правильность NIAH 4/4 на каждой длине;
  2. TTFT и prefill tok/s;
  3. decode tok/s и per-stream скорость под batch;
  4. реальное потребление общей памяти;
  5. время загрузки и выгрузки моделей;
  6. положительный correct/min против уже работающего узла;
  7. стоимость одного полезного запроса, а не пиковая скорость в пустом single-user режиме.

ROCm здесь интересен не как религиозная замена CUDA, а как способ проверить, где у нас настоящий алгоритмический выигрыш, а где выигрыш принадлежит конкретным CUDA kernels. Если PFlash на CUDA даёт 5.34× внутри Lucebox, а на ROCm недоступен, это не опровержение метода. Это ограничение backend. Но для сравнения узлов это всё равно важный эксплуатационный факт.

Как бы я проверял это без самообмана

Перенёс бы корпус вместе с дисциплиной:

  • manifest фиксировал бы железо, runtime, модели, кванты, хэши и версии драйверов;
  • baseline снимался бы на каждом узле до speculation;
  • неподдержанная ветка получала бы unsupported или оставалась бы за пределами вывода;
  • сырые логи сохранялись бы до агрегации;
  • результат сравнивался бы по correct/min и per-stream latency;
  • модель и хэш сверялись бы по server log;
  • один плохой запуск не переписывал бы ТЗ и не отменял остальные ячейки.

То есть я бы начинал не с покупки красивого бокса, а с инвентаризации доступного железа и его фактической утилизации. Если bottleneck - память, нужен большой unified-memory узел. Если bottleneck - prefill, CUDA/NVIDIA может оказаться полезнее для Lucebox и обучения голов. Если bottleneck - плохой batching, сначала проверяется сервер и маршрутизация, а не заказывается новая машина.

Итог

На RTX 4060 я закрыл то, что можно было закрыть честно: MTP, DFlash на разных доменах, EAGLE-3 с доступной головой, PFlash и 96K NIAH. PFlash дал 5.34× внутри Lucebox и 1.84× end-to-end против upstream на 96K; длинный контекст оказался ограничен временем prefill, а не VRAM.

На другом классе железа я бы отдельно проверил:

  • обучение EAGLE-3 head под точную ревизию модели;
  • MLX и multi-user поведение на Apple Silicon;
  • Ryzen AI Max+ 395 / ROCm с той же frozen-матрицей;
  • перенос PFlash и DFlash после compatibility gate.

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

DFlash-плечо внутри Lucebox не заработало: демон требует драфт своего формата, доступный GGUF конвертируется под upstream, после переименования метаданных падает на assert - разная схема fusion. Официальный safetensors закрыт (401). Ветка зафиксирована как blocked, не подменена.

И главное: 6-10× из блога вендора на моём железе не воспроизводятся в заявленном виде. Те цифры сняты на 33B-модели, RTX 3090 и 256K контекста. На 4060 с 4B-моделью воспроизводится скромный, но реальный 1.2-1.8×, растущий с длиной. Это не обман вендора - это другой стенд. Но читать чужие бенчмарки без специфики железа - та же ошибка, что ставить фронтирную модель на пульт стенда.

Вывод

Длинный контекст на 8 ГБ - не «невозможно», а «влезает до ~96K с квантованным KV, и узкое место переносится из памяти в prefill». Prefill - сжимаемая величина. Формула для агентных сценариев: гибридно-внимательная модель, квантованный KV и сжатие префилла на длинных контекстах. Все патчи и скрипты - в репозитории проекта.