Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

GroupEach contender emits
Simple Emissionone record with a string message
Structured Emissionone record with three key-value attributes
Burst 10k10,000 records in a row
Latency Distributionone 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 a tracing_subscriber::fmt subscriber that formats the event on the calling thread and writes it to std::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):

Scenariorlg fire()tracing::info!log::info! (no-op)rlg ÷ tracing
Simple Emission598 ns (582–615)591 ns (589–593)2.7 ns1.01
Structured Emission, 3 attributes947 ns (923–969)1,117 ns (1,115–1,120)3.0 ns0.85
Burst of 10,000 records6.86 ms (6.57–7.20)6.97 ms (6.95–7.00)0.03 ms0.98
Latency Distribution649 ns (639–659)587 ns (585–590)2.7 ns1.11

Before (run 36773354952):

Scenariorlg fire()tracing::info!log::info! (no-op)rlg ÷ tracing
Simple Emission848 ns (833–864)353 ns (351–355)1.7 ns2.40
Structured Emission, 3 attributes1,145 ns (1,128–1,162)676 ns (672–679)1.9 ns1.69
Burst of 10,000 records9.44 ms (9.18–9.83)4.00 ms (3.98–4.02)0.02 ms2.36
Latency Distribution793 ns (787–800)333 ns (332–333)1.5 ns2.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:

PieceBeforeAfter
Timestamp (now_iso8601)252 ns94 ns
fire() end to end618 ns376 ns
unpark() of the flusher1 ns1 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!.