|
| 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> |
0 commit comments