Я устал писать одноразовые скрипты для бенчмарков LLM и собрал харнесс, который сам считает Pareto-front

от автора

LLM inference benchmark

LLM inference benchmark

С чего все началось

У меня была вполне приземленная задача: понять, на каком бэкенде гонять одну и ту же открытую модель — на vLLM, llama.cpp, ONNX Runtime или просто на transformers. Звучит как вопрос на пять минут, пока ты не начинаешь честно мерить.

Проблема в том, что почти все готовые бенчмарки меряют не то, что нужно на практике. А мне нужно было держать в голове сразу четыре оси: p95-latency, throughput (tok/s), пиковый VRAM и то, что модель вообще не сошла с ума под нагрузкой. И главный вопрос звучал так: какая конфигурация влезает в мой бюджет по видеопамяти и при этом держит p95 ниже порога? Ответа на него не давал никто, поэтому я в очередной раз открывал ноутбук и писал bench_v3_final_FINAL.py.

Когда таких скриптов накопилось штук пять, и каждый мерил по-своему (а значит, числа между собой сравнивать было нельзя), я сел писать нормальный харнесс. Так появился llm-inference-benchmark.

Что это в итоге за тулза

Если совсем коротко — это воспроизводимый харнесс для экспериментов с инференсом. Ты описываешь эксперимент в YAML, он гоняет одну и ту же нагрузку через разные бэкенды и конфиги, складывает результаты в CSV + JSON-манифест, а потом сам говорит, какая конфигурация оптимальна при заданных ограничениях. Сверху прикручен браузерный дашборд, чтобы не втыкать в голый CSV.

Кому это нужно:

  • тем, кто выбирает бэкенд/квантизацию под конкретное железо;

  • тем, кому нужна воспроизводимость — чтобы через месяц можно было доказать, откуда взялась цифра;

  • тем, кто хочет CI-гейт на регрессии инференса (об этом ниже).

Бэкенды, которые поддерживаются прямо сейчас (v1.8.3):

Бэкенд

Чем хорош

mock

детерминированный, для CI — модель не нужна вообще

transformers

AutoModelForCausalLM от HF, CUDA

llama-cpp

GGUF-квантизация, pre-built CUDA-wheel, без nvcc

openai

любой сервер с /v1/chat/completions (Ollama, LM Studio, vLLM)

onnx

ONNX Runtime через Optimum, INT8/FP16

vllm

high-throughput движок, Linux only

Дальше — самое интересное, что под капотом.

Под капотом: YAML, Run Matrix и Sweep

Один эксперимент = один YAML

Базовая единица — конфиг одного прогона. Никакой магии, просто декларация того, что мы меряем:

# configs/llama-cpp-gpu.yamlbackend: llama-cppmodel: ~/models/Llama-3.2-3B-Instruct-Q4_K_M.ggufrequests: 20warmup_requests: 2prompts_file: data/prompts/smoke.txtrepeats: 3                 # медиана ± std по 3 прогонамllama_cpp:  n_ctx: 2048  n_gpu_layers: 99        # 99 = выгрузить все слои на GPU; 0 = только CPU  max_tokens: 50  temperature: 0.0        # greedy, детерминированно

Запуск:

uv run llm-bench --config configs/llama-cpp-gpu.yaml --output results/run.csv

Любое поле можно переопределить из CLI, не трогая YAML — удобно, когда хочется быстро дернуть один параметр:

uv run llm-bench --config configs/llama-cpp-gpu.yaml \  --set llama_cpp.n_gpu_layers=20 --requests 50 --seed 42

Run Matrix: перестать копипастить конфиги

Один прогон — это скучно. Реальный вопрос всегда звучит как «а сравни-ка мне вот эти пять вариантов». Для этого есть матрица. В явном виде это просто список прогонов с общей папкой результатов:

results_dir: resultsruns:  - name: quant-q4km    config: configs/llama-cpp-q4km-best.yaml  - name: quant-q8    config: configs/llama-cpp-q8-best.yaml
llm-bench matrix --config configs/llama-cpp-quant-compare.yaml

Каждая комбинация дает свой CSV и свой JSON-манифест — ничего не перетирается, имена прогонов валидируются (никаких путей и ../ в name, иначе словишь ValueError еще на этапе парсинга, а не в середине часового прогона).

Sweep: декартово произведение из коробки

А вот когда вариантов становится много, явный список превращается в запутанную конструкцию. Тут включается sweep — ты задаешь оси, а харнесс сам разворачивает их в cartesian product:

# n_gpu_layers × n_ctx → 3 × 2 = 6 прогонов из одного base-конфигаbase_config: configs/llama-cpp-gpu.yamlresults_dir: resultssweep:  llama_cpp.n_gpu_layers: [0, 20, 99]  llama_cpp.n_ctx: [512, 2048]
# Сначала посмотреть, что вообще сгенерится, не запуская:llm-bench matrix --config configs/llama-cpp-sweep.yaml --dry-run

Под капотом это буквально itertools.product по спискам значений, но с парой важных предохранителей, на которые я потратил больше времени, чем планировал:

  • dot-path валидируется до запуска. llama_cpp.n_gpu_layers сверяется с реальными полями pydantic-модели. Опечатался в имени поля — узнаешь об этом сразу, а не через час, когда прогон тихо проигнорировал твой «оверрайд».

  • Имена прогонов детерминированы и собираются из значений (sweep-n_gpu_layers-99-n_ctx-2048). Если два значения схлопываются в одно и то же имя — харнесс ругается и просит перейти на явный runs:, а не молча затирает результат.

CLI: предпросмотр матрицы прогонов (--dry-run)

CLI: предпросмотр матрицы прогонов (--dry-run)

Глубину оверрайдов я намеренно ограничил двумя уровнями (секция.поле). Можно было сделать произвольную вложенность через рекурсию, но это ровно тот случай, когда гибкость оборачивается тем, что конфиги становится невозможно читать. Лучше пусть будет чуть жестче, зато предсказуемо.

Аналитика: Pareto и рекомендатель, который думает за тебя

Окей, мы нагенерили двадцать CSV. Дальше начинается то, ради чего все затевалось.

Pareto: отсеиваем заведомо проигрышные конфиги

Сравнивать конфиги «на глаз» по таблице из двадцати строк и семи колонок — неприятное дело. Поэтому есть классификация по доминированию по Парето.

Конфиг A доминирует над B, если A не хуже B по каждой метрике и строго лучше хотя бы по одной. Все, что доминируется, — это заведомо проигрышный вариант: всегда найдется другой конфиг, который не хуже по всем фронтам. Остаются только Pareto-оптимальные точки — фронт компромиссов, между которыми уже есть смысл выбирать руками.

Направления оптимизации зашиты честно по смыслу метрики:

  • минимизируем: p95-latency, VRAM, perplexity, время загрузки модели, TTFT;

  • максимизируем: tok/s, sanity-rate, task-quality, judge-score.

Ключевая деталь, которой я горжусь чуть больше, чем стоило бы: отсутствующие метрики сужают сравнение, а не роняют его. Если у одного прогона нет, скажем, perplexity (потому что бэкенд ее не отдает), эта ось просто не участвует в сравнении этой пары — вместо того чтобы крашить анализ или штрафовать прогон нулем. На практике это значит, что можно мешать в одну кучу результаты с transformers (где есть perplexity и judge) и с llama-cpp (где их нет), и ничего не развалится.

llm-bench pareto results/*.csv
CLI: классификация по Парето

CLI: классификация по Парето
Web UI: сравнение прогонов (Δ% к базе)

Web UI: сравнение прогонов (Δ% к базе)

Числа в разных разделах сняты в разных прогонах, отсюда небольшой разброс — именно поэтому к каждому прогону пишется манифест.

Рекомендатель: «дай мне лучший конфиг под мои ограничения»

Pareto-фронт — это все еще несколько вариантов. Финальный шаг — сказать харнессу свои жесткие ограничения, и пусть выбирает сам:

llm-bench recommend results/*.csv \  --max-vram-mb 4096 \  --max-p95-ms 1000 \  --max-ttft-ms 200 \  --min-sanity 1.0

Логика в три шага, без магии:

  1. Фильтр. Выкидываем все прогоны, которые нарушают хоть одно ограничение. Причем — важная деталь — если по метрике задано ограничение, а у прогона этого значения нет (не померили), он тоже вылетает. Не знаешь VRAM, а я просил --max-vram-mb? Извини, в кандидаты не берем.

  2. Pareto среди выживших. Из тех, кто прошел фильтр, берем Pareto-оптимальные.

  3. Tiebreak по p95. Если оптимальных несколько — побеждает самый низкий p95.

И — то, что я особенно ценю — он показывает причину исключения каждого кандидата:

Recommendation__________________________________________________Backend  : transformersModel    : gpt2-mediumN        : 10p95      : 512.02 mstok/s    : 104.1Load     : 9015.0 msTTFT p50 : 10.8 msVRAM     : 875.0 MBSanity   : 100.0%Task Q   : N/APPL      : N/AJudge    : N/AWhy: lowest p95 among 1 candidate(s) passing all constraints; Pareto-optimal.Excluded (3)__________________________________________________  llama-cpp Llama-3.2-3B-Instruct-Q4_K_M.gguf → VRAM too high (2361.0 MB > 1000.0 MB)  llama-cpp Llama-3.2-3B-Instruct-Q8_0.gguf → VRAM too high (3697.0 MB > 1000.0 MB)  llama-cpp Bonsai-8B.gguf → VRAM too high (1499.0 MB > 1000.0 MB)

Никакого «доверься мне, это лучший вариант». Видно, кого выкинули и за что.

Web UI: рекомендация

Web UI: рекомендация
CLI: рекомендация

CLI: рекомендация

Нестандартные метрики: скорость бессмысленна, если модель сломалась

Это та часть, из-за которой я и считаю, что обычные бенчмарки показывают неполную картину.

Sanity checks: а оно вообще что-то осмысленное выдавало?

Можно разогнать throughput до космоса, если модель в ответ генерирует пустые строки или зациклилась на одном токене. Поэтому каждый прогон считает простые структурные проверки:

  • empty_output_count — сколько ответов оказались пустыми;

  • min_output_chars / mean_output_chars — длины ответов;

  • repeated_output_count — сколько ответов дословно повторяют друг друга;

  • sanity_pass_rate — доля непустых ответов, в [0.0, 1.0].

Честная оговорка, которую я зашил прямо в докстринг: при temperature=0.0 и циклической прогонке промптов повторы математически ожидаемы. Так что repeated_output_count — это сигнал деградации, а не абсолютный приговор. Это не оценка смысла, это детектор того, что под капотом что-то сломалось, пока мы радостно мерили скорость.

Task-quality, perplexity и LLM-as-judge

Кому нужна оценка посерьезнее — есть три уровня:

  • quality_file: — YAML-рубрика с проверками вроде contains_all, regex, forbidden. Тупо, но работает и воспроизводимо.

  • Self-perplexity — на собственных логитах модели.

  • LLM-as-judge — модель в один forward-pass отвечает «да/нет» на фиксированный вопрос о своем же ответе, а score — это P(yes), вытащенная из конкурирующих логитов Yes/No через 2-way softmax (по сути sigmoid(yes − no)).

Сразу отмечу, без иллюзий: judge тут — это модель, судящая саму себя одним фиксированным вопросом. Это не калиброванная preference-модель и не замена человеческой разметке. Я держу это как простой вариант, а не как истину в последней инстанции. Perplexity и judge, кстати, пока живут только на бэкенде transformers — там есть доступ к токен-левел логитам, а у GGUF через llama.cpp его так просто не возьмешь.

Energy efficiency: tokens per Joule

Самая моя любимая метрика, потому что про нее все забывают. Throughput — это хорошо, но если конфиг A на 10% быстрее конфига B, а жрет при этом в полтора раза больше ватт, то в продакшене с тысячами GPU это «чуть быстрее» превращается в неприятный счет за электричество.

Меряется в два захода:

  1. GPU через nvidia-smi. Фоновый поток поллит power.draw каждые 0.5 с, в конце берем среднюю мощность и умножаем на длительность окна → джоули.

  2. CPU через Intel RAPL. Читаем счетчик energy_uj из /sys/class/powercap/... на входе и выходе, берем дельту. Тут пришлось повозиться с переполнением счетчика (он циклический) — если дельта отрицательная, добавляем max_energy_range_uj. Классический edge-case, который ловишь только когда долгий прогон дает отрицательную энергию и ты сидишь и думаешь, в какой момент нарушил законы термодинамики.

На выходе — energy_joules и tokens_per_joule. Если ни один источник не читается — там честный None, а не выдуманный ноль.

График: tokens/joule по бэкендам (gpt2-medium — модель другого класса, межбэкендный tok/J здесь лишь для иллюстрации разброса)

График: tokens/joule по бэкендам (gpt2-medium — модель другого класса, межбэкендный tok/J здесь лишь для иллюстрации разброса)

Реальные цифры

Чтобы это была не «тулза ради тулзы», вот живой результат с моего железа — RTX 3050 Laptop (4 GB), Llama 3.2 3B Instruct, llama.cpp, все 28 слоев на GPU:

Квантизация

p50 (ms)

p95 (ms)

tok/s

VRAM (MiB)

Sanity

Q4_K_M

899

910

55.6

2361 (58%)

100%

Q8_0

1186

1210

41.9

3697 (90%)

100%

Q4_K_M выходит в 1.33× быстрее и ест в 1.57× меньше VRAM при той же sanity. На карте с 4 ГБ это разница между «работает с запасом» и «впритык».

А вот свип по n_gpu_layers — наглядно, почему частичная выгрузка слоев это ловушка:

Слоев на GPU

p95 (ms)

tok/s

VRAM (MiB)

0 / 28 (только CPU)

3093

17.5

655

20 / 28 (частично)

1420

36.6

1829

28 / 28 (все на GPU)

984

51.4

2361

Полная выгрузка — в 3.1× быстрее, чем чистый CPU.

Web UI: нормализованное сравнение метрик (выше — лучше)

Web UI: нормализованное сравнение метрик (выше — лучше)

Воспроизводимость: JSON-манифест на каждый прогон

Это то, что отличает харнесс от скрипта на коленке. На каждый прогон пишется JSON-манифест, по которому результат можно защитить на ревью:

  • git-коммит и git_dirty — был ли рабочий каталог грязным в момент прогона;

  • SHA-256 конфига и файла промптов — чтобы доказать, что мерили именно то, что думали;

  • полный фингерпринт окружения — Python, OS, CPU, версия драйвера, имя GPU, объем VRAM, версии torch/transformers/vllm.

Все это собирается «мягко»: на CPU-only машине без nvidia-smi манифест все равно запишется, просто GPU-поля будут None. Никаких падений из-за того, что на CI нет видеокарты.

Как запустить за две минуты

Самый быстрый путь — поднять дашборд и кликать:

pip install git+https://github.com/Happynood/llm-inference-benchmark.gitllm-bench serve# http://localhost:8080 → жмем "+ New Run"
Web UI: список прогонов

Web UI: список прогонов
Web UI: карточка прогона

Web UI: карточка прогона
Web UI: форма нового прогона

Web UI: форма нового прогона

Или из исходников через uv (рекомендуемый вариант):

git clone https://github.com/Happynood/llm-inference-benchmarkcd llm-inference-benchmarkuv sync && uv run llm-bench serve

С GPU (NVIDIA, CUDA 12.x, nvcc не нужен — ставится pre-built wheel):

make setup-gpu     # детект GPU + CUDA-wheel + прокидка runtime-либmake webui-gpu     # дашборд на :8080

Для CI есть отдельный гейт на регрессии — падает, если любая метрика просела больше чем на N%:

llm-bench diff results/before.csv results/after.csv --fail-on-regression 5

В дашборде, помимо очевидного, есть live-стриминг stdout через Server-Sent Events (видно, как идет прогон), вкладка Recommend с формой ограничений и интерактивный Pareto-scatter на Plotly с выбором осей.

Web UI: Pareto-чарт (оси переключаются, фронт подсвечен)

Web UI: Pareto-чарт (оси переключаются, фронт подсвечен)
Web UI: лидерборд

Web UI: лидерборд

Честно о костылях и планах

Раз уж обещал без глянца — вот что меня самого до сих пор раздражает:

  • CUDA 12/13 и pre-built wheel. Чтобы cu124-wheel завелся на свежем драйвере, приходится руками собирать LD_LIBRARY_PATH из nvidia/*.so внутри venv. Это те самые танцы с бубном, которые я спрятал в make-таргет, но осадочек остался.

  • peak_cuda_memory_mb = 0.0 у llama.cpp. Он использует свой CUDA-аллокатор, а не PyTorch’евский, поэтому метрика из torch всегда ноль. Реальную память приходится брать из peak_vram_memory_mb (поллинг nvidia-smi). Грабли, на которые наступит каждый, кто сравнит две колонки.

  • TTFT только в streaming-режиме, а perplexity и judge — только transformers. Это не лень, это ограничения бэкендов, но в документации про это надо помнить.

В планах — расширять список бэкендов (присматриваюсь к TensorRT-LLM и SGLang) и докрутить контаминация-устойчивую оценку качества на реальных датасетах (wildchat, gsm8k, humaneval уже подтягиваются, но как полноценную метрику качества это пока не доведено).

Вопрос к залу

И вот тут мне правда нужно мнение тех, кто гоняет инференс в проде.

Насколько вообще честно сравнивать tokens_per_joule между бэкендами? У llama.cpp свой CUDA-аллокатор, vLLM агрессивно занимает VRAM под KV-кэш, transformers живет по-своему — и nvidia-smi power.draw меряет потребление всей карты, а не конкретного процесса. Получается, на шумном GPU (где крутится что-то еще) метрика энергии превращается в тыкву.

Я вижу три пути: (а) требовать эксклюзивный GPU на время замера, (б) вычитать idle-baseline мощности, (в) забить и считать энергию валидной только для изолированных прогонов. Каждый вариант что-то ломает.

Как вы меряете энергоэффективность инференса — и меряете ли вообще? Не делаю ли я ошибку, считая p95 при фиксированной concurrency, когда в реале нагрузка open-loop с переменным arrival-rate? Расскажите в комментариях, как у вас устроено.


Репозиторий: github.com/Happynood/llm-inference-benchmark — MIT, PR и issue приветствуются. Если потыкаете на своем железе и числа сойдутся (или не сойдутся) — это самый ценный фидбек.

ссылка на оригинал статьи https://habr.com/ru/articles/1052678/