ruststream 0.7.0

Async messaging framework for Rust: broker-agnostic traits, router, codecs, and a conformance harness for broker authors.
Documentation
# Бенчмарки

Между клиентом брокера и обработчиком фреймворк тратит время на каждое сообщение: поток подписки,
декодирование, диспетчеризация, 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`, его брокер остаётся в
  строке «результаты ещё не опубликованы». Так сломанная публикация видна, а не пропадает молча.