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. 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, 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.