The ASGI executor per core, against uvicorn with uvloop — 2026-10-06
A design note from the engineering record. The 1.11.0 pre-release run's
bench-linux-conclusionsread "ASGI against uvicorn+uvloop, per core" as INVERTS, at macOS 1.01x against Linux 0.92x, and the benchmark page said uvloop's per-core lead did not survive on Linux. This note measures the question in three environments, finds where the per-core cost goes, and corrects the page. It follows the-conclusions-on-linux.md and inversion-on-a-constrained-box.md.
What was measured
apps/asgi_bare under wrk -t2 -c16 with browser headers, the shape of
the benchmark page's ASGI row, m0serve 1.11.0 at 2746288. Each run did
three rounds, with the arms alternating within a round and uvicorn
re-measured every round as the drift control. Four arms:
- the default executor, which runs two threads ("pump");
- the same binary with
M0_INVERTED=1, the Mojo loop inside the asyncio loop on one thread; uvicorn --loop uvloop;uvicorn --loop asyncio.
The three environments:
- macOS: an M4,
scripts/bench_asgi_wrk.sh. - container: the
m0lincontainer, 8 aarch64 vCPUs, the arms ofbench_linux_arms.pywith an inverted arm added. The server and wrk share the VM's cpus. - rented: a Linode
g6-dedicated-4, 4 dedicated x86-64 vCPUs (AMD EPYC 7713), Debian 12. The server was pinned withtasksetto two vCPUs and wrk to the other two, because the 2026-09-09 run on the same plan was noisy with both on four. Two passes, six rounds. The account refused the 8-vCPU plans.
Raw data, the drivers and the profile summaries are in
bench/results/asgi-per-core-2026-10/.
The answer
Per core is requests per second over the cores the server process used.
| environment | pump, per core vs uvloop | inverted, per core vs uvloop | inverted rps / pump rps |
|---|---|---|---|
| macOS | 0.99x (131.7k on 1.60 cores) | 1.31x (99.9k on 0.91) | 0.76x |
| container | 0.91x (106.3k on 1.34) | 1.42x (123.9k on 1.00) | 1.17x |
| rented, pinned | 0.94x (28.6k on 1.22) | 1.27x (31.7k on 1.00) | 1.11x |
- The per-core gap is the pump's second thread, not the executor's Python. The default is level with uvloop per core on macOS and a few percent behind on Linux. The one-thread shape is ahead of uvloop per core everywhere measured.
- On Linux at 16 connections, the inverted loop also serves more. On macOS the pump serves more, by spending 0.6 of a second core. At 256 connections from two cores up, the pump serves more on Linux too (inversion-on-a-constrained-box.md).
- The rented box confirms the container's direction. Its pinned inverted arm ranged 1.17–1.56x of uvloop across rounds, so the size is less settled than the sign. Its unpinned run of the standard arms put the pump at 0.91x, the container's figure.
Where the pump's extra CPU goes
macOS sample, 10 s against each shape under the same load, self time per
thread normalized per request. Sampling cost the pump some throughput (109k
against 131k) and the inverted loop almost none (101k against 100k).
| per request | pump | inverted |
|---|---|---|
| everything but idle waits | 12.0 µs | 8.6 µs |
| client socket reads and writes | 2.9 µs | 2.5 µs |
| the loop-to-executor datagram handoff | 1.85 µs | — |
| CPython | 3.0 µs | 3.6 µs |
| m0serve's own code | 2.3 µs | 1.3 µs |
The handoff explains about half the gap. At 16 connections a pass of the loop carries about one request, so every request pays four syscalls uvloop never makes:
- the loop's
sendtoof the submit (flush_lane); - the executor's
readof it; - the executor's
sendtoof the completion (complete_many); - the loop's
recvfromof the completion (drain_completions_into).
The 2026-09-08 note priced the whole round trip at 0.43 µs, at 256 connections, where one datagram carries many requests. At low concurrency it is four times that.
About another microsecond is the loop's fixed work per pass. In the
pump a pass runs about once per request, and the Mojo frames are generic
LoopState specializations the release build does not name further. The
inverted loop's CPython figure is higher because its pass is itself a
callback inside asyncio.
Levers, re-priced
- Take the handoff off the socket where a peer is awake. The pool's
ring did this and was worth 13–19 % on bare WSGI
(pool-ring-handoff.md): an in-memory ring, and
an fd write only to wake a peer that is parked.
- The completion direction is the simpler half: the executor writes the ring, the loop drains it every pass, and wakes it only when it is parked, which at c16 is about a third of the time. Its ceiling is about 0.7 µs a request.
- The submit direction needs a check before the executor parks. asyncio offers none, and uvloop's selector cannot be wrapped.
- The channel is the densest ordering seam in the tree: loop-inversion.md records a job that overtook a slot's disconnect tag on it. Anything that leaves it has to keep its order against everything that stays. The completion half was tried the same day as an experiment patch and measured flat (below).
- The inversion as a default stays declined, for the reasons recorded on 2026-09-08: they are about switching execution models on detection, and the topologies it cannot serve, not about these numbers.
- The benchmark page said uvloop leads per core on macOS and that Linux answers at or above parity. The macOS figure had already crossed 1.0, and two Linux environments now answer below it. The row is corrected to this note.
The completion half, tried the same day
exec-ring-experiment.patch beside the data is the experiment, behind
M0_EXEC_RING:
- the executor's
complete_manypushes its slots onto the completion ring the pool already uses, reads the loop's parked flag once, and pokes only a parked loop (the pool'scompleteprotocol for N slots); - the drain reads the channel before the ring (the next section says why).
It was measured with a fresh server for every arm, the arms alternated
every round, CPU from the process's own time, six rounds against M0_EXEC_RING=0
and uvicorn+uvloop. Paired within each round:
| environment | per core, on / off | requests per second, on / off |
|---|---|---|
| macOS | 0.97–1.09, median 1.00 | 0.90–1.07; five of six rounds 5–10 % lower, p50 about 10 µs higher |
| container | 0.99–1.04, median 1.00 | 0.98–1.03, median 1.02 |
Flat, so not built. The saving exists only for a completion that finds the loop busy. One that finds it parked still pays a poke. The pool's ring paid because it took both directions off the socket, the jobs as well as the completions. The executor's submit direction cannot follow: the executor parks in asyncio's selector, which only an fd wakes, and neither asyncio nor uvloop offers the check-before-parking that the ring's protocol needs. The macOS throughput loss was not traced further.
A window found on the way
drain_completions_into reads the completion ring, then the channel. A
--blocking-threads stream completes its head onto the ring
(blocking_pool.mojo: begin frame, then complete(slot), then the body)
and sends an abort on the channel. If that push and that send both land
between the drain's two reads, the drain takes the abort without its head.
The loop drops an abort for a slot that is not streaming, takes the head
at the next drain, and the stream stays open, kept alive by its heartbeat.
A pool thread sends such an abort before the loop has seen the head only when the chunk channel refuses the body's first frames: once credit has come back, the head was taken. Reading the channel first closes the window, because anything on the channel was pushed to the ring before it. That is the experiment's second edit.
Fixed the same day on that argument (#563). No test can place a push
between two reads inside one call, so test_offload.mojo pins the
channel-first order instead, and fails when the drain is put back
ring-first.