Production Benchmark
This end-to-end benchmark uses a production-shaped deployment: FastAPI on uvicorn, DLRM CPU inference in the request path, Aerospike CE, k6 HTTP load, and a serialized response body.
The measurement includes HTTP parsing, JSON serialization, and the model forward pass. These shared costs make the client difference smaller than in the Isolated Benchmark, but they produce the numbers to use for capacity planning.
Sources: internal benchmark runs, April 2026. Raw measurement artifacts are not committed to the repo (see git history before the
benchmark/rewrite for the originalbenchmark/results/*.mdfiles). The headline numbers below are reproduced from those artifacts; a clean re-measurement on the new isolated harness is tracked in Bottleneck Analysis.
Where the gap lives
Mean latency advantage of aerospike-py vs official (Python 3.11 + GIL)
A) Pure DB client ████████████████████ −80% (108 → 22 ms) 🔥
B) uvicorn ASGI █████ −21% (290 → 228 ms)
C) uvicorn + DLRM ███████████ −42% p95 (324→189 ms) 🔥
p95 advantage holds even when mean compresses (C): the GIL serialises
official-client result conversion at the tail.
- A — Pure DB client
- B — uvicorn ASGI only
- C — uvicorn + DLRM (real serving)
Environment A — Pure DB Client
What this isolates. This environment removes FastAPI, model inference, and HTTP load generation. A Python loop calls batch_read directly, so the client difference is largest here.
Setup
| Item | Value |
|---|---|
| Source | Internal run, April 2026 (pre-rewrite artifact; recoverable from git history) |
| Clients | official (sync C), official-async (sync wrapped via executor), py-async (aerospike-py) |
| Sets / batch sizes | 9 / 50, 200 |
| Concurrency / iterations | 10 / 30 |
Aggregate result (avg over 9 sets × 2 batch sizes)
| Client | avg mean (ms) | avg p99 (ms) | avg TPS |
|---|---|---|---|
| official | 107.56 | 195.34 | 138.2 |
| official-async | 110.64 | 211.33 | 125.7 |
| py-async (aerospike-py) | 22.45 | 120.67 | 373.7 |
| py-async vs official | 4.8× faster latency | 1.6× | 2.7× higher TPS |
| py-async vs official-async | 4.9× faster | 1.8× | 3.0× |
Per-set speedup distribution (aerospike-py vs official)
Batch 50: mean speedup across 9 sets 4.4× ──────── 7.8× (median ~5.8×)
Batch 200: mean speedup across 9 sets 3.1× ──────── 6.6× (median ~4.5×)
Outlier: set_8 (0% found rate) → fast not-found path, not real read latency.
set_8 is the outlier0% found rate — the official client returns errors faster than success paths, so this row reflects "fast not-found" rather than real read latency. All other sets show 4–8× mean speedup.
official-async wraps the synchronous C client with loop.run_in_executor. It is slightly slower than the bare sync client for every set because each request crosses the thread pool. As a result, aerospike-py's lead over official-async is consistently larger than its lead over official.
Environment B — uvicorn ASGI Only
What this isolates. Add FastAPI and uvicorn around batch_read but no model inference. The "REST API in front of a key-value lookup" shape.
Setup
| Item | Value |
|---|---|
| Source | Internal run, April 2026 (pre-rewrite artifact) |
| Concurrency / iterations | 5 / 50 |
Pipeline breakdown (latency in ms)
| Client | total mean | p50 | p90 | p95 | p99 | aerospike step | inference | TPS |
|---|---|---|---|---|---|---|---|---|
| official | 289.56 | 289.36 | 429.74 | 461.15 | 473.60 | 280.00 | 1.55 | 16.6 |
| aerospike-py | 228.49 | 189.56 | 457.42 | 468.03 | 497.09 | 221.12 | 1.46 | 19.4 |
- Total mean: −21% (290 → 228 ms)
- Aerospike step: −21% (280 → 221 ms) — most wall time is the DB call, so the client gain transfers to E2E
- TPS: +17% (16.6 → 19.4 req/s)
- p99 is roughly equivalent (474 vs 497 ms) — at concurrency=5, the Tokio model isn't yet stressing the GIL hard enough to widen the tail
Higher concurrency exposes backpressure
A second run at concurrency=10, 200 iter (same April 2026 artifact set):
| Client | total mean | p95 | TPS | errors |
|---|---|---|---|---|
| official | 465.81 | 885.64 | 20.9 | 0 |
| aerospike-py | 580.70 | 986.93 | 13.9 | 56 |
This run saturated the test harness (56 errors from request rejections / timeouts under load that the official client absorbed differently). It's not apples-to-apples — it's evidence that without proper backpressure tuning, native async clients can issue more in-flight work than the server pool can absorb. See Bottleneck Analysis for the gather→single recipe that resolves this.
Environment C — uvicorn + DLRM (Real Serving)
What this isolates. Full production-style pipeline:
HTTP request → key extraction → batch_read(9 sets × 200 keys)
→ feature build → DLRM inference (PyTorch CPU) → response
Closest measurement to a real recsys serving pod.
Setup
| Item | Value |
|---|---|
| Source | Internal run, April 2026 (k6 runtime/client comparison; config "3.11 + GIL, stage OFF") |
| Image | aerospike-benchmark:latest (Python 3.11.14) |
| Loadgen | k6 10 VUs × 60s per scenario, 4 scenarios |
| Pods | 2 replicas, 4 CPU / 4 GiB request, 8 CPU / 8 GiB limit |
Both endpoints (/predict/official/sample and /predict/py-async/sample) live in the same pod, called alternately by k6 — server state and network are identical.
k6 client-side latency
single mode (10 VUs × 60s)
p95 aerospike-py ███████████████ 189 ms
official █████████████████████████ 324 ms −42% 🔥
p90 aerospike-py █████████████ 173 ms
official ██████████████████████ 293 ms −41% 🔥
avg aerospike-py █████████ 118 ms
official ████████████ 146 ms −19%
| Mode | aerospike-py p95 | official p95 | aerospike-py advantage |
|---|---|---|---|
| single | 189 | 324 | −42% 🔥 |
| gather (9× fan-out) | 234 | 266 | −12% |
| merge_gather (aero-py only) | 202 | — | — |
| stress (0→20→50 VUs ramp) | 592 | — | — |
The gather gap shrinks because both clients hit GIL serialisation on the result conversion step — see Bottleneck Analysis.
Server-side Prometheus (same run, single mode)
| Metric | aerospike-py | official |
|---|---|---|
predict_duration_seconds p95 (FastAPI E2E) | 202 ms | 274 ms |
aerospike_batch_read_all_duration_seconds p95 | 137 ms | 252 ms |
Server- and client-side numbers move in the same direction — the ~13 ms gap between them is network RTT + gateway + connection setup.
Pattern across environments
layers added → ratio compresses, tail still wins
───────────── ───────────────── ───────────────
A) Pure DB (no HTTP/ML) mean 4.8× p99 1.6×
B) uvicorn ASGI mean 1.27× ≈ noise at C=5
C) uvicorn + DLRM mean 1.24× p95 −42% 🔥
Adding layers around the database call reduces the mean ratio from 4.8× to 1.24×. The upper-percentile advantage remains: aerospike-py releases the GIL during I/O, while the official client serializes GIL acquisition through run_in_executor, increasing tail latency.
Python 3.14t free-threaded — same pipeline, GIL removed
Python 3.14t is the free-threaded build of CPython — the GIL is disabled (Py_GIL_DISABLED=1). The numbers below come from the same Environment C pipeline (FastAPI + DLRM + k6 10 VUs × 60s), with only the runtime swapped. No Rust or C source changes.
TL;DR
p95 single-mode (k6 10 VUs × 60s, FastAPI + DLRM)
aerospike-py
3.11 + GIL ███████████████ 189 ms
3.14t ████████ 97 ms −49% 🔥
official (C extension)
3.11 + GIL █████████████████████████ 324 ms
3.14t ██████████ 128 ms −60% 🔥
Throughput (aerospike-py): 41.6 → 61.2 iter/s +47%
The GIL was a shared bottleneck for both clients. aerospike-py's gain came without touching Rust.
What gets faster (and why)
- aerospike-py: stage breakdown
- official C client: even bigger ratio
Internal stage timings under load. The two GIL-bound stages collapse to near-zero.
Stage 3.11 + GIL 3.14t Change
────────────────────────────────────────────────────────────────────
spawn_blocking_delay 234 ms ████████ 0.12 ms −99.95% 🔥
event_loop_resume_delay 39.7 ms ██ ≈ 0 ≈ −100%
io (Aerospike network) 7.51 ms ▌ 1.27 ms −83%
merge_to_dict 4.48 ms ▎ 3.54 ms −21%
key_parse 967 μs · 1.06 ms +10% (noise)
tokio_schedule_delay 83.1 μs 49.5 μs −40%
limiter_wait 3.56 μs 0.96 μs −73%
Two stages dominate the gain:
spawn_blocking_delaydrops from 234 ms to 0.12 ms. Under GIL, when a Rust async future completes and needs to convert results intoPy<...>objects, it dispatches that work to aspawn_blockingworker. That worker has to acquire the GIL — and under contention from the asyncio event loop and other workers, the queue stretches into hundreds of milliseconds. With no GIL, the worker runs immediately.event_loop_resume_delaydrops to effectively zero. Under GIL, after the future resolves and the event loop is woken up, the coroutine still has to wait its turn for the GIL before resuming. Free-threaded mode lets multiple coroutines resume in parallel.
io shrinking 8× is a second-order effect: Tokio workers can now parse Aerospike protocol responses without contending with Python code for the GIL.
The official aerospike client is a synchronous C extension; applications wrap it with loop.run_in_executor(ThreadPoolExecutor, ...). Each request:
1. Thread-pool worker dispatch
2. C extension acquires GIL to parse arguments ← serialised under GIL
3. Network call (releases GIL)
4. C extension reacquires GIL to build Python dict ← serialised under GIL
Under GIL contention, steps 2 and 4 serialise across all in-flight requests.
Removing the GIL parallelises them. The official client's p95 falls 60% (324 → 128 ms) — even more than aerospike-py's 49% in absolute terms — because it had more to lose.
Same-workload comparison on 3.14t
On 3.14t, when both clients hit the same server load (alternating endpoints in the same pod, k6 10 VUs):
| Client | p95 (single mode) | Difference |
|---|---|---|
| aerospike-py | 126 ms | baseline |
| official aerospike | 128 ms | +2 ms (~1.5%, noise) |
The 42% gap that existed under 3.11 + GIL collapses to ~2 ms under 3.14t.
This is the cleanest evidence that GIL contention — not architectural difference — was responsible for the bulk of the original gap.
Throughput (TPS)
3.11 + GIL, stage OFF ████████████ 41.6 baseline
3.11 + GIL, stage ON █████████████ 44.1 +6% (noise)
3.14t, aerospike-py only ███████████████████ 61.2 +47% 🔥
3.14t, both clients (split load) ██████████████ 47.3 +14%
| Config | iterations/s | http_reqs/s |
|---|---|---|
| 3.11 + GIL, stage OFF | 41.6 | 50.8 |
| 3.11 + GIL, stage ON | 44.1 | 52.9 |
| 3.14t, aerospike-py only | 61.2 | 80.0 |
| 3.14t, both clients (split load) | 47.3 | 59.8 |
But aerospike-py still wins under solo load on 3.14t
The "126 vs 128 ms" tie comes from a configuration where each client only sees ~5 effective VUs (10 VUs split across two endpoints). When the load is concentrated on one client, the gap reopens.
| Metric | aerospike-py solo | official solo | aerospike-py advantage |
|---|---|---|---|
| k6 single p95 | 97 ms | 134 ms | −28% |
| k6 gather p95 (9× fan-out) | 107 ms | 253 ms | −58% 🔥 |
Server predict_duration_seconds p95 | 100 ms | 138 ms | −28% |
Server batch_read_all p95 | 64 ms | 67 ms | −4% |
| Theoretical capacity (Little's Law: 10 VUs / p95) | ~103 req/s | ~75 req/s | +37% |
Why aerospike-py still leads under solo load on 3.14t:
- Native async vs threadpool wrapping. Even without GIL contention,
run_in_executoradds a thread-pool hop per request. aerospike-py awaits directly on the asyncio loop. - Lazy dict conversion.
batch_read()returns aLazyBatchRecordshandle. The Python-dict materialisation happens lazily when the caller invokes.to_dict();.to_numpy(dtype)provides the GIL-released structured-array path. - Single FFI boundary crossing. The aerospike-py Rust code completes a full
batch_readinside one PyO3 call. The C extension crosses the Python ↔ C boundary multiple times per call.
These advantages compound as concurrency rises (the gather number — 107 vs 253 ms — is the clearest example).
Migration notes
- Add a 3.14t row to CI matrix. Run unit + integration tests under
python:3.14.2t-slim. - Audit
unsafeand shared mutable state in Rust before declaringgil_used = false. - Wait for or build official client wheels. PyPI does not yet ship
cp314twheels for the officialaerospikepackage; source build works.
Side-effect: inference also got faster. DLRM inference (PyTorch CPU, control variable) dropped from 43.5 ms → 20.7 ms (−52%) on 3.14t. Unrelated to aerospike-py — a free side benefit for any GIL-bound inference path running alongside async I/O.
What's next:
- Isolated Benchmark — strip the FastAPI/JSON/inference cost out to see the client gap by itself
- Bottleneck Analysis — per-stage profiling, the
gather→singlerecipe (TODO: re-measure on the new harness)