benchmarking #
Benchmark notes should separate behavior domains and only compare equivalent work. If a measurement does not exist for the same unit of work, keep it out of the comparison table instead of filling the cell with a nearby but different operation.
references #
- ZDS:
bench/main.zig - Tranquil:
crates/tranquil-store/benches/metastore.rs - Official PDS:
packages/pds/src/actor-store/record/reader.ts
current matrix #
The active apples-to-apples matrix is intentionally small:
- apply one record commit
- current record CID lookup
Adjacent probes are tracked separately:
- record-index list
- full record materialization
- blob/block put and get
- repo export
- sync event delivery
ZDS, Tranquil, and the official PDS have matching single-caller numbers for apply-one-record and current-CID lookup. ZDS and Tranquil also have direct 10/100 caller concurrency curves. Official-PDS batch probes stay outside the main matrix until they are measured through the same unit of work.
Current local comparison. The Tranquil rows are from a fresh local metastore run on June 18, 2026. The ZDS write row was remeasured after completing the lazy-MST adoption in the repo write path.
| operation | callers | zds | tranquil | note |
|---|---|---|---|---|
| apply one record commit | 1 | 1598 ops/s, p95 0.65 ms | 267 ops/s, p95 4.13 ms | ZDS no longer eagerly loads the whole repo CAR |
| apply one record commit | 10 | 2313-2525 ops/s, p95 11.2-12.2 ms | 503 ops/s, p95 26.1 ms | ZDS row is repeated-run range |
| current CID lookup | 1 | 523k ops/s, p95 2 us | 116k ops/s, p95 12.6 us | ZDS ahead |
| current CID lookup | 10 | 319.7k ops/s, p95 8 us | 294.6k ops/s, p95 54 us | both use record-index lookup |
Tranquil's built-in 1000-caller metastore row currently trips handler backpressure in the benchmark harness. Keep it out of the table until the harness handles backpressure as a measured result.
ZDS write authoring uses zat.mst.Mst.loadLazy with a SQLite-backed
repo_blocks reader. This avoids reading every stored repo block into a CAR
before each write while preserving the signed commit, MST update, record index,
and sync event behavior.
read path #
ZDS follows the official PDS read shape for getRecord and listRecords: read
the current record index row, fetch matching repo_blocks bytes, decode
DAG-CBOR, and render JSON for the XRPC response. Tranquil's metastore
list_records is a different unit of work because it returns only rkeys and
CIDs. Keep those rows separate.
Current ZDS read breakdown on this machine:
| operation | callers | ops | result |
|---|---|---|---|
| current CID lookup | 10 | 10000 | 222k ops/s, p95 225 us |
| current CID lookup | 100 | 20000 | 211k ops/s, p95 887 us |
| index + block fetch | 10 | 10000 | 128k ops/s, p95 11 us |
| index + block fetch | 100 | 20000 | 121k ops/s, p95 15 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 |
Adjacent Tranquil list comparison:
| operation | callers | zds | tranquil | note |
|---|---|---|---|---|
| list records, limit 50 | 1 | 22.5k ops/s, p95 58 us | 32.9k ops/s, p95 37 us | ZDS full materialization; Tranquil index-only |
| list records, limit 50 | 10 | 18.2k ops/s, p95 97 us | 38.9k ops/s, p95 271 us | not same unit of work |
| list records, limit 50 | 100 | 21.5k ops/s, p95 60 us | 38.5k ops/s, p95 2.75 ms | not same unit of work |
The official PDS probe on the same machine reports:
| operation | result |
|---|---|
| apply one record commit | 448 ops/s, p99 4.0 ms |
| current CID lookup | 3.1k ops/s, p99 511 us |
| full getRecord | 3.0k ops/s, p99 525 us |
| full listRecords, limit 50 | 635 ops/s, p99 2.4 ms |
The official PDS batched CID lookup probe reports:
| batch shape | converted throughput | batch p99 |
|---|---|---|
| 10 callers x 100 lookups | 3.3k lookups/s | 317 ms |
| 100 callers x 20 lookups | 3.2k lookups/s | 630 ms |
For tiny records, DAG-CBOR decode and JSON rendering are below useful timer resolution. Larger records and index-only list probes are tracked as separate comparison gaps instead of being folded into the current table.
permissioned data #
Permissioned-data measurements should stay separate from the existing repo,
blob, sync, and HTTP route matrices. The experimental com.atproto.space.*
surface has its own storage model, actor-local space state, writer repo state,
record oplogs, credentials, and ranged blob reads, so mixing it into the public
repo tables would hide the actual access pattern.
The first benchmark slice should be small and diagnostic rather than exhaustive:
- seed one self-owned private space and one writer repo.
- measure
createSpace, record write, record read,listRecords, full-stategetRepoCAR construction, rangedgetBlob, and writer repo oplog catch-up separately. - report p50/p95/p99/max for the concurrent paths, but keep write, read, blob, and oplog rows separate.
- compare to Daniel's permissioned-data branch only after we can run that PDS locally with the same seeded work.
The question for this slice is simply whether any route has an obviously bad query shape, full-table scan, unnecessary blob materialization, or avoidable global lock hold. It does not need to become a giant protocol benchmark suite.
HTTP route path #
just bench http starts a local ReleaseFast ZDS server and measures a seeded
route matrix with ApacheBench. These numbers include HTTP request parsing,
routing, response serialization, auth/session work, httpz connection
handling, and whatever endpoint work the route performs. Keep them separate
from the storage microbenchmarks above.
The current harness covers public metadata, OAuth metadata, session/account
reads, preference reads, repo reads, sync reads, blob reads, and repo CAR reads.
It deliberately does not loop over stateful write endpoints such as
createRecord, applyWrites, uploadBlob, or passkey registration because a
static ApacheBench body would mostly measure duplicate/error paths after the
first request.
just bench websocket-churn covers the WebSocket lifecycle separately. It
repeatedly opens and closes cohorts of subscribeRepos clients at the server's
live-connection limit, then checks that detached connection workers return to the
baseline thread count. A warm second round also guards against renewed,
unbounded RSS growth while allowing the allocator to retain its first-round
high-water capacity.
Current local route baseline after adopting httpz, with 5000 requests at
concurrency 50 for normal routes and 500 requests at concurrency 10 for the
larger blob/CAR routes:
| route | httpz |
|---|---|
/ |
15.0k req/s, p95 10 ms |
/favicon.svg |
40.2k req/s, p95 1 ms |
/og-image |
11.6k req/s, p95 5 ms |
/xrpc/_health |
31.9k req/s, p95 1 ms |
/.well-known/did.json |
38.9k req/s, p95 1 ms |
/.well-known/atproto-did |
38.5k req/s, p95 1 ms |
| OAuth metadata/JWKS | 35.6k-37.0k req/s, p95 2 ms |
oauth/authorize JSON |
4.4k req/s, p95 28 ms |
describeServer |
38.7k req/s, p95 1 ms |
createSession |
7.4k req/s, p95 20 ms |
getSession |
23.8k req/s, p95 6 ms |
getServiceAuth |
24.5k req/s, p95 5 ms |
| account/security reads | 20.5k-34.9k req/s, p95 4-7 ms |
getPreferences |
32.0k req/s, p95 5 ms |
resolveHandle |
23.9k req/s, p95 2 ms |
describeRepo |
39.0k req/s, p95 1 ms |
getRecord |
30.0k req/s, p95 5 ms |
listRecords, limit 50 |
26.2k req/s, p95 6 ms |
listMissingBlobs |
27.6k req/s, p95 6 ms |
| sync JSON reads | 24.1k-39.4k req/s, p95 1-4 ms |
sync.getBlob |
34.5k req/s, p95 1 ms |
sync.getBlob HEAD |
34.8k req/s, p95 1 ms |
sync.getRepo |
37.9k req/s, p95 below 1 ms |
sync.getRepo HEAD |
36.6k req/s, p95 below 1 ms |
The separate permissioned-data scenario (just bench space) covers space
discovery, management-policy reads, record writes and reads, writer/oplog
enumeration, full repo CAR construction, referenced-blob enumeration, and blob
serving. Keep it separate from public-repo benchmarks because the storage and
commit formats are intentionally different.
The only pre-httpz route numbers currently available are the original narrow
route set, run against commit 4841aec with the same four probes:
| 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 |
traffic model #
For roughly 100 hosted accounts, useful benchmarks should mirror real traffic:
- clients call
getSession, preferences, profiles, timelines, and proxied appview routes repeatedly while open. - apps call
createRecord,applyWrites,getRecord, andlistRecords. - media posts call
uploadBlobbefore record creation. - relays and tools call sync endpoints, repo export, and blob reads.
- the landing page reads residents, record counts, recent records, and profile blobs.
This traffic model is the basis for future route-mix benchmarks and per-route latency instrumentation. Keep those measurements separate from isolated storage microbenchmarks.
startup and deployment interruption #
Run just bench startup 5 10000 for a ReleaseSafe build (the production
optimization mode) and five samples per scenario. Build time is excluded.
tools/startup_bench.py measures subprocess launch to the first HTTP 200 health
response with status: ok. It emits JSON with platform, binary SHA-256,
individual samples and min/median/max. Polling adds up to roughly 5 ms plus HTTP
request overhead; these are warm-filesystem runs, not cold VM boots.
The lanes separate fresh schema creation, empty existing-database restart, and history-shaped restart. The history fixture has 10,000 commits, 60,000 blocks with 4 KiB payloads, 10,000 sequence events with 4 KiB payloads, and 2,000 unversioned tree blocks which match neither records nor commits. It is a synthetic SQLite workload, not a signed repository or an API throughput benchmark. Increase the second argument to examine scaling.
For an existing local SQLite backup, run:
python3 tools/startup_bench.py --snapshot /path/to/backup.sqlite3 --runs 5
The source is opened read-only and copied with SQLite's backup API before every
sample. The server only opens disposable copies. Runs bind to loopback, disable
crawler notifications, discard inherited ZDS_* configuration, and clean up
child processes and temporary data. Output exports stage durations, not server
logs or account data. Use local backups rather than a production database path.
Startup logs report storage_ms, account_setup_ms, and http_init_ms. They
measure storage initialization, account bootstrap/announcement setup, and HTTP
server construction respectively. Storage also reports migrations_ms and
sequence_ms to separate those two phases. The existing "listening" message precedes
server.listen(); HTTP health remains the authoritative readiness measurement.
2026-09-05: index commit CID lookups #
The revision backfill runs on startup and matches NULL-revision blocks against
commits by (did, cid). Without a matching index, unmatched historical tree
blocks repeatedly scan an account's commit history. A non-unique composite
index removes those scans without skipping backfill or integrity validation.
Five-run medians on macOS arm64, ReleaseSafe, warm filesystem. This initial fixture had block and commit history but no sequence-event payloads:
| lane | before | indexed |
|---|---|---|
| fresh schema | 22.00 ms | 22.34 ms |
| empty restart | 9.11 ms | 9.33 ms |
| history restart | 3147.59 ms | 15.38 ms |
| old history snapshot, including index creation | — | 29.01 ms |
These fixture results are not production startup timings. Before the change, Fly logs showed about 5 seconds from process launch to the listening message. An external one-second health probe with a two-second request timeout observed 8 consecutive timeouts spanning roughly 17 seconds during the prior deployment; that includes machine replacement and proxy routing, not just process startup.
For production rollouts, record both process stage timings and an external health probe across replacement. Compare failure windows separately from healthy response latency. A single machine with a local SQLite volume has a deployment interruption even when application startup is fast; avoid extra restarts solely to publish benchmark notes.
sequence recovery #
The first indexed production deployment still recorded storage_ms=5709.
The fixture improvement alone did not establish a production improvement.
Read-only production probes then identified another scan: computing the next
sequence via MAX over a union of every commit and event sequence. That query
scanned the event table (including its large row storage). Computing MAX(seq)
inside each branch first preserves empty-table and gap semantics while letting
SQLite seek to each table's final integer primary key. A production read-only
comparison returned 10914 for both queries, taking 556 ms and 1 ms respectively;
these are individual warm query measurements, not full startup measurements.
The history fixture now includes 10,000 events with 4 KiB payloads to exercise this path. Regression tests cover empty histories, either history being ahead, gaps, and an empty commit table with retained events.
narrow revision backfill to missing revisions #
Production EXPLAIN QUERY PLAN showed SCAN repo_blocks for both backfill
updates even after commit lookup was indexed. Unlike the read-only SELECT
probes, UPDATE selected a full scan of payload-bearing block rows. A partial
index on (did, cid) WHERE repo_rev IS NULL bounds subsequent backfill work to
unversioned blocks. It is created after the legacy repo_rev column migration.
Index creation still scans existing blocks once on upgrade; it does not remove
or bypass migration work. The fixture now includes 4 KiB block payloads as well
as events, exposing the cost of scanning payload-bearing tables.
With payloads in the fixture, five-run macOS ReleaseSafe median readiness fell from 33.86 ms (commit index only) to 9.22 ms (partial index plus per-table sequence maxima). Empty restarts measured 9.16 ms. These warm-cache results are distinct from the initial small-block comparison above.
September 2026: independent simplespace read/write access #
On macOS arm64, Zig 0.16.0, ReleaseFast, three just bench space 1000
runs before and after the API/storage update gave these median throughputs:
| storage operation | before ops/s | after ops/s |
|---|---|---|
| listSpaces | 56,055 | 54,399 |
| listMembers | 38,596 | 37,099 |
| createRecord | 7,389 | 7,224 |
| getRecord | 180,344 | 174,748 |
| listRecords | 44,609 | 43,957 |
| getRepo | 624 | 628 |
These sequential local samples show small changes (roughly 1–4% on most read paths), not a production capacity estimate. The member list now returns two additional flags and space configuration reads return independent policies. At 10,000 records, a single export probe measured 69.0 ops/s before and 68.6 after; createRecord measured 7,016 before and 7,319 after. Membership size is not scaled with record count in this fixture.
A ReleaseSafe startup rehearsal on a consistent production SQLite backup preserved account, session, OAuth, public block, permissioned record, space configuration, and membership state. The migration stage took 10 ms locally; first readiness was 336 ms and a subsequent restart reached readiness in 11 ms. These are individual warm-filesystem samples, not Fly replacement or network interruption measurements.