Я написал свой inference stack для локальных LLM — и выжал 128K контекста из RTX 3060 12 ГБ

Yttri Forge начинался как попытка нормально запускать современные модели на обычной видеокарте. В какой-то момент стало понятно, что очередной набор параметров для готового движка уже не поможет. Пришлось лезть в KV-cache, FlashAttention, CUDA Graphs, Tensor Cores, планировщик, собственные CUDA-ядра и устройство памяти GPU. Рассказываю, что из этого получилось.

Я написал свой inference stack для локальных LLM — и выжал 128K контекста из RTX 3060 12 ГБ

Всё началось не с желания написать свой inference engine

Если вам просто нужно запустить локальную языковую модель, писать собственный движок - довольно странный способ провести свободное время.

Есть llama.cpp. Есть vLLM, SGLang, TensorRT-LLM, Ollama, LM Studio и ещё несколько десятков проектов. Они поддерживаются командами людей, работают на огромном количестве конфигураций и экономят месяцы разработки.

Поэтому изначально задачи «напишу свой inference engine» у меня вообще не было.

Я разрабатываю Yttri - local-first рабочее пространство со встроенным AI-агентом. И для него мне нужен был локальный inference-сервер с довольно специфическим набором требований:

  • современные Qwen-подобные модели;
  • большой контекст;
  • несколько параллельных клиентов;
  • работа на потребительских NVIDIA GPU;
  • OpenAI, Anthropic - совместимые API;
  • предсказуемое управление VRAM;
  • возможность экспериментировать с KV-cache, speculative decoding, model routing и другими частями стека;
  • Windows как полноценная целевая платформа, а не случайно работающий побочный сценарий.

Тестовой машиной при этом долгое время была обычная RTX 3060 с 12 ГБ VRAM.

Вот это последнее обстоятельство довольно быстро превращает теоретический разговор об инференсе в очень практический.

Почему 12 ГБ VRAM - хороший учитель

Когда у вас 48, 80 или 141 ГБ видеопамяти, многие архитектурные ошибки можно некоторое время лечить деньгами. На 12 ГБ так не получается.

У вас есть веса модели, KV-cache, CUDA Graphs, временные буферы префилла, промежуточные тензоры, allocator, память драйвера и сама операционная система. И всё это одновременно хочет жить на одной видеокарте.

Условная модель размером 6 ГБ вовсе не означает, что оставшихся 6 ГБ хватит на контекст. Особенно если речь идёт о 64 - 128 тысячах токенов. И особенно под Windows с WDDM.

Я написал свой inference stack для локальных LLM — и выжал 128K контекста из RTX 3060 12 ГБ

Первый неприятный сюрприз: 36 токенов в секунду превращаются в 2,4

Один из самых полезных багов проекта выглядел примерно так. Модель на RTX 3060 нормально генерирует около 36 токенов/с.

Увеличиваем контекст примерно до 24K. И скорость внезапно падает до 2,4 токена/с.

Не на 20%. Не в два раза. Примерно в 15 раз.

При этом CUDA не сообщает ничего особенно драматичного. Модель продолжает работать.

Причина оказалась не в самой модели и не в вычислительной сложности внимания.

VRAM была заполнена примерно на 97–98%, и Windows начинала вытеснять часть GPU-буферов в shared memory.

Формально память есть. Физически часть критического пути теперь ходит через PCIe и RAM. Производительность заканчивается.

Это довольно важный практический вывод:

для inference-сервера свободная VRAM — это не потерянный ресурс, а рабочий запас.

Выжимать память до последнего мегабайта часто хуже, чем сознательно оставить 1–2 ГБ свободными.

KV-cache пришлось перестать воспринимать как «просто кеш»

На длинном контексте KV-cache становится одним из центральных объектов всей архитектуры.

В Forge я использую страничный пул KV, который выделяется с учётом доступной памяти и конфигурации модели. Для экономии VRAM появился режим int8.

В одном из исторических тестов на RTX 3060:

Я написал свой inference stack для локальных LLM — и выжал 128K контекста из RTX 3060 12 ГБ

Здесь важно понимать: ускорение появилось не потому, что int8 каким-то магическим образом считает attention в пять раз быстрее. Просто первая конфигурация фактически сваливалась в WDDM paging.

После уменьшения KV память снова начала действительно жить на GPU.

Это довольно хорошо демонстрирует разницу между: «модель помещается в видеокарту» и «модель эффективно работает в видеокарте». Это не одно и то же.

Потом я захотел 128K контекста

На этом месте нормальный человек, вероятно, купил бы видеокарту с большим количеством памяти.

Но тогда было бы не так интересно.

Целью стало полноценное окно около 128 тысяч токенов на RTX 3060 12 ГБ.

Причём не так, чтобы сервер один раз каким-то чудом обработал запрос, а в рабочем режиме:

  • несколько последовательных запросов;
  • без постоянного роста VRAM;
  • без падений CUDA OOM;
  • с prefix cache;
  • с нормальной скоростью декода.

Ключевой частью решения стал Q8 KV pool. На 128K он занимает примерно вдвое меньше памяти, чем F16. Но здесь появилась следующая проблема.

Сэкономил память — потерял скорость

Первоначальная реализация Q8 KV действительно позволяла держать огромный контекст.

Но decode заметно замедлился.

Причина достаточно очевидна задним числом: перед использованием K приходилось преобразовывать из int8 обратно в F16.

То есть мы уменьшили трафик памяти, но добавили работу на каждом attention-слое. На 128K это уже хорошо измеряется.

В одной из ранних версий рабочая конфигурация давала примерно:

24,65 tok/s на ~127K контекста. Работает. Но хотелось быстрее.

Иногда оптимизация — это 16 лишних байт

Вот один из моментов разработки Forge, который мне особенно нравится.

После перехода на int8-QK профиль CUDA показал огромное количество операций с shared memory.

Ядро должно было читать меньше данных, но почему-то не становилось настолько быстрее, насколько ожидалось.

NVIDIA Nsight Compute показал: 42,7 млн shared-load wavefronts.

При этом вычислительная работа сама по себе не требовала такого количества.

Начал разбирать адресацию shared memory.

Ширина строки была ровно 256 байт.

И несколько потоков постоянно попадали в одни и те же memory banks.

Классический bank conflict.

Добавил к строке staging-буфера 16 байт padding.

То есть вместо 256 байт шаг стал 272.

Количество shared-load wavefronts: 42,7 млн → 14,25 млн.

Время одного q8 attention-ядра на длинном контексте: 1,59 мс → 1,24 мс.

Примерно минус 22%.

Никакой новой нейросетевой архитектуры. Никакого революционного алгоритма. Просто шестнадцать байт.

Я написал свой inference stack для локальных LLM — и выжал 128K контекста из RTX 3060 12 ГБ

Дальше int8 начал считать QK напрямую

Следующий шаг - перестать каждый раз превращать K обратно в F16 перед матричным умножением.

Вместо этого Q квантуется в int8, и произведение Q×K считается через integer Tensor Cores.

То есть данные остаются компактными не только при хранении, но и непосредственно участвуют в вычислении.

В результате отдельные оптимизации начали складываться.

На контексте около 127K:

  • первая рабочая версия: 24,65 ток/с;
  • после оптимизаций распаковки и attention: около 30 ток/с;
  • после int8-QK и исправления конфликтов shared memory: около 35,5–35,8 ток/с

То есть одна и та же RTX 3060 и одна и та же задача.

Разница - примерно 45% производительности между первой рабочей реализацией 128K и текущей.

Я написал свой inference stack для локальных LLM — и выжал 128K контекста из RTX 3060 12 ГБ

Что получилось с 128K в итоге

Один из последних стабильных прогонов использовал промпт длиной примерно: 126 917 токенов. На RTX 3060 12 ГБ.

После серии исправлений сервер показал:

  • TTFT / prefill около 115 секунд;
  • decode около 35,8 ток/с;
  • память после последовательных прогонов стабилизировалась примерно на 7,9 ГиБ dedicated VRAM;
  • повторный запрос с уже закешированным большим префиксом стартует примерно за 1,2 секунды.

Для меня интересен здесь даже не сам рекорд.

Важно другое: это уже не демонстрационный запрос.

Я прогонял последовательно контексты около 30K, 62K и 127K, следил за mempool, prefix cache, CUDA Graphs и VRAM.

После исправления retained memory в CUDA pool память перестала постепенно расти от запроса к запросу.

А это уже отличие бенчмарка от сервера, которым можно пользоваться.

Prefix cache оказался отдельной большой темой

Для AI-агентов длинный контекст часто состоит в основном из уже знакомого серверу префикса.

Например:

  • system prompt;
  • описание инструментов;
  • исходный код проекта;
  • предыдущая история диалога;
  • документация.

Пересчитывать десятки тысяч одинаковых токенов при каждом запросе довольно бессмысленно.

Поэтому Forge хранит снимки состояния префикса.

Сначала я физически копировал KV в отдельный snapshot.

Это работало, но на префиксе около 30K означало почти гигабайт дополнительного перемещения данных.

Позже появился pool-backed prefix cache.

Вместо копии всего KV запись кеша содержит ссылку на уже существующие строки paged-пула плюс generation/length metadata.

Размер такой записи уменьшился примерно до 50 МиБ, а повторный запрос на тестовом сценарии ускорился примерно: 0,95 с → 0,66–0,68 с.

На огромных контекстах эффект ещё заметнее.

И всё это время рядом стоял llama.cpp

Я не считаю llama.cpp конкурентом, который обязательно нужно «победить». Наоборот. Это очень полезный эталон.

Если твой собственный inference engine в несколько раз медленнее llama.cpp на той же модели и том же GPU, скорее всего, у тебя не уникальная архитектура.

Скорее всего, у тебя баг или плохое ядро.

В августе именно так и было.

На ранних версиях Forge разница местами составляла десятки процентов.

После этого начался довольно длинный цикл:

benchmark - profiler - гипотеза - kernel - benchmark.

Например, на Ornith-1.5-9B с контекстом около 30K первоначальный очный тест выглядел так:

Я написал свой inference stack для локальных LLM — и выжал 128K контекста из RTX 3060 12 ГБ

Проигрываем. Значит, ищем дальше.

Где исчезли последние проценты

Профилирование показало интересную вещь. Основные DeltaNet-слои уже почти упирались в пропускную способность памяти RTX 3060. А attention-слои при меньшем количестве весов занимали больше времени.

Проблема оказалась не в матричном умножении. Вокруг него выполнялось множество мелких операций:

  • RMSNorm;
  • RoPE;
  • transpose;
  • contiguous;
  • преобразование layout;
  • gate;
  • копирование V;
  • подготовка Q/K.

Каждая операция выглядит дешёвой.

Но если запускать отдельное CUDA kernel для каждой из них на каждом слое и каждом токене - сумма уже перестаёт быть дешёвой.

Я объединил подготовку attention в одно ядро.

GPU-время attention-блоков на шаге сократилось примерно: 5,1 мс → 3,8 мс.

Общее время GPU-шага: 19,58 мс → 18,29 мс.

После этого bottleneck переехал дальше.

Что, собственно, и происходит почти всегда.

В конце мы дошли до паритета

На одном из последних head-to-head тестов использовались:

  • одна и та же RTX 3060 12 ГБ;
  • один и тот же Ornith-1.5-9B GGUF;
  • одинаковый prompt;
  • одинаковый тип KV;
  • пять чередующихся прогонов Forge / llama.cpp.
Я написал свой inference stack для локальных LLM — и выжал 128K контекста из RTX 3060 12 ГБ

Разница: +0,28% по prefill +0,64% по decode

По сути это паритет. Именно так я к этому и отношусь.

Заявлять на основании половины процента, что «Forge быстрее llama.cpp», было бы довольно смешно: достаточно другого драйвера, температуры GPU или версии ядра.

Для меня важен сам факт, что собственный стек дошёл до той же области производительности, что и чрезвычайно зрелый llama.cpp.

И при этом архитектуру теперь можно менять под собственные задачи.

Что сейчас представляет собой Yttri Forge

Из маленького специализированного сервера проект постепенно превратился в полноценный inference stack.

Сейчас в нём есть поддержка моделей семейств Qwen 3.5 / 3.6 / 3.8 и Ornith 1.0 / 1.5, GGUF, собственный fork Candle и набор оптимизаций вокруг него.

Из того, что уже реализовано:

Continuous batching. Несколько запросов могут одновременно жить в scheduler, а освободившийся слот не обязан ждать завершения всего batch.

Paged KV cache. KV хранится страницами и может использовать F16 либо компактный Q8-пул.

Prefix cache. Повторно используемые части контекста не обязательно пересчитывать с нуля.

CUDA Graphs. Стабильные decode-пути можно захватить и воспроизводить без сотен host launch на каждый токен.

FlashAttention-2 и собственные изменения attention kernels.

Tensor-Core MMQ.

MTP / speculative decoding. Поддержка есть, хотя здесь я пока осторожен: MTP ускоряет не любую модель и не при любом acceptance rate.

MoE expert offload. Для больших MoE-моделей эксперты могут жить в pinned RAM, а горячая часть кешироваться в VRAM.

Мультимодальность. Vision/video путь тоже уже появился.

Горячее переключение моделей.

Сервер поддерживает совместимые интерфейсы:

  • OpenAI Chat Completions;
  • OpenAI Responses;
  • Anthropic Messages.

Есть web UI, а Open WebUI можно развернуть рядом с сервером.

Основной исполняемый файл называется yforge.

Я написал свой inference stack для локальных LLM — и выжал 128K контекста из RTX 3060 12 ГБ

А зачем вообще ещё один inference engine?

Хороший вопрос.

И ответ: большинству людей он не нужен.

Если нужно скачать GGUF, открыть API и начать работать - берите llama.cpp, Ollama или LM Studio.

Они решат задачу намного быстрее, чем самостоятельная сборка Forge.

Мне нужен другой уровень контроля.

Например, я хочу экспериментировать с тем, как агент использует огромный постоянно растущий контекст. Или хранить его состояние между запросами. Или менять стратегию KV в зависимости от модели и доступной VRAM. Или часть MoE-модели держать в GPU, а часть — в системной памяти. Или оптимизировать inference не под абстрактный benchmark, а под реальные паттерны запросов Yttri.

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

Когда движок свой - платить приходится временем разработки.

Зато границ почти нет.

Отдельная ветка - собственный формат весов

Ещё одна область экспериментов - формат модели.

Сейчас основной production-путь Forge работает с GGUF.

Это удобно: огромное количество моделей уже сконвертировано, есть готовые кванты, совместимость с экосистемой llama.cpp.

Но GGUF оптимизирован прежде всего как универсальный контейнер.

А универсальность не всегда означает оптимальный layout для конкретного CUDA kernel.

Поэтому в отдельной части проекта я экспериментирую с собственным контейнером и конвертером.

Идея простая:

не делать: модель → универсальный формат → адаптация под kernel

а сразу: модель → формат и layout, которые хочет наш kernel.

Это позволяет выбирать тип квантования отдельно для разных тензоров, заранее делать padding/interleave и не выполнять часть преобразований во время загрузки или inference.

Но здесь я не тороплюсь.

GGUF остаётся fallback и основной совместимой точкой входа.

Собственный формат должен сначала доказать преимущество тестами.

Что пока не идеально

Проект совершенно не находится в состоянии «мы всё оптимизировали».

Например, некоторые пути длинного prefill всё ещё требуют работы.

MTP на части моделей не окупает стоимость draft/verify.

MoE offload требует очень аккуратной работы с PCIe и кешем экспертов.

Разные поколения NVIDIA требуют разных решений.

Metal-ветка заметно менее оптимизирована, чем CUDA.

После нескольких изысканий и тестов, я пришел к выводу, что лучше всего работать с Swift и MLX версиями моделей для Apple Silicon (Metal ядер).

И набор поддерживаемых архитектур несопоставим с llama.cpp.

Это нормальная цена специализированного движка.

Forge сейчас интересен мне не потому, что поддерживает больше всего моделей.

А потому, что я могу взять конкретный bottleneck, открыть profiler и изменить весь путь от HTTP-запроса до CUDA kernel.

Самое полезное, что я вынес из этой работы

До этого проекта я довольно много работал с LLM на уровне архитектуры систем. Модели, API, RAG, агенты, маршрутизация, кеширование.

После Forge сильно меняется восприятие слова «инференс».

Потому что в конечном итоге за красивыми терминами вроде «контекст 128K» находятся очень приземлённые вещи:

256 байт в строке shared memory;

количество registers на thread;

лишний cudaMalloc;

неправильный размер KV pool;

одна лишняя копия гигабайта памяти;

bank conflicts;

CUDA Graph из нескольких тысяч узлов;

и Windows, которая решила немного помочь, выгрузив половину вашего inference в RAM.

Иногда между 24 и 36 токенами в секунду нет нового алгоритма.

Там просто лежат шестнадцать байт padding.

Что дальше

Следующая цель - уже не доказать, что свой движок вообще имеет смысл.

Этот этап, кажется, пройден.

Теперь интереснее использовать его как основу для локального AI в Yttri:

  • большие persistent-контексты;
  • несколько одновременно работающих агентов;
  • быстрый reuse контекста;
  • маршрутизация между моделями;
  • эффективная работа с MoE;
  • локальный multimodal inference;
  • self-hosted сервер для нескольких устройств и пользователей.

И продолжать проверять каждую красивую идею профайлером.

Потому что после нескольких месяцев работы над inference я окончательно перестал доверять фразе: «теоретически это должно быть быстрее».

GPU довольно быстро объясняет, что он думает по этому поводу.

Ссылки

Сайт проекта:

Yttri Forge:

Inference server:

Оба репозитория публичные. Если вам интересен низкоуровневый inference, там лежит заметно больше деталей, чем я смог разумно уместить в одну статью.
Мой канал в Телеграм и Max: