Qwen3.8. MTP и тензорный параллелизм в llama.cpp: 75 ток-сек на двух RTX 3090. ai.. ai. gguf.. ai. gguf. llama.cpp.. ai. gguf. llama.cpp. llm.. ai. gguf. llama.cpp. llm. qwen.. ai. gguf. llama.cpp. llm. qwen. qwen3.8.. ai. gguf. llama.cpp. llm. qwen. qwen3.8. искусственный интеллект.. ai. gguf. llama.cpp. llm. qwen. qwen3.8. искусственный интеллект. квантование.. ai. gguf. llama.cpp. llm. qwen. qwen3.8. искусственный интеллект. квантование. Компьютерное железо.. ai. gguf. llama.cpp. llm. qwen. qwen3.8. искусственный интеллект. квантование. Компьютерное железо. локальные модели.. ai. gguf. llama.cpp. llm. qwen. qwen3.8. искусственный интеллект. квантование. Компьютерное железо. локальные модели. локальные нейросети.. ai. gguf. llama.cpp. llm. qwen. qwen3.8. искусственный интеллект. квантование. Компьютерное железо. локальные модели. локальные нейросети. Настройка Linux.

Плотная 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 в тензорном режиме (игнорируется).

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

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

Автор: kotafey

Источник