benchmarks#
bench/ contains local benchmarks for ZDS storage paths. The goal is to track
scaling curves and compare protocol work against reference implementations, not
to produce a single universal score.
The benchmark style follows atproto-bench:
state the exact work being measured, keep correctness work visible, compare
equivalent operations, and treat run-to-run variance as real.
run#
just bench all
just bench write
just bench read
just bench repo
just bench blob
just bench metastore 10 1000
just bench get-cid 10 1000
just bench get-block 10 1000
just bench decode-record 10 1000
just bench render-record 10 1000
just bench get-record 10 1000
just bench list-records 10 1000
just bench http
just bench official-pds
just bench write-profile 10 500
just bench run --scenario write --records 10000
just bench ... is a Just module declared from the repository root with
mod bench.
current scenarios#
write:applyWritescreatingapp.bsky.feed.postrecords.read: repeatedlistRecordsqueries against one seeded repo.repo: full repo CAR materialization throughwriteRepoCar.blob: blob write/read against disk blobstore plus SQLite metadata.metastore: Tranquil-shaped apply/get/list benchmark with caller counts and latency percentiles.get-cid: CID-only record lookup, matching Tranquil'sget_record_cidmetastore benchmark.get-block: record index lookup plusrepo_blocksbyte fetch.decode-record: DAG-CBOR decode of one seeded record block, without SQLite.render-record: DAG-CBOR decode plus JSON rendering, without SQLite.get-record: fullcom.atproto.repo.getRecordstorage materialization.list-records: fullcom.atproto.repo.listRecordsstorage materialization.http: starts a local ReleaseFast ZDS server and measures a seeded HTTP route matrix with ApacheBench. This is the route-level check for thehttpzserver boundary and should be tracked separately from storage microbenchmarks.official-pds: copies the official-PDS read probe into a localbluesky-social/atprotocheckout and runs it with Vitest.write-profile:applyWritesstage timing under write concurrency.
Each run uses temporary SQLite and blobstore state.
methodology#
- Build ZDS with
-Doptimize=ReleaseFast. - Use temporary SQLite and blobstore state for every run.
- Seed records before read benchmarks so lookup/list work is separated from write setup.
- Report throughput plus p50/p95/p99/max/mean latency for concurrent paths.
- Keep HTTP route benchmarks separate from storage benchmarks. Route numbers
include request parsing, routing, response serialization, auth/session work,
and
httpzconnection handling. - Compare ratios from the same machine and same run window. Absolute numbers move with system load, thermal state, compiler version, and storage backend.
- Do not compare paths that perform different correctness work. For example,
CID-only metadata lookup is separate from full
getRecordmaterialization.
traffic model#
For a small hosted PDS, the hot paths are not exotic. A hundred active accounts mainly means more session checks, appview-proxied feed/profile reads, record writes from clients and apps, blob uploads, sync subscribers, crawl requests, and occasional repo export/import. The storage benchmarks should therefore keep these domains separate:
- record index: current CID lookups, existence checks, and index-only lists.
- record materialization: index lookup, block fetch, DAG-CBOR decode, and JSON response rendering.
- write path: validation, repo mutation, commit/block persistence, event publication, and crawl notification.
- blob path: upload body ingest, disk write, metadata persistence, and readback.
- sync path: event delivery and repo CAR materialization.
current local baseline#
On this machine, with --records 1000 unless noted:
| scenario | zds |
|---|---|
| write records | 144 ops/s |
| list records | 30k ops/s |
| write repo car | 252 ops/s, 440 MB/s |
| blob put+get | 368 ops/s, 46 MB/s |
Current local HTTP route baseline after adopting httpz, measured with
just bench http using 5000 requests at concurrency 50 for normal routes and
500 requests at concurrency 10 for blob/CAR routes. The harness covers public
metadata, OAuth metadata, session/account reads, preference reads, repo reads,
sync reads, blob reads, and repo CAR reads. Stateful write endpoints and
WebSocket subscribeRepos need separate harnesses.
| route group | httpz |
|---|---|
| static/landing routes | 11.6k-40.2k req/s |
| public identity/server metadata | 31.9k-38.9k req/s |
| OAuth metadata/JWKS | 35.6k-37.0k req/s |
| OAuth authorize JSON | 4.4k req/s, p95 28 ms |
| session/account reads | 20.5k-34.9k req/s |
createSession |
7.4k req/s, p95 20 ms |
| repo reads | 26.2k-39.0k req/s |
listRecords, limit 50 |
26.2k req/s, p95 6 ms |
| sync JSON reads | 24.1k-39.4k req/s |
| blob/CAR reads | 34.5k-37.9k req/s |
The pre-httpz column below is the original narrow route set run against commit
4841aec; it should not be treated as coverage for the expanded route matrix.
| route | pre-httpz | httpz |
|---|---|---|
/xrpc/_health |
34.1k req/s, p95 3 ms | 34.5k req/s, p95 3 ms |
describeServer |
38.4k req/s, p95 1 ms | 39.3k req/s, p95 1 ms |
getSession |
19.9k req/s, p95 8 ms | 20.8k req/s, p95 6 ms |
listRecords, limit 50 |
19.8k req/s, p95 11 ms | 22.4k req/s, p95 7 ms |
reference matrix#
Run Tranquil's metastore benchmark:
cd /tmp/tranquil-pds
SQLX_OFFLINE=true cargo bench -p tranquil-store --bench metastore --features tranquil-store/test-harness
Run the matching ZDS scenarios:
just bench metastore 10 1000
just bench metastore 100 200
just bench get-cid 10 1000
just bench get-cid 100 200
This matrix includes only operation/count pairs measured with the same unit of
work. Tranquil's get_record_cid is a record-index operation. The official PDS
probe measures its actor-store read paths with Vitest; its single-operation rows
are included for matching internal read/write boundaries.
Summary from the May 31, 2026 local run: ZDS is fastest in the single-caller
CID lookup and full-materialization read probes. Tranquil is fastest in the
10/100 caller CID lookup probes and its index-only list_records path is faster
than ZDS's full listRecords materialization. For writes, Tranquil and ZDS are
close around the single-caller 1000-op shape, Tranquil is faster with much
tighter tails at 10 callers, and ZDS has higher 100-caller throughput with much
worse tail latency.
| operation | callers | zds | tranquil | official PDS |
|---|---|---|---|---|
| apply one record commit | 1 | 281 ops/s, p95 8.1 ms | 289 ops/s, p95 4.9 ms | 448 ops/s, p99 4.0 ms |
| current record CID lookup | 1 | 509k ops/s, p95 2 us | 111k ops/s, p95 12.6 us | 3.1k ops/s, p99 511 us |
The direct caller-count concurrency matrix is ZDS and Tranquil only. Official PDS concurrency probes are kept separate until they use the same caller model.
| operation | callers | ops | zds | tranquil |
|---|---|---|---|---|
| apply one record commit | 10 | 10000 | 289 ops/s, p95 86.1 ms | 744 ops/s, p95 15.9 ms |
| apply one record commit | 100 | 20000 | 1423 ops/s, p95 65.0 ms, p99 806 ms | 1051 ops/s, p95 100 ms |
| current record CID lookup | 10 | 10000 | 345k ops/s, p95 18 us | 610k ops/s, p95 27 us |
| current record CID lookup | 100 | 20000 | 302k ops/s, p95 273 us | 630k ops/s, p95 212 us |
Official PDS batched CID lookup:
| operation | batch shape | converted throughput | batch p99 |
|---|---|---|---|
| current record CID lookup | 10 callers x 100 lookups | 3.3k lookups/s | 317 ms |
| current record CID lookup | 100 callers x 20 lookups | 3.2k lookups/s | 630 ms |
Tranquil's metastore list_records benchmark is index-only, while ZDS
listRecords joins repo_blocks and materializes record data. Keep these rows
as adjacent probes until ZDS grows an explicit index-only list benchmark.
| operation | callers | ops | zds full materialization | tranquil index-only |
|---|---|---|---|---|
| list records, limit 50 | 1 | 5000 | 22.5k ops/s, p95 58 us | 32.9k ops/s, p95 37 us |
| list records, limit 50 | 10 | 10000 | 18.2k ops/s, p95 97 us | 38.9k ops/s, p95 271 us |
| list records, limit 50 | 100 | 20000 | 21.5k ops/s, p95 60 us | 38.5k ops/s, p95 2.75 ms |
The current Tranquil metastore bench panics at its built-in 1000-caller
get_record_cid row with metastore handler backpressure; treat that as a
backpressure behavior to investigate, not as a throughput result.
official PDS probe#
The official-PDS probe lives at bench/official-pds-records.bench.ts and is
run with:
ATPROTO_REPO=/tmp/bluesky-atproto just bench official-pds
It seeds 1000 app.bsky.feed.post records through the official test network,
then measures actor-store reads directly:
| operation | official PDS |
|---|---|
| apply one record commit | 448 ops/s, p99 4.0 ms |
| current record CID lookup | 3.1k ops/s, p99 511 us |
| full getRecord materialization | 3.0k ops/s, p99 525 us |
| full listRecords, limit 50 | 635 ops/s, p99 2.4 ms |
The matching ZDS single-caller rows are:
| operation | zds |
|---|---|
| apply one record commit | 642 ops/s, p95 2.9 ms |
| current record CID lookup | 414k ops/s, p95 4 us |
| full getRecord materialization | 48.6k ops/s, p95 28 us |
| full listRecords, limit 50 | 21.6k ops/s, p95 60 us |
local probes#
These rows are useful for ZDS tuning but are not direct Tranquil comparisons.
| operation | callers | ops | zds |
|---|---|---|---|
| get record block | 10 | 10000 | 128k ops/s, p95 11 us |
| get record block | 100 | 20000 | 121k ops/s, p95 15 us |
| decode DAG-CBOR | 10 | 10000 | 40.6M ops/s, p95 below timer resolution |
| decode DAG-CBOR | 100 | 20000 | 12.6M ops/s, p95 below timer resolution |
| render record JSON | 10 | 10000 | 24.8M ops/s, p95 below timer resolution |
| render record JSON | 100 | 20000 | 12.4M ops/s, p95 1 us |
| full getRecord | 10 | 10000 | 38.4k ops/s, p95 173 us |
| full getRecord | 100 | 20000 | 38.1k ops/s, p95 11.7 ms |
| full listRecords, limit 50 | 10 | 10000 | 21.1k ops/s, p95 57 us |
| full listRecords, limit 50 | 100 | 20000 | 19.5k ops/s, p95 78 us |
The read-path split says SQLite serialization and row materialization dominate single-record reads. Decode/render is effectively invisible for tiny benchmark records; it needs a larger-record probe before we can extrapolate image-heavy or rich-record traffic.
write profile#
write-profile tracks where concurrent writes spend time:
| callers | ops | throughput | lock wait | load repo | build commit | sqlite/event |
|---|---|---|---|---|---|---|
| 10 | 5000 | 708 ops/s | 25% | 26% | 3% | 46% |
| 100 | 2000 | 3683 ops/s | 64% | 10% | 1% | 25% |
ZDS separates repo ordering from database serialization: writes take a per-DID lane before loading and mutating the repo, then use the DB mutex for the shared SQLite connection. Tranquil remains the reference shape for bounded write queues, explicit backpressure, less full-repo state loading, and a narrower SQLite persistence phase.
comparison work#
Tranquil benchmark source:
crates/tranquil-store/benches/metastore.rscrates/tranquil-store/benches/metastore_scale.rscrates/tranquil-store/benches/blockstore.rscrates/tranquil-store/benches/eventlog.rs
Official PDS source for matching probes:
packages/pds/src/actor-store/record/reader.tspackages/pds/src/api/com/atproto/repo/applyWrites.tspackages/pds/src/actor-store/repo/sql-repo-reader.ts
Open apples-to-apples comparison gaps:
- single-repo write curve as record count grows
- multi-repo concurrent writes
- record-index list latency without record body materialization, across ZDS and Tranquil
- official-PDS direct concurrency harness for the same read/write boundaries
- full repo export cost
- blob/block store throughput
ZDS and Tranquil both report latency percentiles for the metastore-shaped write
and CID lookup rows. The remaining comparison gap is broader coverage of
blockstore, eventlog, full repo export behavior, an official-PDS benchmark
harness, and an explicit ZDS index-only list probe that matches Tranquil's
list_records.