atproto pds in zig
zds docs benchmarking.md
8.8 kB
Markdown
at main

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-state getRepo CAR construction, ranged getBlob, 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. WebSocket subscribeRepos also needs a separate harness.

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 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, and listRecords.
  • media posts call uploadBlob before 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.