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 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.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.
- 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 |
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: ZDS is fastest in the single-caller read probes. Tranquil is fastest in the direct 10/100 caller CID lookup probes. The official PDS is slower in these microbenchmarks because the probe goes through its actor-store/test-network stack rather than a bare SQLite reader. For writes, ZDS is fastest in the single-caller row, Tranquil is faster than ZDS at 10 callers, and ZDS is faster than Tranquil at 100 callers but with a worse p99/max tail.
| operation | callers | zds | tranquil | official PDS |
|---|---|---|---|---|
| apply one record commit | 1 | 642 ops/s, p95 2.9 ms | 287 ops/s, p95 4.1 ms | 448 ops/s, p99 4.0 ms |
| current record CID lookup | 1 | 414k ops/s, p95 4 us | 96.8k ops/s, p95 15 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 | 259 ops/s, p95 98.9 ms | 780 ops/s, p95 14.1 ms |
| apply one record commit | 100 | 20000 | 1223 ops/s, p95 67.5 ms | 1007 ops/s, p95 109 ms |
| current record CID lookup | 10 | 10000 | 222k ops/s, p95 225 us | 455k ops/s, p95 43 us |
| current record CID lookup | 100 | 20000 | 211k ops/s, p95 887 us | 537k ops/s, p95 233 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 not in this table because the
ZDS row reports the official-PDS-shaped listRecords: record index lookup,
repo_blocks fetch, DAG-CBOR decode, and JSON materialization. Add an
index-only ZDS list probe before comparing that row.
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.