Skip to content

Commit 549c543

Browse files
marcus-pousetteMarcus Pousette
authored andcommitted
Add wa-sqlite runtime performance benchmark
1 parent 5e8e544 commit 549c543

4 files changed

Lines changed: 408 additions & 1 deletion

File tree

packages/treecrdt-wa-sqlite/e2e/main.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import './src/closed-client';
77
import './src/drop-opfs';
88
import './src/lifecycle';
99
import './src/responsiveness';
10+
import './src/runtime-bench';
1011
import './src/sync';
1112

1213
const container = document.getElementById('root');

packages/treecrdt-wa-sqlite/e2e/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"preview:path": "vite preview --host --port 4172 --base /base-path/ --strictPort",
1313
"dev": "vite",
1414
"test:e2e": "pnpm -C ../../treecrdt-benchmark run build && pnpm -C ../../sync-protocol/protocol run build && pnpm -C ../../sync-protocol/material/sqlite run build && pnpm -C ../../treecrdt-engine-conformance run build && pnpm run build:wa-sqlite && playwright test",
15-
"bench": "pnpm -C ../../treecrdt-benchmark run build && pnpm run build && playwright test tests/bench-opfs.spec.ts tests/bench-sync.spec.ts --reporter=line"
15+
"bench": "pnpm -C ../../treecrdt-benchmark run build && pnpm run build && playwright test tests/bench-opfs.spec.ts tests/bench-sync.spec.ts tests/bench-runtime.spec.ts --reporter=line"
1616
},
1717
"dependencies": {
1818
"@treecrdt/benchmark": "workspace:*",
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
import { createTreecrdtClient, type TreecrdtClient } from '@treecrdt/wa-sqlite';
2+
import { makeOp, nodeIdFromInt, quantile, type BenchmarkResult } from '@treecrdt/benchmark';
3+
import type { Operation } from '@treecrdt/interface';
4+
import { replicaFromLabel } from './op-helpers.js';
5+
6+
type RuntimeChoice = 'direct' | 'dedicated-worker' | 'shared-worker';
7+
type StorageChoice = 'memory' | 'opfs';
8+
9+
type RuntimeMixedWriteBenchOptions = {
10+
runtime: RuntimeChoice;
11+
storage?: StorageChoice;
12+
docId?: string;
13+
filename?: string;
14+
prefillOps?: number;
15+
remoteOps?: number;
16+
remoteBatchSize?: number;
17+
localWrites?: number;
18+
readSamples?: number;
19+
readIntervalMs?: number;
20+
localWriteIntervalMs?: number;
21+
yieldBetweenRemoteBatchesMs?: number;
22+
};
23+
24+
type RuntimeMixedWriteBenchResult = BenchmarkResult & {
25+
extra: {
26+
runtime: RuntimeChoice;
27+
storage: StorageChoice;
28+
prefillOps: number;
29+
remoteOps: number;
30+
remoteBatchSize: number;
31+
remoteBatchCount: number;
32+
remoteBatchDurationsMs: number[];
33+
remoteBatchMinMs: number;
34+
remoteBatchP50Ms: number;
35+
remoteBatchP95Ms: number;
36+
remoteBatchMaxMs: number;
37+
localWrites: number;
38+
localWriteDurationsMs: number[];
39+
localWriteMinMs: number;
40+
localWriteP50Ms: number;
41+
localWriteP95Ms: number;
42+
localWriteMaxMs: number;
43+
readSamples: number;
44+
readDurationsMs: number[];
45+
readMinMs: number;
46+
readP50Ms: number;
47+
readP95Ms: number;
48+
readMaxMs: number;
49+
localWriteIntervalMs: number;
50+
readIntervalMs: number;
51+
yieldBetweenRemoteBatchesMs: number;
52+
expectedChildCount: number;
53+
finalChildCount: number;
54+
};
55+
};
56+
57+
const ROOT_NODE = '0'.repeat(32);
58+
59+
function orderKeyFromOffset(offset: number): Uint8Array {
60+
const bytes = new Uint8Array(4);
61+
new DataView(bytes.buffer).setUint32(0, offset + 1, false);
62+
return bytes;
63+
}
64+
65+
async function sleep(ms: number): Promise<void> {
66+
await new Promise<void>((resolve) => setTimeout(resolve, ms));
67+
}
68+
69+
function deferred() {
70+
let resolve!: () => void;
71+
const promise = new Promise<void>((done) => {
72+
resolve = done;
73+
});
74+
return { promise, resolve };
75+
}
76+
77+
function sampleBatchIndex(sample: number, sampleCount: number, batchCount: number): number {
78+
if (sampleCount <= 1 || batchCount <= 1) return 0;
79+
return Math.round((sample * (batchCount - 1)) / (sampleCount - 1));
80+
}
81+
82+
function errorMessage(error: unknown): string {
83+
return error instanceof Error ? error.message : String(error);
84+
}
85+
86+
function summarizeDurations(durationsMs: number[]) {
87+
if (durationsMs.length === 0) {
88+
return {
89+
minMs: 0,
90+
p50Ms: 0,
91+
p95Ms: 0,
92+
maxMs: 0,
93+
};
94+
}
95+
return {
96+
minMs: Math.min(...durationsMs),
97+
p50Ms: quantile(durationsMs, 0.5),
98+
p95Ms: quantile(durationsMs, 0.95),
99+
maxMs: Math.max(...durationsMs),
100+
};
101+
}
102+
103+
function makeInsertOps(opts: {
104+
replica: Uint8Array;
105+
count: number;
106+
startCounter: number;
107+
startLamport?: number;
108+
startNodeInt: number;
109+
startOrderOffset?: number;
110+
}): Operation[] {
111+
return Array.from({ length: opts.count }, (_, offset) => {
112+
const counter = opts.startCounter + offset;
113+
const lamport = (opts.startLamport ?? opts.startCounter) + offset;
114+
const orderOffset = (opts.startOrderOffset ?? opts.startNodeInt) + offset;
115+
return makeOp(opts.replica, counter, lamport, {
116+
type: 'insert',
117+
parent: ROOT_NODE,
118+
node: nodeIdFromInt(opts.startNodeInt + offset),
119+
orderKey: orderKeyFromOffset(orderOffset),
120+
});
121+
});
122+
}
123+
124+
export async function runRuntimeMixedWriteBench(
125+
opts: RuntimeMixedWriteBenchOptions,
126+
): Promise<RuntimeMixedWriteBenchResult> {
127+
const runtime = opts.runtime;
128+
const storage = opts.storage ?? 'opfs';
129+
const prefillOps = opts.prefillOps ?? 0;
130+
const remoteOps = opts.remoteOps ?? 2_000;
131+
const remoteBatchSize = opts.remoteBatchSize ?? 500;
132+
const localWrites = opts.localWrites ?? 20;
133+
const readSamples = opts.readSamples ?? 20;
134+
const readIntervalMs = opts.readIntervalMs ?? 0;
135+
const localWriteIntervalMs = opts.localWriteIntervalMs ?? 5;
136+
const yieldBetweenRemoteBatchesMs = opts.yieldBetweenRemoteBatchesMs ?? 1;
137+
const remoteBatchCount = Math.ceil(remoteOps / remoteBatchSize);
138+
const docId = opts.docId ?? `runtime-mixed-${storage}-${runtime}-${crypto.randomUUID()}`;
139+
const filename =
140+
opts.filename ?? `/runtime-mixed-${storage}-${runtime}-${crypto.randomUUID()}.db`;
141+
const remoteReplica = replicaFromLabel(`runtime-remote-${storage}-${runtime}`);
142+
const localReplica = replicaFromLabel(`runtime-local-${storage}-${runtime}`);
143+
const expectedChildCount = prefillOps + remoteOps + localWrites;
144+
const client = await createTreecrdtClient({
145+
docId,
146+
storage:
147+
storage === 'opfs' ? { type: 'opfs', filename, fallback: 'throw' } : { type: 'memory' },
148+
runtime: { type: runtime },
149+
});
150+
151+
try {
152+
if (prefillOps > 0) {
153+
try {
154+
await client.ops.appendMany(
155+
makeInsertOps({
156+
replica: replicaFromLabel(`runtime-prefill-${storage}-${runtime}`),
157+
count: prefillOps,
158+
startCounter: 1,
159+
startNodeInt: 1,
160+
startOrderOffset: 1,
161+
}),
162+
);
163+
} catch (error) {
164+
throw new Error(`runtime mixed benchmark prefill failed: ${errorMessage(error)}`);
165+
}
166+
}
167+
168+
const remoteBatchDurationsMs: number[] = [];
169+
const localWriteDurationsMs: number[] = [];
170+
const readDurationsMs: number[] = [];
171+
const batchStarted = Array.from({ length: remoteBatchCount }, deferred);
172+
let finalChildCount = 0;
173+
const start = performance.now();
174+
175+
const runRemoteIngest = async () => {
176+
for (let batchIndex = 0; batchIndex < remoteBatchCount; batchIndex += 1) {
177+
const batchStartCounter = batchIndex * remoteBatchSize + 1;
178+
const remaining = remoteOps - batchIndex * remoteBatchSize;
179+
const batchOps = Math.min(remoteBatchSize, remaining);
180+
const batch = makeInsertOps({
181+
replica: remoteReplica,
182+
count: batchOps,
183+
startCounter: batchStartCounter,
184+
startLamport: prefillOps + batchStartCounter,
185+
startNodeInt: 100_000 + batchIndex * remoteBatchSize,
186+
startOrderOffset: 100_000 + batchIndex * remoteBatchSize,
187+
});
188+
189+
// Release the samples assigned to this batch only after the background call is about to be
190+
// queued. This spreads foreground reads and local writes across the full ingest window.
191+
batchStarted[batchIndex]!.resolve();
192+
const batchStart = performance.now();
193+
try {
194+
await client.ops.appendMany(batch, { priority: 'background' });
195+
} catch (error) {
196+
throw new Error(`runtime mixed benchmark remote ingest failed: ${errorMessage(error)}`);
197+
}
198+
remoteBatchDurationsMs.push(performance.now() - batchStart);
199+
200+
if (batchIndex < remoteBatchCount - 1 && yieldBetweenRemoteBatchesMs >= 0) {
201+
await sleep(yieldBetweenRemoteBatchesMs);
202+
}
203+
}
204+
};
205+
206+
const runLocalWrites = async () => {
207+
for (let i = 0; i < localWrites; i += 1) {
208+
await batchStarted[sampleBatchIndex(i, localWrites, remoteBatchCount)]!.promise;
209+
if (i > 0 && localWriteIntervalMs > 0) await sleep(localWriteIntervalMs);
210+
const writeStart = performance.now();
211+
try {
212+
await client.local.insert(
213+
localReplica,
214+
ROOT_NODE,
215+
nodeIdFromInt(200_000 + i),
216+
{ type: 'last' },
217+
null,
218+
);
219+
} catch (error) {
220+
throw new Error(`runtime mixed benchmark local write failed: ${errorMessage(error)}`);
221+
}
222+
localWriteDurationsMs.push(performance.now() - writeStart);
223+
}
224+
};
225+
226+
const runReads = async () => {
227+
for (let i = 0; i < readSamples; i += 1) {
228+
await batchStarted[sampleBatchIndex(i, readSamples, remoteBatchCount)]!.promise;
229+
if (i > 0 && readIntervalMs > 0) await sleep(readIntervalMs);
230+
const readStart = performance.now();
231+
try {
232+
finalChildCount = (await client.tree.children(ROOT_NODE)).length;
233+
} catch (error) {
234+
throw new Error(`runtime mixed benchmark read sample failed: ${errorMessage(error)}`);
235+
}
236+
readDurationsMs.push(performance.now() - readStart);
237+
}
238+
};
239+
240+
await Promise.all([runRemoteIngest(), runLocalWrites(), runReads()]);
241+
finalChildCount = (await client.tree.children(ROOT_NODE)).length;
242+
if (finalChildCount !== expectedChildCount) {
243+
throw new Error(
244+
`runtime mixed benchmark child count mismatch: expected ${expectedChildCount}, got ${finalChildCount}`,
245+
);
246+
}
247+
248+
const durationMs = performance.now() - start;
249+
const totalOps = remoteOps + localWrites;
250+
const remoteBatchSummary = summarizeDurations(remoteBatchDurationsMs);
251+
const localWriteSummary = summarizeDurations(localWriteDurationsMs);
252+
const readSummary = summarizeDurations(readDurationsMs);
253+
254+
return {
255+
name: `runtime-mixed-sync-ingest-local-writes-${storage}-${runtime}-prefill-${prefillOps}`,
256+
totalOps,
257+
durationMs,
258+
opsPerSec: durationMs > 0 ? (totalOps / durationMs) * 1000 : Infinity,
259+
extra: {
260+
runtime,
261+
storage,
262+
prefillOps,
263+
remoteOps,
264+
remoteBatchSize,
265+
remoteBatchCount,
266+
remoteBatchDurationsMs,
267+
remoteBatchMinMs: remoteBatchSummary.minMs,
268+
remoteBatchP50Ms: remoteBatchSummary.p50Ms,
269+
remoteBatchP95Ms: remoteBatchSummary.p95Ms,
270+
remoteBatchMaxMs: remoteBatchSummary.maxMs,
271+
localWrites,
272+
localWriteDurationsMs,
273+
localWriteMinMs: localWriteSummary.minMs,
274+
localWriteP50Ms: localWriteSummary.p50Ms,
275+
localWriteP95Ms: localWriteSummary.p95Ms,
276+
localWriteMaxMs: localWriteSummary.maxMs,
277+
readSamples,
278+
readDurationsMs,
279+
readMinMs: readSummary.minMs,
280+
readP50Ms: readSummary.p50Ms,
281+
readP95Ms: readSummary.p95Ms,
282+
readMaxMs: readSummary.maxMs,
283+
localWriteIntervalMs,
284+
readIntervalMs,
285+
yieldBetweenRemoteBatchesMs,
286+
expectedChildCount,
287+
finalChildCount,
288+
},
289+
};
290+
} finally {
291+
await client.drop();
292+
}
293+
}
294+
295+
declare global {
296+
interface Window {
297+
__runTreecrdtRuntimeMixedWriteBench?: typeof runRuntimeMixedWriteBench;
298+
}
299+
}
300+
301+
if (typeof window !== 'undefined') {
302+
window.__runTreecrdtRuntimeMixedWriteBench = runRuntimeMixedWriteBench;
303+
}

0 commit comments

Comments
 (0)