· 3 min read · Arif Rachim

How a benchmark made Renox 3.3× faster

Measuring Renox against bare Axum showed every request cloning a 35-field state tens of times. Making it one Arc tripled throughput.

Before we put a single number on this site, we wanted to measure Renox properly against two things. The first is the floor it is built on: bare Axum, with the same crates and no middleware at all. The second is the framework it is modelled on: Laravel, both under PHP-FPM and under Octane. The benchmark lives in the repository, so anyone can run it.

The first run surprised us.

The first numbers

On two pinned CPU cores, 64 connections, a route that returns Hello, World!:

Requests per secondp50 latency
Axum (bare)105,2160.61 ms
Renox9,2657.16 ms
Laravel Octane1,40345.22 ms
Laravel (PHP-FPM)72288.96 ms

Renox was well ahead of Laravel, as you would expect. But it was an eleventh of the speed of bare Axum. Renox does more on every request than bare Axum: security headers with a CSP nonce, a request id, an encrypted session cookie, CSRF, the user, the locale, the view layer, error pages and a timeout. Even so, that work shouldn’t cost 90% of the throughput.

Queueing, or work?

The first question was whether requests were waiting or working. With a single connection, Renox answered in 0.14 ms and bare Axum in 0.034 ms. So each request really did about four times as much work, and with 64 connections the two cores were simply full.

Skipping the layers one by one

We added a temporary switch that skips a chosen middleware layer, then measured with each layer skipped in turn:

Text
none         9,049 rps
guard        9,324
maintenance 11,032
view        11,165
session     10,981
auth        11,006
…
all of them 65,181

No single layer was slow. Each one gave back about 10 to 20 percent, and all of them together gave back seven times the throughput. Whatever the cost was, every layer paid it.

What every layer does

Each layer clones the application state. Axum’s from_fn_with_state clones it for every call, it is put into the request’s extensions, and each handler’s State extractor clones it again. AppState was a plain struct of about 35 fields: the configuration, the database pool, the mailer, the queue, the cache, the route table, the translator and more. Most of them were Arcs, and one was a Vec.

A small program timed it:

Rust
for _ in 0..1_000_000 {
    std::hint::black_box(state.clone());
}

One clone took 382 ns on one thread. With two threads cloning at the same time, it took about 3 µs. Every Arc clone is an atomic increment. When two cores increment the same counters, the cache lines holding them bounce between the cores, and 35 counters bounce together. A request did tens of clones, and as many drops.

The fix

AppState is now one Arc around its fields, and Deref makes reading them look exactly the same:

Rust
#[derive(Clone)]
pub struct AppState(Arc<AppStateInner>);

impl std::ops::Deref for AppState {
    type Target = AppStateInner;
    fn deref(&self) -> &AppStateInner { &self.0 }
}

state.db, state.config and state.queue all work as before. The only code that changes is code that moved a field out of a state, like let db = state.db; in a seeder. That now reads let db = state.db.clone();, and the compiler tells you so.

After

BeforeAfter
AppState::clone, one thread382 ns12 ns
AppState::clone, two threads~1.6 µs79 ns
/plaintext9,240 rps30,535 rps
/page: 20 rows from SQLite as HTML6,310 rps9,935 rps

That is 3.3× the requests on a bare route. On a real page, with a query and a template, it is 1.6×, because there the database and the template do a larger share of the work.

What’s left

Bare Axum is still about 3.5× faster on a route that does nothing. The remaining cost is spread over the layers again. The session and the maintenance check, which reads a file on every request, cost about 10 percent each. Those are the next things to look at.

We’ll keep the benchmark in the repository and rerun it as Renox changes.