# Бенчмарки
Между клиентом брокера и обработчиком фреймворк тратит время на каждое сообщение: поток подписки,
декодирование, диспетчеризация, ack. Эта страница показывает эту стоимость двумя измерениями:
- **Против сырого клиента.** Пропускная способность фреймворка и сырого клиента на настоящем брокере
очереди, на одной машине и на одной работе. Отвечает, во что фреймворк обходится развёрнутому
сервису.
- **Стоимость кода.** Инструкции и выделения памяти собственного кода фреймворка на сообщение, без
брокера очереди. Отвечает, что изменилось вместе с фреймворком. Точности хватает, чтобы отклонить
пул-реквест, который сделал сообщение дороже.
Каждый крейт измеряет себя сам и публикует свои числа, а страница загружает их и показывает
вместе. Поэтому новые замеры крейта появляются здесь со следующей публикацией его документации.
## Результаты {#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}
Вторая таблица считает только собственный код фреймворка. Для каждого сценария она показывает,
сколько инструкций и выделений памяти уходит на одно сообщение в установившемся режиме. Измерение
идёт на внутрипроцессном транспорте, поэтому числа абсолютные. Стоимость фреймворка поверх
настоящего клиента брокера показывает таблица выше.
<div id="benchmark-code"></div>
Колонка «Холодный старт» считает запуск сервиса вместе с обработкой первого сообщения. Эти
инструкции и выделения сервис тратит один раз, а не на каждом сообщении.
Строка под таблицей описывает машину прогона вплоть до памяти. Счётчики инструкций с разных машин
сравнимы, только если на них собран один и тот же код.
## Что означают эти числа {#what-the-numbers-are}
### Сравнение с сырым клиентом {#the-comparison-against-a-raw-client}
**Каждая строка - свой замер.** Каждую строку измеряет тот, кто ведёт адаптацию библиотеки брокера:
на своей машине, против брокера очереди на localhost. Поэтому строки между собой не сравниваются.
Сравнимы только две колонки внутри одной строки, и ради этого сравнения страница существует.
**Процент - верхняя граница.** Брокер очереди на localhost ставит фреймворк в самые жёсткие условия.
Сетевой задержки нет, поэтому та же абсолютная стоимость сообщения даёт больший процент, чем с
брокером очереди за настоящей сетью. Читайте процент как верхнюю границу стоимости фреймворка для
развёрнутого сервиса, а не как типичную величину.
**«Упирается в брокер очереди» - нижняя граница.** Пометка значит, что сырой клиент почти весь
прогон ждал данных из сокета. Работа фреймворка укладывается в это ожидание, и измеренная разница
стремится к нулю. Для такой нагрузки это честный результат: так выглядит насыщенный консьюмер. Но
это число - нижняя граница стоимости диспетчеризации, а не её измерение. Бесплатной диспетчеризацию
оно не делает.
### Стоимость кода {#the-cost-of-the-code}
**Инструкции - точная мера.** Два прогона одного бинарника дают одно число, и вдвое более быстрая
машина даёт то же число. Поэтому строки этой таблицы сравнимы между собой и с той же строкой,
измеренной на другой машине. Времени счётчик не показывает: та же величина обходится дороже там,
где код промахивается мимо кэша. Поэтому рядом с таблицей измеряется и время по часам.
**Измеряется только крейт.** Величина - работа самого крейта и ничего больше: сервис, написанный как
у пользователя, над внутрипроцессной очередью, с декодированием нагрузки в тип, чтением поля и
подтверждением доставки. Брокера очереди в ней нет, поэтому число меняется тогда и только тогда,
когда меняется код фреймворка. Рост на два процента - дефект, а не шум.
**Установившийся режим.** Каждая величина на сообщение измерена в установившемся режиме. Запуск
сервиса стоит своё один раз: подключение, подписка, первые выделения памяти. Если разделить эту
цену на сообщения прогона, разовая цена попадёт в цену сообщения. Поэтому сценарий измеряется на
тысяче и на двух тысячах доставок, а стоимость сообщения - разница между прогонами. Холодный старт
измеряется отдельно, на одной доставке.
**Выделения памяти** считаются на сообщение. На пути доставки их ноль: у работающего сервиса
сообщение проходит от очереди до тела обработчика, ни разу не обратившись к аллокатору. На пути
публикации выделяется только то, что брокер берёт себе, чтобы владеть переданным сообщением.
## Методология {#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}
Вторую таблицу собирает `just bench` в репозитории крейта. Для него нужны valgrind и раннер
бенчмарков той версии, на которую крейт ссылается в зависимостях.
`just bench 5000` измеряет каждый сценарий на пяти тысячах доставок вместо тысячи: число
устойчивее, прогон дольше. Опубликованный документ остаётся на значении по умолчанию.
**Что измеряется.** Каждый сценарий - сервис, написанный как у пользователя. Его запускает
настоящий рантайм с выключенной тестовой обвязкой, то есть код, который попадает в релиз. Транспорт
внутрипроцессный, поэтому числа не зависят от сокета, нагрузки на сервер и сети. Это абсолютная
собственная стоимость фреймворка. Сравнение с чужим клиентом - первая таблица, её делают адаптации
библиотек брокеров.
**Где идёт сбор.** Только внутри измеряемой области. Очередь наполняется до её начала, поэтому
сценарий измеряет установившуюся доставку, а не подключение, подписку и первые выделения памяти.
Подготовка и уборка работают в том же процессе и через тот же код фреймворка. Если захватить и их,
наполнение очереди сойдёт за стоимость её разбора.
**Три прогона.** Сценарий прогоняется на одной доставке, на тысяче и на двух тысячах. Прогон с
одной доставкой - холодный старт. Разница между двумя другими - стоимость сообщения на запущенном
сервисе. Посреди прогона ничего не выключают, поэтому способ годится и для счётчика выделений
памяти: выключателя у него нет.
**Три числа.** Инструкции считает callgrind, выделения памяти - DHAT. Оба числа точны: инструкции
сравнивают, а выделения ограничивает пол, заявленный сценарием. Время по часам берётся из
отдельного прогона. Оно шумное и приводится для справки.
**Сравнение, а не порог.** `just bench --baseline=main` измеряет ветку против тех же бенчмарков на
целевой ветке. Автор пул-реквеста запускает сравнение сам и приводит его числа.
**Провал** - это рост инструкций в сценарии больше чем на два процента или выделения памяти сверх
заявленных сценарием. Изменение, которое удешевляет сценарий, в том же пул-реквесте опускает и его
заявленный пол. Холодный старт и время по часам только печатаются.
## Как публиковать результаты {#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` говорит, валит ли
регрессия в сценарии `just bench --baseline=main`.
Документы старых схем и неполные документы страница читает так:
- в документе схемы 3 без `median` в скобках печатается худший круг, как было до появления этого
поля;
- без `adapter_verdict` страница сама применяет правило к опубликованным разбросам, чтобы две
колонки одной строки одинаково решали, что различимо. Без всех полей `adapter` средняя колонка
остаётся пустой;
- поле окружения, которого машина не сообщает, записывается как `unknown`, а не придумывается:
скорость памяти берётся из таблиц DMI, а их на большинстве систем читает только root.
Обязательны только `cpu`, `os` и `rustc`, поэтому документ схемы 1 остаётся читаемым;
- крейт, который публикует только `scenarios`, объявляет `schema` 1 и сохраняет свою строку в первой
таблице. Крейт, который не измеряет собственный брокер очереди, не пишет `scenarios` вовсе (а не
оставляет пустой список) и попадает только во вторую таблицу;
- если документ не загрузился или объявляет незнакомую версию `schema`, его брокер остаётся в
строке «результаты ещё не опубликованы». Так сломанная публикация видна, а не пропадает молча.