Плотная 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 ток/с):
|
|
|
|---|---|---|
Генерация, короткий контекст |
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 |
|
|---|---|---|
|
32.4 |
58.5 |
|
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 |
|
Q8_0 / Q8_0 |
Q6_K / Q8_0 |
Блок |
все 8 тензоров Q4_0 |
|
Размер файла |
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. Отдельный бенчмарк не нужен.
После каждой смены флага или сборки:
Скорость. Три прогона,
"cache_prompt": false,temperature: 0, короткий промпт и длинный (40K, 130K, 240K). Если разброс больше пары процентов — сравнивать рано.Качество. Задачи с машинной проверкой: код исполняется и проверяется ассертами, вызов функции сверяется по JSON, формат — регуляркой. Разницу Q6/Q8 такой набор не поймает, но поймает сломанный шаблон или провал вызова функций — именно это ломается чаще всего.
Длинный контекст. Needle-in-a-haystack на глубинах 10/50/90%. Считайте промпт в токенах, а не в символах: у меня первый прогон улетел на 540K вместо 120K и «провалил» тест на всех четырёх сборках сразу.
Что не сработало, чтобы не тратить время: -sm row (не поддерживается), плоский Q6 от unsloth (быстрее Q8 не стал), абляция Uncensored (52.7 ток/с — правка весов сбивает MTP-голову), -ts в тензорном режиме (игнорируется).
Если отдаёте это агенту
Текст устроен как инструкция: у каждого приёма есть флаг, измеренный эффект и строки ошибок, по которым узнаются ловушки. Его можно отдать агенту с доступом к серверу целиком и попросить применить к своей конфигурации, а проверять — по разделу «Как проверить». Самая ценная часть при этом — список «что не сработало»: ровно в эти тупики полезет любой, кто настраивает с нуля.