У меня RTX 5060 Ti на 16 гигабайт. Обычная потребительская карта, и на ней хочется гонять модели покрупнее, желательно с большим контекстом. Дальше история про то, как я скрестил две несовместимые вещи, потратил на это вечер, нашёл баг, который не выдаёт никаких ошибок, а потом выяснил, что настройка по умолчанию отнимала у меня четверть скорости.

Расклад

Есть модель Ternary‑Bonsai-27B от PrismML. Это Qwen3.6–27B, ужатый до примерно 2.1 бита на вес. Весит 6.66 гигабайта. Для 27B на 16-гигабайтной карте это отлично, потому что остаётся много места под контекст.

Проблема в том, что запускается она только на их собственном форке llama.cpp, а там нет сжатия KV‑кеша. А KV‑кеш на длинном контексте съедает больше, чем сама модель.

С другой стороны есть BeeLlama, форк от Anbeeld. В нём как раз есть TurboQuant и KVarN, две схемы сжатия KV. Но Bonsai он не грузит.

Причина простая и обидная: оба проекта завели у себя тип с названием Q2_0, и это разные форматы.

ID

размер блока

байт на блок

раскладка бит

BeeLlama

53

32

10

планарная

PrismML

42

128

34

последовательная

Планарная — это когда байт j хранит элементы j, j+8, j+16, j+24. Последовательная — это когда элемент i лежит в байте i/4, в битах (i%4)*2. Совершенно разные вещи под одним именем.

Загрузчик ругается, но не говорит правду

Первая попытка подсунуть модель BeeLlama дала вот такое сообщение об ошибке:

tensor 'output_norm.weight' has offset 337715200, expected 397312000

Выглядит как «файл битый» или «версия не та». На самом деле, нет. Я поделил одно на другое:

397 312 000 / 337 715 200 = 20/17

Ровно, без остатка. И тут всё встало на место. Считаем байты на вес:

BeeLlama: 10 байт на 32 веса, это 20/64

PrismML: 34 байта на 128 весов, это 17/64

Отношение то же самое. Значит загрузчик читает файл нового формата, но размеры считает по старой геометрии. Полез в код, и правда: в ggml.c тип объявлен тернарным, а константа QK2_0 осталась равна 32. То есть половина правки была сделана, половина нет.

Мораль тут такая, что арифметика в сообщении об ошибке иногда полезнее самого сообщения. Отношение двух чисел указало на причину точнее, чем текст ошибки.

А вот дальше начинает интересное

Поправить константу мало. Я об этом чуть не забыл, и это была бы дорогая забывчивость. Дело в том, что после исправления геометрии файл грузится нормально. Никаких ошибок. Модель отвечает. Кажется, что готово.

Только раскладка бит у форматов разная, а ядра остались старые. Они читают биты не в том порядке. Результат: модель выдаёт правдоподобный текст, который на самом деле мусор. Не падает, не ругается, просто тихо работает неправильно. Это худший вид бага: обычная проверка «а давайте спросим, сколько будет 17+25» его не ловит. Модель бодро отвечает «42», потому что даже покорёженные веса на простом вопросе вытянут.

Пришлось заменить всю цепочку:

Деквантизацию на CUDA

vec_dot для векторного умножения

Загрузчик тайлов для MMQ

То же самое на CPU

Отдельно скажу про MMQ. Это путь префилла, и если собирать с -DGGML_CUDA_FORCE_MMQ=ON, то он вообще единственный. Я его сначала пропустил, потому что искал по названиям функций с q2_0 в имени, а тайловый загрузчик там был общий для нескольких типов. Проверяйте не только то, что называется, как ваш тип, но и то, что диспетчеризуется на ваш тип.

Как убедится, что это не мусор

Раз ошибок нет по определению, нужен численный критерий. Я взял perplexity на одном и том же корпусе, с одинаковыми настройками, и сравнил с эталонной сборкой PrismML.

PrismML (эталон): 3.1125 +/‑ 0.04339

Моя сборка: 3.1125 +/‑ 0.04339

Знак в знак. Вот это уже доказательство. Пара связных ответов доказательством не была бы.

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

Что получилось по цифрам

Скорость, замерено через llama-bench, одна методика для обеих сборок:

PrismML

Моя сборка

pp512 (префилл)

956.9

1079.5

tg128 (генерация)

44.3

51.6

Но главное не это, а память. На 192K контекста:

KV в q8_0: 15 677 МиБ, свободно остаётся 633 МиБ

KV в turbo3: 10 939 МиБ, свободно 5371 МиБ

Почти 5 гигабайт разницы. Что это даёт по контексту:

Контекст

VRAM

Запас

262 144

11 867 МиБ

4443 МиБ

393 216

13 595 МиБ

2715 МиБ

384K влезает свободнее, чем раньше влезало 192K.

Сразу оговорюсь, чтобы не создавать ложных ожиданий: TurboQuant это выигрыш по памяти, а не по скорости. На 32K реального контекста q8_0 даёт 51.3 токена в секунду, turbo3 даёт 47.9. Префилл одинаковый. То есть вы платите примерно 7% генерации за возможность держать контекст в несколько раз длиннее. По‑моему, обмен выгодный, но это обмен, а не бесплатный сыр.

Второй заход: я сломал чужой формат

Выложил первую версию, и тут выяснилось неприятное. Я не сделал два типа сосуществующими, а просто занял чужой. То есть родной Q2_0 самой BeeLlama перестал работать.

Причём перестал он работать не в одном месте, а в четырёх, и все они на пути KV‑кеша:

1. Квантизатор KV остался планарным, но начал писать в 128-элементные тернарные блоки.

2. Хелперы flash‑attention читали неправильную геометрию.

3. -ctk kvarn2 на SWA‑слоях откатывался на тернарный тип.

4. В convert.cu и getrows.cu кто‑то удалил метки case, и строки повисли недостижимым кодом после return. Компилятор об этом молчит.

Ни одна из этих поломок не проявляется на весах. Они все про KV‑кеш. Поэтому мои замеры и perplexity были честными, а вот любой, кто запустил бы сборку с -ctk q2_0, получил бы мусор без единого предупреждения.

Развёл типы по идентификаторам:

Тип

ID

Как называется

Веса

KV‑кеш

Родной BeeLlama

53

q2_0

да

да

Тернарный PrismML

42

q2_0_t

да

нет

Тут есть решение, которое я считаю важным. Имя q2_0 я оставил родному типу, а новому дал q2_0_t. Так у существующих пользователей BeeLlama не меняется вообще ничего: ни команды запуска, ни вывод загрузчика. Новое имя получает тот, кто пришёл позже. Мне кажется, это правильный принцип при форке: неудобство достаётся новичку, а не тем, кто уже пользуется.

Почему тернарный тип не может быть типом KV‑кеша: у него нет квантизатора времени выполнения. PrismML кодирует модель заранее, офлайн. Кодировать в этот формат на лету нечем, так что для KV остаётся планарный тип, как и было.

Честная оговорка: работоспособность типа 53 я подтвердить замером не могу, у меня нет ни одной модели в этом формате. Код я восстановил дословно из исходников до моих правок. Это не проверка, это аккуратность.

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

Я был уверен, что спекулятивный декодинг с Bonsai не заработает. Логика казалась железной: черновые головы обучают против конкретной модели, а Bonsai это сильно пожатая версия, распределения уедут.

Оказалось, я перепутал два разных механизма. У PrismML есть свои головы DSpark, и вот про них в документации написано, что каждая ускоряет ровно ту модель, против которой обучалась. А DFlash в BeeLlama устроен иначе, и это утверждение я перенёс на него без всяких оснований.

Взял голову DFlash от обычной Qwen3.6–27B, подставил к Bonsai. Работает.

Причина видна в логе загрузки:

dflash: contract ok: block_size=16 target_layer_ids=[1,16,31,46,61] n_embd=5120

dflash: target_vocab=248320 drafter_vocab=248320 vocab_match=1

Голова цепляется к скрытым состояниям пяти слоёв целевой модели. В самом файле головы нет ни token_embd, ни output, они берутся из целевой модели в рантайме. То есть, голова привязана к архитектуре, а не к весам конкретного кванта. Архитектура у Bonsai та же самая, поэтому всё сходится.

Но дальше самое полезное. У DFlash есть адаптивный контроллер, который сам подбирает длину черновика. На Bonsai он выбирает 15, и это плохой выбор. Прогнал по значениям вручную:

Значение n_max

Токенов в секунду

Доля принятых

2

62.5

69.4%

3

64.4

63.1%

4

60.3

52.6%

5

54.5

42.3%

6

52.9

40.5%

8

55.7

34.4%

10

55.5

24.5%

15

51.3

15.1%

Без черновой головы вообще: 47.4 токена в секунду.

То есть при n_max=3 получается 64.4 против 47.4, это плюс 36%. А если довериться контроллеру, будет 51.3, то есть всего плюс 8%.

Объяснение простое. Контроллер настроен на обычные кванты, которые принимают около 30% черновых токенов. Bonsai приближение куда грубее, доля принятых падает, и длинный черновик почти весь уходит в отброс. При n_max=15 на 271 принятый токен генерируется 1800 черновых. Работа впустую.

Забавная деталь: тройка это родное значение по умолчанию в самом llama.cpp. Контроллер DFlash его перебивает и делает хуже.

Границы применимости, чтобы никого не ввести в заблуждение: мерил на 32K контекста, коротком промпте, с temperature 0, на одной карте и с одной головой. В диапазоне от 5 до 8 порядок значений уже в пределах шума, там разброс около 3 токенов в секунду между прогонами. А вот пик на значениях 2, 3 и 4 из шума выходит уверенно. На длинном контексте не проверял, доля принятых обычно падает с расстоянием, так что оптимум может сместиться ещё левее.

Что забрать из этой истории

Если коротко, три вещи.

Смотрите на числа в сообщениях об ошибках, а не только на текст. Отношение 20/17 сказало мне больше, чем вся формулировка.

Если формат может сломаться молча, придумайте численную проверку до того, как начнёте радоваться. Совпадение perplexity знак в знак это единственное, что реально закрыло вопрос.

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

Ссылки

Форк с бинарником под Windows и CUDA 12.8, собранный под sm_120:

https://github.com/Andgihat/beellama.cpp — выберите ветку ternary‑bonsai‑q2_0

Основано на BeeLlama preview‑v0.3.2, коммит a620cbd. Нумерация релизов у меня своя, чтобы не занимать версии автора апстрима.

Всё под MIT, как и оригиналы. Спасибо Anbeeld за BeeLlama с её сжатием KV и PrismML за тернарный формат.

Есть ещё проект jarkevithwlad/turboquant‑prismml‑cuda, который решает ту же задачу на другой базе. Конфликт типов там разрулен иначе, через подмену только для моделей qwen35.