Перейти к содержанию

Бенчмарки

Нормативная версия документации - английская

Эта страница переведена с английского языковой моделью. При любом расхождении верен английский оригинал.

Фреймворк между клиентом брокера и обработчиком стоит времени на каждом сообщении: поток подписки, декодирование, диспетчеризация, ack. Эту стоимость страница публикует дважды - двумя измерениями, которые отвечают на разные вопросы.

Первое - пропускная способность против сырого клиента на настоящем брокере, на той же машине и на той же работе. Оно говорит, во что фреймворк обходится развёрнутому сервису.

Второе - стоимость собственного кода фреймворка: инструкции и выделения памяти на сообщение, без брокера внутри числа. Оно говорит, что изменилось, когда изменился фреймворк, и оно достаточно точное, чтобы отклонить пул-реквест, который сделал сообщение дороже.

Каждый крейт меряет себя сам и публикует свои числа. Эта страница загружает их и показывает вместе. Числа сюда не скопированы, поэтому крейт, который перемерил себя, меняет таблицы ниже следующей публикацией своей документации.

Результаты

Против сырого клиента

Медианы по чередующимся кругам, в скобках - наблюдаемый разброс. «Брокерный крейт» - это его собственные консьюмер и паблишер, прогнанные без рантайма, поэтому две разности читаются порознь: что крейт стоит поверх клиента и что рантайм добавляет сверху.

Стоимость кода

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

Колонка «Холодный старт» - во что обошлись запуск сервиса и обработка первого сообщения вместе, инструкции и выделения памяти; сервис платит это один раз, а не на каждом сообщении. Строка под таблицей - машина, на которой сделан прогон, вплоть до памяти: счётчик инструкций сравним между машинами только тогда, когда известно, что на них собран один и тот же код.

Что означают эти числа

Сравнение с сырым клиентом

Каждую строку измерил тот, кто ведёт соответствующий крейт брокера: на своей машине, против брокера на localhost. Поэтому строки нельзя сравнивать между собой: абсолютная пропускная способность одной ничего не говорит о другой. Сравнимы только две колонки внутри одной строки, и ровно ради этого сравнения страница существует.

Брокер на localhost - самые жёсткие условия для фреймворка. Сетевой задержки здесь нет, поэтому та же абсолютная стоимость сообщения даёт больший процент, чем дала бы с брокером за настоящей сетью. Читайте процент как верхнюю границу того, во что фреймворк обходится развёрнутому сервису, а не как типичную величину.

Пометка «упирается в брокер» означает, что сырой клиент почти весь прогон ждал данных из сокета. Работа фреймворка тогда укладывается в это ожидание, и измеренная разница сходится к нулю. Для такой нагрузки это настоящий результат: так выглядит насыщенный консьюмер. Но такое число - нижняя граница стоимости диспетчеризации, а не её измерение, и читать его как «бесплатно» нельзя.

Стоимость кода

Счётчик инструкций точен. Два прогона одного бинарника дают одно и то же число, и вдвое более быстрая машина даёт то же самое число, поэтому строки этой таблицы сравнимы между собой и с той же строкой, измеренной на другой машине. Чего счётчик не говорит - это время: та же величина обходится дороже там, где промахивается мимо кэша, и ради этого рядом с таблицей меряется ещё и пара по часам.

Величина - это работа самого крейта и ничего кроме: сервис, который пишет пользователь, над внутрипроцессной очередью, с декодированием нагрузки в тип, чтением поля и подтверждением доставки. Брокера в ней нет, поэтому число двигается тогда и только тогда, когда двигается код фреймворка - именно это позволяет считать два процента дефектом, а не шумом.

Каждая величина на сообщение - это установившийся режим. Запуск сервиса стоит столько, сколько стоит, один раз: подключение, открытие подписки, первые выделения памяти за ними. Разделить это на сообщения прогона значило бы опубликовать разовую цену как цену сообщения. Поэтому сценарий меряется на тысяче доставок и на двух тысячах, а стоимость сообщения - это разница между прогонами; холодный старт меряется отдельно, на одной доставке.

Выделения памяти считаются на сообщение, и на пути доставки величина - ноль: когда сервис уже работает, сообщение доходит от очереди до тела обработчика, ни разу не обратившись к аллокатору. На пути публикации величина - это то, что брокер берёт себе, чтобы владеть переданным ему сообщением, и ничего сверх.

Методология

Каждый крейт брокера следует процедуре ниже, чтобы число одного брокера значило то же самое, что число другого. Брокер, который отступает от процедуры, говорит об этом на своей странице.

Три цикла

Прогон меряет один и тот же сценарий трижды, и три цикла отличаются ровно одним: чем едут сообщения.

  • Сырой клиент. Собственный клиент брокера, напрямую.
  • Брокерный крейт. Консьюмер и паблишер самого крейта - дескриптор подписки, поток подписчика, его подтверждение, его издатель, - которые гоняет цикл внутри бенчмарка, без рантайма над ними.
  • RustStream. Целиком сервис, как его пишет пользователь: обработчик, приложение, диспетч.

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

  • Тот же клиент, та же конфигурация клиента. Предвыборка, режим ack, consumer group, долговечность, число соединений и любая настройка, специфичная для брокера, одинаковы во всех трёх циклах. Сторона фреймворка настраивает брокер через RustStream, но получившиеся настройки клиента всё равно обязаны совпасть.
  • Та же позиция ack. RustStream отправляет ack после возврата из обработчика, поэтому два других цикла отправляют ack там же. Пакетный ack в конце прогона меряет другой протокол, а не другой фреймворк.
  • То же декодирование в тот же тип. Циклы без обработчика десериализуют полезную нагрузку в ту же структуру тем же кодеком и обращается к полю через std::hint::black_box. Пропустить это - самый простой способ получить неверное число: оптимизатор выбрасывает декодирование, результат которого не используется, и сырая сторона незаметно перестаёт декодировать.
  • Та же полезная нагрузка, байт в байт. Тела сообщений обеим сторонам выдаёт один генератор.
  • Тот же рантайм. Режим tokio (flavor), число рабочих потоков и число одновременно обрабатываемых сообщений с обеих сторон одинаковы.
  • Та же сборка. Профиль, RUSTFLAGS и аллокатор совпадают, а фичи наблюдаемости (logging, metrics, otel) либо выключены с обеих сторон, либо включены с обеих. Машина с -C target-cpu=native в окружении даёт числа, которые другая машина не воспроизведёт, поэтому флаги публикуются вместе с результатами.

Прогон

  • Консьюмер подключён до того, как опубликовано первое сообщение. Иначе одна сторона разбирает то, что у брокера уже накопилось, а другая принимает сообщения по мере публикации, и в большинстве брокеров эти два пути разные.
  • Каждый прогон получает собственные имена. Новый субъект, очередь, поток или consumer group на прогон, чтобы прогон N не увидел того, что оставил прогон N-1.
  • Окно начинается на первом принятом сообщении и заканчивается на ack последнего. Измеряемым прогонам предшествует прогрев, результат которого выбрасывается. Установка соединения, регистрация консьюмера и первые выделения памяти - стартовая стоимость, а не стоимость сообщения.
  • Число сообщений подобрано так, чтобы прогон длился не меньше пяти секунд - тогда стартовые переходные процессы и разрешение таймера остаются в пределах шума.
  • Пары чередуются, а не идут блоками. Сырой, фреймворк, сырой, фреймворк и так далее, не меньше одиннадцати пар, первая выбрасывается. Прогнать сначала одну сторону целиком, потом другую - значит приписать весь дрейф машины (нагрев, фоновая нагрузка, страничный кеш) той стороне, которая шла второй.

Отчёт

  • Обе стороны сообщают медиану и разброс по тем парам, которые оставлены. Одно число из одного прогона - не результат.
  • Разница меньше разброса публикуется как неразличимо, а не процентом: величина ниже шума между прогонами читается как точность, которую никто не измерял.
  • Насыщенный консьюмер помечается. Когда сырая сторона проводит прогон в ожидании брокера, строка получает пометку broker-bound.
  • Окружение публикуется вместе с числами: процессор и число ядер, ядро системы, как запущен брокер (образ, контейнер, хост), версия rustc, версии крейтов, профиль сборки и флаги. Без этого число нельзя ни воспроизвести, ни признать устаревшим.

Измерение кода

Вторую таблицу собирает just bench в репозитории крейта. Ему нужны valgrind и раннер бенчмарков той версии, на которую крейт ссылается в зависимостях. just bench 5000 измеряет каждый сценарий на пяти тысячах доставок вместо тысячи: число устойчивее, прогон дольше, а опубликованный документ и порог в CI остаются на значении по умолчанию.

  • Каждый сценарий - это сервис, который пишет пользователь, запущенный настоящим рантаймом с выключенной тестовой обвязкой: меряется тот код, который уезжает в релиз. Рукописного двойника здесь нет: очередь, которую он читал бы, - это внутрипроцессный брокер самого крейта, то есть крейт мерялся бы сам с собой. Сравнение с клиентом, написанным кем-то другим, - это первая таблица, и делают её брокерные крейты.
  • Транспорт - внутрипроцессный. Предмет измерения - собственный код фреймворка, поэтому числа не должны зависеть от сокета, от нагрузки на сервер и от сети.
  • Очередь наполняется до открытия измеряемой области. Сценарий меряет установившуюся доставку, а не подключение, открытие подписки и первые выделения памяти за ними.
  • Сбор идёт по измеряемой области и не выходит за неё. Подготовка и уборка работают в том же процессе и через тот же код фреймворка, поэтому измерение, которое считало бы и их, отчиталось бы наполнением очереди как стоимостью её разбора.
  • Три прогона на сценарий, и зачем они. Одна доставка, тысяча и две тысячи. Разница между двумя последними - стоимость сообщения на уже запущенном сервисе, а прогон с одной доставкой - холодный старт. Ничего не приходится выключать посреди прогона, и это то, что делает способ пригодным для счётчика выделений памяти, у которого выключателя нет вовсе.
  • Три числа на сценарий. Инструкции от callgrind - точные, и по ним стоит гейт; выделения памяти от DHAT - точные, и по ним стоит гейт; время по часам из отдельного прогона - шумное и справочное.
  • Гейт на изменение, а не на величину. Пул-реквест с меткой run-bench меряется против тех же бенчмарков, прогнанных на целевой ветке: больше двух процентов инструкций в сценарии под гейтом валит его, и так же валит любое выделение памяти сверх заявленного сценарием. Таблица появляется комментарием в пул-реквесте. Холодный старт и время по часам только печатаются.

Как публиковать результаты

Крейт брокера запускает собственную обвязку командой just bench против брокера из своего compose-файла. Итог он публикует на своём сайте документации: страницу, которую читает человек, и один документ JSON, который читает эта страница.

Стабильный путь

https://powersemmi.github.io/<crate>/latest/benchmarks/results.json

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

Документ

{
  "schema": 2,
  "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": 11,
      "raw": { "median": 128412, "min": 126980, "max": 129604 },
      "adapter": { "median": 128090, "min": 126700, "max": 129310 },
      "framework": { "median": 127905, "min": 126100, "max": 129020 },
      "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 - версия этого документа. 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 говорит, валит ли CI регрессия в нём. Крейт, который публикует только scenarios, объявляет schema 1 и остаётся со своей строкой в первой таблице.

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