← Все статьи

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 и про то, как проверять спекуляцию графом.