Опубликовано
На CPU Q4_K_M сократил время обработки документа на 22%
Автор: Гусев Николай [портфолио]
Обновлено
Темы: llm, benchmarks, inference, cpu, local-models
Автор: Гусев Николай
Это пятая часть серии про спекулятивное декодирование и оптимизацию локального инференса:
- Часть 1 - четыре метода на RTX 4060
- Часть 2 - DFlash и граница применимости
- Часть 3 - EAGLE-3, acceptance 0.17
- Часть 4 - 96K в 8 ГБ, PFlash и Lucebox
Четвёртая часть заканчивалась вопросом: какие из найденных на 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 ГБ). - Тестовый корпус: шесть задач трёх классов, знакомых по первой части серии:
- Два русскоязычных текста на свободную генерацию (
ru-doc-01,ru-doc-02). - Три короткие детерминированные задачи с автоматической проверкой: арифметическая сумма (
check-sum), генерация JSON и классификация маршрута (check-route). - Поиск записи со статусом
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), поэтому тест фиксирует сравнение внутри одного дерева исходников на уровне скорости вычислительного ядра, без оценки качества ответов.
Работа над ошибками: что пришлось пересмотреть после первых прогонов
- Несравнимость фаз до и после перезагрузки контейнера. Первичный 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измерялись непрерывно в рамках одной сессии. - Фоновый шум и разброс значений. Из-за параллельной работы боевого шлюза на коротких задачах возникала высокая дисперсия. Например,
check-routeна baseline в трёх повторах показал 9.42, 7.32 и 3.09 t/s, а сngramразброс оказался ещё выше. Ориентироваться на лучший повтор нельзя - это лишь верхняя наблюдённая граница. Поэтому короткие задачи ниже сравниваются строго по медианам трёх прогонов (n=3), а ресурсоёмкие длинные задачи - по единственному прогону (n=1) с явным указанием этого ограничения. - Сбой чекера суммы. В файле
tasks.jsonдля задачи со сложением шести чисел задан верный эталон -109834. Однако в логах Session 2 скрипт проверки записалcorrect=falseво всех фазах без исключения. Из-за расхождения между корпусом и сырыми логами задачаcheck-sumисключена из оценки качества и используется только как замер времени выполнения. - Протокол генерации и обрезка рассуждений. Первые слепые тесты сравнивали ответы, полученные через сырой эндпоинт
/completionпри включённом режиме рассуждений (reasoning). Как выяснилось позже, лимит токенов обрезал генерацию прямо внутри<think>-блока, до формирования финального ответа. Переход наchat templateизменил картину настолько существенно, что этому посвящён отдельный раздел.
Baseline (Session 2)
| Метрика | Значение | Основа измерения |
|---|---|---|
| Короткий decode | медианы 7.32-10.08 t/s | 5 задач по 3 повтора (check-route шумный) |
| Короткий prefill | медианы 31.22-33.33 t/s | 5 задач по 3 повтора |
| Длинный prefill (29 796 токенов) | 31.31 t/s | 1 прогон (n=1) |
| Длинный decode | 3.82 t/s | 8 выходных токенов (справочно) |
| Общее время (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-4B | Qwen3.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, с | Изменение wall | Baseline decode, t/s | MTP decode, t/s |
|---|---|---|---|---|---|
Русский текст 1 (n=3) | 20.88 | 28.42 | plus 36.1% | 9.99 | 7.19 |
Русский текст 2 (n=3) | 17.25 | 31.36 | plus 81.8% | 10.08 | 5.34 |
Сумма (n=3, только время) | 5.85 | 7.02 | plus 20.0% | 9.98 | 7.90 |
JSON (n=3) | 2.98 | 3.65 | plus 22.5% | 10.07 | 7.24 |
Маршрут (n=3) | 2.39 | 3.14 | plus 31.4% | 7.32 | 3.21 |
Длинный документ (n=1) | 953.48 | 1009.81 | plus 5.9% | 3.82 | 2.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) | Baseline | FA plus KV q8_0 | Дельта |
|---|---|---|---|
| Префилл, токенов/с | 31.31 | 16.96 | minus 45.8% |
| Генерация, токенов/с | 3.82 | 3.33 | minus 12.8% |
| Общее время (Wall), с | 953.48 | 1758.70 | plus 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/s | ngram gen, t/s | Изменение gen | Baseline wall, с | ngram wall, с | Изменение wall |
|---|---|---|---|---|---|---|
| Русский текст 1 | 9.99 | 9.62 | minus 3.7% | 20.88 | 21.61 | plus 3.5% |
| Русский текст 2 | 10.08 | 8.34 | minus 17.3% | 17.25 | 20.58 | plus 19.3% |
| Сумма (только время) | 9.98 | 9.92 | minus 0.6% | 5.85 | 5.85 | 0.0% |
| JSON | 10.07 | 9.88 | minus 1.9% | 2.98 | 2.94 | minus 1.3% |
| Маршрут | 7.32 | 9.41 | plus 28.6% | 2.39 | 2.29 | minus 4.2% |
Счётчики acceptance для ngram в серверный JSON не пишутся. По итоговому времени (wall) результаты неоднородны: второй русский текст замедлился на 19.3%, а остальные задачи уложились в погрешность базового прогона. На длинном документе (n=1) время выполнения выросло на символические 0.7% (960.34 с против 953.48 с). Подтверждённого выигрыша на нашем корпусе метод не дал.
Bonsai 2: тернарные веса и цена отдельного форка
Для проверки тернарной модели Bonsai 2 9B (требующей отдельного форка llama.cpp от PrismML) потребовалось два подхода:
- Первая попытка через raw
/completionс жадным декодированием (greedy) оказалась нерелевантной: документация Bonsai прямо предупреждает, чтоthinking-режим модели плохо совместим с greedy-сэмплингом и требует большого бюджета токенов. Пустые ответы в этом прогоне были следствием неверной методики, а не неспособности модели решить задачу. - Во втором заходе модель запускалась по рекомендациям авторов: через 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) |
|---|---|---|
| JSON | 2.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/s | ggml-cpu prefill, t/s | Разница prefill | ZenDNN decode, t/s | ggml-cpu decode, t/s |
|---|---|---|---|---|---|
| 128 | 40.98, разброс 0.48 | 36.12, разброс 6.06 | plus 13.5%* | 7.96, разброс 0.17 | 6.38, разброс 1.57 |
| 512 | 41.16, разброс 0.13 | 41.13, разброс 0.32 | plus 0.1% | 7.80, разброс 0.03 | 7.63, разброс 0.19 |
| 1024 | 40.40, разброс 0.44 | 40.54, разброс 0.01 | minus 0.3% | 6.79, разброс 0.22 | 7.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_S | Q4_K_M | Контекст измерения |
|---|---|---|---|
llama-bench prefill (pp512) | 30.40 t/s | 42.94 t/s | короткий батч, отдельный прогон |
llama-bench decode (tg128) | 10.14 t/s | 6.93 t/s | короткий контекст |
| Серверный prefill (29 796 токенов) | 31.28 t/s | 40.27 t/s | n=1 на каждый квант |
| Серверный decode на длинной задаче | 4.45 t/s | 3.67 t/s | 8 выходных токенов (справочно) |
| Общее время задачи (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/s | Prefill, t/s | RSS, ГБ | Контекст |
|---|---|---|---|---|---|
Qwen3.5-9B Q4_K_M (chat) | 5.75 | 7.6-7.9 | 35-45 | 14.1 | 262K |
Gemma 4 E4B Q4_K_M (chat) | 4.98 | 12.7-13.6 | 67-74 | 10.8 | 128K |
MiniCPM-V-4.7-1B Q4_K_M (chat) | 0.63 | 30-45 | 195-241 | 2.9 | 131K |
(Важно: напрямую переводить разницу в t/s между разными семействами моделей в ускорение бизнес-задачи нельзя из-за различий в токенизаторах и многословности ответов).
- Gemma 4 E4B справилась с длинным документом (
n=1) за 455.9 секунды против 742 секунд у QwenQ4_K_M, выдав корректный ответ. В слепом сравнении на двух коротких текстах зафиксирован паритет на первом и небольшой перевес Qwen на втором. Это сильный кандидат, но для вывода о полном равенстве качества на всём классе отчётов выборка пока мала. - MiniCPM-V-4.7-1B предсказуемо показала огромную скорость, но провалила проверку на качество и следование формату: во всех трёх повторах
ru-doc-01в русский текст просочились китайские (CJK) иероглифы, а в задаче маршрутизации модель трижды ответилаDOCвместо требуемого по контрактуROUTE=DOC. Для нашего шлюза модель отклонена.
Методологический урок: почему raw /completion ломает тесты
Первую итерацию слепого сравнения качества пришлось полностью забраковать по двум причинам:
- При вызове через сырой
/completionс лимитом в 192 токена две генерации оборвались прямо посередине блока рассуждений (reasoning), так и не дойдя до итогового ответа. В другом тесте с лимитом 4096 токенов модель в квантеQ3_K_Sпотратила все 4096 токенов на внутренние рассуждения, оставив финальный ответ пустым. - Модель 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.8 | 13.4 |
| Одновременно | 0.7 | 0.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). В наших сценариях они специально не воспроизводились и к полученным результатам не приписываются.
Практический итог
- Переход на
Q4_K_Mв продакшене. Для боевого шлюза с моделью Qwen3.5-9B основным рабочим вариантом выбран квантQ4_K_M. Несмотря на рост потребления памяти (RSS с 9.3 до 13.1 ГБ) и более медленный короткий декод, на целевой задаче обработки длинного документа он сократил общее время ожидания с 954 до 742 секунд (minus 22%) за счёт наличия оптимизированной ветки перепаковки весов вrepack.cpp, которой лишён форматQ3_K_S. - Отказ от MTP и
ngram-cacheна CPU. Спекулятивное декодирование MTP (даже при хорошемacceptance = 0.611) замедлило все задачи на CPU из-за накладных расходов на шаг драфта. Механизмngram-cacheтакже не дал значимого ускорения. - Отказ от
FA plus KV q8_0на длинных контекстах. Квантизация KV-кэша в связке с Flash Attention снижает потребление памяти, но на документе в 30K токенов уронила скорость префилла почти вдвое (с 31.31 до 16.96 t/s), увеличив время ответа с 953 до 1759 секунд. - Сохранение штатного бэкенда
ggml-cpu. Специализированный бэкенд ZenDNN успешно запустился на EPYC 7352, но на рабочих батчах Qwen показал паритет со штатнымggml-cpu, а на 24 потоках допустил шестикратную просадку декода. Менять стандартный бэкенд смысла нет. - Кандидаты на будущее. Модель Gemma 4 E4B обработала длинный документ значительно быстрее Qwen (455.9 с против 742 с), но требует более широкой проверки качества на корпусе отчётов. На уровне железа главный неиспользованный резерв для ускорения декода - заполнение пустующих четырёх каналов памяти на каждом сокете.
Сырые JSON-логи и журналы прогонов хранятся в рабочем каталоге стенда (не публикуются в открытом доступе из-за специфики окружения); для экспериментов с ZenDNN сохранены сводная CSV-таблица и выводы llama-bench.