[Feature] Optional native backend? Bit-identical drop-in acceleration of Fuse.js (8.8–38.7x warm) via a Mojo Bitap kernel
- I have searched the existing issues and pull requests
Summary
We've built fuse-mojo, a drop-in faster replacement for Fuse.js 7.1.0: same API, bit-identical scores, powered by a clean-room Bitap/Shift-And kernel written in Mojo, with the vendored Fuse.js basic build as an automatic fallback on platforms without a native build. Repo (public, Apache-2.0): https://github.com/thyn-ai/mojo-kernels — package, benchmark, and full details in typescript/fuse-mojo.
This issue asks one question: is an optional native backend interesting to Fuse.js upstream, or would you prefer it remain a standalone drop-in package?
Feature description — why this exists
A V8 CPU profile (node --cpu-prof) of Fuse.js 7.1.0 searching a 100k-document corpus shows ~94% of query CPU is the Bitap bitmask DP executed in JavaScript, per document, per query (self time by function):
| self time | function | what it is |
|---|---|---|
| 48.1% | search (dist/fuse.cjs:713) |
the Bitap bitmask DP itself |
| 45.2% | chunks.forEach closure (dist/fuse.cjs:988) |
BitapSearch.searchIn driving the DP per document (inlined DP work attributed here) |
| 1.8% | (garbage collector) | allocation churn |
| 1.1% | searchIn (dist/fuse.cjs:957) |
per-document dispatch, toLowerCase |
| <1% | everything else | scoring, sort, format |
fuse-mojo moves exactly that loop into a compiled kernel (word-parallel Shift-And, 32 text positions per 32-bit word, plus deterministic thread fan-out across documents) and leaves everything else — key weights, field-norm scoring, sort, format — in JS, bit-compatibly with the reference's operation order.
Measured results
Environment: Apple M4 Max (16 threads), macOS arm64, Node v23.10.0, fuse.js 7.1.0 (npm), Mojo 1.1.0, measured 2026-09-19. Corpora generated from fixed seeds (10k/50k/100k documents of 5–15 word-like tokens), 15 patterns per cell, median of 5 runs; a correctness gate asserting identical refIndex order and scores within 1e-9 runs before every timing pass. Reproduce with benchmarks/bench_fuse.mjs in the repo.
Warm steady state (median of 5 runs)
| corpus | pattern len | Fuse.js ms/query | fuse-mojo ms/query | Fuse.js q/s | fuse-mojo q/s | speedup |
|---|---|---|---|---|---|---|
| 10,000 | 3 | 14.813 | 1.629 | 67.5 | 613.9 | 9.1x |
| 10,000 | 8 | 34.502 | 1.584 | 29.0 | 631.2 | 21.8x |
| 10,000 | 16 | 75.916 | 3.080 | 13.2 | 324.7 | 24.6x |
| 50,000 | 3 | 72.545 | 8.270 | 13.8 | 120.9 | 8.8x |
| 50,000 | 8 | 184.715 | 5.731 | 5.4 | 174.5 | 32.2x |
| 50,000 | 16 | 414.061 | 13.077 | 2.4 | 76.5 | 31.7x |
| 100,000 | 3 | 168.540 | 18.851 | 5.9 | 53.0 | 8.9x |
| 100,000 | 8 | 419.301 | 16.232 | 2.4 | 61.6 | 25.8x |
| 100,000 | 16 | 1,239.508 | 32.003 | 0.8 | 31.2 | 38.7x |
Cold first call (fresh instance: index build + first query, median of 5 runs × 3 patterns)
| corpus | pattern len | Fuse.js cold (ms) | fuse-mojo cold (ms) | speedup |
|---|---|---|---|---|
| 10,000 | 3 | 18.9 | 6.6 | 2.9x |
| 10,000 | 8 | 37.5 | 6.1 | 6.2x |
| 10,000 | 16 | 75.3 | 6.7 | 11.2x |
| 50,000 | 3 | 84.8 | 29.5 | 2.9x |
| 50,000 | 8 | 195.7 | 27.3 | 7.2x |
| 50,000 | 16 | 402.6 | 30.4 | 13.3x |
| 100,000 | 3 | 171.4 | 55.5 | 3.1x |
| 100,000 | 8 | 417.7 | 60.5 | 6.9x |
| 100,000 | 16 | 945.4 | 70.3 | 13.4x |
Index construction is one-time and ~1.4–1.7x slower than Fuse.js's (we lowercase and copy every searchable string into the native index once, instead of re-lowercasing every document on every query); it pays for itself within the first couple of queries at these corpus sizes.
Parity proof
- Scores are bit-identical, not approximations: the maximum measured difference vs Fuse.js 7.1.0 across the benchmark corpus is exactly 0 (the differential gate tolerates ≤1e-9; measured agreement is 0).
- A differential suite runs against the published
fuse.js7.1.0 package: ~200 generated patterns (exact tokens, typo'd tokens, substrings, multi-token spans, absent tokens, unicode/emoji, >32-code-unit chunked patterns) across 20 option cells (threshold, location/distance, ignoreLocation, findAllMatches, minMatchCharLength, ignoreFieldNorm, fieldNormWeight, case sensitivity, string lists and object lists with single/weighted/nested/array keys), asserting identicalrefIndexorder, scores, and match spans. 76 tests pass: 39 on the native backend + 37 on the forced-fallback backend (2 native-only cells skip there by design). - The fallback is the vendored Fuse.js 7.1.0 basic build, so behavior on unsupported platforms (e.g. Windows, where no Mojo toolchain exists) is identical by construction.
Desired solution — the question
Fuse.js already has precedent for pluggable/modular pieces — the Fuse.use() plugin mechanism (e.g. #822) and the basic/full build split — so an optional native backend (per-platform optionalDependencies, always silently falling back to the pure-JS path) doesn't seem architecturally foreign. Concretely:
- Would you consider an optional native backend inside Fuse.js itself (same API, pure-JS fallback always intact)?
- Or would you rather it remain a standalone drop-in (
import Fuse from '@fuse-mojo/core')?
Either answer is fine. If (1) is interesting, we're happy to discuss what an upstreamable, plugin-shaped integration would look like. If (2), no action needed — a docs pointer someday would be appreciated but is entirely your call.
Alternatives considered
Remaining a standalone drop-in package — this works today: npm install @fuse-mojo/core, prebuilt darwin-arm64 / linux-x64 native libraries via optionalDependencies, ABI-version handshake, transparent fallback to the vendored Fuse.js on any load failure or unsupported platform, no Mojo toolchain required at install time.
Honest gaps (unsupported options)
The constructor throws an explicit UnsupportedOptionError (identical on both backends) for:
- extended search (
useExtendedSearch: trueand logical$and/$orquery objects) ignoreDiacritics- custom
getFn(including key-levelgetFn) and customsortFn - external indices (
Fuse.createIndex/Fuse.parseIndex/ constructorindexargument) - non-integer
location/minMatchCharLength, non-finitethreshold
(Your own fuse.basic build makes essentially the same extended-search trade-off.) Extended search is the main gap — if upstream interest exists, closing it would be the first thing we'd take on.
One packaging note for completeness: the platform packages self-contain the Mojo runtime (load paths rewritten with delocate/patchelf); redistribution terms for Modular's runtime binaries would need confirming with Modular before any npm publish.
Disclosure
I work on this as part of thyn-ai's Mojo Kernel Acceleration Program. The kernel is a clean-room implementation of the textbook Bitap/Shift-And algorithm — no Fuse.js code was read or copied into it; Fuse.js is vendored as the fallback backend and used as the test oracle under its own Apache-2.0 license (see NOTICE). fuse-mojo itself is Apache-2.0, © Algenta.
Source: krisk/Fuse