Qwen3.8–27B на двух RTX 3060 12ГБ: как я разогнал OpenCode с ~9 до ~40 токенов/с при 128K контекста

—

от автора

Задача была простой: собрать дома собственный вычислительный блок для coding‑agent’ов и по возможности как можно меньше зависеть от облачных лимитов. Codex и Claude Code мне нравятся, но постоянно упираться в ограничения, а тем более платить за более дорогие тарифы 100 или тем более 200 долларов в месяц, я не хочу. Поэтому решил посмотреть, насколько далеко можно уехать на том, что уже есть.

Железо для эксперимента постарался подобрать с минимальными вложениями: Core i7-4790, 32 ГБ DDR3, ASUS Sabertooth Z97 Mark 2 и две RTX 3060 по 12 ГБ. Никакого NVLink, CUDA P2P между картами тоже нет. Зато суммарно есть 24 ГБ VRAM и желание выжать из них все соки.

Сервер целиком: Z97, i7-4790, 32ГБ DDR3 и две RTX 3060 12 ГБ

Сервер целиком: Z97, i7-4790, 32ГБ DDR3 и две RTX 3060 12 ГБ

В моей конкретной ситуации такая сборка была логичной: значительная часть железа досталась мне бесплатно (мой старый компьютер), а из заметных покупок понадобилась вторая RTX 3060 12 ГБ и большой корпус с шумоподавлением. Поэтому я сравнивал не «новый дорогой сервер против подписки», а разовую доработку уже имеющегося ПК с ежемесячными платежами за более высокие облачные лимиты.

При этом я не ставлю знак равенства между локальной Qwen и Codex или Claude Code. У облачных нейронок выше потолок возможностей на самых сложных многошаговых задачах. Но для большинства моих сценариев, локальной 27B‑модели хватает с большим запасом.

Есть ещё один очень весомый плюс: приватность. Сама модель работает полностью на моём сервере, поэтому исходники, промпты и контекст не нужно отправлять стороннему LLM‑провайдеру. Это удобно для внутреннего кода, конфигураций, технических логов и других чувствительных данных. Разумеется, такое утверждение справедливо только если сама обвязка OpenCode, плагины и подключённые инструменты тоже не настроены отправлять эти данные наружу.

При этом для меня скорость была не самоцелью. Я не хотел получать красивое число токенов в секунду ценой заметной потери качества, перехода на слишком агрессивную квантизацию или обрезки длинного контекста. Цель была ровно обратной: сохранить достаточно качественную 27B‑модель и большой контекст, но заставить две 3060 работать максимально эффективно.

На старте Qwen3.8–27B Q4 работала, но на действительно длинном контексте генерация опускалась примерно до 9–10 токенов/с. После серии экспериментов на тех же двух видеокартах в контролируемом тесте с ~120K входных токенов получилось 28.16 токенов/с, а в реальной сессии OpenCode — 39.6 токенов/с в среднем по серверным логам. Рабочий сценарий для меня важнее всего: модель должна не просто запускаться, а быть достаточно быстрой, чтобы ей было комфортно пользоваться каждый день.

1. Сам подопытный

Компонент

Конфигурация

CPU

Intel Core i7-4790

Материнская плата

ASUS Sabertooth Z97 Mark 2

RAM

32 ГБ DDR3 1600MHz

GPU

2 × NVIDIA GeForce RTX 3060 12 ГБ

Связь GPU

PHB, CUDA P2P read/write недоступен

ПО

Ubuntu, llama.cpp, Docker, CUDA

llama.cpp

build 10991, commit 930e2fa59, Docker tag server‑cuda13-b10991

Основной сценарий

OpenCode / локальный coding‑agent

Сразу важная оговорка: эта статья не про новый алгоритм. Все основные механизмы уже существуют в llama.cpp. Это практический разбор того, какие комбинации настроек реально сработали на конкретном старом домашнем железе, а какие не сработали и почему.

2. С чего я начал

В качестве отправной точки я взял довольно базовый конфиг: обычный Qwen3.8–27B Q4_K_M от ggml‑org, полный серверный контекст 131 072 токена, Flash Attention, Q4 KV‑cache и стандартное распределение модели по слоям между двумя GPU. Это была не специально замедленная модель, а мой реальный первый рабочий вариант: он помещался в память, стабильно запускался и уже позволял пользоваться ей через OpenCode.

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

Почему вообще Q4_K_M и 128K? Мне нужен был не рекорд короткого бенчмарка, а достаточно крупная модель для кода с контекстом, который не заканчивается после нескольких прочитанных файлов и парочки длинных логов. Coding‑agent быстро накапливает исходники, результаты поиска, вывод тестов и собственную историю, поэтому режим на 16K или 32K меня изначально не устраивал.

docker run --rm --gpus all \   -p 8080:8080 \   -v "$HOME/models:/models:ro" \   ghcr.io/ggml-org/llama.cpp:server-cuda13-b10991 \   -m /models/Qwen3.8-27B-Q4_K_M.gguf \   -sm layer \   --fit on \   --fit-target 512 \   -c 131072 \   -np 1 \   -fa on \   -ctk q4_0 \   -ctv q4_0 \   --host 0.0.0.0 \   --port 8080

На коротком запросе исходный режим давал 16.6 токенов/с, что в целом было терпимо. Но после реального заполнения контекста картина менялась: на ~65K оставалось 11.35 токенов/с, а на ~120K — 8.89. В отдельном агентоподобном тесте с уже заполненным ~100K контекстом исходный режим генерировал продолжение со скоростью 9.63 токенов/с. Поэтому 9 токенов/с в заголовке — взято не с потолка, а вполне воспроизводимая характеристика длинного рабочего контекста.

Это означало, что модель уже была полезной, но была медленной как раз там, где мне нужен был локальный агент: после чтения большого проекта. Поэтому дальше я старался менять по одной крупной составляющей и каждый раз проверять не только короткую генерацию, но и реально заполненные 65K-120K.

Здесь и дальше под «65K/120K» я имею в виду реально отправленный и обработанный контекст, а не только значение -c при запуске сервера. Это важно: конфигурация может успешно стартовать с 128K KV‑cache, а затем упасть с CUDA OOM (out of memory) при реальном заполнении (prefill).

3. Смена layer на tensor

Первым делом я решил проверить, насколько вообще эффективно используются две видеокарты. И самым крупным бесплатным ускорением оказался переход от распределения модели по слоям к тензорному распараллеливанию:

-sm layer

заменил на

-sm tensor

В режиме layer разные группы слоёв лежат на разных GPU, и данные последовательно проходят через них. В режиме tensor вычисления одного слоя делятся между двумя картами, после чего частичные результаты объединяются. В моём случае это особенно интересно, потому что платформа не идеальна для multi‑GPU: между картами PHB‑топология и нет прямого CUDA P2P, а также сами GPU работают в режиме PCIe 8x. Несмотря на это, параллельная загрузка двух GPU оказалась выгоднее последовательного распределения по слоям.

Кажется, что tensor split на старой платформе без CUDA P2P выглядит сомнительно: каждый слой требует обмена между GPU. Но при autoregressive decode одной последовательности объём пересылаемых активаций сравнительно невелик относительно объёма весов, которые приходится читать на каждом токене. На моём сервере выигрыш от параллельной работы двух независимых подсистем VRAM оказался заметно больше накладных расходов меж‑GPU обмена — decode почти удвоился уже одним переходом с layer на tensor. Это наблюдение относится конкретно к моей Z97/2×RTX 3060/PHB‑топологии, а не является обещанием для любого PCIe‑сервера.

Здесь я ещё не менял модель, квантизацию весов и KV‑cache. Поэтому этот шаг хорошо изолирует эффект самого режима multi‑GPU: ускорение появилось не потому, что я уменьшил модель или пожертвовал контекстом, а потому, что те же две RTX 3060 стали эффективнее на каждый шаг генерации.

На коротком тесте без MTP скорость выросла с 16.6 до 29.5 токенов/с. На длинном контексте эффект сохранился: с 11.35 до 20.27 токенов/с на ~65K и с 8.89 до 16.09 на ~120K. То есть одна смена layer на tensor дала почти x2 на 120K. При этом первичная обработка совершенно нового огромного промпта стала медленнее, к этому нюансу я ещё вернусь, потому что для интерактивного агента prefill и генерация ведут себя по‑разному.

Отдельный момент — это quantized KV вместе с -sm tensor. На момент подготовки статьи документация llama.cpp всё ещё предупреждала, что для tensor split KV‑cache должен быть f32/f16/bf16 и что quantized KV не поддерживается. Несмотря на это, все результаты этой статьи получены на официальном llama.cpp b10991 (commit 930e2fa59), и на этой версии tensor + q8_0/q8_0 на моём CUDA‑сервере стабильно пережил реально заполненные ~120K токенов. Поэтому номер build здесь стоит считать частью конфигурации: на другой версии поведение может отличаться.

4. Speculative decoding и MTP

Следующий источник ускорения — speculative decoding через MTP (Multi Token Prediction). Если сильно упростить, MTP строит дешёвый черновой прогноз нескольких следующих токенов, а полноценная Qwen проверяет этот прогноз. Принятые токены можно обработать эффективнее, чем генерировать строго по одному. При этом окончательное решение остаётся за основной моделью: MTP не получает права самостоятельно «дописать» ответ мимо неё.

Главный параметр здесь — глубина чернового прогноза, --spec-draft-n-max. Чем она больше, тем дальше MTP пытается заглянуть вперёд. Это не гарантирует ускорение: если дополнительные прогнозы часто бракуются, GPU тратит вычисления на токены, которые потом выбрасываются. Поэтому кроме токенов/с я стал смотреть на число предложенных и принятых токенов и на среднюю длину полезной принятой цепочки.

С отдельным MTP GGUF на небольшом контексте результат выглядел как магия: около 60 токенов/с при n-max=2, 64 при n=3 и примерно 66 при n=4. На короткой нагрузке n=4 был лучшим. Но у внешнего MTP есть цена по памяти: это отдельная модель и отдельный контекст со своими буферами. В сочетании с tensor split и полными 128K, к сожалению, память стала следующим ограничением.

5. Борьба за VRAM

Дальше началась, пожалуй, самая полезная часть эксперимента. Я довольно быстро убедился, что «llama‑server запустился с -c 131072» и «модель реально пережила запрос на 120K» — совершенно разные утверждения. При старте можно успешно выделить KV‑cache, но во время настоящего prefill понадобятся дополнительные рабочие буферы, и конфигурация, которая выглядела рабочей в nvidia-smi, внезапно заканчивается CUDA OOM.

Так и произошло с tensor + Q8 KV без MTP: сервер с 128K стартовал и занимал почти всю память, но реальный длинный prefill уже не оставлял достаточно запаса. Переход на Q4 KV освободил нужную память, и 128K стали стабильно проходить: без MTP я получил 20.29 токенов/с на ~65K и 16.10 на ~120K. С этого момента я перестал считать максимальный -c доказательством работоспособности и проверял каждую интересную конфигурацию с реально заполненным контекстом.

Для внешнего MTP пришлось искать компромисс уже по самому размеру контекста. 112K всё ещё падали при запуске, а 96K помещались. В режиме tensor + Q4 KV + внешний MTP я получил около 61–62 токенов/с на коротком запросе и 21.54 токенов/с на 90K. Однако принятие токена у n=4 на 90K упало до 25%, то есть значительная часть глубокого чернового прогноза уже тратилась впустую. Режим был действительно быстрым, но мне не нравилась цена: ради MTP пришлось отдать четверть контекста, который для агента был одной из главных целей.

6. Что не помогло: NCCL и shared buffers

Следующей гипотезой был NCCL: раз у меня две NVIDIA GPU, логично проверить специализированную библиотеку коллективных операций. Я собрал ту же версию llama.cpp с NCCL, сначала пришлось увеличить /dev/shm в Docker, после чего режим заработал. Но на моём железе чуда не произошло: внутренний механизм объединения результатов давал 66.3 токенов/с, а NCCL — 61.6, то есть был медленнее где‑то на 7%.

После проверки топологии причина выглядела логично: GPU связаны через PHB, прямой CUDA P2P недоступен, и NCCL всё равно приходится работать через менее выгодный путь обмена данными. Это хорошо показывает, почему «более специализированная технология» не автоматически означает «быстрее» — результат сильно зависит от конкретного соединения карт.

После этого я попробовал пойти другим путем и сэкономить память не квантизацией, а совместным использованием вычислительных буферов основной модели и внешнего MTP. Для этого собрал экспериментальную ветку QVAC, снял несколько защитных ограничений и добавил диагностику прямо в scheduler. В итоге получил неутешительный ответ: при tensor split основная модель и внешний MTP создавали разные Meta backend/device objects — same_buft=0 и same_dev=0.

Формально, конечно, можно было продолжать и дальше связывать эти объекты, но это уже переставало быть настройкой llama.cpp и превращалось в изменение архитектуры управления памятью ggml. Я решил на этом остановиться, ведь цель была получить устойчивый рабочий сервер, а не доказать любой ценой, что один конкретный внешний MTP можно запихнуть в оставшиеся сотни мегабайт. Ну и отрицательный результат тоже оказался полезным — стало понятно, где заканчиваются адекватные настройки и начинается разработка backend‑а.

7. Правильный GGUF: Unsloth со встроенным MTP

Поворотным моментом оказался Unsloth Qwen3.8–27B‑UD‑Q4_K_M. В отличие от моего предыдущего набора «основной GGUF + отдельный MTP GGUF», здесь MTP‑тензоры находятся в том же файле модели. Поэтому llama.cpp может создать MTP‑контекст непосредственно против основной модели, без второго полноценного объекта модели и его отдельного набора накладных расходов.

Это оказалось важнее любых попыток выжать ещё несколько сотен мегабайт из внешнего MTP. Я вернулся на обычный официальный Docker‑образ llama.cpp, включил tensor split, полный 128K и уже более точный Q8 KV‑cache. Конфигурация не только стартовала, но и выдержала реальные 65K и 120K запросы.

В итоге в две 12 гиговые видеокарты одновременно влезли полные 128K контекста, tensor split, Q8 KV‑cache и встроенный self‑MTP. В простое сервер занимал 11.3–11.4 ГБ на каждую RTX 3060, то есть запас оставался совсем небольшим, но достаточным для работы. Для меня это была золотая середина, которую я искал: контекст не урезан, KV стал точнее исходного Q4 (к моему удивлению), а ускорение теперь можно было получать MTP без отдельной draft‑модели.

У MTP + multi‑GPU при этом есть известные проблемы. В issue tracker llama.cpp есть отчёты о hard‑lock во время очень длинного prefill (100K+) с MTP и tensor split, а также о заметном падении обработки промпта (prompt processing) при MTP на multi‑GPU layer split. На моём b10991 эти проблемы не воспроизвелись: я специально проверял не только выставленный -c 131072, но и реально заполненный контекст (filled context) вплоть до ~120K. Это ещё одна причина фиксировать версию и проверять длинный prefill на своём железе, а не ограничиваться запуском llama сервера.

8. Основной benchmark: пять конфигураций

После этого я заново собрал основной тест так, чтобы не сравнивать случайные удачные скриншоты из разных этапов. Для каждой конфигурации использовались те же build llama.cpp b10991 и тестовый скрипт, 65 536 и 120 000 входных токенов, 512 генерируемых токенов и по три независимых прогона каждой точки. Разброс между повторами оказался очень небольшим. Все цифры в этом разделе относятся только к моему тестовому образцу: Z97 + 2×RTX 3060 12 ГБ, PHB и без CUDA P2P.

Пять ступеней выбраны специально. A — реальный исходный layer/Q4. B меняет только способ multi‑GPU на tensor. C показывает Unsloth и Q8 KV без MTP. D добавляет встроенный MTP с дефолтной глубиной n=3. E меняет только глубину на n=1, которая на синтетическом длинном тексте оказалась быстрее. Благодаря этому видно не только финальную цифру, но и вклад каждого крупного шага.

Конфигурация

~65K, ток/с

~120K, ток/с

A — ggml‑org, layer, Q4 KV, без MTP

11.35

8.89

B — ggml‑org, tensor, Q4 KV, без MTP

20.27

16.09

C — Unsloth, tensor, Q8 KV, без MTP

23.07

18.37

D — Unsloth, tensor, Q8 KV, MTP n=3

27.74

22.83

E — Unsloth, tensor, Q8 KV, MTP n=1

35.46

28.16

 Результаты основного long-context benchmark

Результаты основного long‑context benchmark

На ~120K путь выглядел так: 8.89 → 16.09 → 18.37 → 22.83 → 28.16 токенов/с. То есть контролируемая длинноконтекстная генерация ускорилась в 3.17 раза относительно исходного режима. На ~65K картина почти такая же: 11.35 → 35.46 токенов/с, в 3.12 раза.

Эти тесты показывают результат как в лабораторных контролируемых условиях, не гарантируя скорость реального запроса. Они фиксируют длину входа и число генерируемых токенов, настоящий агент генерирует ответы разной длины, вызывает инструменты и постоянно переиспользует часть уже рассчитанного контекста.

При этом обнаружился один момент, tensor сильно ускоряет генерацию, но первичная обработка нового огромного промпта (prefill) на сервере стала медленнее. Decode и prefill нагружают систему по‑разному. При токен‑за‑токеном генерации большую роль играет чтение весов из VRAM, и tensor split хорошо использует обе карты параллельно. При большом prefill матричные операции становятся гораздо крупнее, одновременно возрастает объём меж‑GPU синхронизаций и обменов. На сервере без P2P это проявилось прямо в измерениях: decode заметно ускорился, а холодный prefill оказался медленнее layer split. Для одноразового запроса на гигантском новом тексте это может съесть часть профита по общей задержке. Но для coding‑agent ситуация другая: одна и та же большая история живёт много ходов, и сервер может переиспользовать рассчитанный KV‑cache.

9. Почему n‑max=1 выиграл синтетику, но проиграл OpenCode

Отдельно я прогнал серию тестов (sweep) глубины MTP на одном и том же ~65K контексте. Для n=1, 2, 3 и 4 было по три запуска. Это позволило посмотреть не только скорость, но и сколько черновых токенов реально принимала основная модель, и сколько дополнительной VRAM стоила каждая ступень.

n‑max

Decode, ток/с

Предсказано (Draft)

Принято (Accepted)

Принято в%
(Acceptance)

Peak VRAM / GPU

1

35.50

255

255

100

11 317 MiB

2

31.80

510

255

50.0

11 395 MiB

3

27.74

764

255

33.4

11 469 MiB

4

24.95

1018

255

25.0

11 545 MiB

Результат получился как по учебнику. Во всех четырёх режимах было принято ровно 255 черновых токенов. Это связано с фиксированной длиной ответа в этом синтетическом тесте: при n=1 первый speculative token принимался всегда, а увеличение глубины до n=2..4 не увеличило число полезных принятых токенов. При n=1 было предложено 255 и принято 255, при n=4 предложено уже 1018, но полезными всё равно оказались те же 255. Дополнительные уровни прогноза в этом тексте считали то, что потом выбрасывалось. Одновременно каждая новая ступень добавляла около 75 МБ VRAM на GPU. Поэтому n=1 здесь оказался и самым быстрым, и самым лёгким.

Но это оказалось свойством нагрузки, а не правилом. В тесте с повторно используемым контекстом и особенно в настоящем OpenCode n=3 стал выгоднее: код, JSON, патчи и типичные агентные ответы часто содержат более предсказуемые последовательности, и MTP удавалось принимать не один, а несколько токенов подряд. Поэтому я не стал выбирать параметры по синтетическому графику, а отдельно проверил реальную рабочую сессию.

10. Агентный сценарий с повторно используемым контекстом

Чтобы понять, почему OpenCode ускорился сильнее, чем можно ожидать только по холодному prefill, я сделал отдельный агентоподобный тест. Сначала сервер получал большой общий префикс примерно на 100K токенов, а затем несколько последовательных продолжений. В каждом новом ходе почти вся предыдущая история была идентична, а llama.cpp запускался с cache_prompt=true и переиспользовал рассчитанный KV‑cache.

И такой режим ближе к длинной сессии: дорогое чтение большого контекста не происходит перед каждым токеном заново. После первого хода сервер пересчитывает в основном новый хвост — очередное сообщение, результат инструмента и новый ответ модели. Поэтому скорость decode начинает сильнее влиять на процесс работы.

Конфигурация

Холодный запрос, серверное время, с

Повторный ход, серверное время, с

Decode повторного хода, ток/с

A — layer + Q4

211.4

53.53

9.63

B — tensor + Q4

221.1

29.96

17.34

C — Unsloth + Q8, без MTP

217.0

26.28

19.81

D — MTP n=3

215.8

11.82

47.00

E — MTP n=1

220.4

17.83

30.28

Этот тест я специально не использую как универсальный показатель скорости. Синтетический структурированный текст оказался очень удобным для MTP: у n=3 acceptance на повторных ходах доходил до 92–99%, поэтому 47 токенов/с здесь демонстрируют верхнюю планку механизма, а не типичную скорость любого кода. Основной вывод в другом: после заполнения KV‑cache стоимость огромного общего префикса практически исчезает из повторных ходов, и разница в скорости генерации становится заметнее.

11. Реальный OpenCode: n=1 против n=3

После синтетики я сравнил n=1 и n=3 уже в настоящем OpenCode. Проект был зафиксирован одним commit‑ом и для каждого прогона создавалась новая сессия, а агент получал один и тот же запрос: подробно разобрать существующую логику выбора сервера и переподключения, найти связанные классы и тесты, проверить возможные гонки (races) и предложить план исправления без изменения файлов. В обеих сессиях контекст в процессе работы вырос более чем до 100K токенов.

Первый запуск по человеческому времени получился намного длиннее, но тут я ошибся и отошел, забыв подтвердить одно из действий OpenCode, и llama‑server около двадцати минут вообще не получал новых запросов. Поэтому все такие паузы я полностью исключил из сравнения. Ниже только метрики из серверных логов: фактически обработанные токены, скорость генерации и время, когда GPU действительно считали модель.

Метрика

n=1

n=3

Запросов к модели

20

18

Сгенерировано токенов

37 166

33 468

Новых prompt‑токенов

139 898

139 724

Средневзвешенная скорость

32.43 ток/с

39.60 ток/с

Средняя скорость по ответам

36.19 ток/с

46.13 ток/с

Медиана

35.83 ток/с

47.02 ток/с

Вычислительное время сервера

23 мин 36 сек

18 мин 40 сек

В реальном OpenCode результат развернулся относительно синтетического sweep: средневзвешенная скорость выросла с 32.43 токенов/с при n=1 до 39.60 при n=3, что получается около 22%. При этом доля принятых черновых токенов у n=3 была ниже (59% против 78%), но средняя полезная длина принятой цепочки выросла с 1.78 до 2.80 токена. Проще говоря, n=3 чаще ошибался на дальнем прогнозе, зато при успехе позволял перепрыгивать дальше.

Даже на участках с уже большим контекстом преимущество не исчезло, и в рабочем конфиге я в итоге оставил --spec-draft-n-max 3. Для меня это один из самых полезных результатов во всей серии: оптимальную глубину speculative decoding нужно подбирать по реальной нагрузке, а не по одному синтетическому тесту, даже если тот выглядит очень чисто.

Практический вывод: не копируйте n=3 как «правильное» значение для MTP. На моём же железе синтетический long‑context тест выбрал n=1, а настоящий OpenCode выбрал n=3. Acceptance зависит от структуры текста, модели, шаблона ответов и самой агентной нагрузки, поэтому n-max лучше измерять на своём рабочем сценарии.

12. А что с качеством?

Для меня это был принципиальный вопрос. Я специально не хотел ускорять модель ценой перехода на условный Q2, обрезки контекста или другого очевидного ухудшения. Более того, вышло так, что финальный режим использует Q8 KV вместо исходного Q4. Но субъективного «кажется, отвечает так же» для статьи мало, поэтому я сделал два небольших smoke‑теста. Это не SWE‑bench, LongBench и не полноценное исследование качества, цель гораздо практичнее. Мне нужно было проверить, не появилась ли после оптимизации очевидная регрессия именно в моих сценариях.

12.1. Long‑context retrieval и multi‑hop

Был сгенерирован синтетический технический архив почти на 65K и почти на 120K токенов. В него вставлялись 20 уникальных фактов, равномерно распределённых по длине, и 10 цепочек из трёх связанных записей, разнесённых по разным частям документа. Каждый вопрос задавался отдельно, а общий архив переиспользовался через KV‑cache.

Режим

~65K: прямые

~65K: 3-hop

~120K: прямые

~120K: 3-hop

Исходный layer/Q4

20/20

10/10

20/20

10/10

Финальный tensor/Q8/MTP n=3

20/20

10/10

20/20

10/10

Обе конфигурации получили 30/30 на обеих длинах. Это выглядит подозрительно хорошо. Видимо тест оказался недостаточно сложным, чтобы разделить две конфигурации по качеству. Получается, что не «качество абсолютно идентично», а «на этом long‑context smoke‑test заметной регрессии не обнаружено».

12.2. Coding benchmark со скрытыми тестами

Второй тест был ближе к моей реальной задаче. Я сделал восемь маленьких репозиториев с намеренно сломанной реализацией: пагинация, endpoint/IPv6 parser, retry/backoff, deep merge конфигураций, TTL+LRU cache, выбор VPN‑сервера, reconnect state machine и нормализация IPv4/IPv6 маршрутов.

OpenCode видел условие и небольшой публичный набор unit‑тестов, но не видел дополнительные edge‑case проверки. После работы агента я по SHA-256 проверял, что условие и публичные тесты остались неизменными, а затем запускал скрытые тесты. Если агент менял защищённые файлы, задача считалась проваленной.

Задача

Исходный layer/Q4

Финальный tensor/Q8/MTP n=3

Pagination

PASS 5/5

PASS 5/5

Endpoint parser

PASS 8/8

PASS 8/8

Retry/backoff

PASS 6/6

PASS 6/6

Deep merge

PASS 6/6

PASS 6/6

TTL + LRU

PASS 6/6

PASS 6/6

Server selector

PASS 5/5

PASS 5/5

Reconnect state

PASS 4/4

PASS 4/4

Route normalizer

PASS 6/6

PASS 6/6

Итого

8/8, 46/46

8/8, 46/46

Обе конфигурации прошли 8/8 задач и 46/46 скрытых проверок, причём защищённые файлы условий и публичных тестов остались неизменными. Эти тесты я использовал именно как проверку качества, а не как performance‑бенчмарк. Агент в разных запусках может выбрать разное число поисков, чтений файлов и промежуточных действий, поэтому время выполнения отдельной задачи здесь не сравниваю.

На двух небольших прикладных smoke‑тестах я не увидел очевидной регрессии после оптимизации. 30/30 и 8/8 здесь не означают, что конфигурации эквивалентны по качеству вообще. Тут скорее тесты оказались недостаточно сложными, чтобы их разделить. Для моей практической цели этого достаточно, но такие проверки не заменяют крупные общепринятые бенчмарки.

13. Финальный рабочий конфиг

После всех тестов для OpenCode я оставил self‑MTP с n-max=3 и именно в реальной coding‑нагрузке он оказался быстрее единички. Полную команду запуска оставляю намеренно, так как она фиксирует итоговый воспроизводимый конфиг, а не только набор отдельных флагов из текста. Образ тоже закреплён на b10991, потому что поведение tensor + quantized KV зависит от версии.

docker run --rm --gpus all \   -p 8080:8080 \   -v "$HOME/models:/models:ro" \   ghcr.io/ggml-org/llama.cpp:server-cuda13-b10991 \   -m /models/unsloth-qwen38/Qwen3.8-27B-UD-Q4_K_M.gguf \   --alias Qwen3.8-27B-Unsloth-Q4_K_M \   -ngl all \   -c 131072 \   -np 1 \   -sm tensor \   --fit off \   -fa on \   -ctk q8_0 \   -ctv q8_0 \   --spec-type draft-mtp \   --spec-draft-n-max 3 \   --host 0.0.0.0 \   --port 8080

--fit off здесь указан сознательно: для tensor split auto‑fit не поддерживается, и поэтому размер контекста и тип KV‑cache я фиксирую вручную. Флаг --no-mmproj в финальной команде не нужен, модель загружается локально через -m без multimodal projector, поэтому text‑only режим и так однозначен.

Этот режим даёт мне полный 128K серверный контекст, Q8 KV‑cache, тензорное распараллеливание двух RTX 3060 и встроенный MTP. В реальном OpenCode средневзвешенная скорость в тестовой сессии составила 39.6 токенов/с, при этом контекст доходил более чем до 100K.

14. Что в итоге сработало, а что нет

Сработало

Не помогло / упёрлось в ограничения

Tensor split — крупнейший бесплатный прирост decode

NCCL на моей PHB‑топологии — медленнее внутреннего all‑reduce

Unsloth GGUF со встроенным MTP

External MTP + tensor + полный 128K — не помещался

Q8 KV в финальном 128K режиме

Уменьшение ubatch не спасло внешний MTP на 128K

MTP n=3 для реального OpenCode

Shared buffers для разных Meta backends потребовали бы глубокой переделки ggml

Проверка реального filled context, а не только ‑c

Ориентироваться только на short benchmark

15. А что дальше?

Следующим логичным шагом было бы попробовать увеличить контекстное окно до 256К. Здесь уже наверняка придётся балансировать с KV‑cache и VRAM, экспериментировать с более компактным KV‑cache, вплоть до Q4, проверять реальный заполненный контекст и обязательно повторять retrieval‑тест на глубинах 200K+.

Если этот материал окажется интересен, продолжение не заставит себя ждать.

16. Итог

Начиналось всё с практического вопроса: можно ли превратить две RTX 3060 12 ГБ на стареньком i7-4790 и Z97-платформе в локальный coding backend, которым комфортно пользоваться каждый день. В рабочем сценарии путь получился от ~9 до ~40 токенов/с, в реальной тестовой сессии OpenCode финальный n=3 дал 39.6 токенов/с, а контекст в процессе работы превышал 100K. В более жёстком контролируемом тесте с ~120K полностью нового входа генерация выросла с 8.89 до 28.16 токенов/с.

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

Самое полезное открытие оказалось не в одной «волшебной» кнопке. Результат сложился из нескольких вещей: tensor split лучше задействовал две карты, подходящий GGUF позволил использовать встроенный MTP без отдельной draft‑модели, Q8 KV сохранил большой контекст с более аккуратным KV‑cache, а глубину speculative decoding пришлось подбирать уже по реальному OpenCode. Ни одна из этих оптимизаций сама по себе не дала бы финальный результат.

Повторюсь, я не считаю такую сборку полноценной заменой Codex или Claude Code, потому что у облачных агентов выше потолок на самых сложных многошаговых задачах. Но для большинства моих повседневных сценариев локальной Qwen хватает с запасом. В моём случае этот путь оказался особенно рациональным ещё и потому, что значительная часть железа уже была под рукой или досталась бесплатно, а главным вложением стала вторая RTX 3060. Дополнительный плюс — контроль над данными: сам inference остаётся на моём сервере, поэтому внутренний код, конфигурации, логи и другой чувствительный контекст не нужно отправлять LLM‑провайдеру.

В итоге цель была не «создать убийцу Claude Code с помощью двух RTX 3060», а сделать локальный coding backend достаточно быстрым и достаточно качественным, чтобы им реально хотелось пользоваться каждый день. На моём сервере в результате работы разница между «модель запускается» и «это уже рабочий инструмент» оказалась огромной, и это я считаю самым главным достижением.

17. В двух словах: если хочется повторить именно мой сценарий

  1. Использовать GGUF со встроенными MTP‑тензорами, если хочется избежать отдельной draft‑модели и её накладных расходов по VRAM.

  2. Обязательно попробовать -sm tensor и измерить его на своём interconnect: даже без P2P на моём Z97/PHB‑стенде он оказался значительно быстрее layer, но это не универсальная гарантия.

  3. Если VRAM позволяет, проверить Q8 KV. В моей финальной конфигурации q8_0/q8_0 поместился вместе со 128K, сама финальная конфигурация не показала заметной регрессии в long‑context smoke‑тестах.

  4. --spec-draft-n-max подбирать по реальной агентной нагрузке. На моём железе синтетический long‑context тест выбрал n=1, а в реальном сравнении n=1 и n=3 OpenCode быстрее оказался n=3.

И ещё раз: все цифры выше относятся к конкретному железу — Z97, 2×RTX 3060 12 ГБ, PHB, без CUDA P2P, llama.cpp b10991. На другой платформе баланс между layer/tensor, KV и MTP может оказаться другим.

Приложение. Методика измерений

Тест

Как проводился

Основной performance

5 конфигураций, 65К и 120К входных токенов, 512 output, по 3 прогона каждой точки

n‑max sweep

Unsloth/tensor/Q8/MTP, n=1..4, 65К входных токенов, по 3 прогона

External‑MTP промежуточный тест:

~90K при -c 98304

Cached‑agent

~100K общий префикс + последовательные продолжения с cache_prompt

Реальный OpenCode: n=1 vs n=3

Один проект и одинаковая аналитическая задача, сравнение n=1 и n=3, серверные логи, без учёта ручных пауз

Long‑context quality

20 direct retrieval + 10 трёхшаговых цепочек, ~65K и ~120K, каждый вопрос отдельно, ground truth задан заранее

Coding quality

8 мини‑репозиториев, публичные + скрытые тесты, проверка SHA256 защищённых файлов, 46 скрытых assertions, использовался как quality pass/fail, без сравнения времени выполнения

Ограничения методики: выборки небольшие, coding‑задачи не являются заменой SWE‑bench или другим крупным наборам, синтетический long‑context тест проверяет retrieval и простую связку фактов, но не исчерпывает все виды длинноконтекстного reasoning. Поэтому формулировка вывода осторожная: «на проведённых smoke‑тестах заметной регрессии не обнаружено».

Ссылки

llama.cpp multi‑GPU documentation: https://github.com/ggml‑org/llama.cpp/blob/master/docs/multi‑gpu.md

llama.cpp speculative decoding documentation: https://github.com/ggml‑org/llama.cpp/blob/master/docs/speculative.md

llama.cpp container build b10991 (server‑cuda13-b10991): https://github.com/ggml‑org/llama.cpp/pkgs/container/llama.cpp/versions

Issue #28252 — hard lock during 100K+ MTP prefill with tensor split: https://github.com/ggml‑org/llama.cpp/issues/28252

Issue #27428 — MTP prompt‑processing slowdown on multi‑GPU layer split: https://github.com/ggml‑org/llama.cpp/issues/27428

Unsloth Qwen3.8–27B GGUF: https://huggingface.co/unsloth/Qwen3.8–27B‑GGUF

ссылка на оригинал статьи https://habr.com/ru/articles/1086742/