Forge: как я написал свой движок инференса и обогнал llama.cpp
Три недели, один разработчик, пара агентов и арендованная видеокарта: от 3 токенов в секунду и сомнений «а не бросить ли всё» до +19…+50% к llama.cpp на Qwen3.8-27B.
Три недели назад я сомневался, не выбросить ли собственный движок инференса и не перейти ли на llama.cpp. Сегодня тот же движок — я называю его forge — быстрее llama.cpp на всех длинах контекста от 8K до 128K, по префилу и по декоду, на одной и той же модели и одном харнессе. Это рассказ о том, зачем вообще писать свой движок в 2026 году, из чего он состоит, какие формулы определяют потолок, и через какие грабли пришлось пройти, чтобы этот потолок пробить.
Зачем свой движок
Я пишу код с агентами — Codex, OpenCode, свои инструменты — и хочу, чтобы сильная открытая модель для кодинга жила у меня дома, на обычной карте, и обслуживала несколько агентов сразу. Готовые варианты — llama.cpp, LM Studio — работают, но это чужой стек: чужой формат весов, чужие ядра, чужие приоритеты. Когда что-то не так с производительностью на длинном контексте, ты либо ждёшь апстрим, либо лезешь в чужой C++.
У меня уже был задел: форк candle на Rust, в котором я довёл до рабочего состояния инференс Qwen3.5-4B из GGUF, батчевый декод на CUDA и Metal, и проверил всё это на RTX 3060. Проект «Qwen3.6-27B на своём стеке» начался 7 августа с одного обязывающего решения: llama.cpp — только эталон для сравнения, не компонент. За день появился скелет сервера: три API (OpenAI Chat Completions, OpenAI Responses, Anthropic Messages), веб-чат, планировщик на четыре слота. Дальше три недели ушли на то, чтобы всё это работало быстро.
Целевое железо — Windows-машина с RTX 3060 на 12 ГБ. Для разработки — macOS с Metal. Для честных замеров на больших квантах — арендованная почасово RTX 4090 с 48 ГБ.
Стек
Всё на Rust, три репозитория:
- engine — форк candle со своими ядрами: слитый DeltaNet (линейное внимание с рекуррентным состоянием), постраничный KV-кеш с FA2, порты MMQ/MMVQ для квантованных матриц, CUDA-графы, MTP-рантайм. Это самая толстая часть:
model_weights.rs— почти девять тысяч строк. - qwen36-server — HTTP-слой на axum, три API, веб-чат, планировщик слотов, сэмплер, префикс-кеш, горячая замена моделей.
- forge-convert — конвертер весов из HF safetensors в свой контейнер
.ytfс потензорной сверкой против эталона.
Модели — гибриды семейства Qwen3.5/3.6/3.8: из 64 слоёв 48 — DeltaNet с рекуррентным состоянием (~8 МБ на слой на слот у 27B), 16 — обычное GQA-внимание с KV-кешем. Плюс MTP-голова — один дополнительный блок для спекулятивного декодирования. Эта архитектура и есть ниша: vLLM и TensorRT-LLM заточены под батч-серверы, у llama.cpp MTP последовательный и DeltaNet не слит. Один пользователь, одна RTX, гибридная модель — здесь можно выиграть.
Формулы, которые всё определяют
Декод упирается в память. Каждый токен читает все веса:
t_шага ≈ байты_весов / пропускная_способность
27B в Q8_0 ≈ 29 ГБ; RTX 4090 ≈ 900 ГБ/с → ≈ 32 мс → ≈ 31 ток/с
Измерено: llama.cpp 30.6, forge 31.2 на 8K. Оба движка стоят у стены памяти, и без спекуляции обогнать друг друга там нельзя — можно только не отстать. Отсюда важный вывод про кванты: для декода IQ2 быстрее Q8 по природе, гнаться за Q8 ради скорости бессмысленно даже на 48 ГБ.
Спекулятивное декодирование — единственный способ получить больше одного токена за чтение весов. Голова предлагает k черновых токенов, основная модель проверяет их одним проходом на k строк. Если p — вероятность принять черновик, раунд даёт в среднем
E[токенов за раунд] = (1 − p^(k+1)) / (1 − p)
а стоит draft + verify(k), где verify(k) — проход на k строк, читающий веса один раз. При k=2 и p=0.74 раунд даёт 1.74 токена. Замер показал, что verify(2) стоит не один шаг, а 1.28 (mmvq при двух строках считает их последовательно), а порог окупаемости по принятию лежит около 65–70%: на промпте с принятием 65% MTP уходил в минус, при 73–98% давал от +21% до +56%.
KV гибридной модели дёшев. У 27B всего 16 attention-слоёв по 4 kv-головы на 256 измерений:
KV на токен = 16 × 4 × 256 × 2 байта × 2 (K и V) = 64 КБ
У полностью attention-модели той же глубины было бы вчетверо больше. Именно поэтому 128K контекста влезают в 48 ГБ вместе с Q8-весами.
L2 против KV. На одну kv-голову на слой приходится 1 КБ на токен. У 4090 L2 = 72 МБ, значит до ~64K контекста поток одной kv-головы целиком помещается в кеш, а выше — нет. Эта граница объяснила один из самых странных результатов недели, о нём ниже.
Путь
Неделя первая: сервер дышит, Windows врёт
7 августа — скелет. 9–12 августа — первая длинная стабильность на 3060: четыре слота × 8K токенов, 25 минут, четыре потока побитово одинаковы, ноль утечек. По дороге — первый урок про Windows: диспетчер задач показывал 11 ГБ приватной памяти, и я решил, что веса уплыли в системную память. Оказалось, WDDM учитывает CUDA-аллокации в private commit процесса; смотреть надо на GPU Process Memory / Shared Usage. Урок записан в документ, потому что через две недели он спас день.
22 августа: кризис
Qwen3.6-35B-A3B на 3060 давал 3–4 токена в секунду, и я всерьёз написал план: проверить llama.cpp с экспертами в RAM и, если он даст ≥5x, отменить решение «свой движок» и перейти на llama-server за тонким Rust-гейтом. План лежит в репозитории до сих пор — с пустой таблицей результатов, потому что на следующий день нашлась настоящая причина.
23 августа: коллапс объяснён
Декод падал с 36 до 2.4 ток/с при 24K контекста, TTFT второго запроса — четыре минуты, VRAM 97–98%. Цепочка из трёх факторов: порог возврата памяти в CUDA-пуле по умолчанию ноль (каждая синхронизация возвращала страницы ОС), наивный «удерживать всё» тоже вреден (пул держит пиковые транзиенты префила навсегда), и решающий — батчевый декод на каждом шаге каждого attention-слоя деквантовал весь префикс q8→f16 свежими аллокациями по 48 МБ. При 97% занятости драйвер клал эти страницы в WDDM shared memory — то есть в системную RAM через PCIe — и так на каждом шаге. Коллапс ×12. Ограниченный порог пула и один общий буфер, выделенный пока дискретная память свободна, подняли 24K с 2.4 до 11.2 ток/с.
В тот же день впервые измерил MTP: 53 → 14.2 ток/с. Регрессия ×3.7 при отличном принятии. Профилирование показало, что 75% времени раунда — между раундами: планировщик, чекпоинты сэмплера, стриминг. Спекуляция работала, экономика — нет.
24 августа: честный замер и рождение forge
Первый настоящий бенч против llama.cpp на 4B: отставание ×1.1 на коротком контексте и ×1.3 на длинном по декоду, ×1.3 по префилу. Попутно два открытия: CUDA-графы у меня включались только при двух и более слотах (все ранние замеры графов на одном слоте были невалидны), и сервер молча переключал модель по полю model запроса — первые «27B»-числа в 52–57 ток/с на самом деле были 4B. Честные 27B в IQ2_XXS на 3060: 16.7 против 21.1 у llama.cpp.
Из этого разрыва и родился forge как отдельный проект. Гипотеза была красивой: мы потребляем GGUF и портируем ядра llama.cpp, их формат выверен под их ядра годами, поэтому мы структурно догоняем. Нужен свой конвейер: safetensors → свой формат → свои ядра, per-tensor форматы, F16 для compute-bound префила, 2–4 бита для bandwidth-bound декода. Этап 0 — профиль префила — прошёл гейт: матричные проекции занимали 59% времени, есть что ускорять. Этап 1 — конвертер и F16-сайдкар для префила — собрал за день.
25 августа: гипотеза формата умирает, начинается настоящая работа
Эксперимент закрыл этап 2 за один день: Q4_K_M, Q8_0 и F16-сайдкар давали на 3060 одинаковый префил — 790, 832 и 1105 мс на проекции DeltaNet. Формат весов не был узким местом. Узким местом было качество конвейера: накладные расходы хоста, эффективность FA2, синхронизации. Красивая гипотеза с сошедшейся арифметикой оказалась неверна — это повторится ещё не раз.
Зато инфраструктура forge осталась и пригодилась: конвертер, контейнер, потензорная сверка. А главное — 25–26 августа стали днями ядер: CUDA-граф для префила (KV пишется прямо в страничный пул), chunked-ядро DeltaNet для префила с детерминированной редукцией, split-KV на декоде (число сплитов было захардкожено в единицу — на 12K контекста это стоило 20% декода), упаковка GQA в измерение запросов (четыре kv-головы вместо шестнадцати CTA читают KV один раз), декодное ядро DeltaNet в один проход по состоянию, самостоятельный контейнер .ytf без GGUF, int8-пул KV. Разрыв на 4B сжался до ×1.10 на коротком и ×1.30 на длинном контексте — и остаток был уже в ядрах, не в хосте.
Тогда же арендовал 4090 с 48 ГБ: на 12 ГБ 27B влезает только в IQ2_XXS, а мерить хотелось Q8.
27 августа: день MTP и графов
Утро началось с парадокса. MTP давал +19% на 8K, но только в eager-режиме: как только загружалась голова, CUDA-графы отключались целиком, а графы давали +75% на длинном контексте. Множители не складывались — приходилось выбирать. Шесть версий причины уже были перебраны и отвергнуты за предыдущую сессию. Симптом был молчаливым: вердикт «выход совпадает», ошибок нет, скорость выше — три зелёных признака, а спекуляция мертва: drafted=2 accepted=0.
Причина нашлась чтением кода, а не замером, и оказалась не там, где искали. После графового префила KV лежит только в страничном пуле, а проверка черновиков шла старым eager-путём по batched-кешу, который был пуст — и функция честно отказывала: «batched KV освобождён, нужен rehydrate». Планировщик глотал ошибку без единой строки в лог. Все шесть версий искали на стороне префила; ломалась проверка. Второй дефект объяснил, почему после первого провала раундов не было вовсе: адаптивная ширина пропускала раунд, уже открыв транзакцию, и та утекала до конца запроса — в проде с включённым по умолчанию адаптивом MTP умирал на 2–4 раунде каждого запроса.
Решение — проверка через страничный пул. Ключевое наблюдение: проверка k токенов одного слота — это не декод батчем k, а префил-чанк длины k: у декодного append одна позиция на слот, у префил-append — k позиций подряд, а FA2 с причинной маской по правому-нижнему углу даёт ровно «строка i видит 0..kv0+i». DeltaNet при этом идёт по batched-состоянию слота через уже существующую ветку последовательных строк. Двести строк, один общий прогон графа для префила и проверки, пул графов по ключу (k, slot). На 8K: 30.36 → 32.92, множители сложились, вердикт совпал на всех прогонах.
Дальше по столбцам тайминга. Черновик MTP делал на каждом шаге cat всего своего KV, перевод его в F32 и разворот GQA на 24 голов — при 32K это 1.9 ГБ временных тензоров на один токен черновика. Замена на предвыделенный буфер и flash-attn: draft на 32K подешевел в 3.7 раза, 16K перевернулся из 0.89x в 1.03x. Затем повторный прогон при частичном принятии, стоивший как вся проверка: теневые снимки состояния DeltaNet между строками проверки внутри графа — откат стал копией снимка, accept с 5.4 мс до 0.04. 24K перешёл из 0.90x в 1.06x, 8K — в 1.22x.
Три ловушки того же дня, каждая с сошедшейся арифметикой. Окно страничного пула при незаданном контексте было ровно 32768 — при промпте 32768 первый декодный шаг молча уходил в eager, и «графовая база» показывала 16 ток/с вместо 29; я успел построить на этом гипотезу про лимит контекста. База «29.2 на 32K», от которой считались цели, оказалась взята не из тех логов — реальная 28.75, рядом, но урок про происхождение чисел остался. И самое поучительное: стоимость проверки росла линейно с размером пула — 9.3 мкс на страницу — при том же контексте. Число сплитов по K в ядре внимания считалось от окна пула, а не от длины запроса: при большом окне весь контекст обрабатывал один сплит на голову. Правка «дать проверке 128 сплитов, как декоду» сделала 32K плоским по окну — и уронила 128K на сервере на 8%. Объяснение через L2 предсказало смену знака заранее: до ~64K поток kv-головы помещается в кеш и дробление бесплатно, выше — локальность держится только на синхронности длинных CTA, которую 128 коротких ломают. Трёхсторонний опыт подтвердил все четыре предсказания, включая смену знака. Настоящее лечение — та же GQA-свёртка, что у декода: проверка построчно декодными вызовами, где каждая kv-голова читается один раз. На 98K: 60.9 → 49.75 мс, 1.20x.
Префил догнал одной переменной. При чанке 512 токенов сетка attention — 24 головы × 8 m-блоков = 192 CTA на 128 мультипроцессоров: полторы волны, вторая полупустая. Чанк 1024 — три полные волны, +19% на 128K, и forge обошёл llama.cpp по префилу везде.
28 августа: память и хост
По памяти мы уступали ~2.2 ГБ, и разница не зависела от контекста — по самому KV мы даже экономнее. Четыре версии сняты замером за час (удержание пула — ровно ноль). Раскладка: страничный пул и буферы графов создаются лениво на первом запросе, это разовое; а при загрузке — копия эмбеддинга токенов на GPU, которую llama.cpp держит на хосте. Граф начинался с lookup эмбеддинга и требовал копию; теперь на вход графа подаётся готовый вектор, строка декантуется на хосте — минус 1280 МБ; декод не изменился (на 32K до и после ровно 39.8), префил заплатил −0.7% на 32K за передачу вектора на карту, и с ростом контекста это амортизируется. Для 12-гигабайтной карты это важнее самого гигабайта: гейт копии там не проходил никогда и вместе с копией выключал графы.
Наблюдение «одно ядро CPU на 100%» разложилось инструментацией: после перевода ожидания GPU из холостого вращения в сон загрузка ядра упала со 101% до 5% при неизменной скорости — ядро грел драйвер, а не наша работа; сама хостовая часть шага занимает полтора процента его времени. Попутно ушла квадратичность: сервер декодировал текст всей генерации на каждом токене и пересобирал множество токенов для штрафов; и старый баг стрима — потерянный пробел на стыке размышления и ответа, найденный контролем «дельты против полного декода».
Итог
Замер 27 августа, Qwen3.8-27B Q8_0, RTX 4090, один слот, один потоковый харнесс на оба движка.
Декод, токенов в секунду:
| контекст | llama.cpp | forge | forge + MTP | против llama.cpp |
|---|---|---|---|---|
| 8K | 30.6 | 31.2 | 38.8 | +27% |
| 16K | 30.2 | 30.7 | 45.4 | +50% |
| 32K | 29.5 | 29.9 | 41.5 | +41% |
| 64K | 28.2 | 28.4 | 35.4 | +26% |
| 128K | 25.4 | 25.3 | 30.3 | +19% |
Префил, токенов в секунду:
| контекст | llama.cpp | forge | |
|---|---|---|---|
| 8K | 1970 | 2252 | +14% |
| 32K | 2232 | 2431 | +9% |
| 128K | 1818 | 1921 | +6% |
Память — единственное, где уступаем: +1.75 ГБ на 8K, из них 824 МБ — сама голова спекуляции, ~760 — разовые пул и графы. За неделю декод на 8K вырос с 25.1 до 38.8 при +146 МБ памяти.
Честная оговорка: llama.cpp не загружает MTP-голову для этой модели вовсе — в его логе пятнадцать строк «unused tensor blk.64.*». Без спекуляции движки идут вровень, в пределах 0.4–2%. Это сравнение продуктов, а не реализаций одного алгоритма. И всё снято на 4090; целевая 3060 с её втрое более узкой шиной ещё впереди.
Что я понял про метод
За неделю из десяти объяснений шесть были опровергнуты замером, и каждое звучало убедительно; у трёх арифметика сходилась до процентов. Постраничная адресация, «объяснявшая» отставание префила с точностью до десятых секунды; лимит контекста; стоимость сведения сплитов. Выжило то объяснение, которое назвало направление заранее — и в обе стороны.
Строка «токенов в секунду» сама по себе не значит ничего. Дважды за день число выглядело победой при полностью неработавшей спекуляции: графовый префил ускорял базу, и «ускорение 1.14x» рапортовалось при нуле принятых черновиков. Теперь каждый замер проходит шлюз годности: used=false, ноль принятых или слишком мало черновиков относительно сгенерированных — замер недействителен, что бы ни показала скорость.
Ошибки нельзя глотать. Самый дорогой дефект недели стоил день и шесть неверных версий только потому, что планировщик откатывал раунд молча. Первая строка в лог — и диагноз занял бы пять минут.
И про процесс. Почти всю эту работу вели два агента Claude Code в паре: один читает код и пишет правки, другой владеет стендом, гоняет замеры и держит шлюз годности. Правило между ними — не собирать чужое незакоммиченное дерево, не мерить без контроля принятия, называть условие опровержения до замера. Оно и превратило неделю из перебора гипотез в последовательность проверок.
Что дальше
- Проверить на целевой 3060, что графовый декод действительно вернулся без копии эмбеддинга.
- Ядро mmvq для двух строк: сейчас вторая строка проверки стоит +21%, это потолок экономики раунда.
- Одно состояние DeltaNet на слот вместо двух (префил и декод) — ещё 384 МБ.
- Спекуляция на четырёх слотах и шлюз качества по перплексии.
Код пока приватный; если будет интерес — расскажу отдельно про постраничный KV с FA2 на candle и про то, как проверять спекуляцию графом.