The loop thread's user space — measured and cut, 2026-09-06
A design note from the engineering record. It is the step after pool-ring-handoff.md: with the handoff in memory, the event-loop thread of a one-worker, one-handler-thread
m0servestill cost about a microsecond more per request than Granian's tokio thread, all of it user space. This note re-took the profile on the ring build, let its ranking set the order, and reports what moved and what did not.
Bare WSGI at one worker and one handler thread, main at 043ac1d and
this branch, the same session, arms alternated: 186k requests per
second against 170k at 16 connections, 202k against 182k at 64, and
205k against 186k at 256. The loop thread's per-request cost went from
5.8 µs to 5.1 at 16 connections and from 5.3 to 4.7 at 256 — the tokio
thread's figures in the same session are 5.1 and 4.7. The row is
0.98x Granian at 16 connections (from 0.90x that morning, on the same
binaries), 0.97x at 64 and 0.99x at 256.
Where the ranking pointed
On-CPU samples of the loop thread (Instruments' Time Profiler through
xctrace, the instrument loop-thread-bound.md
describes), ring build, 16 connections, before anything here was
built. The kernel — one recvfrom, one sendto, a fifth of a kevent
per request — was 57 % of the thread and within 0.2 µs of tokio's; user
space was 43 %, about 2.7 µs, against tokio's 1.2. Its largest symbols:
| share of the loop thread | symbol | what it was |
|---|---|---|
| 5.6 % | Headers._name_matches |
the case-folding scan behind every header lookup; the loop asks for a dozen names a request, and the parser scanned the growing collection once per field for duplicates |
| 4.2 % | scan_token |
after the SIMD find of the colon, a byte-at-a-time walk of every name through an eighteen-test range-and-compare chain |
| 3.1 % | Headers.set_bytes |
an append per name byte, each a capacity test and a length store |
| 2.7 % | List.extend |
the staging-buffer copy after every recv, and four extends per header line in the encoder |
| 2.4 % | _handle_read_headers |
its own bookkeeping, two clock reads among it |
| 1.9 % | _service_completions |
a fresh List per drain, and on every read of the completion channel a 2 KB buffer allocated and zero-filled one append at a time |
| 1.1 % | ByteReader.peek |
a raising call, in a try, for a byte the caller had already proven present |
The isolated instrument agreed about the parse: scripts/bench_http_parts.mojo
priced parse_request_headers at 0.89 µs for a twelve-header browser
GET, and the whole user-space request at 1.98.
What was built
All in packages/m0-http/lightbug_http/, and one line in the WSGI
bridge.
- A known-name index on
Headers(known_header_id,KH_*,Headers._known). The ten names the server itself asks about — content-length, content-type, connection, transfer-encoding, host, date, cookie, upgrade, expect, server — are classified by length and first byte before any full compare, and the collection records each one's entry index, so a lookup for one of them is O(1) whatever the collection holds. The parser dispatches on the same id once per field instead of asking threename_isquestions.poprebuilds the index. A presence word (Headers._present, one bit per name from its length and first and last bytes) answers "absent" for every other name without touching the index; only a set bit permits the scan. The parser's per-field duplicate check, which was the O(n²) part, is one AND per field. - Inserts by raw copy.
_set_bytesreserves room for the value and the name together — grown geometrically with a floor, becauseList.reservesizes exactly and reserving per insert made an unreserved collection reallocate on every one — then copies the value with onememcpy, lowercases the name eight bytes at a time into the blob, and writes the four index words with one length store. The bridge reserves its response headers with room for what the server adds after the application (Content-Length, Connection, Date), so those inserts no longer reallocate on the loop. - The token scanner verifies with a vector.
is_token_charis two 64-bit words indexed by the byte, andscan_tokenchecks sixteen bytes at a time through_non_tchar_lanes— the complement of tchar as twelve vector compares — falling back to the table for a tail shorter than a vector.try_peekreads the byte directly behind the availability test. - One
recvinto the connection's own buffer. The header path receives past whatrecv_bufferalready holds and bumps its length; the staging buffer and the copy out of it are gone from that path (the body and WebSocket paths keep theirs). Still exactly one read ofrecv_staging.capacity()bytes per call, which the 8 KB header rule depends on. - The encoder writes a header line as one reservation and two
copies (
ByteWriter.write_header_line), tests a value for ASCII sixteen lanes at a time (span_is_ascii), and the loop stamps Date and Connection through the known index rather than a classified__setitem__. The bridge'sall_asciiover every environ value (4.7 % of the handler thread) is the same vector test. - Small things the ranking named: the completion channel's receive
buffer lives on the pool; the loop's list of finished slots is reused
across passes;
_drain_pipelinedis inlined into its six callers; a request's first bytes stamp the header clock without also measuring it; the chunked-body test asks the known index before building anOptional[String].
Measured in isolation (bench_http_parts.mojo, twelve-header browser
GET, 20k iterations, Apple M4):
| part | before | after |
|---|---|---|
parse_request_headers |
0.889 µs | 0.621 µs |
from_parsed (derived) |
0.186 | 0.097 |
Headers in / value_equals_ic / content_length |
38 / 42 / 51 ns | 6 / 12 / 2.5 ns |
OK() construct |
0.488 | 0.221 |
encode_into (derived) |
0.320 | 0.316 |
| whole user-space request | 1.978 | 1.330 |
Measured in situ
ps -M per thread, medians over 8 s of wrk -t2 with browser
headers, apps/wsgi_bare, --workers 1 --blocking-threads 1, Apple
M4, CPython 3.13.6, granian 2.8.2, one session, arms alternated. Base is
main at 043ac1d.
| connections | base | this branch | granian 2.8.2 |
|---|---|---|---|
| 16 | 169.9k / 169.2k rps (loop 98 %, pool 77 %) | 186.0k / 184.8k (loop 94–95 %, pool 75 %) | 189.1k / 189.5k (tokio 96–97 %, blocking 79–81 %) |
| 64 | 182.4k (97 %, 62 %) | 202.1k (99 %, 60 %) | 208.8k (97 %, 80 %) |
| 256 | 185.7k (99 %, 62 %) | 205.1k (97 %, 57 %) | 207.2k (98 %, 77 %) |
Per request on the loop thread: 5.8 → 5.1 µs at 16 connections, 5.3 →
4.9 at 64, 5.3 → 4.7 at 256; tokio's 5.1, 4.6 and 4.7. The pool thread
is cheaper too, from the bridge's reserve and vector test: 4.5 → 4.1 µs
per job at 16 connections (spin included) and 3.3 → 2.8 at 256, against
Granian's blocking thread at 4.2 and 3.7. The layer-split artifact
re-recorded on this tree (bench/results/layer-split-20260906T151035Z.json,
three rounds, medians) has the row at 183.1k against 189.1k, 0.97x, and
the Mojo-only apps/hello row — the same parse and encode with no
handler thread to hide behind — at 195.8k from 148.7k.
The profile afterwards has user space at 36 % of the loop thread.
_name_matches is gone from the list (the scan it did runs only for a
name outside the ten, and only when the presence word permits it);
scan_token is 1.1 %; List.extend 0.4 %; ByteReader.peek gone. What
remains is the parser's own two passes (parse_headers 3.0 %,
parse_request_headers 2.4 %, _set_bytes 3.7 % — most of it the
copies a Headers that owns its bytes has to make), and about 0.7 µs of
per-request state work across _handle_read_headers, _process_request,
_run_pass, _service_completions and _finish_response: moves of
the parsed-headers and request structs (Optional.take, the parked
request), per-slot stores, and the twenty-four-argument calls the loop's
functions make to each other. The allocator is about 4 %: the request's
two header buffers allocated here and freed on the pool thread, and the
response's two plus its body freed here.
What did not move, and is not kept
- A spin before the park. With jobs in flight the loop watched the
completion ring for 2, 5 or 10 µs before raising its parked flag and
entering
kevent, on the theory that the pool's wake datagram and the kernel wake were the bubble at 16 connections. Throughput was unchanged — 172.8k off against 172.8k, 173.6k and 174.1k on — while the loop's CPU rose from 89 % to 91–95 %. The wake is not where the time goes; the idle time is time with nothing runnable. - Completions serviced after every read event, so one did not wait
behind the rest of a batch. At 16 connections +1 %, inside the noise;
at 256 connections 198.0k against 213.4k with the pool thread's cost
per job up from 2.8 µs to 4.4. A per-read
is_emptyon the completion ring reads the cache lines the pool thread writes, and stealing them fifty times a pass is what the pool thread then paid for. The two drains per pass stay. - Lazy cookie jars and query map. Both jars and
URI.queriesareDicts, and a dozen allocations a request looked likely. Measured with opaque sinks, an emptyDictconstruct-and-destroy is 0 ns on Mojo 1.0: it allocates lazily. No lever there. - The clock.
mach_absolute_timeis 1 % of the thread either way; one read per request came out of the header path and the rest stay.
What is next: measured, and not much
At 256 connections the row is at parity and at 16 it is within 3 %.
The first draft of this section named three structural candidates for
the next microsecond, and a microbench the same day priced them
(sizeof and park-and-take round trips through the same Optional
lists the loop and pool use, opaque sinks, 200k iterations, Apple M4):
| candidate | per request |
|---|---|
ParsedRequestHeaders (184 bytes) parked in the provision and taken back |
56 ns |
HTTPRequest (584 bytes) parked in the pool and taken back |
5 ns |
HTTPResponse (288 bytes) parked and taken |
25 ns |
the twelve-String URI fast-path construction |
under 4 ns |
| a call with twenty-four arguments against one state argument | 1.31 ns against 1.34 |
About 85 ns together, under 2 % of the loop's 5.1 µs, and the
twenty-four-argument calls the read path makes cost nothing measurable:
the mut lists travel as pointers, and a wide call is a narrow one.
What remains in the profile is the parser's two passes (about 0.55 µs
for ten headers, most of it copies a Headers that owns its bytes has
to make), the allocator (about 0.2 µs: the request's two header buffers
allocated here and freed on the pool thread, the response's two and its
body freed here), and roughly 0.6 µs of per-request state work spread
thinly across the loop's own functions with no single symbol above
4 %. A fused single-pass parser and a single-allocation Headers might
be worth 0.2 µs between them, 4 % of the thread, on a row that is
already within 3 % of Granian's; the next real difference would need a
line-level profile of that state work, and beyond it both threads are
at the kernel's price for one recv and one sendto per request,
which is the price tokio pays. The loop-thread line of work stops here.