Skip to content

Commit f932c5a

Browse files
committed
benchmarks
1 parent c698ad9 commit f932c5a

32 files changed

Lines changed: 4682 additions & 2 deletions

API.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@ const yhub = await createYHub(config)
277277
| `redis.socket` | `object` | no | Custom socket options merged into the Redis client socket config. See [node-redis socket options](https://github.com/redis/node-redis/blob/master/docs/client-configuration.md#socket-options) for available options. |
278278
| `postgres` | `string` | yes | PostgreSQL connection string |
279279
| `persistence` | `PersistencePlugin[]` | yes | One or more storage plugins (e.g. `S3PersistenceV1`). At least one is required. |
280+
| `computePoolSize` | `number` | no | Worker threads in the compute pool for CPU-intensive Yjs work (merging, state vectors, changesets). Default: number of cpus - 1. Set this explicitly when the process is restricted to a subset of cores — `os.cpus().length` does not reflect `taskset` or cgroup limits. |
280281
| `server` | `object \| null` | no | HTTP/WebSocket server config. Set to `null` to run without a server (worker/script mode). |
281282
| `server.port` | `number` | yes* | Port to listen on |
282283
| `server.auth` | `AuthPlugin` | yes* | Auth plugin created with `createAuthPlugin` |

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,3 +572,10 @@ See [API.md](./API.md) for the REST API documentation including:
572572
- History and timestamps APIs
573573
- Rollback functionality
574574
- Webhook configuration
575+
576+
## Benchmarks
577+
578+
See [benchmarks/README.md](./benchmarks/README.md) for the cost model — what each
579+
operation a y/hub connection performs actually costs, and how it scales — and
580+
[benchmarks/RESULTS.md](./benchmarks/RESULTS.md) for measurements. Run them with
581+
`cd benchmarks && npm start`.

benchmarks/.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
fixtures
2+
3+
# Customer-supplied editing trace. Real user data — must never be committed.
4+
# TRACE-FORMAT.md documents its format and is checked in; the trace itself is not.
5+
custom-trace.anyenc

benchmarks/README.md

Lines changed: 565 additions & 0 deletions
Large diffs are not rendered by default.

benchmarks/RESULTS.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# y/hub benchmark results
2+
3+
_Generated by `cd benchmarks && npm start`. Do not edit by hand._
4+
5+
What each benchmark measures and why is in [README.md](./README.md).
6+
7+
## Environment
8+
9+
| key | value |
10+
|---------------------|--------------------------------------------------|
11+
| date | 2026-07-31 21:57:14 UTC |
12+
| commit | c698ad9 |
13+
| @y/hub | 0.3.0 |
14+
| node | v24.13.1 |
15+
| platform | Linux 7.1.5-200.fc44.x86_64 x64 |
16+
| cpu | 13th Gen Intel(R) Core(TM) i7-1370P (20 threads) |
17+
| memory | 62.4 GiB |
18+
| disk | 185.0 GiB free |
19+
| computePoolSize | 4 |
20+
| taskDebounce (ms) | 10000 |
21+
| taskConcurrency | 5 |
22+
| connections swept | 1, 10, 100, 500, 1500 |
23+
| docSizes swept (MB) | 0, 4, 20 |
24+
| observers swept | 1, 10, 100, 500, 1500 |
25+
26+
S3 is MinIO on localhost, so sync and compaction times are **lower bounds**
27+
real S3 or R2 latency has to be layered onto the `s3Ops` counts analytically.
28+
29+
## Y4: What does an observer cost?
30+
31+
### Y4.3 N clients emitting presence at 1 Hz, no document edits
32+
33+
| state | N | ticks | frames in | frames out | time (ms) | serverCpu (ms) | cpu utilisation (%) | µs cpu per presence tick | loopDelay p99 (ms) | dropped |
34+
|-------|-------|-------|-----------|------------|-----------|----------------|---------------------|--------------------------|--------------------|---------|
35+
| small | 1 | 5 | 5 | 5 | 6 005 | 48.3 | 0.80 | 9 654 | 10.4 | 0 |
36+
| small | 10 | 5 | 50 | 97 | 6 007 | 128.3 | 2.14 | 2 566 | 10.8 | 0 |
37+
| small | 100 | 5 | 500 | 1 502 | 6 032 | 752.1 | 12.5 | 1 504 | 23.6 | 0 |
38+
| small | 500 | 5 | 2 500 | 7 502 | 6 093 | 4 730 | 77.6 | 1 892 | 676.3 | 0 |
39+
| small | 1 500 | 5 | 7 500 | 6 425 | 6 263 | 8 532 | 136.2 | 1 138 | 5 646 | 0 |
40+
| large | 1 | 5 | 5 | 5 | 6 005 | 73.4 | 1.22 | 14 679 | 10.6 | 0 |
41+
| large | 10 | 5 | 50 | 92 | 6 007 | 105.9 | 1.76 | 2 118 | 10.6 | 0 |
42+
| large | 100 | 5 | 500 | 1 502 | 6 022 | 688.0 | 11.4 | 1 376 | 39.9 | 0 |
43+
| large | 500 | 5 | 2 500 | 3 502 | 6 106 | 8 264 | 135.3 | 3 306 | 2 171 | 0 |
44+
| large | 1 500 | 5 | 7 500 | 5 490 | 6 316 | 13 881 | 219.8 | 1 851 | 8 892 | 0 |
45+
46+
Awareness alone: the batch is largest relative to the payload, and the per-subscriber JSON constant is least diluted by anything else. `cpu utilisation` is the fraction of one core the relay thread is spending purely on presence — at 100% the event loop is saturated and every other operation on that pod queues behind it.
47+
48+
## Derived constants
49+
50+
The point of the exercise: plug your own workload numbers into the cost model
51+
from [README.md](./README.md#cost-model) using the constants below, instead of
52+
running another benchmark.
53+
54+
| symbol | meaning | value | from |
55+
|-------------------------------|------------------------------------------------------------|----------------|------|
56+
| t_sv | state-vector scan rate, paid once per sync | _not measured_ | Y1.2 |
57+
| t_merge | binary merge rate (sync, fan-out) | _not measured_ | Y1.1 |
58+
| t_docmerge | document merge rate (compaction, gc) | _not measured_ | Y1.4 |
59+
| k_doc | Y.Doc expansion factor, retained bytes per serialized byte | _not measured_ | Y1.4 |
60+
| t_aw (bare cursor) | awareness merge, per participant state, per subscriber | _not measured_ | Y1.5 |
61+
| t_aw (full presence) | the same with a realistic presence payload | _not measured_ | Y1.5 |
62+
| c_conn | server memory per idle connection | _not measured_ | Y2.2 |
63+
| c_room | server memory per subscribed room | _not measured_ | Y2.1 |
64+
| k_sync | peak server memory per concurrent sync, per MB of document | _not measured_ | Y2.4 |
65+
| per-observer relay | server cpu per update per observer, documents only | _not measured_ | Y4.1 |
66+
| per-observer relay + presence | the same with awareness enabled | _not measured_ | Y4.2 |
67+
| write amplification | bytes written to S3 per byte of final document | _not measured_ | Y5.1 |
68+
69+
```
70+
server_mem ≈ B_srv + n_conn·c_conn + n_room·c_room + concurrent_syncs·k_sync·S
71+
server_cpu/s ≈ Σ_r [ u_r·n_r·(t_merge_small + t_encode) + a_r·n_r·t_aw(n_r) ]
72+
+ j·( t_fetch(S) + t_sv(S) + t_merge(S, n_pending) )
73+
+ (Σ_r u_r)·t_contentids
74+
worker_peak ≈ k_wrk·(S_gc + S_nongc)·taskConcurrency
75+
```
76+
77+
## Predicted vs. measured tiers
78+
79+
Section B of the README grades every operation from reading the source. This is
80+
whether the measurements agree. A tier describes how cost scales with the thing
81+
that grows, not how slow one call is — so `confirmed` means the *shape* held,
82+
not that the operation is cheap. Rows 6, 7 and 10 have no pass/fail: their
83+
grades are about avoidable repetition rather than a curve, and the `finding`
84+
column reports the size of that repetition directly.
85+
86+
| # | operation | predicted | finding | verdict | evidence |
87+
|----|----------------------------------------------|-------------------|----------------|---------|------------------|
88+
| 1 | Websocket upgrade + auth | A | _not measured_ || Y2.2 |
89+
| 2 | Room subscription | A | _not measured_ || Y2.1 vs Y2.2 |
90+
| 3 | Idle connection | A | _not measured_ || Y2.3 |
91+
| 4 | Write an update | B | _not measured_ || Y3.1 |
92+
| 5 | Deliver a batch to one subscriber | B | _not measured_ || Y4.1 |
93+
| 6 | Deliver an awareness batch to one subscriber | B, large constant | _not measured_ || Y1.5, Y4.2, Y4.3 |
94+
| 7 | Fetch the persisted document | C | _not measured_ || Y2.6 |
95+
| 8 | Compute the state vector | C | _not measured_ || Y1.2 |
96+
| 9 | Merge pending updates into the document | C | _not measured_ || Y1.1, Y2.6 |
97+
| 10 | Many clients syncing one document at once | D | _not measured_ || Y2.4, Y2.5, Y2.6 |
98+
| 11 | Compaction of one document | E | _not measured_ || Y5.1, Y5.3 |

benchmarks/TRACE-FORMAT.md

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
# `custom-trace.anyenc` — incremental editing trace
2+
3+
A replayable, per-edit sequence of Yjs updates: one real document plus the individual
4+
edits that produced it, in order, each tagged with who made it and when. `Y7` in this
5+
benchmark suite replays it to measure apply-update throughput, incremental sync,
6+
propagation and compaction against a workload nobody designed to be convenient.
7+
8+
Drop a trace at `benchmarks/custom-trace.anyenc` and `Y7` runs; without one it skips
9+
itself and the rest of the suite is unaffected.
10+
11+
> **The trace file is gitignored and must stay that way — it contains real document
12+
> content.** This format document is checked in so a trace can be produced without one
13+
> to copy from.
14+
15+
- **Encoding:** a single [`lib0`](https://github.com/dmonad/lib0) `encoding.writeAny` value
16+
- **Updates:** Yjs **v1** (`Y.applyUpdate` / `Y.encodeStateAsUpdate`, not V2)
17+
- **Versions:** produce it with the versions yhub uses — `@y/y@14`, `lib0@1`.
18+
`@y/y` (Yjs v14) is **not** wire-compatible with `yjs@13` for these updates.
19+
20+
## Decoding
21+
22+
```js
23+
import * as fs from 'node:fs'
24+
import * as decoding from 'lib0/decoding'
25+
import * as Y from '@y/y'
26+
27+
const trace = decoding.readAny(decoding.createDecoder(new Uint8Array(fs.readFileSync('custom-trace.anyenc'))))
28+
29+
const ydoc = new Y.Doc({ gc: trace.gc }) // gc: false — the trace preserves deleted content
30+
for (const u of trace.updates) {
31+
Y.applyUpdate(ydoc, u.update)
32+
}
33+
```
34+
35+
`benchmarks/src/trace.js` is the suite's loader; `splitTrace()` returns the baseline
36+
update and the incremental edits separately, which is usually what you want.
37+
38+
## Structure
39+
40+
```
41+
{
42+
type: 'yhub:editing-trace:v1',
43+
updateFormat: 'yjs-v1', // Y.applyUpdate / Y.encodeStateAsUpdate (not V2)
44+
gc: false, // updates were carved from the non-gc doc
45+
source: {
46+
documentId, org, docid, branch,
47+
exportedAt // ISO string, when the trace was built
48+
},
49+
users: [ string ], // distinct authors, in first-edit order
50+
totalBytes: number, // sum of all update byte lengths
51+
updates: [ // chronological, apply in array order
52+
{
53+
update: Uint8Array, // a Yjs v1 update
54+
time: number, // ms epoch, when the edit happened
55+
user: string, // whatever your auth plugin puts in `userid`
56+
kind: 'insert' | 'delete' | 'mixed',
57+
ranges: number // how many attributed id-ranges this step covers
58+
}
59+
]
60+
}
61+
```
62+
63+
A document that was bulk-imported and then edited by hand typically has a very large
64+
update `0` (the import) and many tiny ones after it. For benchmarks that care about
65+
incremental work, load update `0` as the baseline and treat `updates.slice(1)` as the
66+
workload — that is exactly what `splitTrace()` gives you.
67+
68+
---
69+
70+
# Creating your own trace
71+
72+
## Where the edit history comes from
73+
74+
You do not need to have recorded anything in advance. **yhub already stores the edit
75+
history as attributions**, and a trace is that history turned back into updates.
76+
77+
For every inbound update, the server derives the id-ranges it touched and writes an
78+
attribution for each (`createContentMapFromParams`, `src/server.js:23-31`):
79+
80+
| side | attributes yhub writes |
81+
|---|---|
82+
| inserted ranges | `insert` = userid, `insertAt` = ms epoch, plus `insert:<k>` for each custom attribution |
83+
| deleted ranges | `delete` = userid, `deleteAt` = ms epoch, plus `delete:<k>` for each custom attribution |
84+
85+
These live in the **contentmap**, persisted next to the document. Decoded, it is:
86+
87+
```js
88+
const contentMap = Y.decodeContentMap(bin) // { inserts: IdMap, deletes: IdMap }
89+
90+
contentMap.inserts.forEach((range, client) => {
91+
// range = { clock: number, len: number, attrs: Array<{ name: string, val: any }> }
92+
// e.g. attrs = [ { name: 'insert', val: 'user_42' }, { name: 'insertAt', val: 1700000001000 } ]
93+
})
94+
```
95+
96+
So the document knows, for every struct in it, **who wrote it and at what millisecond**.
97+
Grouping by `(user, timestamp)` recovers the individual editing steps, and each group can
98+
be carved back out of the full update.
99+
100+
## The algorithm
101+
102+
1. **Read the non-gc document and its contentmap.** It must be the *non-gc* document —
103+
the trace has to reproduce deleted content, and a gc'd document no longer contains it.
104+
2. **Enumerate the steps.** Walk both `IdMap`s and collect the distinct `(user, time)`
105+
pairs from `insert`/`insertAt` and `delete`/`deleteAt`. One pair is one editing step;
106+
a step that both inserts and deletes at the same instant was a single transaction —
107+
overwriting a cell, for example.
108+
3. **Carve each step.** Filter the contentmap down to just that step's ranges, convert to
109+
an `IdSet` pair, and intersect the full update with it:
110+
111+
```js
112+
const stepMap = Y.filterContentMap(
113+
contentMap,
114+
attrs => attr(attrs, 'insert') === user && attr(attrs, 'insertAt') === time,
115+
attrs => attr(attrs, 'delete') === user && attr(attrs, 'deleteAt') === time
116+
)
117+
const contentIds = Y.createContentIdsFromContentMap(stepMap)
118+
const update = Y.intersectUpdateWithContentIds(fullUpdate, contentIds)
119+
```
120+
121+
`intersectUpdateWithContentIds` extracts exactly those structs and delete-set entries
122+
from the full document update — no document is rebuilt and no content is invented.
123+
4. **Sort by timestamp**, then **verify**: replaying every carved update into a fresh
124+
`Y.Doc({ gc: false })` must produce a byte-identical `encodeStateAsUpdate` to the
125+
original. If it does not, the trace is a plausible-looking fiction — throw it away
126+
rather than benchmark against it.
127+
128+
## Option A — from a live yhub deployment (easiest)
129+
130+
[`tools/build-trace.js`](./tools/build-trace.js) does all of the above:
131+
132+
```sh
133+
cd benchmarks
134+
node --max-old-space-size=8192 \
135+
--env-file-if-exists=../.env \
136+
tools/build-trace.js --org <org> --docid <docid> [--branch main] \
137+
[--bucket <s3-bucket>] [--redis-prefix <prefix>] \
138+
[--out custom-trace.anyenc]
139+
```
140+
141+
It connects with `server: null, worker: null` — a read-only hub that touches nothing —
142+
calls `getDoc(room, { nongc: true, contentmap: true })`, reconstructs, verifies, and
143+
writes. It refuses to write an unverified trace, and it tells you if the document is
144+
empty or unattributed rather than emitting a silently useless file.
145+
146+
Point `--bucket` and `--redis-prefix` at the deployment you mean. Reading the wrong
147+
bucket looks exactly like an empty document.
148+
149+
## Option B — from a yhub export
150+
151+
If you have an export rather than database access, the same two inputs are in it:
152+
153+
| file | any-decoded value |
154+
|---|---|
155+
| `storage_assets/id:ydoc:v1_…_0_…` | `{ type: 'asset:ydoc:v1', update }`**non-gc** document (the `0` is the gc flag) |
156+
| `storage_assets/id:ydoc:v1_…_1_…` | the gc'd document — *not* what you want |
157+
| `storage_assets/id:contentmap:v1_…` | `{ type: 'asset:contentmap:v1', contentmap }``Y.decodeContentMap` |
158+
| `postgres_gc.bin`, `postgres_non_gc.bin` | `{ type: 'asset:retrievable:v1', plugin: … }` — pointers only; the payload is in `storage_assets/` |
159+
160+
`assetIdToString` encodes the gc flag as `${gc ? 1 : 0}`, hence `…/main/0/…` for non-gc.
161+
Decode both with `lib0/decoding.readAny`, then follow the algorithm above from step 2 —
162+
the rest of `build-trace.js` applies unchanged.
163+
164+
## Things that will bite you
165+
166+
- **No attributions, no trace.** yhub only attributes writes it received itself. Content
167+
loaded with `unsafePersistDoc`, or written before attribution existed, carries none —
168+
`build-trace.js` will tell you the document has content but no history.
169+
- **Clock ordering.** A step must not depend on structs that arrive later. Sorting by
170+
timestamp is normally enough; the verification step in 4 is what actually proves it.
171+
- **Timestamp granularity.** Two edits by the same user in the same millisecond collapse
172+
into one step. That is usually correct — it was one transaction — but it does mean the
173+
step count is a lower bound on the number of user actions.
174+
- **Privacy.** The trace contains the document's real content, and `user` values are
175+
whatever your auth plugin puts in `userid`. Treat it as production data: it is
176+
gitignored here, and it should not leave wherever you are entitled to keep it.

benchmarks/package.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "@y/hub-benchmarks",
3+
"version": "0.0.0",
4+
"private": true,
5+
"description": "Benchmark suite for @y/hub. Not published.",
6+
"type": "module",
7+
"scripts": {
8+
"start": "node --expose-gc --max-old-space-size=8192 --env-file-if-exists=../.env --env-file-if-exists=../.env.testing src/index.js",
9+
"fixtures": "node --expose-gc --max-old-space-size=8192 src/fixtures.js"
10+
},
11+
"license": "AGPL-3.0 OR PROPRIETARY"
12+
}

0 commit comments

Comments
 (0)