Skip to content

Commit 1199c7a

Browse files
committed
Set up proper benchmarks
1 parent a93b6d0 commit 1199c7a

11 files changed

Lines changed: 1178 additions & 23 deletions

benchmark/concurrent-map-worker.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Web Worker script for the HTML concurrent benchmark.
2+
// Loaded as an ES module via new Worker('/benchmark/concurrent-map-worker.js', { type: 'module' }).
3+
import { ShareableMap } from '/src/index.ts';
4+
5+
let map;
6+
let config;
7+
8+
function randomValue() {
9+
return Math.random().toString(36).substring(2, 15);
10+
}
11+
12+
self.onmessage = function (event) {
13+
const msg = event.data;
14+
15+
if (msg.cmd === 'setup') {
16+
config = msg;
17+
map = ShareableMap.fromTransferableState(config.state);
18+
self.postMessage({ status: 'ready' });
19+
return;
20+
}
21+
22+
if (msg.cmd === 'start') {
23+
const { role, opsCount, initialKeyCount } = config;
24+
const start = performance.now();
25+
let newKeyCounter = 0;
26+
27+
for (let i = 0; i < opsCount; i++) {
28+
const rand = Math.random();
29+
const existingKey = `entry-${Math.floor(Math.random() * initialKeyCount)}`;
30+
31+
if (role === 'reader') {
32+
// 60 % get · 30 % has · 10 % set (new cache keys)
33+
if (rand < 0.60) {
34+
map.get(existingKey);
35+
} else if (rand < 0.90) {
36+
map.has(existingKey);
37+
} else {
38+
map.set(`reader-cache-${newKeyCounter++}`, randomValue());
39+
}
40+
} else if (role === 'writer') {
41+
// 50 % set (new) · 30 % delete · 20 % get
42+
if (rand < 0.50) {
43+
map.set(`writer-new-${newKeyCounter++}`, randomValue());
44+
} else if (rand < 0.80) {
45+
map.delete(existingKey);
46+
} else {
47+
map.get(existingKey);
48+
}
49+
} else {
50+
// mixed: 40 % get · 35 % set (update) · 25 % delete
51+
if (rand < 0.40) {
52+
map.get(existingKey);
53+
} else if (rand < 0.75) {
54+
map.set(existingKey, randomValue());
55+
} else {
56+
map.delete(existingKey);
57+
}
58+
}
59+
}
60+
61+
const elapsedMs = performance.now() - start;
62+
63+
self.postMessage({
64+
status: 'done',
65+
role,
66+
opsCompleted: opsCount,
67+
elapsedMs,
68+
opsPerSec: Math.round(opsCount / (elapsedMs / 1000))
69+
});
70+
}
71+
};

benchmark/index.html

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ <h1>In-Browser Benchmarks</h1>
1010
<p>This page provides access to performance benchmarks for:</p>
1111

1212
<ul>
13-
<li><a href="map.html">ShareableMap Benchmarks</a></li>
13+
<li><a href="map.html">ShareableMap Benchmarks (original)</a></li>
14+
<li><a href="map-single-thread.html">ShareableMap Single-thread Benchmark (set / has / get / delete vs native Map)</a></li>
15+
<li><a href="map-concurrent.html">ShareableMap Concurrent Benchmark (3 workers, mixed workload)</a></li>
1416
<li><a href="array.html">ShareableArray Benchmarks</a></li>
1517
</ul>
1618

benchmark/map-concurrent.html

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>ShareableMap concurrent benchmark</title>
6+
</head>
7+
<body>
8+
<p>Open the browser DevTools console to see benchmark results.</p>
9+
<script type="module">
10+
import { ShareableMap } from '/src/index.ts';
11+
12+
const INITIAL_KEY_COUNT = 50_000;
13+
const OPS_PER_WORKER = 30_000;
14+
const roles = ['reader', 'writer', 'mixed'];
15+
16+
function randomValue() {
17+
return Math.random().toString(36).substring(2, 15);
18+
}
19+
20+
// Pre-populate the shared map
21+
console.log(`Pre-populating ShareableMap with ${INITIAL_KEY_COUNT.toLocaleString()} entries...`);
22+
const map = new ShareableMap({ expectedSize: INITIAL_KEY_COUNT });
23+
for (let i = 0; i < INITIAL_KEY_COUNT; i++) {
24+
map.set(`entry-${i}`, randomValue());
25+
}
26+
const state = map.toTransferableState();
27+
console.log('Done. Spawning 3 workers...');
28+
29+
// Spawn all three workers and send them their configuration
30+
const workers = roles.map(role => {
31+
const worker = new Worker('/benchmark/concurrent-map-worker.js', { type: 'module' });
32+
worker.postMessage({
33+
cmd: 'setup',
34+
state,
35+
role,
36+
opsCount: OPS_PER_WORKER,
37+
initialKeyCount: INITIAL_KEY_COUNT
38+
});
39+
return { worker, role };
40+
});
41+
42+
// Collect ready signals; broadcast 'start' when all three are ready
43+
let readyCount = 0;
44+
let wallStart;
45+
const workerResults = [];
46+
47+
workers.forEach(({ worker, role }) => {
48+
worker.onmessage = function (event) {
49+
const msg = event.data;
50+
51+
if (msg.status === 'ready') {
52+
readyCount++;
53+
if (readyCount === workers.length) {
54+
console.log('All workers ready — starting concurrent run...');
55+
wallStart = performance.now();
56+
workers.forEach(({ worker }) => worker.postMessage({ cmd: 'start' }));
57+
}
58+
return;
59+
}
60+
61+
if (msg.status === 'done') {
62+
workerResults.push(msg);
63+
64+
if (workerResults.length === workers.length) {
65+
const wallClockMs = performance.now() - wallStart;
66+
const totalOps = workerResults.reduce((s, r) => s + r.opsCompleted, 0);
67+
const totalOpsPerSec = Math.round(totalOps / (wallClockMs / 1000));
68+
69+
console.log('---');
70+
console.log(`Concurrent — 3 workers, ${(totalOps / 1_000).toFixed(0)} K total ops`);
71+
72+
// Print in the defined role order
73+
for (const role of roles) {
74+
const r = workerResults.find(x => x.role === role);
75+
console.log(
76+
`[${r.role.padEnd(7)}] ${r.opsCompleted.toLocaleString()} ops ` +
77+
`${r.elapsedMs.toFixed(1)} ms ${r.opsPerSec.toLocaleString()} ops/s`
78+
);
79+
}
80+
81+
console.log(`Total wall-clock: ${wallClockMs.toFixed(1)} ms (${totalOpsPerSec.toLocaleString()} combined ops/s)`);
82+
workers.forEach(({ worker }) => worker.terminate());
83+
}
84+
}
85+
};
86+
});
87+
</script>
88+
</body>
89+
</html>

benchmark/map-single-thread.html

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>ShareableMap single-thread benchmark</title>
6+
</head>
7+
<body>
8+
<p>Open the browser DevTools console to see benchmark results.</p>
9+
<script type="module">
10+
import { ShareableMap } from '/src/index.ts';
11+
12+
function randomString() {
13+
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
14+
}
15+
16+
const DATASET_SIZE = 300_000;
17+
const pairs = Array.from({ length: DATASET_SIZE }, () => [randomString(), randomString()]);
18+
19+
function benchmarkMap(label, map, dataset) {
20+
let start, elapsedMs, opsPerSec;
21+
22+
start = performance.now();
23+
for (const [k, v] of dataset) map.set(k, v);
24+
elapsedMs = performance.now() - start;
25+
opsPerSec = Math.round(dataset.length / (elapsedMs / 1000));
26+
console.log(`[${label}] set ${dataset.length.toLocaleString()} ops ${elapsedMs.toFixed(1)} ms ${opsPerSec.toLocaleString()} ops/s`);
27+
28+
start = performance.now();
29+
for (const [k] of dataset) map.has(k);
30+
elapsedMs = performance.now() - start;
31+
opsPerSec = Math.round(dataset.length / (elapsedMs / 1000));
32+
console.log(`[${label}] has ${dataset.length.toLocaleString()} ops ${elapsedMs.toFixed(1)} ms ${opsPerSec.toLocaleString()} ops/s`);
33+
34+
start = performance.now();
35+
for (const [k] of dataset) map.get(k);
36+
elapsedMs = performance.now() - start;
37+
opsPerSec = Math.round(dataset.length / (elapsedMs / 1000));
38+
console.log(`[${label}] get ${dataset.length.toLocaleString()} ops ${elapsedMs.toFixed(1)} ms ${opsPerSec.toLocaleString()} ops/s`);
39+
40+
start = performance.now();
41+
for (const [k] of dataset) map.delete(k);
42+
elapsedMs = performance.now() - start;
43+
opsPerSec = Math.round(dataset.length / (elapsedMs / 1000));
44+
console.log(`[${label}] delete ${dataset.length.toLocaleString()} ops ${elapsedMs.toFixed(1)} ms ${opsPerSec.toLocaleString()} ops/s`);
45+
}
46+
47+
console.log(`Single-thread benchmark — ${DATASET_SIZE.toLocaleString()} string key-value pairs`);
48+
console.log('---');
49+
50+
benchmarkMap('ShareableMap', new ShareableMap({ expectedSize: DATASET_SIZE }), pairs);
51+
console.log('---');
52+
benchmarkMap('native Map ', new Map(), pairs);
53+
</script>
54+
</body>
55+
</html>
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Worker thread for the concurrent benchmark.
2+
// Imported directly from ShareableMap to avoid the barrel (src/index.ts)
3+
// which uses directory imports that Node.js ESM cannot resolve when tsx is
4+
// loaded via --import tsx/esm in a spawned worker thread.
5+
import { workerData, parentPort } from 'worker_threads';
6+
import { ShareableMap } from '../../src/map/ShareableMap.ts';
7+
import type { TransferableState } from '../../src/TransferableState.ts';
8+
9+
type WorkerRole = 'reader' | 'writer' | 'mixed';
10+
11+
interface WorkerConfig {
12+
role: WorkerRole;
13+
state: TransferableState;
14+
opsCount: number;
15+
initialKeyCount: number;
16+
}
17+
18+
function randomValue(): string {
19+
return Math.random().toString(36).substring(2, 15);
20+
}
21+
22+
const { role, state, opsCount, initialKeyCount } = workerData as WorkerConfig;
23+
const map = ShareableMap.fromTransferableState<string, string>(state);
24+
25+
// Signal that setup is complete and the worker is ready for the 'start' command.
26+
parentPort!.postMessage({ status: 'ready' });
27+
28+
parentPort!.once('message', (msg: { cmd: string }) => {
29+
if (msg.cmd !== 'start') return;
30+
31+
const start = performance.now();
32+
let newKeyCounter = 0;
33+
34+
for (let i = 0; i < opsCount; i++) {
35+
const rand = Math.random();
36+
const existingKey = `entry-${Math.floor(Math.random() * initialKeyCount)}`;
37+
38+
if (role === 'reader') {
39+
// 60 % get · 30 % has · 10 % set (new cache entries)
40+
if (rand < 0.60) {
41+
map.get(existingKey);
42+
} else if (rand < 0.90) {
43+
map.has(existingKey);
44+
} else {
45+
map.set(`reader-cache-${newKeyCounter++}`, randomValue());
46+
}
47+
} else if (role === 'writer') {
48+
// 50 % set (new keys) · 30 % delete · 20 % get
49+
if (rand < 0.50) {
50+
map.set(`writer-new-${newKeyCounter++}`, randomValue());
51+
} else if (rand < 0.80) {
52+
map.delete(existingKey);
53+
} else {
54+
map.get(existingKey);
55+
}
56+
} else {
57+
// mixed: 40 % get · 35 % set (updates) · 25 % delete
58+
if (rand < 0.40) {
59+
map.get(existingKey);
60+
} else if (rand < 0.75) {
61+
map.set(existingKey, randomValue());
62+
} else {
63+
map.delete(existingKey);
64+
}
65+
}
66+
}
67+
68+
const elapsedMs = performance.now() - start;
69+
70+
parentPort!.postMessage({
71+
status: 'done',
72+
stats: {
73+
role,
74+
opsCompleted: opsCount,
75+
elapsedMs,
76+
opsPerSec: Math.round(opsCount / (elapsedMs / 1000))
77+
}
78+
});
79+
});

0 commit comments

Comments
 (0)