Skip to content

Commit eafa7e5

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

4 files changed

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

0 commit comments

Comments
 (0)