35B-модель на RTX 5080 и RTX 3060: 109 ток/с через PCIe Gen2 x4

—

от автора

Как MoE и послойный сплит обнуляют аргумент «одна видеокарта лучше двух».


На Хабре этому посвящена отдельная статья: «Собираем сервер под свой локальный ИИ: сколько нужно VRAM и почему одна видеокарта лучше двух». Там прямым текстом: «1 видеокарта на 32 ГБ лучше чем 2 по 16ГБ», а причина в шине. Второй слот на обычной материнке работает на урезанных x4 или x8, и обмен между картами упирается именно в него. Тот же спор идёт в комментариях под сравнениями вроде «две RTX 5060 Ti 16GB или одна RTX 3090 24GB» и в обсуждениях сборок на нескольких картах. В комментариях к той же статье возражают сразу: 24 ГБ, мол, отлично тянут 27B и 35B-MoE с контекстом 256K.

У меня Qwen3.6-35B-A3B работает на RTX 5080 и RTX 3060. Причём 3060 висит на PCIe Gen2 x4. Это 2 ГБ/с, в восемь раз медленнее, чем линия у 5080. Скорость генерации: 109 токенов в секунду на коротком промпте и 77 на контексте 53 тысяч токенов.

Разберу, почему это работает, и в каком случае аргумент про PCIe всё-таки прав.

Стенд

  • CPU: Ryzen 7 5800X, 8 ядер / 16 потоков

  • RAM: 64 ГБ DDR4

  • GPU 0: RTX 3060 12 ГБ, GDDR6, шина 192 бита, 15 Гбит/с → 360 ГБ/с

  • GPU 1: RTX 5080 16 ГБ, GDDR7, шина 256 бит, 30 Гбит/с → 960 ГБ/с

  • NVLink: нет

  • OS: Arch Linux, ядро 7.1.11 (i use arch btw)

  • Драйвер 610.57.04, CUDA 13.3

  • llama.cpp build 10738

Суммарно 28 ГБ видеопамяти. Полосы пропускания складывать нельзя: карты работают последовательно, а не параллельно. К этому вернусь.

Про PCIe разговор особый, потому что это самая интересная часть стенда:

GPU 0 (RTX 3060):  Gen2 x4   ≈ 2 ГБ/с    (карта умеет Gen4 x16)GPU 1 (RTX 5080):  Gen3 x16  ≈ 16 ГБ/с   (карта умеет Gen5 x16)

3060 досталось четыре линии PCIe вместо шестнадцати, и линия обучилась на Gen2. Обе карты висят на одном PCIe-хосте. Никакого NVLink, никакого x16/x16.

Почему PCIe здесь ни при чём

Классический аргумент против двух карт опирается на тензорный параллелизм. Там каждая карта считает свой кусок каждого слоя, и после каждого слоя нужно синхронизировать результаты через all-reduce. Чем длиннее контекст, тем больше данных гоняется по шине. Вот там Gen2 x4 действительно убивает.

У меня включён послойный сплит (в llama.cpp это режим по умолчанию). Слои просто поделены между картами: одна держит первую половину, вторая другую. По шине ходит не вес, а активация, и ровно один раз за токен.

Считаем. Скрытая размерность модели равна 2048. Активация в fp16 занимает 2048 × 2 байта, то есть 4 КБ. Один переход между картами за токен.

При 109 ток/с это 437 КБ/с. Линия Gen2 x4 даёт 2 ГБ/с. Загрузка шины измеряется сотыми долями процента.

Даже на префилле картина не меняется. Батч из 2048 токенов занимает 8 МБ, и они размазываются по всей обработке промпта. При 1500 ток/с префилла получается около 6 МБ/с.

Вот почему кривая Gen2 x4 не мешает: она не участвует в горячем пути. Веса остаются на своих картах, по шине едет только активация.

Почему MoE меняет арифметику

Если бы Qwen3.6-35B-A3B была плотной моделью, всё было бы иначе. Файл весит 21,3 ГиБ, и плотная модель читала бы все эти гигабайты на каждый токен. На 5080 с её 960 ГБ/с это потолок около 42 ток/с, и то без второй карты. С послойным сплитом карты работают по очереди, поэтому полосы не складываются, а время суммируется.

Но Qwen3.6-35B-A3B это MoE. Из 35B параметров на каждом токене активируются примерно 3B: 8 маршрутизируемых экспертов из 256 плюс один общий. Остальные 32B лежат в памяти и не читаются.

Отсюда и результат. Модель на 35B параметров по требованиям к памяти, но по стоимости одного токена ближе к 3B. Именно это рассогласование и делает возможной работу на двух разных потребительских картах.

Структура модели, по карточке на HuggingFace:

  • 40 слоёв, скрытая размерность 2048

  • 256 экспертов, из них 8 маршрутизируемых + 1 общий на токен

  • чередование Gated DeltaNet (линейное внимание) и Gated Attention

  • контекст 262 144 токена нативно

  • MTP-голова для спекулятивного декодирования

Теперь про внимание. Полное внимание стоит в каждом четвёртом слое, в остальных работает Gated DeltaNet с состоянием постоянного размера. Поэтому KV-кэш растёт медленно, и скорость генерации почти не зависит от длины контекста. Ниже это видно в замерах.

Замеры

Все замеры сняты через /completion с cache_prompt: false, то есть промпт каждый раз обрабатывается заново. Пресет не менялся, сервер не перезапускался, замеры шли последовательно. Ниже не пересчёт старых чисел, а отдельный прогон на живом сервере.

Базовая скорость

Короткий промпт, генерация 256 токенов, пять прогонов подряд:

Прогон

Генерация, ток/с

1

109,2

2

109,7

3

106,0

4

109,6

5

108,4

Разброс между прогонами 3,5%, среднее 108,6. Это меньше, чем разница между большинством конфигураций ниже, так что базовую скорость можно считать устойчивой.

От типа контента

Здесь разброс уже не шумовой, а содержательный:

Задача

Генерация, ток/с

Принято черновиков MTP

Код на Python

114,6

85,7%

JSON по схеме

113,3

85,1%

Код на русском

100,4

68,8%

Проза на английском

96,0

62,9%

Проза на русском

95,4

62,0%

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

Сколько токенов даёт один проход модели

Тот же замер, но с подсчётом циклов спекулятивного декодирования (предсказано минус принято):

Задача

Токенов за проход

Принято черновиков

Код на Python

2,70

85,7%

JSON по схеме

2,68

85,1%

Проза на русском

2,22

62,0%

При --spec-draft-n-max 2 теоретический максимум равен трём токенам за проход. На структурированном выводе модель подходит к нему вплотную.

Оговорюсь: токенов за проход не то же самое, что ускорение. Проверяющий проход обрабатывает несколько токенов сразу и стоит дороже одного. Unsloth в карточке модели обещает ускорение в 1,5–2 раза; я это не изолировал, потому что для честного A/B пришлось бы перезапускать сервер без --spec-type draft-mtp. Пресет я не трогал.

Длинный контекст

Самая интересная таблица:

Контекст, токенов

Обработка промпта, ток/с

Генерация, ток/с

13 865

1820

90,8

27 703

1708

87,9

42 528

1586

80,6

53 401

1504

76,7

Генерация падает на 15% при росте контекста с 14K до 53K: 90,8 → 76,7. Заметно, но не обвал. Работает линейное внимание: состояние Gated DeltaNet не растёт с длиной контекста, а полное внимание стоит только в десяти слоях из сорока, и KV там узкий — 2 головы по 256 измерений, около 10 КБ на токен на все десять слоёв. У плотной модели с полным вниманием на всех сорока слоях кривая здесь ушла бы вниз гораздо круче.

Обработка промпта падает быстрее (1820 → 1504, это 17%), но там работает вычисление, а не память.

Два слота параллельно

Пресет задаёт parallel = 2, то есть контекст 131 072 делится на два слота по 65 536. Два одновременных запроса:

Генерация, ток/с

Слот 1 (код)

69,3

Слот 2 (проза)

60,5

Суммарно

≈ 130

Суммарная пропускная способность выросла с 109 до 130 ток/с, но каждый отдельный запрос замедлился почти вдвое. Для агента, который ходит в сервер параллельно, это скорее минус: два запроса ждут друг друга дольше, чем если бы шли по очереди.

Конфиг

Пресет в ~/.config/llama.cpp/models.ini:

[unsloth/Qwen3.6-35B-A3B-MTP-GGUF-64K]hf = unsloth/Qwen3.6-35B-A3B-MTP-GGUF:UD-Q4_K_XLctx-size = 131072cache-type-k = q8_0cache-type-v = q8_0spec-type = draft-mtpspec-draft-n-max = 2temperature = 1.0top-p = 0.95top-k = 20min-p = 0.0presence-penalty = 1.5repeat-penalty = 1.0

Запускается через роутер llama-server --models-preset, который сам поднимает модель отдельным процессом. Итоговая команда, которую он собирает:

llama-server --host 127.0.0.1 --port <динамический> \  --alias unsloth/Qwen3.6-35B-A3B-MTP-GGUF-64K \  --hf-repo unsloth/Qwen3.6-35B-A3B-MTP-GGUF:UD-Q4_K_XL \  --ctx-size 131072 --parallel 2 \  --cache-type-k q8_0 --cache-type-v q8_0 \  --n-gpu-layers -1 --flash-attn on --load-mode mmap \  --spec-type draft-mtp --spec-draft-n-max 2 \  --temperature 1.0 --top-p 0.95 --top-k 20 --min-p 0.0 \  --presence-penalty 1.5 --repeat-penalty 1.0

Что здесь важно и чего нет:

Нет --split-mode. Стоит режим по умолчанию, то есть послойный. Именно поэтому Gen2 x4 не мешает.

Есть квантизация KV-кэша до q8_0. KV у этой модели узкий (2 головы), и на коротком контексте выигрыш от квантизации незаметен. Но он освобождает около гигабайта VRAM, а на этой паре карт каждый гигабайт решает, сколько слоёв уедет на быструю 5080. На длинном контексте экономия растёт линейно.

--load-mode mmap, а не mlock. Про это стоит сказать отдельно, потому что mlock тут не работает так, как читается. В ядре RLIMIT_MEMLOCK на моей машине выставлен в 8 МБ, а модель весит 22,85 ГБ. llama.cpp честно пишет в лог failed to mlock ... Cannot allocate memory, а VmLck в /proc/<pid>/status остаётся нулевым. То есть модель не заблокирована в памяти, и режим только создаёт иллюзию. mmap в этой ситуации честнее: файл остаётся в страничном кэше и переиспользуется при следующей загрузке.

Параметры сэмплирования взяты из карточки модели для режима размышления: temperature=1.0, top_p=0.95, top_k=20, min_p=0.0, presence_penalty=1.5.

parallel = 2 при включённом MTP. Unsloth в карточке прямо пишет, что -np > 1 и --mmproj вместе с MTP пока не поддерживаются. У меня parallel = 2, то есть -np 2, и связка работает: токены идут, все замеры выше сняты именно с неё. --mmproj в пресете нет, так что вторая половина предупреждения меня не касается.

Что ограничивает на самом деле

Замеры выше отвели подозрение от PCIe. Но что тогда упирается?

Я снимал nvidia-smi раз в секунду во время длинной генерации — 2200 токенов за 24 секунды, 23 сэмпла на карту. В статистику идут только сэмплы с ненулевой загрузкой, иначе в выборку попадает простой и портит минимум.

GPU

Средняя загрузка

Разброс

Средняя мощность

Разброс

Лимит

RTX 3060

62%

58–66%

110,7 Вт

102–113 Вт

170 Вт

RTX 5080

15%

14–18%

67,6 Вт

65–68 Вт

360 Вт

Вот это и есть ответ. 3060 работает на 62% и ест две трети своего лимита. 5080 при этом загружена на 15% и потребляет меньше пятой части того, что может. Карты не насыщены: быстрая простаивает, ожидая медленную.

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

Общая память под модель: 10 462 МиБ на 3060 и 14 986 МиБ на 5080. Быстрая карта получила заметно больше слоёв, но три четверти времени она всё равно ждёт.

Вывод: узкое место не шина и не пропускная способность памяти, а последовательная цепочка зависимостей. 5080 не может начать свои слои, пока 3060 не закончила свои, и наоборот. Сорок слоёв, сорок передач управления, и на каждой карта ждёт соседа. Мы платим латентностью, а не байтами.

MoE делает каждый шаг настолько дешёвым, что даже при такой расточительной схеме получается 109 ток/с.

Чего я не проверял

Три вещи, которые честнее назвать, чем умолчать.

Тензорный сплит на этой паре карт. Он должен убрать простои, потому что карты считают каждый слой одновременно. Но стоит он дороже по шине, а у 3060 линия Gen2 x4. Есть ли выигрыш на такой асимметричной паре, вопрос открытый.

MTP без MTP. Для честного замера нужен второй запуск сервера, а пресет я не менял.

Качество на длинном контексте. Все замеры скорости снимались на одном фиксированном промпте. Что модель одинаково хорошо отвечает на 53K и на 1K, я не проверял. Гонял только скорость.

Итоги

Аргумент «одна видеокарта лучше двух» верен для плотных моделей и тензорного параллелизма. Там полосы пропускания складываются в узкое место, и разница между картами бьёт больно.

Для MoE с послойным сплитом он не работает. Причины две:

  1. Активных параметров в разы меньше общих. 3B из 35B, а плотная модель такого размера читала бы в десять раз больше байт на токен.

  2. По шине едет активация, а не вес. 4 КБ на переход при 2 ГБ/с это ничто. Кривая линия 3060 просто не участвует в горячем пути.

Что в этой схеме действительно стоит денег и внимания: карты работают по очереди, поэтому латентность складывается, а суммарная полоса не растёт. Если бюджет позволяет одну быструю карту вместо двух медленных, плотную модель на ней будет удобнее. Если у вас уже есть две разные карты, как у меня, и вы берёте MoE-модель с узким KV — связка выдаёт 109 ток/с на коротких промптах и 77 на контексте 53 тысяч токенов.

Это по-прежнему не сервер. Но это стоит под столом и не требует ничего, кроме слотов PCIe.

Как повторить у себя

Пресет, с которого я начинал, лежит в открытом репозитории: github.com/aigen31/llama-cpp-preset-base, лицензия MIT. Это установщик, который раскладывает три вещи: лаунчер llama-qwen, INI-пресет ~/.config/llama.cpp/models.ini и проверку, что llama-server вообще есть в PATH.

Компилировать llama.cpp он не пытается. Если бинарника нет, скрипт просто показывает ссылку на апстрим и таблицу бэкендов под ваше железо: CUDA, ROCm, Vulkan, SYCL, Metal или чистый CPU. Сборка остаётся за вами, потому что правильный флаг зависит от того, что стоит в корпусе.

Модели описываются одним ключом hf с полной ссылкой и квантизацией, например hf = unsloth/Qwen3.6-35B-A3B-MTP-GGUF:UD-Q4_K_XL. При первом запросе llama-server сам скачает GGUF с Hugging Face. API-ключ генерируется в формате sk- плюс 48 символов, установщик неинтерактивный и годится для curl | bash. README продублирован на английском, русском и китайском.

Мой рабочий конфиг отличается от шаблона в репозитории: я дописал квантизацию KV-кэша и заменил mlock на mmap после того, как выяснил, что лимит на блокировку памяти на этой машине равен 8 МБ.


Пишу сервисы, приложения, MCP-серверы и другие продукты для бизнеса, так же публикую статьи на своём сайте: inteli-dev.ru. Консультация в Telegram

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