Wado

HTTP Routing Benchmark Performance Analysis

Date: 2026-07-27

Guest-side analysis of benchmark/http_routing (wado serve vs Axum), following the wado-performance method: decompose the per-request cost by ablation, read the WIR for what each request allocates, then A/B one change.

Summary

The guest handler's compute is not the problem — it is ~5 µs per request. The GC work induced by the ~20 short-lived objects that request allocates is ~40 µs. Allocation churn, not routing or serialization logic, is what separates wado serve from Axum on the guest side.

Two source changes (a byte-string literal for the content-type value, write_raw for the body) and one optimizer change (globalizing a constant passed by value) remove 6 of those objects. Each is verified in the WIR; end to end they measure a few percent, which is this host's noise floor — see below.

Method

4-core cloud VM, release wado, servers pinned to cores 0-2 (--workers 3), oha on core 3, 50 connections, best of 3 rounds of 3 s slices, all servers up for the whole run and rotated slice by slice (the bench.sh methodology). Only ratios within one run are meaningful; absolute numbers are machine-specific and lower than benchmark/README.md, which was measured elsewhere.

Ablation apps (app.wado minus one layer at a time):

Variant What it does
base app.wado as committed
hoisted base with the constant content-type value in a global
router hoisted minus JSON and param extraction (constant body)
floor no router, no JSON: constant body, identical wasi:http calls
no-gc base with --collector null (allocates, never collects)

Cost decomposition

Throughput converted to core-µs per request (3 cores ÷ req/s), lower is better:

Request base hoisted no-gc floor axum
GET /user 103 96 80 66 40
GET /event/abcd1234/comments 121 116 76 71 41
GET /static/index.html 120 114 79 68 39

Staged build-up of the guest path (all with the header hoisted, separate run):

Stage GET /user GET /event/:id/comments
floor 69 68
+ router match + dispatch 92 91
+ JSON body + params 99 110

Two conclusions:

The same guest code measured in a CLI loop (match_path + handler, release build) costs 0.86 µs for the match, 3.9 µs including the JSON body — i.e. the compute agrees with no-gc − floor. In the CLI loop the garbage dies immediately and the live set stays tiny, so the copying collector barely notices; under wado serve the same allocation rate against a store that holds the router tables and in-flight request state costs ~2 µs per object churned.

What a request allocates

From wado dump -O2 --world wasi:http/service app.wado, GC objects on the happy path of one dynamic-route request:

Source Objects Note
"application/json".bytes().collect() 5 List + array_new(0) grown 4 → 8 → 16
Request::get_path_with_query lift 2 fresh array from linear memory, then cloned again
match_dynamic (ranges, PathParams, RouteMatch) ~5 escape-promoted locals; static routes allocate none
p["id"] substring + [p["id"]] list 4 String + array, List + array
json::to_bytes (128-byte buffer, Option<char>, ByteSlice) 4
ByteSlice::to_list() 2 copy of the serialized bytes
RouteResponse + resp.body copy in handle 3 second copy of the same bytes

Linear memory is touched separately: cm_lower_string / cm_lower_list_u8 / CM out-pointers do roughly ten malloc/free pairs per request, which is why the freelist allocator's fl_unlink is the most-called guest function.

All four are avoidable without changing what the benchmark measures. Items 1 and 4 are applied to app.wado, items 2 and 3 to the value-copy planner.

  1. Constant collect() in the hot path: "literal".bytes().collect() allocated a List and four arrays every evaluation, because FromIterator for a list starts at capacity 0 and pushes one element at a time and the Iterator trait has no size hint to reserve from. The app now writes b"application/json", which lowers to one struct.new over array.new_data. The two objects left per request are what constant globalization should remove; see below.
  2. Redundant clone when lifting a CM string: the binding allocates a fresh array from linear memory and then clones it into another one before wrapping it in a String. The culprit is the newtype cast in String { repr: bytes as ByteArray, used: len } — freshness read through a cast but the move side did not, so the cast alone forced the copy. Fixed, which also drops the second allocation in String::substring, the per-parameter path-capture cost.
  3. resp.body copied on last use: body_tx.write(resp.body) compiled to an array_new + array_copy of the body even though resp is dead afterwards. Two freshness gaps caused it — an indirect (closure) call counted as borrowed, and so did the value of an if / if let expression — and resp is bound from exactly that shape. Both are fixed; the body now reaches cm_lower_list_u8 straight out of the handler's struct.
  4. StreamWritable::write takes List<u8> by value, so the body was deep-copied into the CM lowering. The app now calls write_raw(resp.body.as_slice()), which lowers the slice directly. On its own that left the value copy of resp.body (item 3) in place — the field read copies before as_slice() sees anything — so the two only pay off together.

Measured levers

The payoff is proportional to objects removed, not to instructions removed: removing all five header objects by hand measured +8.2% / +9.7% on the two routes (28,950 → 31,325 and 25,207 → 27,651 req/s, best of 5 rounds), of which only ~1 µs is compute and the rest is GC that no longer runs.

Items 1 and 4 together — the byte literal plus write_raw — measured +2.3% to +6.2% across three request shapes, and a further hand-hoist of the literal into a global measured +1% to +8% on top of that.

Noise floor

Those single-digit deltas need a scale: an A/B of two servers running the same binary on the same app, interleaved the same way over 5 rounds, spreads -4.2% to +0.4% on this host. So anything under ~5% here is a hint, not a result — including the +2 to +6% above. What is certain is the WIR: the objects are gone.

For a change this size, isolate it instead — a CLI loop, best of three, is stable to a few percent where the HTTP path is not:

The constant is no longer rebuilt per request

const_object_globalization (WEP 2026-05-31) matched a qualifying constant in a let binding or behind a &, but not in a by-value argument position — which is where the header value sits, so it was rebuilt per request. The pass now matches that shape too, gated on the callee's parameter being read-only in the callee's own body, and the header value compiles to an eager array.new_fixed global read straight into Fields::append: zero per-request allocations for the header.

A constant handed to a callee that writes its parameter used to get a caller-side defensive copy of a value that was already fresh: is_owned_value listed StringLiteral but not BytesLiteral, though both lower to the same fresh aggregate over a packed array. That copy is gone too — which leaves the callee gate above as the only thing standing between a hoisted constant and a callee that writes it, exactly as the gate was written to be.

Profiler caveat

The guest profiler's self counts are weighted by call frequency, not by time. On this workload it reports fl_unlink at 24.3% of samples with the freelist allocator, and realloc — a pointer bump — at 24.1% with --allocator bump, while throughput is the same for both. Treat the guest profile as a call-count map; take timing from ablation A/Bs.

Next steps, in payoff order

  1. Router: match_dynamic allocates ranges, PathParams, and RouteMatch per hit while match_static returns a pre-built shell. A ranges buffer owned by the router (or a fixed inline capacity) would make dynamic hits allocation-free in the common case.
  2. Host side, separately: the 68 µs floor against Axum's 40 µs is wado serve's own per-request path, not guest code.