atproto pds in zig pds.zat.dev
pds atproto
24

Configure Feed

Select the types of activity you want to include in your feed.

Add reference PDS benchmark probes

zzstoatzz (May 24, 2026, 3:30 PM -0500) af9dc113 6693ee16

+513 -30
+120 -29
bench/README.md
··· 18 18 just bench blob 19 19 just bench metastore 10 1000 20 20 just bench get-cid 10 1000 21 + just bench get-block 10 1000 22 + just bench decode-record 10 1000 23 + just bench render-record 10 1000 24 + just bench get-record 10 1000 25 + just bench list-records 10 1000 26 + just bench official-pds 21 27 just bench write-profile 10 500 22 28 just bench run --scenario write --records 10000 23 29 ``` ··· 32 38 - `repo`: full repo CAR materialization through `writeRepoCar`. 33 39 - `blob`: blob write/read against disk blobstore plus SQLite metadata. 34 40 - `metastore`: Tranquil-shaped apply/get/list benchmark with caller counts and 35 - latency percentiles. 41 + latency percentiles. Its get/list rows are ZDS-local probes, not all direct 42 + Tranquil comparisons. 36 43 - `get-cid`: CID-only record lookup, matching Tranquil's `get_record_cid` 37 44 metastore benchmark. 45 + - `get-block`: record index lookup plus `repo_blocks` byte fetch. 46 + - `decode-record`: DAG-CBOR decode of one seeded record block, without SQLite. 47 + - `render-record`: DAG-CBOR decode plus JSON rendering, without SQLite. 48 + - `get-record`: full `com.atproto.repo.getRecord` storage materialization. 49 + - `list-records`: full `com.atproto.repo.listRecords` storage materialization. 50 + - `official-pds`: copies the official-PDS read probe into a local 51 + `bluesky-social/atproto` checkout and runs it with Vitest. 38 52 - `write-profile`: `applyWrites` stage timing under write concurrency. 39 53 40 54 Each run uses temporary SQLite and blobstore state. ··· 51 65 - Do not compare paths that perform different correctness work. For example, 52 66 CID-only metadata lookup is separate from full `getRecord` materialization. 53 67 68 + ## traffic model 69 + 70 + For a small hosted PDS, the hot paths are not exotic. A hundred active accounts 71 + mainly means more session checks, appview-proxied feed/profile reads, record 72 + writes from clients and apps, blob uploads, sync subscribers, crawl requests, 73 + and occasional repo export/import. The storage benchmarks should therefore keep 74 + these domains separate: 75 + 76 + - record index: current CID lookups, existence checks, and index-only lists. 77 + - record materialization: index lookup, block fetch, DAG-CBOR decode, and JSON 78 + response rendering. 79 + - write path: validation, repo mutation, commit/block persistence, event 80 + publication, and crawl notification. 81 + - blob path: upload body ingest, disk write, metadata persistence, and readback. 82 + - sync path: event delivery and repo CAR materialization. 83 + 54 84 ## current local baseline 55 85 56 86 On this machine, with `--records 1000` unless noted: ··· 62 92 | write repo car | 252 ops/s, 440 MB/s | 63 93 | blob put+get | 368 ops/s, 46 MB/s | 64 94 65 - ## Tranquil comparison 95 + ## reference matrix 66 96 67 97 Run Tranquil's metastore benchmark: 68 98 ··· 80 110 just bench get-cid 100 200 81 111 ``` 82 112 83 - This table only includes operation/count pairs measured in both implementations. 84 - The operations are matched to Tranquil's metastore bench: write one record, look 85 - up one record CID, and list records from a seeded repo. 113 + This matrix only includes operation/count pairs measured with the same unit of 114 + work. Tranquil's `get_record_cid` and `list_records` are record-index 115 + operations: `list_records` returns `rkey + record_cid`, not record body JSON. 116 + The official PDS probe measures its actor-store read paths with Vitest. That is 117 + apples-to-apples for the internal read operations, but not for concurrency 118 + curves: Vitest reports a single async benchmark loop, while ZDS and Tranquil 119 + also have explicit caller-count runs. 120 + 121 + Summary: ZDS is fastest in the single-caller read probes, Tranquil is fastest in 122 + the 10/100 caller CID lookup probes, and the official PDS is slower in these 123 + microbenchmarks because the probe goes through its actor-store/test-network 124 + stack rather than a bare SQLite reader. For writes, official PDS is faster than 125 + ZDS in the single-caller row, Tranquil is faster than ZDS at 10 callers, and ZDS 126 + is faster than Tranquil at 100 callers but with a worse p99/max tail. 127 + 128 + | operation | callers | zds | tranquil | official PDS | 129 + |---|---:|---:|---:|---:| 130 + | apply one record commit | 1 | 642 ops/s, p95 2.9 ms | 287 ops/s, p95 4.1 ms | 486 ops/s, p99 3.9 ms | 131 + | current record CID lookup | 1 | 414k ops/s, p95 4 us | 96.8k ops/s, p95 15 us | 3.2k ops/s, p99 477 us | 86 132 87 - Summary: Tranquil is faster on the read rows after ZDS moved record materialize 88 - paths to decode DAG-CBOR from `repo_blocks`: about 1.5-1.8x faster for CID 89 - lookup and about 1.6x faster for list records. Tranquil is also faster on 90 - 10-caller writes, by about 2.8x. ZDS is faster on the 100-caller write row, by 91 - about 1.3x, which is the main sign that the sharded write lane is helping under 92 - contention. 133 + The current caller-count concurrency matrix is only ZDS and Tranquil until the 134 + official-PDS probe grows explicit concurrent workers. 93 135 94 136 | operation | callers | ops | zds | tranquil | 95 137 |---|---:|---:|---:|---:| 96 - | apply/write | 10 | 10000 | 282 ops/s, p95 93.8 ms | 780 ops/s, p95 14.1 ms | 97 - | apply/write | 100 | 20000 | 1357 ops/s, p95 66.7 ms | 1007 ops/s, p95 109 ms | 98 - | get record CID | 10 | 10000 | 296k ops/s, p95 6 us | 455k ops/s, p95 43 us | 99 - | get record CID | 100 | 20000 | 294k ops/s, p95 405 us | 537k ops/s, p95 233 us | 100 - | list records | 10 | 10000 | 22k ops/s, p95 52 us | 36k ops/s, p95 310 us | 101 - | list records | 100 | 20000 | 23k ops/s, p95 49 us | 36k ops/s, p95 2.9 ms | 138 + | apply one record commit | 10 | 10000 | 259 ops/s, p95 98.9 ms | 780 ops/s, p95 14.1 ms | 139 + | apply one record commit | 100 | 20000 | 1223 ops/s, p95 67.5 ms | 1007 ops/s, p95 109 ms | 140 + | current record CID lookup | 10 | 10000 | 222k ops/s, p95 225 us | 455k ops/s, p95 43 us | 141 + | current record CID lookup | 100 | 20000 | 211k ops/s, p95 887 us | 537k ops/s, p95 233 us | 142 + 143 + Tranquil's metastore `list_records` benchmark is not in this table because ZDS 144 + does not currently expose an equivalent "list rkeys and CIDs only" benchmark. 145 + ZDS's `listRecords` follows the official Bluesky PDS read shape: join the record 146 + index to `repo_blocks`, decode DAG-CBOR, and return record values. 147 + 148 + ## official PDS probe 149 + 150 + The official-PDS probe lives at `bench/official-pds-records.bench.ts` and is 151 + run with: 152 + 153 + ```sh 154 + ATPROTO_REPO=/tmp/bluesky-atproto just bench official-pds 155 + ``` 156 + 157 + It seeds 1000 `app.bsky.feed.post` records through the official test network, 158 + then measures actor-store reads directly: 159 + 160 + | operation | official PDS | 161 + |---|---:| 162 + | apply one record commit | 486 ops/s, p99 3.9 ms | 163 + | current record CID lookup | 3.2k ops/s, p99 477 us | 164 + | full getRecord materialization | 3.1k ops/s, p99 469 us | 165 + | full listRecords, limit 50 | 639 ops/s, p99 2.3 ms | 166 + 167 + These are useful for the official-PDS column, but they should not be mixed with 168 + ZDS's caller-count rows. The matching ZDS single-caller rows are: 102 169 103 - Tranquil's read row is `get_record_cid`, so ZDS reports `get-cid` for that 104 - comparison. Full `com.atproto.repo.getRecord` is a separate local probe because 105 - it joins the record index to `repo_blocks`, decodes DAG-CBOR, and renders the 106 - XRPC JSON response shape. Tranquil's metastore bench intentionally measures a 107 - metadata lookup for the current CID, not record body materialization. 170 + | operation | zds | 171 + |---|---:| 172 + | apply one record commit | 642 ops/s, p95 2.9 ms | 173 + | current record CID lookup | 414k ops/s, p95 4 us | 174 + | full getRecord materialization | 48.6k ops/s, p95 28 us | 175 + | full listRecords, limit 50 | 21.6k ops/s, p95 60 us | 108 176 109 177 ## local probes 110 178 ··· 112 180 113 181 | operation | callers | ops | zds | 114 182 |---|---:|---:|---:| 115 - | full getRecord | 10 | 10000 | 40k ops/s, p95 352 us | 116 - | full getRecord | 100 | 20000 | 39k ops/s, p95 8.2 ms | 183 + | get record block | 10 | 10000 | 128k ops/s, p95 11 us | 184 + | get record block | 100 | 20000 | 121k ops/s, p95 15 us | 185 + | decode DAG-CBOR | 10 | 10000 | 40.6M ops/s, p95 below timer resolution | 186 + | decode DAG-CBOR | 100 | 20000 | 12.6M ops/s, p95 below timer resolution | 187 + | render record JSON | 10 | 10000 | 24.8M ops/s, p95 below timer resolution | 188 + | render record JSON | 100 | 20000 | 12.4M ops/s, p95 1 us | 189 + | full getRecord | 10 | 10000 | 38.4k ops/s, p95 173 us | 190 + | full getRecord | 100 | 20000 | 38.1k ops/s, p95 11.7 ms | 191 + | full listRecords, limit 50 | 10 | 10000 | 21.1k ops/s, p95 57 us | 192 + | full listRecords, limit 50 | 100 | 20000 | 19.5k ops/s, p95 78 us | 193 + 194 + The read-path split says SQLite serialization and row materialization dominate 195 + single-record reads. Decode/render is effectively invisible for tiny benchmark 196 + records; it needs a larger-record probe before we can extrapolate image-heavy or 197 + rich-record traffic. 117 198 118 199 ## write profile 119 200 ··· 139 220 - [`crates/tranquil-store/benches/blockstore.rs`](https://tangled.org/tranquil.farm/tranquil-pds/tree/main/crates/tranquil-store/benches/blockstore.rs) 140 221 - [`crates/tranquil-store/benches/eventlog.rs`](https://tangled.org/tranquil.farm/tranquil-pds/tree/main/crates/tranquil-store/benches/eventlog.rs) 141 222 142 - The first apples-to-apples comparison should cover: 223 + Official PDS source for matching probes: 224 + 225 + - [`packages/pds/src/actor-store/record/reader.ts`](https://github.com/bluesky-social/atproto/blob/main/packages/pds/src/actor-store/record/reader.ts) 226 + - [`packages/pds/src/api/com/atproto/repo/applyWrites.ts`](https://github.com/bluesky-social/atproto/blob/main/packages/pds/src/api/com/atproto/repo/applyWrites.ts) 227 + - [`packages/pds/src/actor-store/repo/sql-repo-reader.ts`](https://github.com/bluesky-social/atproto/blob/main/packages/pds/src/actor-store/repo/sql-repo-reader.ts) 228 + 229 + The next apples-to-apples comparison work should cover: 143 230 144 231 - single-repo write curve as record count grows 145 232 - multi-repo concurrent writes 146 - - listRecords latency under read concurrency 233 + - record-index list latency without record body materialization, across ZDS and 234 + Tranquil 235 + - official-PDS concurrency probe for the same read/write boundaries 147 236 - full repo export cost 148 237 - blob/block store throughput 149 238 150 - ZDS and Tranquil both report latency percentiles for the metastore-shaped 151 - benchmark. The remaining comparison gap is broader coverage of blockstore, 152 - eventlog, and full repo export behavior. 239 + ZDS and Tranquil both report latency percentiles for the metastore-shaped write 240 + and CID lookup rows. The remaining comparison gap is broader coverage of 241 + blockstore, eventlog, full repo export behavior, an official-PDS benchmark 242 + harness, and an explicit ZDS index-only list probe that matches Tranquil's 243 + `list_records`.
+24
bench/justfile
··· 33 33 get-cid callers="10" ops_per_caller="1000": 34 34 {{zig}} build bench -Doptimize=ReleaseFast -- --scenario get-cid --callers {{callers}} --ops-per-caller {{ops_per_caller}} 35 35 36 + # benchmark record index lookup plus repo_blocks byte fetch 37 + get-block callers="10" ops_per_caller="1000": 38 + {{zig}} build bench -Doptimize=ReleaseFast -- --scenario get-block --callers {{callers}} --ops-per-caller {{ops_per_caller}} 39 + 40 + # benchmark DAG-CBOR decode of a seeded record block without SQLite 41 + decode-record callers="10" ops_per_caller="1000": 42 + {{zig}} build bench -Doptimize=ReleaseFast -- --scenario decode-record --callers {{callers}} --ops-per-caller {{ops_per_caller}} 43 + 44 + # benchmark DAG-CBOR decode plus JSON response rendering without SQLite 45 + render-record callers="10" ops_per_caller="1000": 46 + {{zig}} build bench -Doptimize=ReleaseFast -- --scenario render-record --callers {{callers}} --ops-per-caller {{ops_per_caller}} 47 + 48 + # benchmark full com.atproto.repo.getRecord storage materialization 49 + get-record callers="10" ops_per_caller="1000": 50 + {{zig}} build bench -Doptimize=ReleaseFast -- --scenario get-record --callers {{callers}} --ops-per-caller {{ops_per_caller}} 51 + 52 + # benchmark full listRecords storage materialization 53 + list-records callers="10" ops_per_caller="1000": 54 + {{zig}} build bench -Doptimize=ReleaseFast -- --scenario list-records --callers {{callers}} --ops-per-caller {{ops_per_caller}} 55 + 56 + # run the official Bluesky PDS read probe from a local atproto checkout 57 + official-pds: 58 + ./bench/run-official-pds.sh 59 + 36 60 # profile applyWrites stage timing under write concurrency 37 61 write-profile callers="10" ops_per_caller="500": 38 62 {{zig}} build bench -Doptimize=ReleaseFast -- --scenario write-profile --callers {{callers}} --ops-per-caller {{ops_per_caller}}
+141 -1
bench/main.zig
··· 9 9 repo, 10 10 metastore, 11 11 get_cid, 12 + get_block, 13 + decode_record, 14 + render_record, 15 + get_record, 16 + list_records, 12 17 write_profile, 13 18 }; 14 19 ··· 96 101 .blob => (try benchBlob(allocator, state.account, options.blobs, options.blob_size)).print(), 97 102 .metastore => try benchMetastore(allocator, options), 98 103 .get_cid => try benchGetCid(allocator, options), 104 + .get_block => try benchGetBlock(allocator, options), 105 + .decode_record => try benchDecodeRecord(allocator, options), 106 + .render_record => try benchRenderRecord(allocator, options), 107 + .get_record => try benchGetRecord(allocator, options), 108 + .list_records => try benchListRecords(allocator, options), 99 109 .write_profile => try benchWriteProfile(allocator, options), 100 110 } 101 111 } ··· 400 410 concurrentResult("getRecordCid", level, total_ops, nowNs() - start, latencies).print(); 401 411 } 402 412 413 + fn benchGetBlock(allocator: std.mem.Allocator, options: Options) !void { 414 + const level: ConcurrencyLevel = .{ 415 + .callers = options.callers orelse 10, 416 + .ops_per_caller = options.ops_per_caller orelse 1000, 417 + }; 418 + var state = try initBench(allocator); 419 + defer state.deinit(); 420 + try seedIndexedRecords(allocator, state.account, 1000); 421 + 422 + const total_ops = level.callers * level.ops_per_caller; 423 + const latencies = try allocator.alloc(u64, total_ops); 424 + defer allocator.free(latencies); 425 + const threads = try allocator.alloc(std.Thread, level.callers); 426 + defer allocator.free(threads); 427 + 428 + const start = nowNs(); 429 + for (threads, 0..) |*thread, i| { 430 + thread.* = try std.Thread.spawn(.{}, getBlockWorker, .{ 431 + state.account.did, 432 + i, 433 + latencies[i * level.ops_per_caller ..][0..level.ops_per_caller], 434 + }); 435 + } 436 + for (threads) |thread| thread.join(); 437 + 438 + concurrentResult("getRecordBlock", level, total_ops, nowNs() - start, latencies).print(); 439 + } 440 + 441 + fn benchGetRecord(allocator: std.mem.Allocator, options: Options) !void { 442 + const level: ConcurrencyLevel = .{ 443 + .callers = options.callers orelse 10, 444 + .ops_per_caller = options.ops_per_caller orelse 1000, 445 + }; 446 + const result = try benchConcurrentGets(allocator, level); 447 + result.print(); 448 + } 449 + 450 + fn benchListRecords(allocator: std.mem.Allocator, options: Options) !void { 451 + const level: ConcurrencyLevel = .{ 452 + .callers = options.callers orelse 10, 453 + .ops_per_caller = options.ops_per_caller orelse 1000, 454 + }; 455 + const result = try benchConcurrentLists(allocator, level); 456 + result.print(); 457 + } 458 + 459 + fn benchDecodeRecord(allocator: std.mem.Allocator, options: Options) !void { 460 + try benchRecordBlockCpu(allocator, options, .decode); 461 + } 462 + 463 + fn benchRenderRecord(allocator: std.mem.Allocator, options: Options) !void { 464 + try benchRecordBlockCpu(allocator, options, .render); 465 + } 466 + 403 467 fn benchConcurrentLists(allocator: std.mem.Allocator, level: ConcurrencyLevel) !ConcurrentResult { 404 468 var state = try initBench(allocator); 405 469 defer state.deinit(); ··· 523 587 } 524 588 } 525 589 590 + fn getBlockWorker(did: []const u8, task_id: usize, latencies: []u64) !void { 591 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 592 + defer arena.deinit(); 593 + for (latencies, 0..) |*latency, i| { 594 + _ = arena.reset(.retain_capacity); 595 + const rec_idx = (task_id * 7 + i * 13) % 1000; 596 + var rkey_buf: [32]u8 = undefined; 597 + const rkey = try std.fmt.bufPrint(&rkey_buf, "rec{d:0>8}", .{rec_idx}); 598 + const start = nowNs(); 599 + const block = zds.storage.store.getRecordBlock(arena.allocator(), did, bench_collection, rkey) orelse return error.MissingRecord; 600 + if (block.cid.len == 0 or block.data.len == 0) return error.MissingRecord; 601 + latency.* = nowNs() - start; 602 + } 603 + } 604 + 526 605 fn listWorker(did: []const u8, latencies: []u64) !void { 527 606 var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 528 607 defer arena.deinit(); ··· 531 610 const start = nowNs(); 532 611 const records = try zds.storage.store.listRecords(arena.allocator(), did, bench_collection, 50); 533 612 if (records.len == 0) return error.MissingRecords; 613 + latency.* = nowNs() - start; 614 + } 615 + } 616 + 617 + const CpuPath = enum { decode, render }; 618 + 619 + fn benchRecordBlockCpu(allocator: std.mem.Allocator, options: Options, path: CpuPath) !void { 620 + const level: ConcurrencyLevel = .{ 621 + .callers = options.callers orelse 10, 622 + .ops_per_caller = options.ops_per_caller orelse 1000, 623 + }; 624 + var state = try initBench(allocator); 625 + defer state.deinit(); 626 + try seedIndexedRecords(allocator, state.account, 1); 627 + 628 + var arena = std.heap.ArenaAllocator.init(allocator); 629 + defer arena.deinit(); 630 + const block = zds.storage.store.getRecordBlock(arena.allocator(), state.account.did, bench_collection, "rec00000000") orelse return error.MissingRecord; 631 + 632 + const total_ops = level.callers * level.ops_per_caller; 633 + const latencies = try allocator.alloc(u64, total_ops); 634 + defer allocator.free(latencies); 635 + const threads = try allocator.alloc(std.Thread, level.callers); 636 + defer allocator.free(threads); 637 + 638 + const start = nowNs(); 639 + for (threads, 0..) |*thread, i| { 640 + thread.* = try std.Thread.spawn(.{}, recordBlockCpuWorker, .{ 641 + block.data, 642 + path, 643 + latencies[i * level.ops_per_caller ..][0..level.ops_per_caller], 644 + }); 645 + } 646 + for (threads) |thread| thread.join(); 647 + 648 + concurrentResult(switch (path) { 649 + .decode => "decodeDagCbor", 650 + .render => "renderRecordJson", 651 + }, level, total_ops, nowNs() - start, latencies).print(); 652 + } 653 + 654 + fn recordBlockCpuWorker(data: []const u8, path: CpuPath, latencies: []u64) !void { 655 + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); 656 + defer arena.deinit(); 657 + for (latencies) |*latency| { 658 + _ = arena.reset(.retain_capacity); 659 + const start = nowNs(); 660 + switch (path) { 661 + .decode => { 662 + _ = try zds.zat.cbor.decodeAll(arena.allocator(), data); 663 + }, 664 + .render => { 665 + const json = try zds.storage.store.recordJsonFromDagCbor(arena.allocator(), data); 666 + if (json.len == 0) return error.EmptyRecordJson; 667 + }, 668 + } 534 669 latency.* = nowNs() - start; 535 670 } 536 671 } ··· 649 784 if (std.mem.eql(u8, value, "repo")) return .repo; 650 785 if (std.mem.eql(u8, value, "metastore")) return .metastore; 651 786 if (std.mem.eql(u8, value, "get-cid")) return .get_cid; 787 + if (std.mem.eql(u8, value, "get-block")) return .get_block; 788 + if (std.mem.eql(u8, value, "decode-record")) return .decode_record; 789 + if (std.mem.eql(u8, value, "render-record")) return .render_record; 790 + if (std.mem.eql(u8, value, "get-record")) return .get_record; 791 + if (std.mem.eql(u8, value, "list-records")) return .list_records; 652 792 if (std.mem.eql(u8, value, "write-profile")) return .write_profile; 653 793 return error.UnknownScenario; 654 794 } 655 795 656 796 fn usage() void { 657 797 std.debug.print( 658 - \\usage: zds-bench [--scenario all|write|read|blob|repo|metastore|get-cid|write-profile] [--records N] [--blobs N] [--blob-size BYTES] [--callers N --ops-per-caller N] 798 + \\usage: zds-bench [--scenario all|write|read|blob|repo|metastore|get-cid|get-block|decode-record|render-record|get-record|list-records|write-profile] [--records N] [--blobs N] [--blob-size BYTES] [--callers N --ops-per-caller N] 659 799 \\ 660 800 , .{}); 661 801 }
+90
bench/official-pds-records.bench.ts
··· 1 + import { bench, beforeAll, afterAll } from 'vitest' 2 + import { AtUri } from '@atproto/syntax' 3 + import { TestNetworkNoAppView } from '@atproto/dev-env' 4 + import { prepareCreate } from './src/repo/index.js' 5 + 6 + const records: { uri: string; cid: string; rkey: string }[] = [] 7 + let network: TestNetworkNoAppView 8 + let did: string 9 + let writeIdx = 0 10 + 11 + beforeAll(async () => { 12 + network = await TestNetworkNoAppView.create({ 13 + dbPostgresSchema: 'zds_bench', 14 + }) 15 + const agent = network.pds.getAgent() 16 + await agent.createAccount({ 17 + email: 'bench@test.com', 18 + handle: 'bench.test', 19 + password: 'bench-pass', 20 + }) 21 + did = agent.assertDid 22 + 23 + for (let i = 0; i < 1000; i++) { 24 + const res = await agent.com.atproto.repo.createRecord({ 25 + repo: did, 26 + collection: 'app.bsky.feed.post', 27 + record: { 28 + $type: 'app.bsky.feed.post', 29 + text: `official pds bench record ${i}`, 30 + createdAt: `2026-05-23T00:${(i % 60).toString().padStart(2, '0')}:00.000Z`, 31 + }, 32 + }) 33 + records.push({ 34 + uri: res.data.uri, 35 + cid: res.data.cid, 36 + rkey: new AtUri(res.data.uri).rkey, 37 + }) 38 + } 39 + }, 120_000) 40 + 41 + afterAll(async () => { 42 + await network.close() 43 + }) 44 + 45 + bench('current record CID lookup', async () => { 46 + const rec = records[Math.floor(Math.random() * records.length)] 47 + const cid = await network.pds.ctx.actorStore.read(did, (store) => 48 + store.record.getCurrentRecordCid(new AtUri(rec.uri)), 49 + ) 50 + if (!cid) throw new Error('missing cid') 51 + }) 52 + 53 + bench('apply one record commit', async () => { 54 + const i = writeIdx++ 55 + const write = await prepareCreate({ 56 + did, 57 + collection: 'app.bsky.feed.post', 58 + validate: false, 59 + record: { 60 + $type: 'app.bsky.feed.post', 61 + text: `official pds write bench ${i}`, 62 + createdAt: `2026-05-23T01:${(i % 60).toString().padStart(2, '0')}:00.000Z`, 63 + }, 64 + }) 65 + const commit = await network.pds.ctx.actorStore.transact(did, async (actorTxn) => { 66 + const next = await actorTxn.repo.processWrites([write]) 67 + await network.pds.ctx.sequencer.sequenceCommit(did, next) 68 + return next 69 + }) 70 + await network.pds.ctx.accountManager.updateRepoRoot(did, commit.cid, commit.rev) 71 + }) 72 + 73 + bench('full getRecord materialization', async () => { 74 + const rec = records[Math.floor(Math.random() * records.length)] 75 + const record = await network.pds.ctx.actorStore.read(did, (store) => 76 + store.record.getRecord(new AtUri(rec.uri), rec.cid), 77 + ) 78 + if (!record) throw new Error('missing record') 79 + }) 80 + 81 + bench('full listRecords materialization, limit 50', async () => { 82 + const rows = await network.pds.ctx.actorStore.read(did, (store) => 83 + store.record.listRecordsForCollection({ 84 + collection: 'app.bsky.feed.post', 85 + limit: 50, 86 + reverse: false, 87 + }), 88 + ) 89 + if (rows.length === 0) throw new Error('missing records') 90 + })
+30
bench/run-official-pds.sh
··· 1 + #!/usr/bin/env sh 2 + set -eu 3 + 4 + ATPROTO_REPO="${ATPROTO_REPO:-/tmp/bluesky-atproto}" 5 + BENCH_SOURCE="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/official-pds-records.bench.ts" 6 + BENCH_TARGET="$ATPROTO_REPO/packages/pds/zds-records.bench.ts" 7 + CONFIG_TARGET="$ATPROTO_REPO/zds-vitest.config.ts" 8 + 9 + if [ ! -d "$ATPROTO_REPO/packages/pds" ]; then 10 + echo "missing official atproto checkout: $ATPROTO_REPO" >&2 11 + exit 1 12 + fi 13 + 14 + cp "$BENCH_SOURCE" "$BENCH_TARGET" 15 + cat > "$CONFIG_TARGET" <<'EOF' 16 + import { defineConfig } from 'vitest/config' 17 + 18 + export default defineConfig({ 19 + test: { 20 + include: ['packages/pds/zds-records.bench.ts'], 21 + benchmark: { 22 + include: ['packages/pds/zds-records.bench.ts'], 23 + }, 24 + }, 25 + }) 26 + EOF 27 + trap 'rm -f "$BENCH_TARGET" "$CONFIG_TARGET"' EXIT INT TERM 28 + 29 + cd "$ATPROTO_REPO" 30 + corepack pnpm exec vitest bench --run --config zds-vitest.config.ts
+79
docs/benchmarking.md
··· 1 + # benchmarking 2 + 3 + Benchmark notes should separate behavior domains and only compare equivalent 4 + work. If a measurement does not exist for the same unit of work, keep it out of 5 + the comparison table instead of filling the cell with a nearby but different 6 + operation. 7 + 8 + ## references 9 + 10 + - ZDS: `bench/main.zig` 11 + - Tranquil: `crates/tranquil-store/benches/metastore.rs` 12 + - Official PDS: `packages/pds/src/actor-store/record/reader.ts` 13 + 14 + ## current matrix 15 + 16 + The active three-way matrix is: 17 + 18 + - apply one record commit 19 + - current record CID lookup 20 + - record-index list 21 + - full record materialization 22 + - blob/block put and get 23 + - repo export 24 + - sync event delivery 25 + 26 + ZDS, Tranquil, and the official PDS now have matching single-caller numbers for 27 + apply-one-record and current-CID lookup. ZDS and Tranquil also have explicit 28 + 10/100 caller concurrency curves; the official probe should grow those workers 29 + before it is included in the concurrency matrix. 30 + 31 + ## read path 32 + 33 + ZDS follows the official PDS read shape for `getRecord` and `listRecords`: read 34 + the current record index row, fetch matching `repo_blocks` bytes, decode 35 + DAG-CBOR, and render JSON for the XRPC response. Tranquil's metastore 36 + `list_records` is a different unit of work because it returns only rkeys and 37 + CIDs. Keep those rows separate. 38 + 39 + Current ZDS read breakdown on this machine: 40 + 41 + | operation | callers | ops | result | 42 + |---|---:|---:|---:| 43 + | current CID lookup | 10 | 10000 | 222k ops/s, p95 225 us | 44 + | current CID lookup | 100 | 20000 | 211k ops/s, p95 887 us | 45 + | index + block fetch | 10 | 10000 | 128k ops/s, p95 11 us | 46 + | index + block fetch | 100 | 20000 | 121k ops/s, p95 15 us | 47 + | full getRecord | 10 | 10000 | 38.4k ops/s, p95 173 us | 48 + | full getRecord | 100 | 20000 | 38.1k ops/s, p95 11.7 ms | 49 + | full listRecords, limit 50 | 10 | 10000 | 21.1k ops/s, p95 57 us | 50 + | full listRecords, limit 50 | 100 | 20000 | 19.5k ops/s, p95 78 us | 51 + 52 + The official PDS probe on the same machine reports: 53 + 54 + | operation | result | 55 + |---|---:| 56 + | apply one record commit | 486 ops/s, p99 3.9 ms | 57 + | current CID lookup | 3.2k ops/s, p99 477 us | 58 + | full getRecord | 3.1k ops/s, p99 469 us | 59 + | full listRecords, limit 50 | 639 ops/s, p99 2.3 ms | 60 + 61 + For tiny records, DAG-CBOR decode and JSON rendering are below useful timer 62 + resolution. The important next read benchmark is larger records plus an 63 + index-only list probe matching Tranquil. 64 + 65 + ## traffic model 66 + 67 + For roughly 100 hosted accounts, useful benchmarks should mirror real traffic: 68 + 69 + - clients call `getSession`, preferences, profiles, timelines, and proxied 70 + appview routes repeatedly while open. 71 + - apps call `createRecord`, `applyWrites`, `getRecord`, and `listRecords`. 72 + - media posts call `uploadBlob` before record creation. 73 + - relays and tools call sync endpoints, repo export, and blob reads. 74 + - the landing page reads residents, record counts, recent records, and profile 75 + blobs. 76 + 77 + This points to two instrumentation tasks: per-route latency counters in ZDS 78 + logs, and benchmark scenarios that replay observed route mixes instead of only 79 + isolated microbenchmarks.
+29
src/storage/store.zig
··· 972 972 return allocator.dupe(u8, row.?.text(0)) catch null; 973 973 } 974 974 975 + pub const RecordBlock = struct { 976 + cid: []const u8, 977 + data: []const u8, 978 + }; 979 + 980 + pub fn getRecordBlock(allocator: std.mem.Allocator, did: []const u8, collection: []const u8, rkey: []const u8) ?RecordBlock { 981 + db_mutex.lockUncancelable(store_io); 982 + defer db_mutex.unlock(store_io); 983 + requireInitialized() catch return null; 984 + 985 + const row = conn.row( 986 + \\SELECT r.cid, rb.data 987 + \\FROM records r 988 + \\JOIN repo_blocks rb ON rb.did = r.did AND rb.cid = r.cid 989 + \\WHERE r.did = ? AND r.collection = ? AND r.rkey = ? 990 + , .{ did, collection, rkey }) catch return null; 991 + if (row == null) return null; 992 + defer row.?.deinit(); 993 + 994 + return .{ 995 + .cid = allocator.dupe(u8, row.?.text(0)) catch return null, 996 + .data = allocator.dupe(u8, row.?.nullableBlob(1) orelse return null) catch return null, 997 + }; 998 + } 999 + 975 1000 pub fn getByUri(uri: []const u8) ?Record { 976 1001 db_mutex.lockUncancelable(store_io); 977 1002 defer db_mutex.unlock(store_io); ··· 2413 2438 fn recordJsonFromBlock(allocator: std.mem.Allocator, data: []const u8) ![]const u8 { 2414 2439 const value = zat.cbor.decodeAll(allocator, data) catch return Error.InvalidDagCbor; 2415 2440 return cbor_json.writeAlloc(allocator, value); 2441 + } 2442 + 2443 + pub fn recordJsonFromDagCbor(allocator: std.mem.Allocator, data: []const u8) ![]const u8 { 2444 + return recordJsonFromBlock(allocator, data); 2416 2445 } 2417 2446 2418 2447 fn oauthRequestFromRow(row: zqlite.Row, allocator: std.mem.Allocator) !OAuthRequest {