Skip to content

Benchmark method

The ladder

bench/apps.py defines ASGI apps where each rung adds one layer; all return a small JSON body.

rung app request
l0_raw bare ASGI callable, fixed bytes GET /
l1_starlette one Starlette Route, JSONResponse GET /
l1b_starlette_params Starlette /items/{item_id:int} reading path and query GET /items/42?q=hello
l1c_starlette_50routes 10 param plus 40 static Starlette routes GET /r39, the last
l2_fastapi_dict FastAPI route returning an annotated dict GET /
l2b_fastapi_untyped same, no return annotation GET /
l2c_fastapi_included same route via include_router GET /
l5_fastapi_50routes 50 routes via add_api_route GET /r39
l5b_fastapi_50routes_included 50 routes on an APIRouter, included GET /r39
l3_fastapi_params item_id: int path, optional q: str query GET /items/42?q=hello
l4_fastapi_model pydantic body with response_model POST /items

A rung's µs/request minus l0_raw is the cost of everything above the server; bench.run prints it as vs l0.

Load

bench/run.py starts the server for one rung, warms it for 2 s, then runs oha three times:

oha -z 5s -c 64 --no-tui --output-format json http://127.0.0.1:8123/

A success rate below 100% aborts. It records requests/s, p50 and p99 per repeat; µs/request is 1,000,000 over the median requests/s. uvicorn runs with --loop uvloop --http httptools --no-access-log --log-level warning, granian with --interface asgi --workers 1 --loop uvloop --no-log; other server setups are tagged, as in l2_fastapi_dict[granian].

uv run python -m bench.run
BENCH_ONLY=l2_fastapi_dict,l3_fastapi_params uv run python -m bench.run

BENCH_REPEATS, BENCH_DURATION and BENCH_CONCURRENCY override 3, 5s and 64. oha must be on PATH; granian comes from the bench dependency group.

Profiles

BENCH_SAMPLE=1 runs macOS sample(1) on the server for 8 s under a 9 s load; bench/analyze_sample.py buckets top-of-stack frames by library (kernel, malloc, uvloop, httptools, pydantic_core, interpreter, other). BENCH_PYINSTRUMENT=1 wraps the app in pyinstrument (0.5 ms interval, async mode disabled) and writes HTML and text at shutdown. Output goes to bench/out/.

Before and after

Each change was measured in one window against the previous commit, stashed, at a load average low enough that repeats agree; later changes alternate after/before/after. Runs during a system stall (indexing, a backup, load average of 7 and above) were discarded and the commit says so; one commit's window was too noisy to measure at all. A gc.freeze() control left medians unchanged with p99 spikes present: the tail is sporadic stalls, not garbage collection.

A rung's same-window figure and its full-ladder figure differ by a few percent: the plain route is 17.1 µs in the last same-window run and 18.3 µs in results_ladder_v3.json. The per-change before/after figures on What changed are same-window runs; every table (landing page, benchmarks page, docs overview, README) quotes the full ladder files.

The single-machine ceiling

Multi-worker runs put the load generator and the server on the same cores. Six granian workers measured 140,000 requests/s with oha and about 160,000 with wrk; six uvicorn workers 153,000 and 175,000; twelve granian workers were slower than six, with p99 above 30 ms. These numbers describe the machine, not the framework. Cross-core scaling needs a separate client machine.

Raw files

Under bench/baseline/: results_ladder_v1.json (upstream FastAPI 0.141.1, day one), results_ladder_v3.json (current full ladder; v2 is older), results_fixN_before.json and results_fixN_after.json (one pair per change), results_servers_v1.json (uvicorn default and tuned, granian, granian --task-impl rust), results_workers_sweep_v1.json and wrk_vs_oha_w6_v1.txt (multi-worker), results_controls_v1.json and results_gc_freeze_check.json. Compare two files rung by rung:

uv run python bench/compare.py bench/baseline/results_fix1_before.json bench/baseline/results_fix1_after.json

Ladder v5

results_ladder_v5.json (2026-09-03 evening, AC power, master with all 41 changes) is assembled from three runs taken 15 minutes apart while the machine was in use: a full run, then two re-runs of the rungs a load burst had hit, accepted only when both raw ASGI floors matched the full run within 5 percent (13.5 to 13.9 µs on uvicorn, 7.9 on granian). Which run a rung comes from is recorded in the session notes; every row is a median of three 4 s oha runs at 64 connections.

Ladder v6

results_ladder_v6.json (2026-09-11 morning, AC power, master with all 43 changes) is one full run with BENCH_MAX_LOAD=6: before each rung the runner waited until the 1-minute load average was below 6, so no rung ran during a load burst. Every row is a median of three 5 s oha runs at 64 connections. Against v5 the uvicorn floor moved from 13.9 to 13.2 µs, so rows should be compared to their floor: the three-dependency route went from 9.5 to 8.9 µs above the uvicorn floor and from 5.2 to 3.2 µs above the granian floor (the compiled solver plan), and 50 routes via include_router from 3.0 to 2.0 µs above the uvicorn floor (direct dispatch of included routes).