Skip to content

Performance

Measured on an M3 Max running macOS 26 and CPython 3.14.

CodSpeed runs benchmarks/test_benchmarks.py on every commit, so a regression is caught where it is introduced rather than whenever someone next thinks to measure. Each benchmark is parametrised by event loop, so the same run records both the change and the comparison. The HTTP rows below come from the standalone scripts, which need a server process and an external load generator.

Benchmark asyncio uvloop zuvloop zuvloop / uvloop
call_soon 2.69M/s 4.69M/s 5.91M/s 1.26x
call_soon with arguments 2.43M/s 3.87M/s 6.47M/s 1.67x
timer schedule + cancel 1.58M/s 2.62M/s 9.55M/s 3.64x
bulk stream 8.4 GiB/s 8.5 GiB/s 10.2 GiB/s 1.20x
loop iterations (sleep(0)) 73.2k/s 78.2k/s 78.2k/s 1.00x
echo round trips, 1 KiB 39.0k/s 56.8k/s 58.5k/s 1.03x
uvicorn, plaintext 55.3k req/s 71.9k req/s 75.9k req/s 1.06x
uvicorn, 10 KiB body 52.6k req/s 68.7k req/s 73.6k req/s 1.07x
aiohttp server 49.0k req/s 59.8k req/s 60.6k req/s 1.01x
aiohttp client 13.2k req/s 16.2k req/s 16.9k req/s 1.04x
UDP round trips, 512 B 39.8k/s 53.1k/s 54.2k/s 1.02x
getaddrinfo, numeric host 28.5k/s 1.57M/s 1.90M/s 1.21x
subprocess spawn 582/s 1,174/s 1,109/s 0.95x
subprocess pipe 838 MiB/s 677 MiB/s 1,547 MiB/s 2.29x

Reading these numbers

Stock asyncio is the control. A benchmark run is only trustworthy if asyncio's number reproduces its known value; when it does not, nothing else in the run means anything either. That check is what caught a run where uvicorn was silently measured against its pure-Python parser instead of httptools — every loop was a third of its real throughput, which reads exactly like a regression.

benchmarks/uvicorn_bench.py prints which parser it found, for that reason.

Spreads matter more than ratios. aiohttp server at 1.01x is parity, not a win. call_soon with arguments has the widest spread in the suite; read its 1.67x loosely.

Where the wins come from

Scheduling and timers are where the design differs most. Arguments live inside the handle rather than in a tuple, and timers share one uv_timer_t behind a heap instead of taking a libuv handle each.

Writes are batched per turn. benchmarks/write_batching.py isolates this by serving one fixed response two ways — as a single write(), and as the header write plus body write that ASGI and aiohttp actually do:

response split into two writes asyncio uvloop zuvloop
throughput lost -36% -0.1% -0.7%

A loop that writes each piece as it arrives spends a syscall per piece. That single difference is most of the gap on the HTTP rows.

Address literals never reach libc. inet_pton is an order of magnitude cheaper than getaddrinfo, and the enum members every result tuple needs are cached rather than looked up through EnumMeta.__call__ at over a hundred nanoseconds each.

Running them

$ uv run --group bench pytest benchmarks/ --codspeed
$ uv run --group bench python benchmarks/uvicorn_bench.py
$ uv run --group bench python benchmarks/aiohttp_bench.py
$ uv run --group bench python benchmarks/write_batching.py

The HTTP benchmarks need oha on your PATH.