Плотная 27B-модель на двух RTX 3090 в llama.cpp из коробки даёт 32 токена в секунду. Интуитивно понятно, что это не предел: две карты по 24 ГБ и 936 ГБ/с каждая не должны выдавать столько, сколько выдаёт одна. Значит, где-то узкое горлышко, и надо разобраться, что и где подкрутить. Оказалось, что горлышек несколько, и все они снимаются флагами запуска: те же веса — 75 токенов в секунду. Ниже каждый флаг описан одинаково: зачем, что писать, что даёт, где ломается. В конце — готовый docker run.

Стенд: 2× RTX 3090 (24 ГБ, PCIe 4.0 x16, без NVLink), Threadripper 3960X, 121 ГБ RAM, Ubuntu 26.04, драйвер 595.71.05. llama.cpp — образ ghcr.io/ggml-org/llama.cpp:full-cuda, build 10666. Модель — Qwen3.8-27B (плотная, мультимодальная, с MTP-головой внутри GGUF).

Результат: 32.4 → 74.8 ток/с на генерации, два запроса по 262 144 токена одновременно, качество на моих тестах не изменилось.

Почему 32 — это не «упёрлись в железо»

Плотная модель на каждый токен читает все веса. Файл 24.1 ГБ, память 3090 — ~936 ГБ/с. При послойном разбиении карты работают по очереди: 12 ГБ / 936 ГБ/с ≈ 12.9 мс на карту, 25.7 мс на токен, потолок ≈ 39 ток/с. Из коробки — 32.4, то есть 83% от потолка. Сам потолок низкий, и его поднимают ровно две вещи: читать веса на обеих картах одновременно и получать больше одного токена за проход. Это приёмы 2 и 1.

Приём 1. Включить MTP-спекулятивное декодирование

Зачем. В GGUF Qwen3.8 лежит слой Multi-Token Prediction (blk.64.nextn.*) — встроенная draft-модель. По умолчанию llama.cpp его не использует и пишет в лог unused tensor.

Флаги:

--spec-type draft-mtp --spec-draft-n-max 3
-ctkd q4_0 -ctvd q4_0

Эффект: 32.4 → 58.5 ток/с (послойный режим), ×1.8.

Где ломается:

  • KV-кэш черновика — отдельный. -ctk/-ctv на него не действуют; без -ctkd/-ctvd он хранится в f16 и молча ест VRAM.

  • Глубина черновика больше 4 вредит. Замеры: 2 → 52.8, 3 → 57.3, 4 → 57.6, 6 → 54.3 ток/с. Ставьте 3 или 4.

  • Выигрыш зависит от промпта: на коде и структурированном тексте черновик угадывает чаще, на свободном рассуждении — реже. Все цифры здесь на одном фиксированном промпте.

Приём 2. -sm tensor вместо -sm layer

Зачем. С build 10666 у --split-mode есть значение tensor — тензорный параллелизм через NCCL: обе карты считают каждый слой одновременно, вместо того чтобы работать по очереди.

Флаг: -sm tensor. Плюс на контейнере обязательно --ipc=host (или --shm-size).

Эффект (обе колонки с MTP, три прогона на точку, разброс ±1 ток/с):

-sm layer

-sm tensor

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

58.5

74.7

Генерация @ 130K

41.4

58.9

Генерация @ 240K

34.4

55.4

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

36.3 + 36.3

47.4 + 50.3

Обработка промпта @ 40K

1410

1121

Обработка промпта @ 240K

764

743

VRAM

42.4 ГБ

43.6 ГБ

Чем длиннее контекст, тем больше выигрыш. Моё объяснение (не измерение): при послойном разбиении карта читает свой кусок KV-кэша, пока вторая ждёт; при тензорном обе читают параллельно, а по шине ходит только all-reduce активаций, который от длины контекста не зависит.

Цена: обработка промпта −20% на 40K, −11% на 130K, −3% на 240K. Для интерактивной работы выгодно; для batch-прогонов коротких промптов — не факт.

Где ломается:

  • Без --ipc=host первый же decode падает с CUDA error: unhandled system error в ggml_backend_cuda_comm_allreduce_nccl. Сообщение на docker не намекает.

  • -ts не работает: llama_params_fit is not implemented for SPLIT_MODE_TENSOR. Автоподбора нет, размер контекста подбирайте руками и проверяйте по nvidia-smi.

  • backend sampling not supported в логе — не ошибка: сэмплирование уезжает на CPU, на скорости не сказалось.

  • -sm row на этой конфигурации не работает вообще: error loading model: device CUDA0 does not support split buffers.

  • У меня обе карты на PCIe 4.0 x16. На x8/x4 all-reduce дороже, результат может быть другим — не проверял.

Приёмы 1 и 2 вместе (одни веса, один промпт):

без MTP

с MTP

-sm layer

32.4

58.5

-sm tensor

48.2

74.8

Приём 3. Выбирать сборку GGUF по таблице тензоров

Зачем. Два файла с одной меткой «Q6» могут быть устроены по-разному, и меньший не обязательно быстрее. Эффект здесь единицы процентов, раздел скорее про метод.

Что я сравнил. Четыре сборки одной модели: unsloth Q8_0, bartowski Q8_0, bartowski Q6_K_L, orcarouter Uncensored (абляция). Качество: все четыре — 13/16 на моём наборе, провалили одни и те же три задачи. Набор: 3 задачи на код с исполнением, 2 вызова функций с проверкой JSON, 3 на формат, 5 логических ловушек, тест на второе system-сообщение, needle-in-a-haystack на 69K и 240K (3/3 у всех). Это дымовой тест, не бенчмарк; perplexity не мерил.

Скорость в один день, одни аргументы: unsloth Q8_0 — 57.6, bartowski Q6_K_L — 60.2 ток/с. Двумя неделями раньше плоский Q6_K от unsloth против того же Q8_0: 56.0 против 56.7 — ничего. Прямого замера Q6 против Q6 в один день нет (unsloth к тому моменту удалил плоские K-кванты), но через общую базу Q8_0 картина такая: Q6 от bartowski быстрее Q8, Q6 от unsloth — нет.

Почему (гипотеза, согласуется с замерами, вручную не проверял). Таблицы тензоров двух файлов:

bartowski Q6_K_L

unsloth UD-Q6_K

Типов квантования

3

7

token_embd / output

Q8_0 / Q8_0

Q6_K / Q8_0

Блок blk.64 (голова MTP)

все 8 тензоров Q4_0

eh_proj — Q6_K

Размер файла

24.1 ГБ

~22.9 ГБ

При --spec-draft-n-max 3 голова MTP выполняется три раза за шаг — это самый горячий блок в модели, и её формат важнее формата тела. bartowski положил её в Q4_0, unsloth — в Q6_K. Файл bartowski больше, но быстрее, так что дело не в пропускной способности памяти.

Как применить:

  • Перед бенчмарком сдампите таблицу тензоров. Заголовок GGUF читается последовательно; единственная подлость — массивы в метаданных нужно вычитывать до конца, иначе поедет позиция. Дампер — полсотни строк на Python.

  • Заголовок файла с HuggingFace берётся range-запросом, качать 24 ГБ не нужно:

curl -sSL -r 0-16000000 -o header.gguf \
  https://huggingface.co/<repo>/resolve/main/<file>.gguf
  • Не сравнивайте сборки по draft acceptance из лога: у меня он гулял от 56% до 100% в зависимости от промпта. Сравнивайте конечную скорость на одинаковом входе.

  • На модели без MTP-головы вывод «bartowski быстрее» может не повториться.

Приём 4. Брать чат-шаблон отдельно от весов

Зачем. С --jinja llama.cpp форматирует запросы шаблоном из метаданных GGUF. Шаблон bartowski — стоковый от Qwen, и на трёх проверках он:

  • молча выбрасывает все system-сообщения после первого;

  • отвергает reasoning_effort=high ошибкой;

  • не валидирует аргументы вызова функций.

В сборке unsloth все три места исправлены.

Флаг:

--chat-template-file /models/Qwen3.8-27B/chat-template-unsloth.jinja

Шаблон — строковое поле tokenizer.chat_template в метаданных, вынимается из чужого GGUF тем же range-запросом.

Эффект: после подмены второе system-сообщение доходит, вызов функции приходит с правильными аргументами, reasoning_effort=high принимается. Скорость не меняется.

Где ломается: квант-репозитории живут своей жизнью. unsloth перезалил файлы через несколько часов после моей первой загрузки (с починенным шаблоном), а через неделю заменил плоские K-кванты семейством UD-*. Проверяйте по коммитам квант-репозитория и sha256, а не по дате релиза модели.

Приём 5. q4_0 для KV-кэша

Зачем. KV-кэш в f16/q8_0 на 262K токенов и двух слотах требует ~52 ГБ и не влезает в 48. На q4_0 тот же пул занимает вдвое меньше.

Флаги:

-ctk q4_0 -ctv q4_0 -c 524288 --parallel 2 -ub 256

Эффект: два слота по 262 144 токена одновременно на тех же 48 ГБ. Не путайте: 524 288 — суммарный пул, а не длина одного запроса.

Что проверил: multi-needle retrieval на 240K (92% слота), 20 отвлекающих записей с кодами, отличающимися на один символ, — 10/10. Тест говорит только, что точное извлечение не сломалось; о качестве рассуждения на длинном контексте он не говорит ничего. На некоторых моделях q4_0 длинный контекст портит заметно — проверьте на своей нагрузке.

Где ломается (при измерении, обе ловушки дали мне ложные провалы):

  • маленький n_predict обрезает ответ — модель успевает только начать рассуждать;

  • нельзя грепать весь вывод: в рассуждении модель цитирует отвлекающие коды, правильно их отвергая. Оценивайте только текст после </think>.


Итоговый конфиг

docker run -d --name qwen38 --restart unless-stopped --gpus all \
  --ipc=host --shm-size=2g \
  -p 8080:8080 -v /models:/models:ro \
  --entrypoint /app/llama-server ghcr.io/ggml-org/llama.cpp:full-cuda \
  --model /models/Qwen3.8-27B-bartowski/Qwen3.8-27B-Q6_K_L.gguf \
  --mmproj /models/Qwen3.8-27B-bartowski/mmproj-Qwen3.8-27B-f16.gguf \
  --chat-template-file /models/Qwen3.8-27B/chat-template-unsloth.jinja \
  --flash-attn on -sm tensor -ngl 99 --no-mmap \
  -ctk q4_0 -ctv q4_0 -ctkd q4_0 -ctvd q4_0 \
  -c 524288 --parallel 2 -ub 256 \
  --spec-type draft-mtp --spec-draft-n-max 3 \
  --cache-ram 16384 --jinja --reasoning off \
  --host 0.0.0.0 --port 8080

74.8 ток/с генерация, 1121 ток/с обработка промпта при 40K, два слота по 262 144, 43.6 ГБ VRAM из 48. Мультимодальность через --mmproj работает.

Как проверить, что стало лучше, а не хуже

llama-server возвращает timings в ответе /completion: predicted_per_second и prompt_per_second. Отдельный бенчмарк не нужен.

После каждой смены флага или сборки:

  1. Скорость. Три прогона, "cache_prompt": false, temperature: 0, короткий промпт и длинный (40K, 130K, 240K). Если разброс больше пары процентов — сравнивать рано.

  2. Качество. Задачи с машинной проверкой: код исполняется и проверяется ассертами, вызов функции сверяется по JSON, формат — регуляркой. Разницу Q6/Q8 такой набор не поймает, но поймает сломанный шаблон или провал вызова функций — именно это ломается чаще всего.

  3. Длинный контекст. Needle-in-a-haystack на глубинах 10/50/90%. Считайте промпт в токенах, а не в символах: у меня первый прогон улетел на 540K вместо 120K и «провалил» тест на всех четырёх сборках сразу.

Что не сработало, чтобы не тратить время: -sm row (не поддерживается), плоский Q6 от unsloth (быстрее Q8 не стал), абляция Uncensored (52.7 ток/с — правка весов сбивает MTP-голову), -ts в тензорном режиме (игнорируется).

Если отдаёте это агенту

Текст устроен как инструкция: у каждого приёма есть флаг, измеренный эффект и строки ошибок, по которым узнаются ловушки. Его можно отдать агенту с доступом к серверу целиком и попросить применить к своей конфигурации, а проверять — по разделу «Как проверить». Самая ценная часть при этом — список «что не сработало»: ровно в эти тупики полезет любой, кто настраивает с нуля.

Комментарии (0)