ruststream 0.7.0-rc.10

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>

### Стоимость кода {#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.** Целиком сервис, как его пишет пользователь: обработчик, приложение, диспетч.

Первая разность - что брокерный крейт стоит поверх клиента, который он оборачивает; за неё
отвечает сам крейт. Вторая - что рантайм добавляет сверху именно на этом брокере, и она
публикуется по каждому брокеру, потому что крейты встречаются с рантаймом по-разному: как поток
отдаёт доставки, приходят ли они пачками, как до консьюмера доходит обратное давление.

- **Тот же клиент, та же конфигурация клиента.** Предвыборка, режим 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://powersemmi.github.io/<crate>/latest/benchmarks/results.json
```

Файл лежит в репозитории брокера по пути `docs/benchmarks/results.json`. Сборка документации
копирует его как есть, а публикация сайта кладёт его под псевдоним `latest` рядом со страницей,
которая его объясняет. Сайты брокеров находятся на том же домене, что и этот, поэтому страница
читает их напрямую.

### Документ {#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 нёс медиану с крайними значениями. В таблице
печатается `best`, а в скобках `median`. Поле `worst` читается, но не печатается: на расстоянии
между лучшим и худшим кругом стоит правило вердикта. Документ схемы 3 без `median` оставляет в
скобках худший круг - так он и печатался до появления этого поля. `unit` - короткая подпись
рядом с каждым значением строки, а не предложение. `verdict` принимает значение `measured` или `indistinguishable` по правилу выше;
`overhead_percent` записывается в обоих случаях, а показывается только при вердикте `measured`; это
фреймворк против сырого клиента, от края до края. Поля `adapter` и `adapter_overhead_percent` -
собственные консьюмер и паблишер крейта против того же клиента, измеренные без рантайма, а
`adapter_verdict` применяет к этой разности то же правило разброса, что `verdict` к другой, а если
крейт его не опубликовал, страница применяет то же правило к опубликованным разбросам сама - чтобы
две колонки одной строки не спорили о том, что вообще различимо. Крейт, не опубликовавший ничего из
этого, оставляет среднюю колонку пустой. В `environment` может лежать
`round_trip` - та самая проба, на которой стоит арифметика `broker_bound`, чтобы читатель мог
пересчитать сам.
`broker_bound` помечает прогон, темп которого задавал брокер, а не консьюмер.

`environment` описывает машину и сборку. Поля `cpu`, `architecture`, `cpu_frequency`, `cores`,
`memory` и `memory_speed` - это машина, `profile` и `features` - то, чем собраны бенчмарки. Поле,
которого машина не сообщает, пишется как `unknown`, а не выдумывается: скорость памяти берётся из
таблиц DMI, а их на большинстве систем читает только root. Всё, кроме `cpu`, `os` и `rustc`,
необязательно, поэтому документ схемы 1 остаётся читаемым.

`code` - вторая таблица, по записи на сценарий. Поле `framework` дано на сообщение в
установившемся режиме, а `cold` - это целиком стоимость запуска сервиса и первой доставки, ни на
что не делённая. Поле `gated` говорит, валит ли регрессия в нём `just bench --baseline=main`. Крейт, который публикует только
`scenarios`, объявляет `schema` 1 и остаётся со своей строкой в первой таблице. Крейт, который не
меряет собственного брокера, не пишет `scenarios` вовсе, а не оставляет пустой список, и попадает
только во вторую таблицу.

Документ, который не загрузился или объявляет незнакомую версию в поле `schema`, оставляет свой
брокер в строке «результаты ещё не опубликованы»: сломанная публикация видна, а не пропадает молча.