# Бенчмарки
Между клиентом брокера и обработчиком фреймворк тратит время на каждое сообщение: поток подписки,
декодирование, диспетчеризация, ack. Эта страница показывает эту стоимость двумя измерениями:
- **Против сырого клиента.** Пропускная способность фреймворка и сырого клиента на настоящем брокере
очереди, на одной машине и на одной работе. Отвечает, во что фреймворк обходится развёрнутому
сервису.
- **Стоимость кода.** Инструкции и выделения памяти на сообщение. В `ruststream` это собственный
код фреймворка, без брокера очереди. Отвечает, что изменилось вместе с кодом. Точности хватает,
чтобы отклонить пул-реквест, который сделал сообщение дороже.
Каждый крейт измеряет себя сам и публикует свои числа. Эта страница загружает их из ветки `main`
в репозитории крейта и показывает вместе. Новый замер появляется здесь сразу после слияния.
## Результаты {#results}
### Против сырого клиента {#against-a-raw-client}
<div id="benchmark-results" data-benchmark-labels='{"loading": "Загружаем опубликованные результаты...", "broker": "Брокер", "scenario": "Сценарий", "raw": "Сырой клиент", "adapter": "Адаптация библиотеки брокера", "framework": "RustStream", "overhead": "Оверхед", "against": "({percent} к клиенту)", "indistinguishable": "неразличимо", "brokerBound": "упирается в брокер очереди", "measured": "измерено", "details": "Полные результаты и методология", "pending": "Результаты ещё не опубликованы: {brokers}.", "crate": "Крейт", "instructions": "Инструкции", "allocations": "Выделения", "cold": "Холодный старт"}'></div>
Как читать таблицу:
- в ячейке - лучший из трёх чередующихся кругов, в скобках - медиана этих трёх;
- «неразличимо» стоит там, где разница меньше разброса от лучшего круга до худшего. Худший круг
публикуется, но в ячейку не попадает;
- «Адаптация библиотеки брокера» - собственные консьюмер и паблишер адаптации без рантайма. Две
разности читаются отдельно: сколько адаптация стоит поверх клиента брокера и сколько добавляет
рантайм.
Лучший круг ближе всего к стоимости без помех, а медиана показывает типичный круг
([подробнее об отчёте](#the-report)).
### Стоимость кода {#cost-of-the-code}
Вторая таблица показывает для каждого сценария, сколько инструкций и выделений памяти уходит на
одно сообщение в установившемся режиме. Числа абсолютные. Стоимость фреймворка поверх настоящего
клиента брокера показывает таблица выше.
В `ruststream` вторая таблица считает только собственный код фреймворка: измерение идёт на
внутрипроцессном транспорте. Адаптация библиотеки брокера измеряет себя на настоящем транспорте, а
что входит в её числа, сообщает страница бенчмарков адаптации.
<div id="benchmark-code"></div>
Колонка «Холодный старт» считает запуск сервиса вместе с обработкой первого сообщения. Эти
инструкции и выделения сервис тратит один раз, а не на каждом сообщении.
Строка под таблицей описывает машину прогона вплоть до памяти, затем называет версии крейта и
`ruststream`, на которых шёл замер, и его дату. Счётчики инструкций с разных машин сравнимы, только
если на них собран один и тот же код.
## Что означают эти числа {#what-the-numbers-are}
### Сравнение с сырым клиентом {#the-comparison-against-a-raw-client}
**Каждая строка - свой замер.** Каждую строку измеряет тот, кто ведёт адаптацию библиотеки брокера:
на своей машине, против брокера очереди на localhost. Поэтому строки между собой не сравниваются.
Сравнимы только две колонки внутри одной строки, и ради этого сравнения страница существует.
**Процент - верхняя граница.** Брокер очереди на localhost ставит фреймворк в самые жёсткие условия.
Сетевой задержки нет, поэтому та же абсолютная стоимость сообщения даёт больший процент, чем с
брокером очереди за настоящей сетью. Читайте процент как верхнюю границу стоимости фреймворка для
развёрнутого сервиса, а не как типичную величину.
**«Упирается в брокер очереди» - нижняя граница.** Пометка значит, что сырой клиент почти весь
прогон ждал данных из сокета. Работа фреймворка укладывается в это ожидание, и измеренная разница
стремится к нулю. Для такой нагрузки это честный результат: так выглядит насыщенный консьюмер. Но
это число - нижняя граница стоимости диспетчеризации, а не её измерение. Бесплатной диспетчеризацию
оно не делает.
### Стоимость кода {#the-cost-of-the-code}
**Инструкции - точная мера.** В `ruststream` два прогона одного бинарника дают одно число, и вдвое
более быстрая машина даёт то же число. Поэтому строки `ruststream` сравнимы между собой и с той же
строкой, измеренной на другой машине. Времени счётчик не показывает: та же величина обходится
дороже там, где код промахивается мимо кэша.
**Измеряется только крейт.** В `ruststream` величина - работа самого крейта и ничего больше:
сервис, написанный как у пользователя, над внутрипроцессной очередью, с декодированием нагрузки в
тип, чтением поля и подтверждением доставки. Брокера очереди в ней нет, поэтому число меняется
тогда и только тогда, когда меняется код фреймворка. Рост на два процента - дефект, а не шум.
**Установившийся режим.** Каждая величина на сообщение измерена в установившемся режиме. Запуск
сервиса стоит своё один раз: подключение, подписка, первые выделения памяти. Если разделить эту
цену на сообщения прогона, разовая цена попадёт в цену сообщения. Поэтому сценарий измеряется двумя
прогонами, второй вдвое длиннее первого, а стоимость сообщения - разница между ними. В `ruststream`
это тысяча и две тысячи доставок. Холодный старт измеряется отдельно, на одной доставке.
**Выделения памяти** считаются на сообщение. В `ruststream` на пути доставки их ноль: у работающего
сервиса сообщение проходит от очереди до тела обработчика, ни разу не обратившись к аллокатору. На
пути публикации в `ruststream` выделяется только то, что брокер берёт себе, чтобы владеть
переданным сообщением.
**Адаптации библиотек брокеров** измеряют себя на настоящем транспорте, чаще всего на брокере
очереди из своего compose-файла. Поэтому в их числа входит и работа клиента брокера, а сами числа
немного меняются от прогона к прогону. Страница бенчмарков адаптации сообщает, что входит в число,
насколько оно меняется, на скольких доставках идут прогоны и какой допуск к нему применяется.
## Методология {#methodology}
Процедура ниже общая для всех адаптаций библиотек брокеров, поэтому их числа значат одно и то же.
Адаптация, которая от неё отступает, сообщает об этом на своей странице.
### Три цикла {#the-three-loops}
Прогон измеряет один сценарий трижды. Циклы различаются только тем, через что проходят сообщения:
- **Сырой клиент.** Собственный клиент брокера, напрямую.
- **Адаптация библиотеки брокера.** Консьюмер и паблишер самой адаптации (дескриптор подписки, поток
подписчика, его подтверждение, его издатель), которые крутит цикл внутри бенчмарка, без рантайма.
- **RustStream.** Сервис целиком, как его пишет пользователь: обработчик, приложение,
диспетчеризация.
Из трёх циклов получаются две разности:
- **адаптация минус клиент** - сколько адаптация библиотеки брокера стоит поверх клиента. За эту
разность отвечает сама адаптация;
- **RustStream минус адаптация** - сколько рантайм добавляет поверх адаптации. Её публикуют для
каждой адаптации, потому что адаптации передают сообщения рантайму по-разному: как поток отдаёт
доставки, приходят ли они пакетами, как до консьюмера доходит обратное давление.
Всё остальное во всех трёх циклах одинаково:
- **Клиент и его конфигурация.** Предвыборка, режим ack, consumer group, долговечность, число
соединений и любые настройки брокера. Фреймворк настраивает брокер через RustStream, но итоговые
настройки клиента обязаны совпасть.
- **Позиция ack.** RustStream отправляет ack после возврата из обработчика, поэтому два других
цикла отправляют ack там же. Пакетный ack в конце прогона измеряет другой протокол, а не другой
фреймворк.
- **Декодирование в тот же тип.** Циклы без обработчика десериализуют полезную нагрузку в ту же
структуру тем же кодеком и читают поле через `std::hint::black_box`. Без этого проще всего
получить неверное число: оптимизатор выбрасывает декодирование, результат которого не
используется, и сырая сторона незаметно перестаёт декодировать.
- **Полезная нагрузка, байт в байт.** Тела сообщений всем циклам выдаёт один генератор.
- **Рантайм.** Режим tokio (`flavor`), число рабочих потоков и число одновременно обрабатываемых
сообщений.
- **Сборка.** Профиль, `RUSTFLAGS` и аллокатор совпадают, а фичи наблюдаемости (`logging`,
`metrics`, `otel`) выключены или включены во всех трёх циклах сразу. Числа с
`-C target-cpu=native` в окружении другая машина не воспроизведёт, поэтому флаги публикуются
вместе с результатами.
### Прогон {#the-run}
- **Консьюмер подключается до публикации первого сообщения.** Иначе одна сторона разбирает то, что
уже накопилось у брокера очереди, а другая принимает сообщения по мере публикации. В большинстве
брокеров очереди это два разных пути.
- **Каждый прогон получает свои имена.** Новый субъект, очередь, поток или consumer group на каждый
прогон, чтобы прогон N не увидел остатков прогона N-1.
- **Окно открывается на первом принятом сообщении и закрывается на ack последнего.** Перед
измеряемыми прогонами идёт калибровочный, его результат выбрасывается. Установка соединения,
регистрация консьюмера и первые выделения памяти - стоимость старта, а не сообщения.
- **Прогон длится не меньше пяти секунд.** Под это подбирается число сообщений, чтобы переходные
процессы старта и разрешение таймера оставались в пределах шума.
- **Циклы чередуются, а не идут блоками.** Сырой, адаптация, фреймворк, снова сырой, адаптация,
фреймворк, всего три круга. Если прогнать один цикл целиком, а потом следующий, весь дрейф машины
(нагрев, фоновая нагрузка, страничный кеш) достанется последнему.
### Отчёт {#the-report}
**Три круга.** Одно число из одного прогона результатом не считается. Каждый цикл сообщает три
круга: лучший, медианный и худший. Шум на машине только замедляет прогон, поэтому самый быстрый
круг ближе всего к стоимости без помех. Медианный круг - типичный. Самый медленный показывает,
насколько машина была далека от покоя.
**Ячейка таблицы.** В таблице печатается лучший круг, в скобках медианный. Худший остаётся в
документе, но в ячейку не попадает: по нему считается разброс, расстояние от лучшего круга до
худшего. Разница меньше разброса публикуется как `неразличимо`, а не процентом: величина ниже шума
между прогонами изображала бы точность, которую никто не измерял.
**Насыщенный консьюмер.** Если сырая сторона провела прогон в ожидании брокера очереди, строка
получает пометку `broker-bound`.
**Окружение** публикуется вместе с числами: процессор и число ядер, ядро системы, способ запуска
брокера очереди (образ, контейнер, хост), версия rustc, версии крейтов, профиль сборки и флаги. Без
этого число нельзя ни воспроизвести, ни признать устаревшим.
### Измерение кода {#the-code-measurement}
Строки второй таблицы каждый крейт собирает собственной командой бенчмарков. Её называет
страница бенчмарков крейта. В `ruststream` это `just bench`. Для команды нужны valgrind и раннер
бенчмарков той версии, на которую крейт ссылается в зависимостях.
В `ruststream` команда `just bench 5000` измеряет каждый сценарий на пяти тысячах доставок вместо
тысячи: число устойчивее, прогон дольше. Опубликованный документ остаётся на значении по умолчанию.
**Что измеряется.** Каждый сценарий - сервис, написанный как у пользователя. Его запускает
настоящий рантайм с выключенной тестовой обвязкой, то есть код, который попадает в релиз. В
`ruststream` транспорт внутрипроцессный, поэтому числа не зависят от сокета, нагрузки на сервер и
сети: это абсолютная собственная стоимость фреймворка. Адаптация библиотеки брокера измеряет себя на
настоящем транспорте, а что он добавляет к числу, сообщает её страница бенчмарков. Сравнение с
чужим клиентом - первая таблица, её делают адаптации библиотек брокеров.
**Где идёт сбор.** Только внутри измеряемой области. Очередь наполняется до её начала, поэтому
сценарий измеряет установившуюся доставку, а не подключение, подписку и первые выделения памяти.
Подготовка и уборка работают в том же процессе и через тот же код фреймворка. Если захватить и их,
наполнение очереди сойдёт за стоимость её разбора.
**Три прогона.** Первый прогон - одна доставка, второй и третий - длинные, и третий вдвое длиннее
второго. В `ruststream` это тысяча и две тысячи доставок. У адаптации библиотеки брокера число
доставок указано на её странице бенчмарков. Прогон с одной доставкой - холодный старт. Разница между
двумя другими - стоимость сообщения на запущенном сервисе. Посреди прогона ничего не выключают,
поэтому способ годится и для счётчика выделений памяти: выключателя у него нет.
**Два числа.** Инструкции считает callgrind, выделения памяти - DHAT. Оба числа точны: инструкции
сравнивают, а выделения ограничивает пол, заявленный сценарием. В `ruststream` отдельный бенчмарк
измеряет время по часам на собственных сценариях. Оно шумное, и `just bench` печатает его для
справки.
**Сравнение, а не порог.** С аргументом `--baseline=main` команда измеряет ветку против тех же
бенчмарков на целевой ветке. Автор пул-реквеста запускает сравнение сам и приводит его числа.
**Провал** - это рост инструкций в сценарии сверх допуска крейта или выделения памяти сверх
заявленных сценарием. Допуск у каждого крейта свой. В `ruststream` он равен двум процентам, а
допуск адаптации библиотеки брокера указан на её странице бенчмарков. Допуск относительный, поэтому
действует только при сравнении с базовым прогоном. Пол выделений памяти проверяется на каждом
прогоне. Изменение, которое удешевляет сценарий, в том же пул-реквесте опускает и его заявленный
пол. Холодный старт проверяется так же.
## Как публиковать результаты {#publishing-results}
Каждая адаптация библиотеки брокера измеряет себя сама. Она запускает `just bench` против брокера
очереди из своего compose-файла и кладёт итог в свой репозиторий: страницу для человека
и документ JSON для этой страницы.
### Стабильный путь {#the-stable-path}
```text
https://raw.githubusercontent.com/powersemmi/<crate>/main/docs/benchmarks/results.json
```
Файл хранится в репозитории адаптации: `docs/benchmarks/results.json`, рядом со страницей, которая
его объясняет. Эта страница читает его из ветки `main`, поэтому новый замер виден сразу после
слияния.
### Документ {#the-document}
```json
{
"schema": 3,
"crate": "ruststream-nats",
"crate_version": "0.7.0",
"core_version": "0.7.0",
"measured_at": "2026-08-20",
"environment": {
"cpu": "AMD Ryzen 9 5950X 16-Core Processor",
"architecture": "x86_64 (x86-64-v3)",
"cpu_frequency": "base 3400 MHz, max 4900 MHz",
"cores": "16 physical, 32 logical",
"memory": "62.7 GiB",
"memory_speed": "DDR4, 3600 MT/s",
"os": "Linux 6.16.7",
"broker": "nats:2.10-alpine in Docker on localhost",
"rustc": "1.90.0",
"valgrind": "3.25.1",
"profile": "bench, inheriting release (opt-level = 3, lto = false, codegen-units = 16)",
"features": "--no-default-features --features memory,macros,json",
"rustflags": "-C target-cpu=native"
},
"scenarios": [
{
"name": "core NATS, 512 B JSON, ack each",
"unit": "msg/s",
"messages": 200000,
"pairs": 3,
"raw": { "best": 129604, "median": 128470, "worst": 126980 },
"adapter": { "best": 129310, "median": 128040, "worst": 126700 },
"framework": { "best": 129020, "median": 127640, "worst": 126100 },
"overhead_percent": 0.4,
"adapter_overhead_percent": 0.3,
"adapter_verdict": "indistinguishable",
"verdict": "indistinguishable",
"broker_bound": true
}
],
"code": [
{
"name": "consume, JSON decode into a small struct",
"messages": 1000,
"framework": { "instructions": 2839.8, "allocations": 0.0 },
"cold": { "instructions": 19219, "allocations": 26 },
"gated": true
}
]
}
```
Основные разделы документа:
- `schema` - версия документа. Версия 3 сообщает по каждому циклу три круга (`best`, `median`,
`worst`), версия 2 добавила раздел `code`, версия 1 содержала медиану с крайними значениями;
- `environment` - машина (`cpu`, `architecture`, `cpu_frequency`, `cores`, `memory`,
`memory_speed`) и сборка бенчмарков (`profile`, `features`);
- `scenarios` - первая таблица, по записи на сценарий;
- `code` - вторая таблица, по записи на сценарий.
В записи `scenarios` поля `raw`, `adapter` и `framework` дают три круга каждого цикла. В таблице
печатается `best`, в скобках `median`. `worst` читается, но не печатается: по расстоянию между
лучшим и худшим кругом выносится вердикт. `unit` - короткая подпись рядом с каждым значением
строки, а не предложение.
`verdict` - это `measured` или `indistinguishable` по правилу выше. `overhead_percent` - разница
между фреймворком и сырым клиентом от края до края. Её записывают всегда, а показывают только при
вердикте `measured`.
`adapter_overhead_percent` - разница между сырым клиентом и собственными консьюмером и паблишером
адаптации, измеренными без рантайма. `adapter_verdict` применяет к ней то же правило разброса.
`broker_bound` помечает прогон, темп которого задавал брокер очереди, а не консьюмер. В
`environment` может быть поле `round_trip`: проба, по которой считается `broker_bound`, чтобы
читатель мог пересчитать сам.
В записи `code` поле `framework` дано на сообщение в установившемся режиме, а `cold` - полная
стоимость запуска сервиса и первой доставки, ни на что не делённая. `gated` говорит, валит ли
регрессия в сценарии прогон бенчмарков крейта.
Крейт, который измеряет код отдельно от сравнения с сырым клиентом, записывает дату и версии этого
прогона в `code_measured`: поля `measured_at`, `crate_version` и `core_version`. Дату и версии для
строки под второй таблицей страница берёт оттуда. В документе без `code_measured` они берутся из
полей верхнего уровня.
Документы старых схем и неполные документы страница читает так:
- в документе схемы 3 без `median` в скобках печатается худший круг, как было до появления этого
поля;
- без `adapter_verdict` страница сама применяет правило к опубликованным разбросам, чтобы две
колонки одной строки одинаково решали, что различимо. Без всех полей `adapter` средняя колонка
остаётся пустой;
- поле окружения, которого машина не сообщает, записывается как `unknown`, а не придумывается:
скорость памяти берётся из таблиц DMI, а их на большинстве систем читает только root.
Обязательны только `cpu`, `os` и `rustc`, поэтому документ схемы 1 остаётся читаемым;
- крейт, который публикует только `scenarios`, объявляет `schema` 1 и сохраняет свою строку в первой
таблице. Крейт, который не измеряет собственный брокер очереди, не пишет `scenarios` вовсе (а не
оставляет пустой список) и попадает только во вторую таблицу;
- если документ не загрузился или объявляет незнакомую версию `schema`, его брокер остаётся в
строке «результаты ещё не опубликованы». Так сломанная публикация видна, а не пропадает молча.