Benchmarks¶
An adapter between the Redis client and a message type costs time on every delivery: the read,
the decode, the settle. This page says how much, measured against the same work written by hand on
fred.
A scenario is run three ways in one process. Raw client is a loop written by hand on fred.
ruststream-fred is the same loop written by hand on this crate: the broker, the subscription,
the stream of deliveries and the ack, with no service, no handler and no dispatch.
RustStream service is the service a user writes, started through the real runtime.
That gives two differences, and they answer different questions. The adapter against the raw client is what this crate's own consumer costs over the client it wraps, which is the number this repository is responsible for. The service against the raw client is what a reader pays end to end. The gap between them is what the runtime costs on top of this broker in particular; it is published per broker because every adapter is thin, so a share that differs between brokers lives in how the two meet - how the stream yields, how deliveries arrive, how back-pressure reaches the consumer.
Everything else is held equal: the pool and its size, the consumer group and the consumer name, the
read commands with their COUNT and BLOCK, the position of the ack, the decode into the same
type, the payload bytes, the tokio runtime and the build. The procedure is the framework's own and
is described under
methodology; this page
publishes what it produced here.
Three consumer forms are measured, one per delivery shape this crate offers: a Redis Streams
consumer group acknowledging every entry, a reliable list work queue, and a Pub/Sub channel. Each
is measured against the three server forms this crate connects to: a standalone server, a cluster
and a master behind Sentinel. The subscription is the same on all three; what differs underneath is
the client's routing, so a difference between those rows is a finding about that. The one exception
is deliberate: the Pub/Sub row on the cluster is sharded, SSUBSCRIBE and SPUBLISH, the form a
cluster is used with, where classic PUBLISH is broadcast to every node.
The numbers¶
The best of three interleaved rounds, with the median round in parentheses. Higher is
better. A difference smaller than the spread between runs is reported as indistinguishable
rather than as a percentage.
The table is read from the document below every time the page is opened, so what it shows is the last run and nothing else.
Settling a delivery on Redis costs a command of its own - XACK for a stream entry, LREM for a
list entry - and on a server reached over the loopback that command costs tens of microseconds,
which is an order of magnitude more than a delivery spends inside this crate. The consumer spends
its window waiting on the socket, so those two rows carry the broker-bound mark: everything above
the socket does its work inside a wait the consumer was already paying for, which for a consumer
that settles every message is a real result and at the same time a lower bound on those costs
rather than a measurement of them.
Pub/Sub settles nothing, and it is the row where the work above the socket has room to show: a delivery there is a socket read, an unframing and a decode, and it costs an order of magnitude less than a stream delivery.
The machine-readable form of the same run, which the framework's site reads to build its
cross-broker table, is at
benchmarks/results.json.
The crate's own code¶
The second table is this crate's own cost per message, counted rather than timed: instructions
under callgrind and allocations under DHAT. Each scenario is the service a user writes, built on
RedisBroker and started against the standalone server of the stand, so every command in it is
one a service sends. A stream scenario reads a RedisStream consumer group with XREADGROUP,
settles with XACK and replies with XADD through RedisPublish. A reliable list moves each entry
with LMOVE and settles it with LREM. A Pub/Sub scenario reads a channel or a pattern and settles
nothing. A .pipeline() scenario sends a fetch's settles together, and its handler queues one
INCR of its own per delivery; .pipeline().atomic() wraps each delivery's settle in its own
MULTI / EXEC.
The service runs on a single-threaded tokio runtime, and fred drives its connections on the same
thread. Everything on that thread is counted: the framework, this crate, and fred writing the
commands and parsing the replies. The server is another process and is not in the number, and
neither is the kernel's side of a socket call. The messages are appended to the stream from
another thread before the measured drain starts, so producing them is not counted either.
Instructions and allocations are per message in the steady state: the slope between a run of 1000 deliveries and a run of 2000. The last column is what connecting the pool, creating the consumer group, opening the subscription and taking the first delivery cost once. The numbers are absolute, the framework's own cost included; the core publishes that cost alone on its benchmarks page.
The service talks to a real server, so a count moves a little between runs: over six runs the
instruction totals of a scenario stayed within 0.4% of each other and its allocations within a
few blocks. Each floor is therefore the highest count seen plus a margin of 0.1%.
just bench-code fails on an allocation above the floor a scenario declares, and with
--baseline=main on more than two percent more instructions, and a pull request that changes the
cost cites its numbers.
The machine¶
The build flags are published with the numbers because they change them: a binary built with
-C target-cpu=native produces a figure no other machine can reproduce, so the recipe clears the
variable before it builds.
What they do not mean¶
This is one consumer, one key, a small body and a server on the loopback. It measures what a delivery costs in this crate and in the runtime over it, not what Redis can carry, and a row here is not comparable with a row published for another broker: the transports do different work per message.
The window a run measures opens at the first delivery and closes when the last one is taken, alike in all three loops, so one acknowledgement out of a run sits outside the number everywhere.
The publish path is not in these rows. All three loops are fed by the same pipelined fred
publisher, so that what differs between them stays on the consuming side; what this crate's own
publisher costs is a measurement of its own.
The stand runs its servers on the host network and without persistence: no port proxy between the client and the server, no append-only file, no snapshot. What is measured is the cost of a delivery, not the bridge in front of the server or the disk under it. A service that keeps persistence on pays for it, and pays the same on both sides.
The Pub/Sub figure is taken under a publisher that never waits for the consumer. Redis Pub/Sub drops what a consumer is not there to take rather than queueing it, so what that row reports is deliveries handled per second by a saturated consumer.
The numbers are a snapshot of one machine on one day. They are re-measured by hand, on a machine given to the run alone: the difference this page is about is smaller than the noise of a shared one.
Running it yourself¶
The recipe starts the stand from docker-compose.test.yml, runs every scenario, stops the stand
and rewrites docs/benchmarks/results.json with what it measured. It takes about ten minutes and
wants the machine to itself. The message count is not fixed: a probe run sets it so that every
measured run lasts at least five seconds on whatever machine it is taken on.
The recipe starts the same stand, counts the code table under valgrind against its standalone
server, stops the stand and rewrites the code section of the same document. It needs valgrind
and the benchmark runner: cargo install --locked gungraun-runner --version =0.19.4.