Benchmarking uvloop Against the Default Event Loop¶
Someone on the team swapped in uvloop, the dashboards did not move, and now there is a debate about whether it was worth the extra native dependency. Or the opposite: a blog post promised "2–4x faster asyncio", the change shipped, and p99 latency is unchanged because the service spends 85% of its time serialising JSON and waiting on Postgres. Both situations come from the same mistake — treating the loop backend as a global speed dial instead of measuring how much of your request time the loop actually owns. This guide builds a repeatable A/B harness: a transport-only benchmark that isolates the loop, a workload-shaped benchmark that includes your real handler, and a profile that tells you what fraction of wall time the loop implementation can influence at all.
Prerequisites¶
- Python 3.12+ so both backends can be selected with
asyncio.Runner(loop_factory=...); on 3.11 the same factory argument works onasyncio.Runner, onlyasyncio.run(loop_factory=...)is 3.12-only. - uvloop on Linux or macOS:
pip install uvloop. It has no Windows build, so the harness keeps the stdlib loop as the baseline everywhere. - Loop configuration basics from Event Loop Configuration, and the production selection code in how to properly configure asyncio event loops for production, which this page assumes is already in place.
- A quiet machine. Loop benchmarks measure microseconds per callback; a noisy neighbour or CPU frequency scaling will swamp the difference you are trying to see.
The question to answer is not "is uvloop faster" — on transport-heavy microbenchmarks it nearly always is — but "how much of my latency is spent in the part uvloop replaces".
1. Select the backend from one switch¶
A benchmark that compares two code paths is worthless if the paths differ in anything but the loop. Route the backend choice through a single factory so the server, the client and every helper run identically, and make the active backend visible in the output so a silent fallback cannot contaminate the numbers.
# pip install uvloop
import asyncio
import os
import sys
def loop_factory():
backend = os.environ.get("LOOP", "asyncio")
if backend == "uvloop":
import uvloop # fail loudly: no silent fallback in a benchmark
return uvloop.new_event_loop()
return asyncio.new_event_loop() # SelectorEventLoop on Unix
def run(main):
with asyncio.Runner(loop_factory=loop_factory) as runner:
loop = runner.get_loop()
print(f"backend={type(loop).__module__}.{type(loop).__name__} "
f"python={sys.version.split()[0]}", file=sys.stderr)
return runner.run(main())
Verify: LOOP=uvloop python bench.py prints backend=uvloop.Loop, and running without the variable prints backend=asyncio.unix_events._UnixSelectorEventLoop. If the uvloop run prints the selector loop, the import is being shadowed or the wrong interpreter is on the path — stop and fix that before measuring anything.
2. Measure the transport with an echo benchmark¶
Start with the case where the loop matters most: many connections exchanging small messages, with a handler that does almost nothing. This measures the loop's socket readiness handling, buffer management and callback dispatch — the exact code uvloop replaces with libuv.
import asyncio
import time
PAYLOAD = b"x" * 128 + b"\n"
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
while line := await reader.readline():
writer.write(line)
await writer.drain()
writer.close()
async def client(port: int, deadline: float, counts: list[int]) -> None:
reader, writer = await asyncio.open_connection("127.0.0.1", port)
n = 0
while time.perf_counter() < deadline:
writer.write(PAYLOAD)
await writer.drain()
await reader.readline()
n += 1
counts.append(n)
writer.close()
await writer.wait_closed()
async def echo_bench(conns: int = 200, seconds: float = 10.0) -> float:
server = await asyncio.start_server(handle, "127.0.0.1", 0)
port = server.sockets[0].getsockname()[1]
counts: list[int] = []
deadline = time.perf_counter() + seconds
async with asyncio.TaskGroup() as tg:
for _ in range(conns):
tg.create_task(client(port, deadline, counts))
server.close()
await server.wait_closed()
return sum(counts) / seconds
Verify: run each backend at least five times and keep every result, not just the best. On a typical Linux box the echo rate with uvloop lands well above the selector loop — often around double — and the spread between runs should be a few percent. A spread wider than the gap between backends means the machine is too noisy to conclude anything.
3. Benchmark the shape of your real workload¶
The echo number is an upper bound, not a forecast. Now replace the trivial handler with one that does what your service does per request: parse a payload, await a simulated dependency with realistic latency, and serialise a response. The dependency sleep and the CPU work stay identical across backends, so any change in throughput is the loop's share alone.
import asyncio
import json
import random
DEPENDENCY_MS = (2.0, 8.0) # measured p50..p95 of your real downstream call
DOC = {"id": 1, "items": [{"sku": i, "qty": i % 7} for i in range(40)]}
async def realistic_handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
rng = random.Random(42) # same delays for both backends
while line := await reader.readline():
request = json.loads(line) # CPU: parse
await asyncio.sleep(rng.uniform(*DEPENDENCY_MS) / 1000) # I/O: downstream wait
body = json.dumps({"echo": request, "doc": DOC}) # CPU: serialise
writer.write(body.encode() + b"\n")
await writer.drain()
writer.close()
Drive it with the same client, sending a small JSON line instead of the fixed payload, and record per-request latency with time.perf_counter() around each round trip so you can report p50 and p99 as well as throughput.
Verify: compare the uvloop/asyncio ratio from this run with the echo ratio from step 2. If the echo benchmark showed 2x and this one shows 1.1x, that gap is the finding: most of the request is spent in code the loop backend does not touch.
4. Profile the fraction of time the loop owns¶
Throughput ratios tell you that the gain shrank; a profile tells you why, and predicts the gain before you deploy. Sample a running process and bucket the stacks into loop internals versus your code.
import cProfile
import pstats
LOOP_MARKERS = ("selector_events", "base_events", "events.py", "streams.py",
"selectors.py", "transports")
def loop_share(profile_path: str) -> float:
stats = pstats.Stats(profile_path)
total = stats.total_tt
loop_tt = sum(tt for (filename, _, _), (_, _, tt, _, _) in stats.stats.items()
if any(m in filename for m in LOOP_MARKERS))
return loop_tt / total
# Profile the stdlib-loop run only: uvloop's internals are C and invisible to cProfile.
# cProfile.run("run(workload_bench)", "asyncio.prof")
# print(f"loop share of self time: {loop_share('asyncio.prof'):.0%}")
cProfile inflates Python-level overhead, so treat the share as an estimate and cross-check it with a sampling profiler such as py-spy record --native on a live instance, which is covered in profiling asyncio applications with py-spy.
Verify: the share from the echo benchmark should be high (most self time in selector_events and streams), and the share from the workload benchmark noticeably lower. Plug the workload share into the arithmetic in the next step and the prediction should land within a few percent of the ratio you measured in step 3.
5. Decide with Amdahl's law, not the headline number¶
If a fraction f of request time is loop overhead and uvloop runs that part s times faster, the whole request speeds up by 1 / ((1 - f) + f / s). With s around 2.5 — a reasonable figure for transport-heavy code — a service whose loop share is 10% gains about 6%, while a proxy whose loop share is 70% gains nearly 70%.
def predicted_speedup(loop_share: float, loop_speedup: float = 2.5) -> float:
return 1 / ((1 - loop_share) + loop_share / loop_speedup)
for share in (0.1, 0.3, 0.5, 0.7, 0.9):
print(f"loop share {share:.0%}: {predicted_speedup(share):.2f}x overall")
Adopt uvloop when the predicted and measured gain is material and the operational cost is acceptable: a compiled wheel per platform, no Windows support, and slightly different behaviour in debug tooling. For proxies, WebSocket fan-out, and gateways with thin handlers, it usually is. For a CRUD API dominated by the database, spend the effort on connection pool sizing instead.
Verify: the decision is written down with three numbers — echo ratio, workload ratio, loop share — so the next person who asks "should we use uvloop" reads the measurement instead of repeating the debate.
Verification¶
A trustworthy comparison satisfies all of these:
- Backend confirmed per run: every result line carries the
backend=banner, and no uvloop run silently fell back to the selector loop. - Repeated, not cherry-picked: at least five runs per backend, reported as median and spread; the spread is smaller than the difference being claimed.
- Two benchmarks, two ratios: an echo ratio (upper bound) and a workload ratio (forecast), with the workload ratio never larger than the echo ratio.
- Prediction matches measurement: the Amdahl estimate from the profiled loop share lands close to the workload ratio; if it does not, the profile is attributing time to the wrong bucket.
- Latency, not only throughput: p99 per round trip is reported for both backends, because a loop that is faster on average can still have a worse tail under GC or buffer pressure.
Pitfalls & edge cases¶
- Client and server in the same loop. Running the load generator inside the process under test means the client's overhead is also sped up by uvloop, inflating the ratio. For a publishable number, run the client in a separate process pinned to different cores with
taskset. - Debug mode left on.
PYTHONASYNCIODEBUG=1or-X devadds slow-callback tracking to the stdlib loop that uvloop implements differently, which skews the comparison heavily in uvloop's favour. Benchmark both with debug off, as in production. - Loopback hides the network.
127.0.0.1has no latency or packet loss, so the loop share looks larger than it will on a real network. Add a realistic dependency delay (step 3) before drawing conclusions. - Frequency scaling and turbo. A CPU that ramps up during the first run makes whichever backend runs second look faster. Warm up for a few seconds, alternate backends between runs, and set the governor to
performanceon the benchmark host. - Libraries with their own loop assumptions. Some tools check
isinstance(loop, asyncio.SelectorEventLoop)or rely onloop._selector. Run the service's own test suite under uvloop before trusting a benchmark that only exercised streams.
Frequently Asked Questions¶
How much faster is uvloop than the default asyncio event loop?
On transport-heavy microbenchmarks such as echo servers with many small messages, uvloop commonly runs around two to four times faster than the stdlib selector loop. The gain in a real service is far smaller whenever request time is dominated by application code, serialisation or downstream waits, because uvloop only speeds up the loop's own share of the work.
How do I select uvloop in Python 3.12 without the event loop policy API?
Pass a loop factory: asyncio.run(main(), loop_factory=uvloop.new_event_loop), or use asyncio.Runner(loop_factory=uvloop.new_event_loop) which also works on Python 3.11. Recent uvloop releases additionally provide uvloop.run(main()). The policy-based uvloop.install() still works but the policy system is deprecated in newer Python versions.
Why didn't switching to uvloop improve my API latency?
Most likely the loop owns only a small fraction of each request. If JSON parsing, ORM work and database round trips account for ninety percent of the time, even an infinitely fast loop improves latency by at most about ten percent. Profile the loop share first and apply Amdahl's law to predict the gain before changing the backend.
Can I benchmark uvloop with cProfile?
Only indirectly. uvloop's internals are compiled C code, so cProfile cannot see inside them. Profile the stdlib-loop run to estimate the fraction of time spent in selector_events, base_events and streams, then use that share to predict the uvloop gain, and confirm with a sampling profiler that can record native frames.
Related¶
- Event Loop Configuration — up to the topic overview for loop backends, executors and runner lifecycles.
- When to use asyncio.run() vs loop.run_until_complete() — the runner API that the loop factory plugs into.
- Asyncio Fundamentals & Event Loop Architecture — the section overview explaining what the loop does per iteration and where its time goes.