Как я подружил тернарную модель Bonsai 27b на 2 бита с чужим движком [Dflash + TurboQuant]
У меня 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.