← На главную

Опубликовано

На CPU Q4_K_M сократил время обработки документа на 22%

Автор: Гусев Николай [портфолио]

Обновлено

Темы: llm, benchmarks, inference, cpu, local-models

Схема спекулятивного декодирования: draft предлагает блок, target проверяет и принимает совпавшие токены Автор: Гусев Николай

Это пятая часть серии про спекулятивное декодирование и оптимизацию локального инференса:

Четвёртая часть заканчивалась вопросом: какие из найденных на GPU оптимизаций перенесутся на чистый CPU-инференс без видеокарты? Для ответа взял рабочий двухсокетный сервер с AMD EPYC 7352 (архитектура Zen 2) и 256 ГБ RAM.

Замеры выполнялись внутри LXC-контейнера (Ubuntu 24.04 LTS), которому выделено 26 vCPU. Процесс llama-server был жёстко привязан к NUMA-узлу 0 и его локальной памяти; в конфигурации задано 12 потоков на декод и 24 потока на batch-префилл. Важная деталь: 26 vCPU контейнера не означают 26 выделенных физических ядер одного сокета. Кроме того, на этом же LXC-контейнере параллельно работал боевой шлюз с моделью Qwen3.5-9B, поэтому прогоны шли под естественной фоновой нагрузкой. Конфигурацию контейнера и рабочий сервис ради тестов я не останавливал.

В предыдущих частях мы варьировали четыре рычага: саму модель, настройки KV-кэша, квантизацию и алгоритм генерации. В этот раз я спустился на уровень ниже и проверил, можно ли заменить само ядро матричного умножения и получить прирост на том же Zen 2. Идея возникла во время исследования переноса движков на edge-устройства, но проверка бэкенда ZenDNN выделилась в самостоятельный CPU-эксперимент. Бэкенд успешно собрался и действительно взял на себя матричные операции Q4_K_M, однако на Qwen скорость префилла практически совпала со штатным ggml-cpu. При этом в тесте на 24 потоках декод резко просел (причина по имеющимся данным не установлена), а по другим моделям картина оказалась смешанной.

Куда более важный результат дало сопоставление двух квантов с исходным кодом llama.cpp. В замерах префилла более «тяжёлый» квант Q4_K_M обогнал Q3_K_S примерно на 41%. Анализ функции ggml_repack_get_optimal_repack_type() показал, что для Q3_K в диспетчере просто нет ветки перепаковки весов под векторные инструкции, тогда как для Q4_K она предусмотрена (при подходящей форме тензора). Это не изолированный причинный тест одного флага репака - одновременно менялись формат кванта и путь матричного умножения, - но именно это позволило сократить общее время обработки длинного документа на 22%.

Стенд и правила тестирования

  • Железо и ОС: 2 × AMD EPYC 7352 (Zen 2), 256 ГБ RAM на хосте (Proxmox). Внутри рабочего LXC-контейнера: Ubuntu 24.04 LTS, выделено 26 vCPU.
  • Движок и сборка: llama.cpp 0.3.0-dev, сборка под архитектуру znver2, нативный бэкенд выключен, перепаковка весов (repack) включена.
  • Базовая конфигурация модели: Qwen3.5-9B в кванте Q3_K_S, контекст 131 072 токена на два слота, 12 потоков на генерацию (-t 12) и 24 на префилл (-tb 24). Утилита numactl привязывает память и вычисления к NUMA-узлу 0. На этом узле 24 физических ядра, но в cpuset контейнера попадает только 13 (остальные vCPU относятся ко второму сокету), поэтому флаг -tb 24 не означает работу на 24 физических ядрах одного узла. Процесс занимает 9.3 ГБ RSS (в пике - 9.4 ГБ).
  • Тестовый корпус: шесть задач трёх классов, знакомых по первой части серии:
  1. Два русскоязычных текста на свободную генерацию (ru-doc-01, ru-doc-02).
  2. Три короткие детерминированные задачи с автоматической проверкой: арифметическая сумма (check-sum), генерация JSON и классификация маршрута (check-route).
  3. Поиск записи со статусом deprecated в системном журнале объёмом ~30 тысяч токенов - чистый тест префилла и времени обработки длинного документа.
  • Параметры генерации: температура 0, top_k 1, кэш промпта выключен, метрики снимаются напрямую из timings сервера. Корпус заморожен до старта, baseline снимается в тех же условиях, что и тестируемые методы.

Специфика боевой нагрузки: целевая задача шлюза - саммаризация крупных отчётов. Это определяет половину выводов статьи: документ на 30 тысяч токенов нужно сначала «прочитать» (prefill), и это чтение на CPU обходится кратно дороже генерации короткого ответа. Поэтому главная бизнес-метрика здесь - секунды на документ (wall time), а не скорость генерации токенов на выходе (decode t/s).

Отдельный стенд для ZenDNN: бэкенд проверялся 20 октября через llama-bench, а не через HTTP-корпус статьи. Для него собрали отдельный бинарник llama.cpp 0.3.0-dev с флагом GGML_ZENDNN=ON, а контрольный ggml-cpu скомпилировали из того же дерева исходников с repack и -march=native. В матрице llama-bench задано два повтора (-r 2), основная модель - Qwen3.5-9B Q4_K_M, дополнительно проверены YandexGPT-5-Lite-8B и Gemma 4 E4B. Точный хэш коммита в артефактах сборки не сохранился (build: unknown), поэтому тест фиксирует сравнение внутри одного дерева исходников на уровне скорости вычислительного ядра, без оценки качества ответов.

Работа над ошибками: что пришлось пересмотреть после первых прогонов

  1. Несравнимость фаз до и после перезагрузки контейнера. Первичный baseline и тесты от 16-17 октября оказались разделены перезагрузкой LXC-контейнера. Повторный прогон идентичного конфига 19 октября показал сдвиг профиля: генерация на коротких задачах выросла с ~8 до ~10 t/s, а префилл просел с 38-41 до 31-33 t/s. Считать дельты между такими сессиями нельзя. Поэтому все сравнения методов ниже опираются исключительно на Session 2, где baseline, MTP, связка FA plus KV q8_0 и ngram измерялись непрерывно в рамках одной сессии.
  2. Фоновый шум и разброс значений. Из-за параллельной работы боевого шлюза на коротких задачах возникала высокая дисперсия. Например, check-route на baseline в трёх повторах показал 9.42, 7.32 и 3.09 t/s, а с ngram разброс оказался ещё выше. Ориентироваться на лучший повтор нельзя - это лишь верхняя наблюдённая граница. Поэтому короткие задачи ниже сравниваются строго по медианам трёх прогонов (n=3), а ресурсоёмкие длинные задачи - по единственному прогону (n=1) с явным указанием этого ограничения.
  3. Сбой чекера суммы. В файле tasks.json для задачи со сложением шести чисел задан верный эталон - 109834. Однако в логах Session 2 скрипт проверки записал correct=false во всех фазах без исключения. Из-за расхождения между корпусом и сырыми логами задача check-sum исключена из оценки качества и используется только как замер времени выполнения.
  4. Протокол генерации и обрезка рассуждений. Первые слепые тесты сравнивали ответы, полученные через сырой эндпоинт /completion при включённом режиме рассуждений (reasoning). Как выяснилось позже, лимит токенов обрезал генерацию прямо внутри <think>-блока, до формирования финального ответа. Переход на chat template изменил картину настолько существенно, что этому посвящён отдельный раздел.

Baseline (Session 2)

МетрикаЗначениеОснова измерения
Короткий decodeмедианы 7.32-10.08 t/s5 задач по 3 повтора (check-route шумный)
Короткий prefillмедианы 31.22-33.33 t/s5 задач по 3 повтора
Длинный prefill (29 796 токенов)31.31 t/s1 прогон (n=1)
Длинный decode3.82 t/s8 выходных токенов (справочно)
Общее время (Wall) длинной задачи953.48 с1 прогон (n=1)
Память процесса (RSS)9.3 ГБ (пик 9.4 ГБ)мониторинг процесса

Скорость префилла на baseline ожидаемо снижается с ростом глубины контекста: с 36.28 t/s на 8192 токенах до 31.62 t/s на 28 672 токенах (падение примерно на 13% в рамках одной сессии).

Сам ответ в длинной задаче состоит всего из 8 токенов, поэтому скорость его декода приведена лишь справочно - для обработки документа решающее значение имеет общее время (wall), которое составило чуть меньше 16 минут (953.48 с).

Сводная матрица результатов

Метод / ТехнологияКонтекст по прошлым частям (GPU)Результат на CPU-стендеВывод для продакшена
MTP (Multi-Token Prediction)На RTX 4060 тестировалась младшая Qwen3.5-4BQwen3.5-9B Q3_K_S: медианный wall коротких задач вырос; длинный wall - 1009.81 с против 953.48 с (n=1)Ускорения на CPU нет; накладные расходы драфта не окупаются
Flash Attention plus KV q8_0На GPU оценивали экономию памяти и качество на 96KДлинный wall вырос с 953.48 с до 1758.7 с; prefill упал с 31.31 до 16.96 t/s (n=1)Экономит RAM, но катастрофически (plus 84% по времени) замедляет длинный префилл на CPU
ngram-cacheВ прошлых частях на GPU не публиковалсяДлинный wall - 960.34 с против 953.48 с (n=1); на коротких задачах результаты в пределах шумаЗначимого выигрыша или проигрыша не зафиксировано
Bonsai 2 (тернарные веса)На RTX 4060 тестировали Bonsai 2 27BЗамеряли Bonsai 2 9B в форке PrismML; требует особого chat-протокола, медленнее на проверенных задачахПрямое сравнение ограничено разницей протоколов; выигрыша для шлюза нет
ZenDNNОтдельный тест llama-bench (не спекуляция)Qwen Q4_K_M: pp512 plus 0.1%, pp1024 minus 0.3%; Gemma E4B: pp512 plus 3.5% (n=2)Работоспособность подтверждена, но системного преимущества над ggml-cpu нет

MTP: высокий acceptance rate не дал ускорения на CPU

В рамках Session 2 доля принятых черновых токенов (acceptance rate) у MTP составила внушительные 0.611 по 16 замерам при средней принятой длине шага 2.83 токена. Однако высокий acceptance сам по себе не гарантирует выигрыша во времени.

Ниже приведены данные из s2-mtp.json: для коротких задач указаны медианы по трём повторам (n=3), для длинного документа - единственный прогон (n=1).

ЗадачаBaseline wall, сMTP wall, сИзменение wallBaseline decode, t/sMTP decode, t/s
Русский текст 1 (n=3)20.8828.42plus 36.1%9.997.19
Русский текст 2 (n=3)17.2531.36plus 81.8%10.085.34
Сумма (n=3, только время)5.857.02plus 20.0%9.987.90
JSON (n=3)2.983.65plus 22.5%10.077.24
Маршрут (n=3)2.393.14plus 31.4%7.323.21
Длинный документ (n=1)953.481009.81plus 5.9%3.822.52

MTP замедлил выполнение всех без исключения задач. Даже если смотреть не по медианам, а по лучшим повторам, MTP проигрывает от 11% до 52% на коротких задачах (при разбросе внутри фазы до 205%).

Почему так происходит:

  • Просадка префилла: медианная скорость префилла с MTP снизилась на 5.5-6.2% на коротких задачах, а на длинном контексте отставание составило 3.8% (на глубине 8192) и 5.2% (на глубине 28 672). Это согласуется с появлением постоянной добавочной работы на каждом токене, хотя без послойного профилирования нельзя утверждать, что дело исключительно в проходе по драфт-голове.
  • Накладные расходы в декоде: расчёт по серверным timings для задачи ru-doc-01 показывает, что один шаг спекуляции (дающий в среднем 2.83 токена) занял 0.389 с, тогда как обычная пошаговая генерация того же объёма занимает около 0.278 с. На CPU затраты на генерацию и верификацию чернового блока не окупают сэкономленные шаги основного декода.

(Примечание: поскольку короткие тесты MTP запускались через raw /completion, где ответы частично обрезались внутри блока рассуждений, эти данные отражают только скорость работы движка, но не эквивалентность качества ответов).

Flash Attention с KV-кэшем q8_0: двукратное замедление длинного запроса

В раннем тестовом запуске от 16 октября добавление флагов -fa on -ctk q8_0 -ctv q8_0 снизило потребление памяти (RSS) с 9.7 до 6.6 ГБ. В Session 2 я проверил эту связку подряд с baseline, чтобы точно измерить влияние на скорость (в этой сессии RSS в JSON не логировался, а у baseline стоял режим -fa auto без явного запрета FA в логах).

На коротких задачах медианная скорость генерации практически не изменилась, а wall time колебался в пределах шума (от minus 0.4% до minus 10.5% на крошечной задаче check-route из пяти выходных токенов). Но запуск на 30-тысячном контексте выявил серьёзную деградацию:

Метрика (контекст ~30K, n=1)BaselineFA plus KV q8_0Дельта
Префилл, токенов/с31.3116.96minus 45.8%
Генерация, токенов/с3.823.33minus 12.8%
Общее время (Wall), с953.481758.70plus 84.5%

Контрольные точки по глубине контекста (8K, 16K и 28K токенов) показывают, что отставание префилла нелинейно растёт с длиной документа: minus 8%, minus 23% и minus 45% соответственно. Поскольку оба флага (FA и квантизация KV-кэша в q8_0) включались одновременно, эксперимент не разделяет их индивидуальный вклад. Также стоит помнить, что у Qwen3.5 гибридная архитектура внимания, поэтому квадратичный рост сложности относится не ко всем слоям равномерно.

Вывод: для длинных документов на CPU эта связка противопоказана, если приоритетом является время ответа. Использовать её имеет смысл только на коротких контекстах при жёстком дефиците оперативной памяти.

ngram-cache: в пределах погрешности

Механизм ngram-cache ищет повторяющиеся последовательности токенов в промпте и использует их как черновые кандидаты для спекуляции. Теоретически это должно помогать на шаблонных текстах и логах.

Задача (n=3)Baseline gen, t/sngram gen, t/sИзменение genBaseline wall, сngram wall, сИзменение wall
Русский текст 19.999.62minus 3.7%20.8821.61plus 3.5%
Русский текст 210.088.34minus 17.3%17.2520.58plus 19.3%
Сумма (только время)9.989.92minus 0.6%5.855.850.0%
JSON10.079.88minus 1.9%2.982.94minus 1.3%
Маршрут7.329.41plus 28.6%2.392.29minus 4.2%

Счётчики acceptance для ngram в серверный JSON не пишутся. По итоговому времени (wall) результаты неоднородны: второй русский текст замедлился на 19.3%, а остальные задачи уложились в погрешность базового прогона. На длинном документе (n=1) время выполнения выросло на символические 0.7% (960.34 с против 953.48 с). Подтверждённого выигрыша на нашем корпусе метод не дал.

Bonsai 2: тернарные веса и цена отдельного форка

Для проверки тернарной модели Bonsai 2 9B (требующей отдельного форка llama.cpp от PrismML) потребовалось два подхода:

  1. Первая попытка через raw /completion с жадным декодированием (greedy) оказалась нерелевантной: документация Bonsai прямо предупреждает, что thinking-режим модели плохо совместим с greedy-сэмплингом и требует большого бюджета токенов. Пустые ответы в этом прогоне были следствием неверной методики, а не неспособности модели решить задачу.
  2. Во втором заходе модель запускалась по рекомендациям авторов: через chat-шаблон с отключённым рассуждением (enable_thinking=false) и температурой 0.7.

Из-за разницы протоколов (greedy/raw у Qwen против non-thinking chat с температурой 0.7 у Bonsai) таблицу ниже нельзя считать строгим сравнением скорости движков - она лишь фиксирует операционный результат:

Задача (n=3)Baseline (Qwen, raw/greedy)Bonsai 2 9B (chat, non-thinking)
JSON2.9 с (3/3 верно)4.4 с (3/3 верно)
Классификация маршрута2.3 с (3/3 верно)2.6 с (3/3 верно)
Русская записка20.3 с29.6 с

Даже с отключённым блоком рассуждений Bonsai 2 не показала преимуществ по времени, но при этом требует поддержки отдельного форка и специфических настроек сэмплирования. Для боевого шлюза такой переход нецелесообразен.

Где нашёлся реальный выигрыш: перепаковка весов (repack.cpp)

Когда ни спекулятивное декодирование, ни тернарные веса не дали ускорения, ключ к оптимизации нашёлся при сопоставлении двух форматов квантизации - Q3_K_S и Q4_K_M - с исходным кодом llama.cpp.

В файле ggml/src/ggml-cpu/repack.cpp находится функция-диспетчер ggml_repack_get_optimal_repack_type(). Перепаковка (repacking) - это переупорядочивание квантованных весов в памяти при загрузке модели таким образом, чтобы они идеально ложились на векторные SIMD-инструкции процессора (в нашем случае AVX2 на архитектуре Zen 2) и обрабатывались быстрым специализированным ядром вместо медленного универсального.

В диспетчере ggml_repack_get_optimal_repack_type() перечислены девять типов данных: Q1_0, Q2_K, Q4_0, Q4_K, Q5_K, Q6_K, Q8_0, IQ4_NL и MXFP4. Формата Q3_K в этой функции нет вообще. На нашем сборочном пути под Zen 2 (AVX2) оптимизированные ветки перепаковки доступны для Q4_0, Q4_K и IQ4_NL (при соблюдении условий по размерностям тензоров). Разумеется, сам факт наличия AVX2 не гарантирует, что абсолютно каждый тензор попадёт в быстрое ядро, но для основных матриц разница оказалась решающей.

Цифры наглядно подтверждают это различие в путях вычисления:

  • В синтетическом тесте llama-bench на коротком префилле (pp512) квант Q4_K_M выдал 42.94 t/s против 30.40 t/s у Q3_K_S - прирост на 41%.
  • В изолированном прогоне на глубине 29 796 токенов префилл ускорился с 21.55 до 27.44 t/s.
  • В боевом серверном замере на той же длине документа (n=1) скорость префилла выросла с 31.28 до 40.27 t/s, а общее время выполнения задачи (wall) сократилось с 954 до 742 секунд (экономия 22% времени, или более 3.5 минут на одном документе).

Строго говоря, это не изолированный тест флага repack, так как одновременно изменились и битность кванта, и вычислительное ядро. Однако практический результат очевиден: переход на более крупный квант Q4_K_M существенно ускорил чтение длинных промптов.

ZenDNN: альтернативный бэкенд, почти тот же результат

Убедившись, насколько сильно влияет выбор вычислительного ядра, я проверил специализированный бэкенд от AMD - ZenDNN. До того как задумываться о написании собственных ядер под edge-устройства, логично проверить, даст ли прирост готовая библиотека матричных операций на профильном процессоре Zen 2.

Сборка прошла успешно: в выводе llama-bench бэкенд определился как ZenDNN, а логи подтвердили, что операции MUL_MAT для весов Q4_K_M действительно идут через него.

Ниже приведено сравнение Qwen3.5-9B Q4_K_M на 12 потоках (llama-bench -r 2, указано среднее и разброс):

Длина промпта (pp)ZenDNN prefill, t/sggml-cpu prefill, t/sРазница prefillZenDNN decode, t/sggml-cpu decode, t/s
12840.98, разброс 0.4836.12, разброс 6.06plus 13.5%*7.96, разброс 0.176.38, разброс 1.57
51241.16, разброс 0.1341.13, разброс 0.32plus 0.1%7.80, разброс 0.037.63, разброс 0.19
102440.40, разброс 0.4440.54, разброс 0.01minus 0.3%6.79, разброс 0.227.59, разброс 0.11

*Примечание: на длине 128 контрольный прогон ggml-cpu дал аномальный разброс разброс 6.06 t/s, поэтому разницу в 13.5% нельзя считать статистически значимой.

На рабочих размерах батча (512 и 1024) скорость префилла у ZenDNN и штатного ggml-cpu совпала с точностью до десятых долей процента. В декоде на длине 1024 ZenDNN отстал примерно на 11%.Проверка на других архитектурах при 12 потоках и pp512 показала такую же пёструю картину: на YandexGPT-5-Lite-8B бэкенд ZenDNN отстал по префиллу на 2.0%, а на Gemma 4 E4B выиграл 3.5% (при чуть более медленном декоде у обеих моделей).

Проблема масштабирования по потокам: При сканировании числа потоков (thread sweep) обнаружилась неприятная аномалия. При переходе с 12 на 48 потоков префилл рос синхронно у обоих бэкендов (plus 51% у ZenDNN и plus 52% у ggml-cpu). Однако на 24 потоках декод в ZenDNN рухнул до 1.06 t/s (разброс 0.93) против 6.29 t/s (разброс 0.43) у штатного CPU-бэкенда, хотя на 48 потоках показатели снова выровнялись (3.56 против 3.64 t/s). Вероятная гипотеза - конфликт пулов потоков (OpenMP/внутренний пул), но без профилирования причина остаётся открытой. Попытка прогнать тест на глубине ~30K токенов завершилась ошибкой парсинга вывода на обоих бэкендах (885.6 с у ZenDNN и 14.3 с у CPU без валидных строк результата).

Вывод: штатный ggml-cpu с включённым repack уже оптимизирован под AVX2 настолько хорошо, что подключение внешнего ZenDNN на архитектуре Zen 2 не даёт ощутимого выигрыша, но привносит риск просадок при определённом числе потоков.

Пропускная способность памяти и предел скорости декода

В отличие от префилла, который упирается в вычисления (compute-bound), авторегрессионный декод генерирует по одному токену за шаг и обычно упирается в пропускную способность оперативной памяти (memory-bound).

Сделаем грубую оценку сверху: файл модели Q3_K_S занимает 4.01 ГиБ. Если бы при генерации каждого токена веса полностью читались из оперативной памяти, то для скорости 10.14 t/s потребовалась бы полоса пропускания около 43.7 ГБ/с. Синтетический тест чтения массива в памяти на тех же 12 потоках показал практически идентичную цифру - 43.5 ГБ/с. Разумеется, это совпадение порядков величин, а не аппаратный замер трафика llama-server через perf-счётчики (часть обращений обслуживается кэшами CPU), но оно хорошо иллюстрирует физический потолок конфигурации.

Сканирование числа потоков в llama-bench подтвердило, что точка в 12 потоков близка к оптимуму для данного контейнера:

  • 8 потоков - 9.84 t/s
  • 12 потоков - 10.36 t/s
  • 16 потоков - 7.03 t/s
  • 20 потоков - 6.09 t/s

Потенциал апгрейда памяти: вывод dmidecode на хосте показывает, что сейчас установлено 8 модулей по 32 ГБ (по 4 модуля на сокет). Таким образом, у каждого процессора EPYC задействовано только 4 из 8 каналов памяти. Чтобы задействовать все 16 каналов на двух сокетах, нужно либо докупить ещё 8 планок по 32 ГБ (увеличив объём до 512 ГБ), либо заменить текущие модули на 16 планок по 16 ГБ (сохранив 256 ГБ). До проверки документации материнской платы и реальных замеров этот резерв остаётся теоретическим.

Квантовая лестница: размер файла не определяет скорость задачи

Сведём результаты Q3_K_S и Q4_K_M в единую таблицу:

МетрикаQ3_K_SQ4_K_MКонтекст измерения
llama-bench prefill (pp512)30.40 t/s42.94 t/sкороткий батч, отдельный прогон
llama-bench decode (tg128)10.14 t/s6.93 t/sкороткий контекст
Серверный prefill (29 796 токенов)31.28 t/s40.27 t/sn=1 на каждый квант
Серверный decode на длинной задаче4.45 t/s3.67 t/s8 выходных токенов (справочно)
Общее время задачи (Wall)954 с742 сn=1 на каждый квант (minus 22% времени)
Потребление памяти (RSS, 2 слота)9.3 ГБ13.1 ГБмониторинг процесса

Этот тест опровергает интуитивное правило «чем меньше квант, тем быстрее работает модель». На декоде лёгкий Q3_K_S действительно быстрее (10.14 против 6.93 t/s), так как меньше нагружает шину памяти. Но на длинном документе, где основное время уходит на префилл, Q4_K_M за счёт векторной перепаковки выигрывает более трёх с половиной минут.

В качестве следующего шага в расчётной модели рассматривается квант IQ4_NL (для него в repack.cpp тоже есть быстрая ветка, а прогноз скорости декода составляет ~7.5 t/s), но на реальном 30-тысячном документе он пока не проверялся.

Сравнение архитектур и ошибки протокола оценки качества

Помимо квантизации, мы проверили на том же стенде ещё две модели в кванте Q4_K_M через chat-эндпоинт: Gemma 4 E4B и MiniCPM-V-4.7-1B.

Модель и протоколРазмер файла, ГиБDecode, t/sPrefill, t/sRSS, ГБКонтекст
Qwen3.5-9B Q4_K_M (chat)5.757.6-7.935-4514.1262K
Gemma 4 E4B Q4_K_M (chat)4.9812.7-13.667-7410.8128K
MiniCPM-V-4.7-1B Q4_K_M (chat)0.6330-45195-2412.9131K

(Важно: напрямую переводить разницу в t/s между разными семействами моделей в ускорение бизнес-задачи нельзя из-за различий в токенизаторах и многословности ответов).

  • Gemma 4 E4B справилась с длинным документом (n=1) за 455.9 секунды против 742 секунд у Qwen Q4_K_M, выдав корректный ответ. В слепом сравнении на двух коротких текстах зафиксирован паритет на первом и небольшой перевес Qwen на втором. Это сильный кандидат, но для вывода о полном равенстве качества на всём классе отчётов выборка пока мала.
  • MiniCPM-V-4.7-1B предсказуемо показала огромную скорость, но провалила проверку на качество и следование формату: во всех трёх повторах ru-doc-01 в русский текст просочились китайские (CJK) иероглифы, а в задаче маршрутизации модель трижды ответила DOC вместо требуемого по контракту ROUTE=DOC. Для нашего шлюза модель отклонена.

Методологический урок: почему raw /completion ломает тесты

Первую итерацию слепого сравнения качества пришлось полностью забраковать по двум причинам:

  1. При вызове через сырой /completion с лимитом в 192 токена две генерации оборвались прямо посередине блока рассуждений (reasoning), так и не дойдя до итогового ответа. В другом тесте с лимитом 4096 токенов модель в кванте Q3_K_S потратила все 4096 токенов на внутренние рассуждения, оставив финальный ответ пустым.
  2. Модель Gemma 4 без родного chat-шаблона через /completion вообще отказалась запускать генерацию.

Вывод для методики бенчмарков: флаг --reasoning off сам по себе не заменяет корректный chat template. Чтобы сравнивать время до получения готового ответа у разных моделей, недостаточно выставить одинаковый n_predict - необходимо жёстко фиксировать шаблон чата, правила работы thinking-блока и критерий завершения ответа.

Два инференс-сервера на одном CPU: проверка конкуренции

Можно ли повысить общую пропускную способность хоста, запустив два экземпляра сервера параллельно? Я поднял на стенде одновременно Qwen3.5-9B Q4_K_M и Gemma 4 E4B и замерил скорость декода поодиночке и при синхронной генерации:

Режим запускаQwen3.5-9B, токенов/сGemma 4 E4B, токенов/с
Поодиночке7.813.4
Одновременно0.70.48

При одновременной генерации скорость обеих моделей обвалилась более чем на порядок. При этом оперативной памяти хватало с избытком: swap = 0, major page faults = 0, оба процесса полностью находились в RAM. Тест не разделяет, что именно стало главным узким местом - конкуренция за пропускную способность каналов памяти, вытеснение кэшей CPU или планировщик потоков ОС, - но даёт чёткий эксплуатационный ответ: запускать два параллельных инференса в одной такой конфигурации нельзя, запросы необходимо выстраивать в очередь.

Границы данных: что осталось за кадром

Для полноты картины зафиксирую методологические ограничения проведённых экспериментов:

  • Единичные прогоны длинных задач (n=1). Замеры на 30-тысячном контексте занимают от 7 до 30 минут на итерацию, поэтому выполнялись по одному разу на конфигурацию. Они надёжно показывают направление крупных сдвигов (например, minus 22% у Q4_K_M или plus 84% у FA plus q8_0), но не позволяют оценить дисперсию.
  • Фоновый шум. Сервер находился в эксплуатации; счётчик внешних запросов к шлюзу во время тестов не вёлся.
  • Малая выборка оценки качества. Слепое сравнение опиралось на два текста и базовые авточекеры.
  • Теоретический статус IQ4_NL и апгрейда RAM. Прогноз в 7.5 t/s для IQ4_NL, а также эффект от заполнения всех 8 каналов памяти на сокет (переход со схемы $8 \times 32$ ГБ на $16 \times 16$ ГБ либо $16 \times 32$ ГБ) на практике ещё не проверялись.
  • Косвенная оценка полосы памяти. Упор декода в пропускную способность DRAM выведен расчётным путём из размера GGUF-файла и теста чтения массива, а не снят аппаратными счётчиками памяти процесса llama-server.
  • Сторонние баги upstream. В репозитории llama.cpp есть открытые тикеты: issue #27852 (утечка состояния ngram-cache между запросами на одном слоте, помечен stale) и issue #25618 (расхождение greedy-вывода draft-mtp на квантованной Gemma, помечен bug-unconfirmed). В наших сценариях они специально не воспроизводились и к полученным результатам не приписываются.

Практический итог

  1. Переход на Q4_K_M в продакшене. Для боевого шлюза с моделью Qwen3.5-9B основным рабочим вариантом выбран квант Q4_K_M. Несмотря на рост потребления памяти (RSS с 9.3 до 13.1 ГБ) и более медленный короткий декод, на целевой задаче обработки длинного документа он сократил общее время ожидания с 954 до 742 секунд (minus 22%) за счёт наличия оптимизированной ветки перепаковки весов в repack.cpp, которой лишён формат Q3_K_S.
  2. Отказ от MTP и ngram-cache на CPU. Спекулятивное декодирование MTP (даже при хорошем acceptance = 0.611) замедлило все задачи на CPU из-за накладных расходов на шаг драфта. Механизм ngram-cache также не дал значимого ускорения.
  3. Отказ от FA plus KV q8_0 на длинных контекстах. Квантизация KV-кэша в связке с Flash Attention снижает потребление памяти, но на документе в 30K токенов уронила скорость префилла почти вдвое (с 31.31 до 16.96 t/s), увеличив время ответа с 953 до 1759 секунд.
  4. Сохранение штатного бэкенда ggml-cpu. Специализированный бэкенд ZenDNN успешно запустился на EPYC 7352, но на рабочих батчах Qwen показал паритет со штатным ggml-cpu, а на 24 потоках допустил шестикратную просадку декода. Менять стандартный бэкенд смысла нет.
  5. Кандидаты на будущее. Модель Gemma 4 E4B обработала длинный документ значительно быстрее Qwen (455.9 с против 742 с), но требует более широкой проверки качества на корпусе отчётов. На уровне железа главный неиспользованный резерв для ускорения декода - заполнение пустующих четырёх каналов памяти на каждом сокете.

Сырые JSON-логи и журналы прогонов хранятся в рабочем каталоге стенда (не публикуются в открытом доступе из-за специфики окружения); для экспериментов с ZenDNN сохранены сводная CSV-таблица и выводы llama-bench.

Все части серии: Часть 1 | Часть 2 | Часть 3 | Часть 4