Skip to content

Commit 9ba192c

Browse files
committed
feat: add FuseWorker for parallel search via Web Workers
Adds a new FuseWorker class that distributes search across multiple Web Workers for near-linear speedup on large datasets. Benchmarked at ~5x faster with 8 workers on 100K documents, with zero UI jank. API: search, add, setCollection, terminate. Import: import { FuseWorker } from 'fuse.js/worker' Also consolidates bench/ and bench.mjs into benchmark/.
1 parent e638680 commit 9ba192c

16 files changed

Lines changed: 3389 additions & 3 deletions

bench.mjs renamed to benchmark/bench.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import Fuse from './dist/fuse.mjs'
1+
import Fuse from '../dist/fuse.mjs'
22

33
// Generate test data
44
const books = []
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>Fuse.js FuseWorker Test</title>
6+
<style>
7+
* { box-sizing: border-box; margin: 0; padding: 0; }
8+
body { font-family: system-ui, sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; }
9+
h1 { margin-bottom: 8px; }
10+
.subtitle { color: #666; margin-bottom: 24px; }
11+
.config { background: #f5f5f5; padding: 16px; border-radius: 8px; margin-bottom: 24px; }
12+
.config label { display: block; margin-bottom: 8px; }
13+
.config select { padding: 4px 8px; margin-left: 8px; }
14+
button { padding: 10px 24px; font-size: 16px; cursor: pointer; background: #2563eb; color: white; border: none; border-radius: 6px; }
15+
button:disabled { background: #93c5fd; cursor: not-allowed; }
16+
#status { margin-top: 16px; color: #666; min-height: 24px; }
17+
table { width: 100%; border-collapse: collapse; margin-top: 24px; }
18+
th, td { text-align: right; padding: 8px 12px; border-bottom: 1px solid #e5e7eb; }
19+
th:first-child, td:first-child { text-align: left; }
20+
.pass { color: #16a34a; }
21+
.fail { color: #dc2626; }
22+
#jank-demo { margin-top: 24px; padding: 16px; background: #f0f9ff; border-radius: 8px; }
23+
#jank-ball { width: 40px; height: 40px; background: #2563eb; border-radius: 50%; transition: none; }
24+
.bar-cell { width: 200px; }
25+
.bar { height: 20px; background: #2563eb; border-radius: 3px; min-width: 2px; }
26+
.bar.baseline { background: #94a3b8; }
27+
</style>
28+
</head>
29+
<body>
30+
<h1>Fuse.js FuseWorker Test</h1>
31+
<p class="subtitle">Compares single-thread Fuse vs FuseWorker with parallel Web Workers</p>
32+
33+
<div class="config">
34+
<label>Dataset size:
35+
<select id="datasetSize">
36+
<option value="10000">10,000</option>
37+
<option value="50000">50,000</option>
38+
<option value="100000" selected>100,000</option>
39+
<option value="200000">200,000</option>
40+
</select>
41+
</label>
42+
<label>Benchmark runs:
43+
<select id="benchRuns">
44+
<option value="3" selected>3</option>
45+
<option value="5">5</option>
46+
<option value="10">10</option>
47+
</select>
48+
</label>
49+
</div>
50+
51+
<button id="runBtn">Run Benchmark</button>
52+
<div id="status"></div>
53+
54+
<div id="jank-demo">
55+
<p style="margin-bottom: 8px"><strong>UI responsiveness test</strong> — this ball animates during search. If it stutters, the main thread is blocked.</p>
56+
<div id="jank-ball"></div>
57+
</div>
58+
59+
<table id="results" style="display:none">
60+
<thead>
61+
<tr>
62+
<th>Method</th>
63+
<th>Avg (ms)</th>
64+
<th>Min (ms)</th>
65+
<th>Max (ms)</th>
66+
<th>Speedup</th>
67+
<th class="bar-cell">Relative</th>
68+
</tr>
69+
</thead>
70+
<tbody id="resultsBody"></tbody>
71+
</table>
72+
73+
<p id="correctness" style="margin-top: 16px"></p>
74+
75+
<script type="module">
76+
import Fuse from '../../dist/fuse.min.mjs'
77+
import { FuseWorker } from '../../dist/fuse-worker.mjs'
78+
79+
const QUERIES = ['john smith', 'quantum', 'xyz corp', 'engineering', 'banana']
80+
const WORKER_COUNTS = [2, 4, 8]
81+
const WARMUP_RUNS = 1
82+
83+
const FUSE_OPTIONS = {
84+
keys: ['name', 'email', 'company', 'description'],
85+
threshold: 0.4,
86+
includeScore: true
87+
}
88+
89+
// -- Dataset generation --
90+
const firstNames = ['John','Jane','Alice','Bob','Charlie','Diana','Eve','Frank','Grace','Hank','Ivy','Jack','Karen','Leo','Mona','Nick','Olivia','Paul','Quinn','Rita','Sam','Tina','Uma','Victor']
91+
const lastNames = ['Smith','Johnson','Williams','Brown','Jones','Garcia','Miller','Davis','Rodriguez','Martinez','Anderson','Taylor','Thomas','Moore','Jackson','Martin','Lee','Perez','Thompson','White']
92+
const companies = ['Acme Corp','Globex Inc','Initech','Umbrella Co','Stark Industries','Wayne Enterprises','XYZ Corp','Quantum Labs','Nova Systems','Apex Digital']
93+
const domains = ['gmail.com','yahoo.com','outlook.com','company.com','work.org']
94+
const words = ['engineering','marketing','sales','design','research','quantum','neural','cloud','data','analytics','platform','mobile','security','infrastructure','development','operations','strategy','innovation','optimization','integration','automation','visualization','banana']
95+
96+
function pick(arr) { return arr[Math.floor(Math.random() * arr.length)] }
97+
98+
function generateDataset(size) {
99+
const docs = []
100+
for (let i = 0; i < size; i++) {
101+
const first = pick(firstNames), last = pick(lastNames)
102+
const descLen = 5 + Math.floor(Math.random() * 10)
103+
docs.push({
104+
id: i,
105+
name: `${first} ${last}`,
106+
email: `${first.toLowerCase()}.${last.toLowerCase()}@${pick(domains)}`,
107+
company: pick(companies),
108+
description: Array.from({ length: descLen }, () => pick(words)).join(' ')
109+
})
110+
}
111+
return docs
112+
}
113+
114+
// -- Jank ball animation --
115+
let animating = false
116+
function animateBall() {
117+
const ball = document.getElementById('jank-ball')
118+
let x = 0, dir = 1
119+
function frame() {
120+
if (!animating) return
121+
x += dir * 3
122+
if (x > 300) dir = -1
123+
if (x < 0) dir = 1
124+
ball.style.transform = `translateX(${x}px)`
125+
requestAnimationFrame(frame)
126+
}
127+
animating = true
128+
frame()
129+
}
130+
131+
// -- Single thread search --
132+
function searchSingleThread(docs, queries) {
133+
const fuse = new Fuse(docs, FUSE_OPTIONS)
134+
const results = {}
135+
for (const q of queries) results[q] = fuse.search(q)
136+
return results
137+
}
138+
139+
// -- FuseWorker search --
140+
async function searchWithFuseWorker(docs, queries, numWorkers) {
141+
const fuse = new FuseWorker(docs, FUSE_OPTIONS, {
142+
numWorkers,
143+
workerUrl: '../../dist/fuse.worker.mjs'
144+
})
145+
146+
const results = {}
147+
for (const q of queries) {
148+
results[q] = await fuse.search(q)
149+
}
150+
151+
fuse.terminate()
152+
return results
153+
}
154+
155+
// -- Benchmark runner --
156+
async function bench(label, fn, runs) {
157+
for (let i = 0; i < WARMUP_RUNS; i++) await fn()
158+
159+
const times = []
160+
for (let i = 0; i < runs; i++) {
161+
const start = performance.now()
162+
await fn()
163+
times.push(performance.now() - start)
164+
}
165+
166+
const avg = times.reduce((a, b) => a + b, 0) / times.length
167+
return { label, avg, min: Math.min(...times), max: Math.max(...times) }
168+
}
169+
170+
// -- Main --
171+
async function runBenchmark() {
172+
const btn = document.getElementById('runBtn')
173+
const status = document.getElementById('status')
174+
const resultsTable = document.getElementById('results')
175+
const resultsBody = document.getElementById('resultsBody')
176+
const correctnessEl = document.getElementById('correctness')
177+
178+
btn.disabled = true
179+
resultsTable.style.display = 'none'
180+
resultsBody.innerHTML = ''
181+
correctnessEl.textContent = ''
182+
183+
const datasetSize = parseInt(document.getElementById('datasetSize').value)
184+
const benchRuns = parseInt(document.getElementById('benchRuns').value)
185+
186+
status.textContent = `Generating ${datasetSize.toLocaleString()} documents...`
187+
await new Promise(r => setTimeout(r, 50))
188+
189+
const docs = generateDataset(datasetSize)
190+
191+
animateBall()
192+
193+
// Single thread
194+
status.textContent = 'Running: single thread (watch the ball stutter)...'
195+
await new Promise(r => setTimeout(r, 50))
196+
const single = await bench('Fuse (single thread)', () => searchSingleThread(docs, QUERIES), benchRuns)
197+
198+
// FuseWorker with different worker counts
199+
const parallel = []
200+
for (const n of WORKER_COUNTS) {
201+
status.textContent = `Running: FuseWorker (${n} workers)...`
202+
await new Promise(r => setTimeout(r, 50))
203+
const result = await bench(
204+
`FuseWorker (${n})`,
205+
() => searchWithFuseWorker(docs, QUERIES, n),
206+
benchRuns
207+
)
208+
parallel.push(result)
209+
}
210+
211+
animating = false
212+
213+
// Verify correctness
214+
const singleResults = searchSingleThread(docs, QUERIES)
215+
const parallelResults = await searchWithFuseWorker(docs, QUERIES, 4)
216+
let correct = true
217+
for (const q of QUERIES) {
218+
if (singleResults[q].length !== parallelResults[q].length) correct = false
219+
}
220+
221+
// Display results
222+
const allResults = [single, ...parallel]
223+
const maxAvg = single.avg
224+
225+
resultsTable.style.display = ''
226+
for (const r of allResults) {
227+
const speedup = single.avg / r.avg
228+
const barWidth = Math.round((r.avg / maxAvg) * 200)
229+
const row = document.createElement('tr')
230+
row.innerHTML = `
231+
<td>${r.label}</td>
232+
<td>${r.avg.toFixed(1)}</td>
233+
<td>${r.min.toFixed(1)}</td>
234+
<td>${r.max.toFixed(1)}</td>
235+
<td>${speedup.toFixed(2)}x</td>
236+
<td class="bar-cell"><div class="bar ${r.label.startsWith('Fuse (') ? 'baseline' : ''}" style="width:${barWidth}px"></div></td>
237+
`
238+
resultsBody.appendChild(row)
239+
}
240+
241+
correctnessEl.className = correct ? 'pass' : 'fail'
242+
correctnessEl.textContent = correct
243+
? 'Correctness: PASS (result counts match)'
244+
: 'Correctness: MISMATCH'
245+
246+
status.textContent = 'Done.'
247+
btn.disabled = false
248+
}
249+
250+
document.getElementById('runBtn').addEventListener('click', runBenchmark)
251+
</script>
252+
</body>
253+
</html>
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import Fuse from '../../dist/fuse.min.mjs'
2+
3+
self.onmessage = (e) => {
4+
const { id, docs, options, queries } = e.data
5+
6+
const fuse = new Fuse(docs, options)
7+
8+
const results = {}
9+
for (const q of queries) {
10+
results[q] = fuse.search(q)
11+
}
12+
13+
self.postMessage({ id, results })
14+
}

0 commit comments

Comments
 (0)