m0serve Realtime from a synchronous Python app

Server performance: the raw HTTP hot path

Throughput and latency of the Mojo server serving Mojo handlers, with the environment each number was taken in.

Measured 2026-08-18, on the raw lightbug_http event loop (apps/hello, single process, epoll backend). Companion to WSGI_PERFORMANCE.md, which measures the Django/WSGI stack; this document is about the server itself, with the handler cost near zero.

Setup

  • 4-core Linux container, 16 GB RAM, server and load generator on one box. Absolute numbers drift hard between sessions and comparing across them will mislead you. Measured directly: the same Go binary served 36k req/s in one session and 60k in another, a 1.7x swing from nothing but container load. Every comparison in this document was therefore taken in the same session as its counterparts, and any before/after claim comes from alternating A/B runs rather than a remembered number.
  • apps/hello/server.mojo compiled with mojo build (Mojo 1.0, the uv.lock pin). Single process, listen_and_serve_nonblocking, default ServerConfig.
  • wrk 4.1.0: 2 threads, 16 connections, 10 s runs after warm-up, keep-alive, against / (13-byte plain-text body). Three runs per configuration; the tables show a representative middle run.
  • Competitors, same box, same wrk settings: Go 1.x net/http (GOMAXPROCS(1), hello-world handler), Node 22 http (single process), uvicorn (single worker, plain ASGI app, no uvloop). All four servers are CPU-saturated on their serving core at these rates (verified via /proc/<pid>/stat — each burns ~100% of one core).

Where the time went: a syscall budget

strace -c over a 5 s window of the pre-change server, keep-alive load:

syscall calls/request notes
recvfrom 1.0 necessary
sendto 1.0 necessary — whole response in one send
epoll_ctl 2.0 half of them failing with EEXIST
timerfd_settime 1.0 idle-timeout re-arm, every request

The epoll_ctl pair was the keep-alive re-arm: add_read tries EPOLL_CTL_ADD (fails EEXIST — the fd is still registered), then falls back to MOD. The timerfd_settime was the idle timer being re-armed after every response. Five-plus syscalls per request where two suffice.

Beyond syscalls, gdb stack sampling under load put ~65% of the saturated core inside __libc_send itself — on loopback, send() runs the peer's softirq delivery and wakeup inline, so that share is partly the load generator's cost billed to the server, and every server measured here pays it. The user-space remainder concentrated in String.to_lowercase (header Dict keys), HTTPResponse.encode, and allocation (tc_new).

Changes landed in this pass

Each was measured in isolation against the previous step, same session:

  1. Skip redundant event re-registration (event_loop.mojo, slot_read_armed). Read filters are persistent on both backends; the only operation that disarms one is add_write_oneshot (on epoll it replaces the fd's mask). Tracking that transition per slot eliminates both per-request epoll_ctl calls in steady state.
  2. Idle timeout by deadline sweep (slot_idle_deadline). Instead of timerfd_settime per request, each response stamps a deadline and the loop sweeps active slots at most once a second (the loop already wakes at least that often — wait(1000)). Timeouts are whole seconds; nothing observable changes. Removes the last per-request epoll/timer syscall. Changes 1+2 together: 15.2k → 16.8k req/s (+10%).
  3. TCP_NODELAY on accepted sockets (set_tcp_nodelay, accept path). Responses are single send()s — there is nothing for Nagle to coalesce, it only delays a response behind the previous one's ACK. Every mainstream server disables it. 16.8k → ~19.1k req/s (+5% throughput, p50 0.93 ms → 0.81 ms).
  4. Date header: format once per second, not once per response (http_date_from_unix + loop-level cache). Formatting cost ~10 small String allocations plus gmtime per response — measured ~9% of hello throughput when it ran per-response in the HTTPResponse constructor. The constructor no longer stamps Date; the event loop injects a cached string (falling back to encode()'s own stamp for responses sent outside the loop).
  5. ASCII fast path for header encoding (write_header_latin1). The ISO-8859-1 transcode allocated a scratch buffer per header per response; pure-ASCII values (all of them, in practice) are now written directly.
  6. Request construction on the cheap (HTTPRequest.from_parsed): the parsed header Dict is now moved (swap) instead of deep-copied, and an origin-form path with no %-escape and no query string skips URI.parse entirely — every derived URI field is the path itself. Neutral on wrk's minimal requests, but removes a full Dict copy + URI parse per request for real traffic.

After all six: syscalls per keep-alive request are exactly recvfrom + sendto (plus a shared epoll_wait amortized over ~16 requests), and the strace error column is zero.

Results

Same box, same wrk settings (t2/c16 keep-alive unless noted):

server req/s p50 p99
mojo-http before this pass 15,200 1.03 ms 1.7 ms
mojo-http after 18,900 0.83 ms 1.45 ms
Go net/http (GOMAXPROCS=1) 36,000 0.44 ms 1.2 ms
Node 22 http 18,200–26,500 0.54 ms 1.6 ms
uvicorn (no uvloop) 5,000 3.1 ms 5.1 ms

+24% throughput, −20% p50. The patched server holds its rate under concurrency — 18.3k at c64, 17.3k at c256 (p99 21.8 ms, purely queueing) — and does 11.4k in Connection: close mode, where accept-path costs (2 × fcntl, header-timer timerfd_create/epoll_ctl/close) dominate. Node's range reflects genuine run-to-run drift in this shared container.

Go's remaining ~1.9× is real and is not syscalls — both servers now issue the same two per request. It is the per-request object machinery, detailed next.

Span-based headers

The item ranked first below landed next, and it was the largest single win in this file's history: +72% throughput on top of everything above.

Headers stored a Dict[String, String]. Filling it cost two String allocations per header, .lower() allocated a third to normalize each name, and every lookup allocated a fourthkey.lower() builds a probe copy before it can hash. At 5-15 headers per request that is dozens of allocations to answer questions like "is this connection close?".

It now stores one flat byte blob holding every name and value back to back, indexed by parallel (offset, length) arrays — the SoA pattern used elsewhere in this repo. Names are lowercased on the way in, so a lookup is a linear scan comparing lengths first and folding only the probe's bytes: no allocation at all. Over 5-15 entries that beats hashing outright, and insertion order is preserved, which the Dict never guaranteed.

Three things fell out of the new representation for free:

  • content_length() parses digits straight out of the blob instead of materializing a String first — it runs for every request with a body.
  • value_equals_ignore_case() answers the Connection: close question without building the two Strings the old get(k).value().lower() == v needed, on every single request.
  • The parser dispatches field names with name_is() against known- lowercase constants, and trims OWS by moving span endpoints, so a header only becomes a String if it is a cookie.

Measured by alternating A/B runs in one session (wrk t2/c16 keep-alive):

variant req/s p50 p99
Dict[String, String] 29,000 535 us 0.90 ms
flat blob + offsets 50,000 320 us 485 us

Five alternating rounds; the spread within each variant was under 5% and the two never overlapped. Go net/http measured 60k in that same session, so this moved mojo-http from 0.48x to 0.83x of Go on the same box.

Note what these absolute numbers are not comparable to: the 18.9k in the table above was a different session on the same container. Against that session's Go baseline of 36k, 18.9k was 0.53x. The honest way to read the two tables together is by ratio, not by subtraction.

How much this depends on header count. The A/B above used wrk's default request, which sends a single Host header — the least favourable shape for a change whose cost scaled with header count. Measured separately on the Django path, where the same before/after pair was run at one header and at twelve: the benefit was 2.4x larger with twelve (see WSGI_PERFORMANCE.md). Real traffic sits at the twelve end, so the number in the table is a floor, not a ceiling.

The profile then flipped. Before, stack samples showed String.to_lowercase, Headers.__init__, and the allocator. After, 31 of 35 samples sit in __libc_send — the write syscall itself — with exactly one in the allocator and one each in set_bytes and Headers.__init__. The server is now syscall-bound on loopback rather than allocation-bound.

That result retires the rest of the header work. The parser still builds a String per name and per value in parse_headers (create_string_from_reader), and the original plan was to make HTTPHeader hold offsets too. The profile says that is no longer worth its complexity: the leaf cost it would remove does not appear in the samples. Revisit only if a future profile disagrees.

What this deliberately did not do

The literal design sketched below — spans pointing into the provision's receive buffer, with zero copy at all — would require Headers to carry an origin parameter, which propagates to ParsedRequestHeaders, HTTPRequest, and therefore the HTTPService trait. That breaks every handler in the repo at once: all six apps, the five demo services in service.mojo, and the README example. The blob copies the header bytes once per request and gets the allocation win without touching a single caller — Headers's public API (get, [], in, pop, keys, content_length) is unchanged, which is why this landed as a pure internal substitution.

(HTTPService's methods later gained default bodies, which does not change this. A default removes the cost of adding a hook; it does nothing for changing a signature, and func — the one method with no default — is the one an origin parameter would rewrite.)

Where the remaining gap lives

The canonical fast HTTP/1.1 servers (fasthttp, may-minihttp/ntex, and TechEmpower's top plaintext entries generally) share one design decision: nothing request-scoped is allocated. fasthttp documents it directly — headers stay []byte slices into the read buffer, request/response objects are pooled per connection, []bytestring conversions are avoided in hot paths. The header half of that checklist is now done (above). What each request still allocates:

  • two Strings per header inside parse_headers (create_string_from_reader) before Headers copies the bytes into its blob — the one remaining piece of the header story, and the profile says it is no longer worth chasing;
  • an HTTPRequest (cookie jar, URI struct with 12 String fields) and an HTTPResponse (its own Headers, 3–4 inserts);
  • a fresh ByteWriter per responsedone, and it was worth less than this list implied — but the field it uses was already costing memory. It was never blocked: a buffer moves out of a list-element field by swap, the same idiom from_parsed and Headers already used, verified on the 1.0 toolchain. Each slot now rotates one buffer between encode_into and _after_send. Measured in isolation at 76 ns per response (1.04x on the encode step), and not separable from run-to-run noise end to end — see below;
  • a copy of the body bytes out of recv_buffer.

Ranked next steps, by expected value:

  1. Response encode into a reusable per-slot bufferdone; this ranking was wrong. The change is in, and the honest result is that it buys 76 ns per response in isolation and nothing measurable end to end. An alternating A/B of the two binaries (ab -k -c16 -n20000, five rounds each) put both between 75k and 89k req/s with the distributions fully overlapping. That is what the post-header profile already predicted — one allocator sample out of 35 — and the general lesson is worth keeping: after the header work, no remaining allocation on this path is large enough to see through loopback noise. Rank the next two accordingly.

    It landed anyway, and on a different argument than throughput. ProvisionPool eagerly constructs every provision at startup — for _ in range(capacity) over max_connections, 1024 by default — and each one allocated an encoding_buffer of socket_buffer_size. That is ~4 MB of heap per process that nothing read or wrote. The choice was never "76 ns or nothing"; it was "use the buffer, or delete the field". Using it also retires the false claim that Mojo cannot move out of a list-element field, which is what put this item at the top of this list in the first place.

  2. Close-mode accept path: accept4(SOCK_NONBLOCK) (one syscall instead of accept + 2 × fcntl), and pool header-timeout timerfds instead of create/close per connection. Only matters for non-keep-alive clients; the close-mode p99 suggests accept-queue latency is the tail.

  3. Body handling: hand the handler a span into recv_buffer instead of a copied Bytes (needs a lifetime story for the handler contract).

Temper all three against the post-header profile: with 31 of 35 samples in __libc_send, user-space work is no longer what limits this server on loopback. The next honest win is likely more sockets doing the sending — M0_WORKERS prefork already exists (see WSGI_PERFORMANCE.md) and everything here compounds per worker — rather than more shaving in the request path. Measure before building.

The instrument, and what it overturned (2026-08-28)

Everything above was measured with wrk and gdb stack samples, and the post-header profile's verdict — 31 of 35 samples in __libc_send, "no remaining allocation on this path is large enough to see through loopback noise" — retired the rest of the header work. That verdict was drawn at ~50k rps, when a request cost ~20 µs. The server now does 116k rps/core, a request costs ~8.6 µs, and a cost that was 5% of the old request is 12% of the new one. Loopback sampling cannot see a change of a few hundred nanoseconds; a per-part instrument can, and scripts/bench_http_parts.mojo is that instrument — the same shape as bench_bridge_parts.mojo, one layer down. Twelve-header browser GET, 20k iterations, Apple M4:

part before after
find_header_end 0.047 µs 0.046 µs
parse_request_headers 2.520 µs 2.052 µs
from_parsed (derived) 0.261 µs 0.281 µs
Headers lookups (in, get, value_equals_ic, content_length) 40–70 ns 40–75 ns
Router.match hit / miss 0.100 / 0.027 µs 0.099 / 0.029 µs
OK() construct 0.672 µs 0.651 µs
encode_into (derived) 0.275 µs 0.347 µs
whole user-space request 3.743 µs 3.309 µs

Two things the table says that the profile did not. The parse is two thirds of the user-space request, not a leaf; and the two allocation findings this pass set out to measure are noise — the Array[HTTPHeader, 100] fill was 66 ns and parse_http_version's List 39 ns. What was not noise was the item ranked first in "Where the remaining gap lives" and then retired: the two Strings per header that parse_headers built for parse_request_headers to read back as bytes and copy into the blob. HTTPHeader now holds four offsets into the buffer the parser was given, the token scanners expose their spans (scan_token, scan_to_eol; the String wrappers stay for the request line and the response status), and the wrappers slice the buffer they already hold. The Array fill became 3.2 KB of integers as a side effect, and parse_http_version compares seven bytes instead of building a list.

−0.47 µs on the parse, −12% on the user-space request, with all 556 m0-http tests and the eight parse-sensitive smokes (pipelining, the 8 KB request, half-close, the client's response parser, cookies, the WebSocket handshake, the header timeout) unchanged. Less than the 1.2 µs a scratch attribution predicted for 24 allocations: short names and values fit Mojo's inline string storage and never touched the heap, which is the kind of thing only the measurement settles.

What was left in the 2.05 µs was recorded as the next lever: below 64 bytes remaining the scanners fell back to a try_peek per byte, set_bytes appended and lowercased twelve names into the blob, and the wrapper's three RFC checks were three linear in scans.

The lever, taken (2026-08-29)

The same instrument, plus a scratch spike splitting the parse row in two, said where the 2 µs actually went — and the split was not what the entry above assumed. The low-level scanner (http_parse_request_headers) was 1.07 µs, of which 0.3 µs was the Array[HTTPHeader, 100] fill — the 66 ns the table above measured for it was the fill in isolation, where the compiler could elide most of it; in the parse, passed to a function, it is 3.2 KB of stores per request. The wrapper — the blob build and the RFC checks — was 1.16 µs, more than half. And inside the scanner, the SIMD helpers were spending their time not in SIMD: having learned with one reduce_min that a chunk held a match, they walked up to 64 lanes with a scalar loop to find which — 9.4 ns per chunk for a CR at lane 46, against 0.8 ns for a select of iota and a second reduce_min that names the lane with no branch.

Four changes, in the order they paid: the lane walk replaced by the select/iota idiom in every scanner, each now running 64 lanes wide, then 16, then scalar, so the last headers of a request stop falling to a try_peek per byte; the offsets array uninitialized rather than filled (the parser writes all four offsets of an entry before counting it); Headers.reserve sizing the blob and index once from the bytes consumed and the field count, with a value copied in by one extend (twelve inserts had been growing the blob through nine reallocations and the index through seven); and the three RFC scans folded into the dispatch loop the wrapper already ran — the Host check had been building a String to measure its length. Same twelve-header browser GET, 20k iterations, medians of three:

part before after
find_header_end 0.045 µs 0.016 µs
parse_request_headers 1.96 µs 0.86 µs
from_parsed (derived) ~0.2 µs ~0.2 µs
Headers lookups 32–67 ns 31–69 ns
Router.match hit / miss 0.099 / 0.027 µs 0.093 / 0.026 µs
OK() construct 0.66 µs 0.52 µs
encode_into (derived) 0.37 µs 0.36 µs
whole user-space request 3.33 µs 1.97 µs

−1.1 µs on the parse (−56%), −40% on the user-space request, with the OK() row's −0.14 µs a side effect: a response's headers go through the same set_bytes. The instrument gained a warm-up pass on the way — with the parse under a microsecond, its row (the first heavy loop) read the allocator's cold start, 0.85 in one run and 1.20 in the next with the parse-plus-build row below it.

One answer changed, and the old one was wrong: the wide scan looked for the first CR and only then for any other control byte, so a field value ended by a bare LF ran on to the next line's CR whenever one lay in the same 64-byte chunk, and that line vanished into the value. The mask is now the scalar tail's own predicate, so the widths agree; the test that pins it fails on the old scanner, and length sweeps from 1 to 140 bytes for values, names and targets cross every hand-off between widths in both directions (a 16-wide block returning its lane without its base fails the value sweep and nothing else — that is what the sweeps are for).

On the wire it is +23% to +24%. apps/hello built from the old and the new parser, run side by side on distinct ports (SO_REUSEPORT would otherwise let one take the other's connections), byte parity asserted first, browser headers, keep-alive, three rounds alternating between the two so drift lands on both, cores measured off each process (bench/results/parse-lever-ab/hello-wrk-parse-ab-20260829T043918Z.json, medians):

old new
c16 rps / p50 / cores 122.1k / 113 µs / 0.98 150.2k / 90 µs / 0.97 +23%
c64 rps / p50 / cores 123.1k / 487 µs / 0.98 152.1k / 390 µs / 0.96 +24%

Re-measured later the same day on a machine confirmed idle (another Claude Code session had run alongside some of the day's later benchmarks, and this row has no in-run comparator to show it clean): hello-wrk-parse-ab-20260829T145148Z.json, c16 118.9k → 142.3k (+20%), c64 122.0k → 152.0k (+25%), p50 114 → 91 µs and 491 → 391 µs — the same gain within run-to-run drift, so the first table stands.

That is ~155–158k rps per core on the hello row where the page above says 116k, and the arithmetic checks: −1.36 µs of user-space work on a request that was ~8.6 µs all-in is −16% of the request, and 1/0.84 is 1.19 before the cache effects of touching less memory per request. The p50s move by 23 µs at c16 and 97 µs at c64 — the same per-request cost removed, multiplied by the connections queued behind it in a closed-loop client.

Through the ASGI executor the same change is worth +8–10%, because the parse is a smaller share of a request that also runs Python: on the benchmark page's row (stdlib asyncio, c16, uvicorn asyncio re-measured beside each arm, bench/results/parse-lever-ab/) the pump went 55.7k → 60.1k rps and M0_INVERTED=1 54.5k → 59.7k — both now above the comparator's ~58.5k, which neither was before — and on the uvloop executor 63.2k → 69.1k and 60.2k → 66.4k. ROADMAP.md's inversion entry carries the reading.

What remains is ~0.86 µs of parse against the 5.3 µs of a request that is the two syscalls, which no parser touches.

The outbox sweep (2026-08-29)

The other per-pass cost the inversion entry in ROADMAP.md had named. Every pass swept all 1,024 slots for a streaming connection whose outbox needed draining, and the miss path — two flag loads per slot, none set — measured 1.2–1.3 µs per pass in isolation (a scratch spike over two List[Bool]s of 1,024). A pass carries one or two requests at c16, so that is a visible share of a request that costs 8.6 µs on the hello row and ~15 µs through the inverted executor.

The ceiling first, with the sweep skipped outright on builds whose benchmark routes never stream (bench/results/outbox-sweep/, uvicorn re-measured beside every executor arm and within the clean band on all of them):

with sweep without
hello c16 146.5k @0.99 151.6k @0.98 +3.5%
hello c64 154.7k @0.99 157.9k @0.97 +2.1%
executor, M0_INVERTED=1, c16 59.3k @0.88 61.7k @0.89 +4.1%
executor, pump, c16 60.0k @0.97 58.2k @1.03 −2.9%, +6% CPU
executor, pump, c256 79.1k @1.02 79.0k @1.01 ±0

The pump row is the finding. Its loop thread does not run the application; it parks requests and flushes them to the executor thread as one datagram per pass, and a loop that returns to wait a microsecond sooner finds fewer events waiting, batches fewer submits, and wakes the executor more often per request. The sweep's microsecond was accidental pacing, and at c256 — where batches are large regardless — it makes no difference either way.

So the gate is scoped: OffloadLoopState.streaming_hint, an upper bound on the flagged slots that the two flag-setting sites raise and the sweep itself recounts, skips the sweep while it is zero — except when the pool says sweeps_every_pass, which only the pump's wiring sets. Realized: hello 152.3k → 157.4k at c16 (+3.3%, +5.5% per core), +1.3% at c64; inverted 59.3k → 62.0k (+4.6%); pump 60.0k → 59.7k @0.98, parity within noise.

The pump row's question — is an explicit pause better than the accidental one? — was answered the same day with fourteen more arms (bench/results/outbox-sweep/pacing/; the table is in ROADMAP.md, "Pacing the pump's loop thread"). A spin of 1.2–2 µs on every pass reproduces the sweep to within noise (60.0–60.1k against 59.95k); a spin only before a partial flush does nothing (58.6–59.0k, the same as no pause), because the batch is already whatever the previous wait returned — the pause that matters is the one after writing responses, which lets the clients' next requests arrive before the loop parks; and re-polling after the pause to fold new events into the same batch is worse (55.7k at 1.08 cores). The pump keeps its pacing through the sweep it already runs; there is no knob to add. The per-byte token validation (is_token_char over a name's bytes) was measured against a bit table and is a wash — 1 ns per byte either way — and is not a lever. Nor is a state-machine rewrite in the picohttpparser shape: the row it would attack is now a fifth of the user-space request and a tenth of the whole.

Non-goals, considered and rejected

  • Pipelined-request support — no longer a non-goal: implemented (RFC 9112 §9.3), because dropping the tail was never just a benchmark gap — it was a hang for any client that pipelines. request_end marks where an answered request ends, the keep-alive reset preserves the bytes past it, and _drain_pipelined answers them without a fresh read edge; poe smoke-pipelining pins it. The benchmark note this entry used to carry still stands: TechEmpower plaintext runs 16-deep pipelining and flatters servers that batch parse/write — mojo-http now answers that shape, but attempts no batching: one response per parse, one send each.
  • writev for header/body split — the response is already assembled into one buffer and sent with one syscall; vectored IO would only help if encode stopped copying the body, i.e. after next-step 4.
  • io_uring — a different backend entirely; the epoll loop is not the bottleneck at current rates.

Reproducing

wrk is not a dependency of this repo (same policy as gunicorn in the WSGI doc). The shape of a run:

uv run poe build-all
uv run mojo build -I packages/m0-core/ -I packages/m0-http/ \
  apps/hello/server.mojo -o /tmp/hello_server
/tmp/hello_server &

wrk -t2 -c16 -d10s --latency http://127.0.0.1:8080/       # keep-alive
wrk -t2 -c16 -d10s -H 'Connection: close' http://127.0.0.1:8080/

# Syscall budget (the strace overhead inflates µs/call ~10×; use the
# calls and errors columns, not the time):
strace -c -p "$(pgrep -x hello_server)" & sleep 5; kill -INT %%

scripts/bench_hello.sh automates the matrix (baseline + concurrency sweep) and prints a summary table.