Benchmarks
What the benchmarks measure, how to run them, and what the numbers do and do not mean.
What is measured
crates/rlg/benches/competitive_bench.rs times the cost to the
calling thread of emitting a record, in four groups:
| Group | Each contender emits |
|---|---|
| Simple Emission | one record with a string message |
| Structured Emission | one record with three key-value attributes |
| Burst 10k | 10,000 records in a row |
| Latency Distribution | one record, sampled for its spread |
The three contenders do different work, and the numbers only make sense with that in mind:
- rlg
fire()checks the level and pushes the record into the ring buffer. Formatting and I/O happen later on the flusher thread and are not in the timed path. That is the design being measured. tracing::info!runs through atracing_subscriber::fmtsubscriber that formats the event on the calling thread and writes it tostd::io::sink.log::info!goes to a logger that does nothing: no formatting, no I/O. It is the floor, the cost of the facade alone.
So rlg against tracing compares “enqueue and return” with “format on
this thread”, and neither is comparable to the log floor.
Running them
cargo bench -p rlg --bench competitive_bench # this suite
cargo bench --workspace # every crate's benches
Numbers from a laptop swing by tens of percent between runs; compare results from the same machine, back to back.
Published results
bench-publish.yml runs every workspace benchmark on a GitHub-hosted
ubuntu-latest runner for each release tag, and uploads the Criterion
report and a JSON summary as workflow artifacts (kept 90 days). Until
0.0.13 that workflow ran no benchmarks at all: its output directory did
not exist, and the failure was swallowed.
0.0.13 (release branch)
Two runs on GitHub-hosted ubuntu-latest, stable Rust, release profile,
before and after the hot-path fix below. Typical time per iteration with
Criterion’s 95% confidence interval.
Runners differ in speed between runs: tracing, whose code did not
change, measured 353 ns in one and 591 ns in the other. So compare
within a run (the ratio to tracing), not across runs.
After (run 36802323103):
| Scenario | rlg fire() | tracing::info! | log::info! (no-op) | rlg ÷ tracing |
|---|---|---|---|---|
| Simple Emission | 598 ns (582–615) | 591 ns (589–593) | 2.7 ns | 1.01 |
| Structured Emission, 3 attributes | 947 ns (923–969) | 1,117 ns (1,115–1,120) | 3.0 ns | 0.85 |
| Burst of 10,000 records | 6.86 ms (6.57–7.20) | 6.97 ms (6.95–7.00) | 0.03 ms | 0.98 |
| Latency Distribution | 649 ns (639–659) | 587 ns (585–590) | 2.7 ns | 1.11 |
Before (run 36773354952):
| Scenario | rlg fire() | tracing::info! | log::info! (no-op) | rlg ÷ tracing |
|---|---|---|---|---|
| Simple Emission | 848 ns (833–864) | 353 ns (351–355) | 1.7 ns | 2.40 |
| Structured Emission, 3 attributes | 1,145 ns (1,128–1,162) | 676 ns (672–679) | 1.9 ns | 1.69 |
| Burst of 10,000 records | 9.44 ms (9.18–9.83) | 4.00 ms (3.98–4.02) | 0.02 ms | 2.36 |
| Latency Distribution | 793 ns (787–800) | 333 ns (332–333) | 1.5 ns | 2.38 |
Reading these honestly. After the fix, fire() costs about what
tracing::info! costs on the calling thread, and less with attributes,
while tracing formats the event there and rlg does not. rlg’s
advantage remains that the caller never waits on a sink’s I/O, which
this suite’s discarding writer does not exercise.
Where the per-record cost went
Timing each piece of fire() in isolation (release build, one
machine) showed most of it was formatting that had stayed on the
calling thread, not the queue or the wake-up:
| Piece | Before | After |
|---|---|---|
Timestamp (now_iso8601) | 252 ns | 94 ns |
fire() end to end | 618 ns | 376 ns |
unpark() of the flusher | 1 ns | 1 ns |
The timestamp is now written digit by digit into a fixed buffer instead
of through format! (output identical, checked against the old code on
two million instants), and the caller attribute is built without
format!.