Hardware-Accelerated Fuzzy String Matching & Sequence Alignment
Cross-platform GPU compute via WebGPU (wgpu) & Multi-Core CPU parallelism with Rayon. Zero CUDA dependencies.
fuzzgpu is a high-throughput string distance and sequence alignment engine written in Rust with native Python and WebAssembly bindings. It leverages GPU compute shaders (wgpu / WGSL) and Rayon multi-threading to accelerate large-scale batch queries and distance matrix computations across:
- Apple Silicon (Metal)
- Linux (Vulkan)
- Windows (DirectX 12 / Vulkan)
- Integrated GPUs (Intel Iris Xe, AMD Radeon)
- WebAssembly (In-browser execution)
No NVIDIA CUDA drivers or complex toolkits required.
- WRatio score_cutoff parity — WRatio now threads a rescaled running cutoff through its token/partial-token steps exactly as rapidfuzz does; high cutoffs no longer leak scaled token ratios (verified against rapidfuzz 3.14.5).
-
process.cdistcorrectness — the generic path now zeroes below-cutoff scores for scorers withoutscore_cutoffsupport, and the fast path honorsscore_multiplier(cutoff is applied to the raw score, then scaled) — matching rapidfuzz. -
Randomized rapidfuzz differential suite — new
tests/test_rapidfuzz_differential.pypins every scorer, cutoff, alignment, distance module, editops, andprocesshelper to rapidfuzz 3.14.5 across a seeded 200-pair corpus (runs on every push). -
Gotoh sentinel —
NEG_INFhardened toi64::MIN/4so pathological gap penalties can no longer beat it. -
CPU-only wheel —
maturin build --no-default-featuresnow ships a GPU-free variant, built and smoke-tested in CI. -
Flaky stress asserts gated — wall-clock budgets in
test_stress.pyare disabled viaFUZZGPU_SKIP_PERF_ASSERTS=1(correctness checks always run). - Accurate GPU timeout message — readback timeouts now report the actual configured timeout.
All fixes from v0.3.0 are included.
Previous (v0.3.0)
-
Kernel
get()panics eliminated — all four GPU kernels (GpuLevenshteinKernel,GpuJaroKernel,GpuNeedlemanAffineKernel,GpuDamerauKernel) previously called.unwrap()onOnceLock::get()after initialization, which could panic the Python interpreter under rare concurrent races. Replaced with.ok_or_else(...)returning a properFuzzGpuError::NoDevice. -
debug_assert!→ real release guards —MyersPattern::new,jaro_bitpar,jaro_4waywere guarded only bydebug_assert!. In release builds, calling them with inputs outside their contract (non-ASCII or > 64 bytes) would silently produce wrong results. Promoted to properassert!with descriptive messages that surface immediately in both debug and release.
-
fuzz.cdist— pairwise score matrix, mirrorsrapidfuzz.fuzz.cdist. Delegates toprocess.cdistwithratioas the default scorer. -
DamerauLevenshtein.editops/.opcodes— full Lowrance-Wagner traceback returningEditops/Opcodes(insert/delete/replace), completing parity with rapidfuzz's alignment API for this module. -
fuzz.__all__now includespartial_ratio_alignmentandcdist(were missing).
-
Jaro.similarity/JaroWinkler.similarityscore_cutoff— changed default from0.0toNone, matching rapidfuzz's semantics (0.0treated scores of exactly 0.0 as filtered, which was wrong). -
ratio_batch(workers=)— was silently ignored (del workers). Now wires up aThreadPoolExecutorfor the processor path; the no-processor path continues to use Rayon internally (ignoringworkersis correct there — Rayon already uses all cores). -
process.cdistfast path — rewrote to usefuzz_ratio_batchrow-by-row (each row runs under Rayon across all cores) instead of a deadraw = _native.fuzz_ratio_batchassignment followed by the same loop. Removed the deadraw:type-hint-only line. -
process.cdistsilent swallow —except Exception: passreplaced withwarnings.warn(...)so unexpected fast-path failures are visible instead of silently producing slow results.
All fixes from v0.1.7 are included — see the v0.1.7 changelog below.
Previous (v0.1.7)
-
Jaro GPU shader FXC crash fixed —
jaro.wgslandjaro_matrix.wgslused dynamic vector component writes (v[j >> 5u] = ...) inbit_set(). Fixed by rewritingbit_setwithselect()-based static construction (Vulkan, Metal, DX12 all compile identically).
-
fuzzgpu-wasmandfuzzgpu-fuzz— 4 ×E0061arity mismatch forpartial_ratio/token_sort_ratio/token_set_ratio/wratio. Fixed by passing0.0as the cutoff.
Previous (v0.1.6)
The full Python layer is now byte-identical to rapidfuzz 3.14.5 over a 169,744-pair differential harness across ratio, partial_ratio, token_sort_ratio, token_set_ratio, token_ratio, WRatio, QRatio, partial_token_*, jaro, jaro_winkler, levenshtein, indel, hamming, osa — 0 mismatches.
-
ratio/partial_ratiocutoff imprecision — port of rapidfuzz's load-bearingNormSim_to_NormDist = min(1, 1 - cutoff/100 + 1e-5)term. -
ratioscore formula — switched from((len-dist)/len)*100to(1 - dist/len)*100to match rapidfuzz C++'s exact ulp order.
-
Editops/Opcodes/Editop/Opcode/MatchingBlock/ScoreAlignmentclasses (rapidfuzz-compatible). -
Levenshtein.editops/.opcodes,LCSseq,Prefix,Postfix,Hamming.editops,Indel.editopsmodules. -
fuzz.partial_ratio_alignmentreturnsScoreAlignment(rapidfuzz-compatible). -
process.extract/extractOne/cdistdefault toWRatio. - All alignment types re-exported at the package root.
Previous (v0.1.5)
-
Damerau-Levenshtein safety gate now fires in release builds (
assert!notdebug_assert!) — non-ASCII inputs no longer silently produce wrong distances in production wheels - Needleman-Wunsch GPU f32 precision guard — scoring parameters that exceed the exact f32 integer range (2²⁴ = 16,777,216) now automatically route to CPU, preventing silent precision loss
-
Wavefront shader race condition fixed —
diags[1]seed initialization consolidated into a single thread with a properworkgroupBarrier() -
extract_oneearly-exit fixed — thebreakat score==100.0 now only fires after theis_bettercheck -
Distance module processor bug fixed across all
distance/*.pymodules —similarity/normalized_*now apply the processor once, then computemaximumon the processed strings
-
Zero-allocation SIMD hot paths —
levenshtein_cdist,levenshtein_batch, andjaro_winkler_batchnow use stack-allocated[&[u8]; 8]instead of per-group heapVec, eliminating millions of tiny allocations at 1M-cell matrix scale -
token_set_ratiousesCow<str>to skip heap allocation when intersection/difference sets are empty -
process.cdistfast path routes through the Rayon/GPUratio_batchwhen the default scorer is used, instead of one Python call per cell
-
partial_ratio_alignment(s1, s2)→(score, src_start, dest_start, length)— rapidfuzz-compatible alignment result -
partial_token_sort_ratio,partial_token_set_ratio,QRatio— now exposed at the top level -
Jaro-Winkler GPU routing in Python —
jaro_winkler_batchandjaro_winkler_cdistnow use the GPU kernel on discrete GPUs -
Needleman-Wunsch GPU routing in Python —
needleman_wunsch_affine_batchnow usesGpuNeedlemanAffineKernel -
editopsandopcodesre-exported at the top level (fuzzgpu.editops,fuzzgpu.opcodes) - Complete type stubs (
__init__.pyi,fuzz.pyi,process.pyi)
Hardware: Intel(R) Iris(R) Xe Graphics — integrated GPU (Vulkan) + Intel Core i7 (Rayon, all cores)
Versions: fuzzgpu 0.4.0 · rapidfuzz 3.14.5 · python-Levenshtein 0.27.4
Median of 7 runs after warmup. Reproduce: python benchmarks/bench_compare.py
GPU class note: These numbers are from an integrated GPU (iGPU), which shares memory bandwidth with the CPU and has a ~1 ms dispatch round-trip. On a discrete GPU (dGPU) the GPU columns improve significantly — expect 3–10× better GPU throughput and GPU routing kicking in at much smaller batch sizes (threshold drops from ~500 pairs to ~64 pairs automatically). Concurrent Python-thread dispatch is supported on all backends.
| Batch Size |
fuzzgpu (GPU) |
fuzzgpu (CPU) |
rapidfuzz |
vs RF (GPU) | vs RF (CPU) |
|---|---|---|---|---|---|
| 100 | 0.04 ms | 0.00 ms | 0.01 ms | 0.24× | 1.90× |
| 1,000 | 0.50 ms | 0.04 ms | 0.07 ms | 0.13× | 1.89× |
| 10,000 | 1.53 ms | 0.40 ms | 0.64 ms | 0.42× | 1.61× |
| 50,000 | 6.87 ms | 3.36 ms | 4.46 ms | 0.65× | 1.33× |
| Batch Size |
fuzzgpu (GPU) |
fuzzgpu (CPU) |
rapidfuzz |
vs RF (GPU) | vs RF (CPU) |
|---|---|---|---|---|---|
| 100 | 0.04 ms | 0.04 ms | 0.10 ms | 2.68× | 2.63× |
| 1,000 | 0.21 ms | 0.20 ms | 0.98 ms | 4.71× | 4.89× |
| 10,000 | 1.39 ms | 1.49 ms | 9.93 ms | 7.14× | 6.67× |
| 50,000 | 10.19 ms | 9.91 ms | 66.57 ms | 6.54× | 6.72× |
Note: Both fuzzgpu and rapidfuzz's
DamerauLevenshteinimplement the unrestricted Lowrance-Wagner (1975) algorithm (non-adjacent transpositions allowed):damerau("ca", "abc") == 2in both. For the restricted variant usefuzzgpu.distance.OSA(== 3, also matching rapidfuzz).
| Batch Size |
fuzzgpu (GPU) |
fuzzgpu (CPU) |
rapidfuzz |
vs RF (GPU) | vs RF (CPU) |
|---|---|---|---|---|---|
| 100 | 0.02 ms | 0.01 ms | 0.02 ms | 0.87× | 1.14× |
| 1,000 | 0.18 ms | 0.13 ms | 0.12 ms | 0.70× | 0.94× |
| 10,000 | 1.01 ms | 0.83 ms | 0.97 ms | 0.96× | 1.18× |
| 50,000 | 5.61 ms | 3.25 ms | 6.69 ms | 1.19× | 2.06× |
| Batch Size |
fuzzgpu (GPU) |
fuzzgpu (CPU) |
rapidfuzz |
|---|---|---|---|
| 100 | 0.07 ms | 0.05 ms | — |
| 1,000 | 1.65 ms | 0.43 ms | — |
| 10,000 | 12.49 ms | 3.91 ms | — |
| 50,000 | 47.86 ms | 26.95 ms | — |
rapidfuzz has no Needleman-Wunsch API — no comparison available.
| Matrix Size | Total Pairs |
fuzzgpu (GPU) |
fuzzgpu (CPU) |
rapidfuzz |
python-Levenshtein | vs RF (GPU) | vs RF (CPU) |
|---|---|---|---|---|---|---|---|
| 10 × 10 | 100 | 0.05 ms | 0.02 ms | 0.01 ms | 0.06 ms | 0.18× | 0.56× |
| 50 × 50 | 2,500 | 0.58 ms | 0.11 ms | 0.09 ms | 2.17 ms | 0.16× | 0.81× |
| 100 × 100 | 10,000 | 0.67 ms | 0.25 ms | 0.18 ms | 5.75 ms | 0.27× | 0.70× |
| 200 × 200 | 40,000 | 1.19 ms | 0.81 ms | 0.61 ms | 22.31 ms | 0.51× | 0.75× |
pip install fuzzgpuFor a smaller, dependency-light install with no GPU/WebGPU support (CPU + Rayon only):
pip install fuzzgpu-cpu# Rust
[dependencies]
fuzzgpu-core = "0.4.0"import fuzzgpu
# ── Core distance metrics ─────────────────────────────────────────────────────
lev = fuzzgpu.levenshtein("kitten", "sitting") # 3
dam = fuzzgpu.damerau("ab", "ba") # 1 (transposition)
jw = fuzzgpu.jaro_winkler("MARTHA", "MARHTA") # 0.9611...
# ── Batch (auto-routed GPU/CPU) ───────────────────────────────────────────────
candidates = ["hallo", "hullo", "jello", "yellow", "hello world"] * 10_000
distances = fuzzgpu.levenshtein_batch("hello", candidates)
jw_scores = fuzzgpu.jaro_winkler_batch("hello", candidates, p=0.1)
nw_scores = fuzzgpu.needleman_wunsch_affine_batch(
"AGTACGCA", candidates, match=2, mismatch=-1, gap_open=-3, gap_extend=-1
)
# ── Cross-product distance matrix ─────────────────────────────────────────────
matrix = fuzzgpu.levenshtein_cdist(["abc", "def", "xyz"], ["abd", "axy", "def"])
# ── Zero-allocation outputs (write into preallocated numpy arrays) ────────────
import numpy as np
out_u32 = np.empty(len(candidates), dtype=np.uint32)
out_f64 = np.empty(len(candidates), dtype=np.float64)
mat_u32 = np.empty((3, 3), dtype=np.uint32)
fuzzgpu.levenshtein_batch_into("hello", candidates, out_u32)
fuzzgpu.jaro_winkler_batch_into("hello", candidates, out_f64)
fuzzgpu.levenshtein_cdist_into(["abc", "def", "xyz"], ["abd", "axy", "def"], mat_u32)
# ── Global sequence alignment (Gotoh 1982 affine gap) ────────────────────────
score = fuzzgpu.needleman_wunsch_affine("AGTACGCA", "TATGC", 2, -1, -3, -1)
# ── Fuzzy ratios (rapidfuzz-compatible) ──────────────────────────────────────
from fuzzgpu.fuzz import (
ratio, partial_ratio, partial_ratio_alignment,
token_sort_ratio, token_set_ratio,
partial_token_sort_ratio, partial_token_set_ratio,
QRatio, WRatio,
)
ratio("fuzzy was a bear", "fuzzy was a bear") # 100.0
partial_ratio("hello", "oh hello there") # 100.0
score, src, dst = partial_ratio_alignment("hello", "oh hello there")
# ScoreAlignment(score=100.0, src_start=0, src_end=5, dest_start=3, dest_end=8)
token_sort_ratio("new york mets", "mets new york") # 100.0
token_set_ratio("fuzzy was a bear", "fuzzy bear") # 100.0
# ── Alignment helpers (rapidfuzz-compatible) ──────────────────────────────────
from fuzzgpu.distance import Levenshtein
ops = fuzzgpu.editops("kitten", "sitting") # top-level alias
codes = fuzzgpu.opcodes("kitten", "sitting")
# ── Search ────────────────────────────────────────────────────────────────────
from fuzzgpu.fuzz import extract, extractOne
best = extractOne("hellp", ["hello", "world", "help"], score_cutoff=50.0)
# ("help", 88.88888888888889, 2)
top_3 = extract("apple", ["apply", "ape", "banana", "applesauce"],
score_cutoff=50.0, limit=3)
# ── rapidfuzz.process-compatible API ─────────────────────────────────────────
from fuzzgpu.process import extract, extractOne, cdist
matrix = cdist(["hello", "world"], ["hallo", "wurld"]) # uses GPU/Rayon
# ── distance submodule (rapidfuzz.distance-compatible) ───────────────────────
from fuzzgpu.distance import Levenshtein, DamerauLevenshtein, Jaro, JaroWinkler
from fuzzgpu.distance import Hamming, OSA, Indel
Levenshtein.distance("kitten", "sitting") # 3
Levenshtein.normalized_similarity("kitten", "sitting") # 0.571...
Levenshtein.similarity(" abc ", "abc", processor=str.strip) # 3
DamerauLevenshtein.distance("ca", "abc") # 2 (unrestricted)
OSA.distance("ca", "abc") # 3 (OSA / rapidfuzz-compatible)
JaroWinkler.similarity("MARTHA", "MARHTA", prefix_weight=0.1) # 0.9611...
# ── Hardware diagnostics ──────────────────────────────────────────────────────
print(fuzzgpu.gpu_info()) # "Intel(R) Iris(R) Xe Graphics (Vulkan)"
print(fuzzgpu.hardware_info()) # adapter, threshold, last routing stats
fuzzgpu.set_gpu_threshold(100) # force GPU for batches >= 100 pairs
fuzzgpu.set_gpu_threshold(None) # restore auto-selection
fuzzgpu.set_cpu_only(True) # force CPU-only mode[dependencies]
fuzzgpu-core = "0.4.0" # GPU + CPU fallback
# fuzzgpu-core = { version = "0.4.0", default-features = false } # CPU-onlyuse fuzzgpu_core::levenshtein::gpu_ext::GpuLevenshteinKernel;
fn main() -> fuzzgpu_core::Result<()> {
let kernel = GpuLevenshteinKernel::get()?;
// Batch
let pairs = vec![("kitten", "sitting"), ("hello", "hullo")];
let distances = kernel.compute(&pairs)?; // [3, 1]
// Cross-product matrix
let matrix = kernel.compute_matrix(&["abc", "def"], &["abc", "xyz"])?;
// Multi-op batch (one GPU dispatch + readback amortized across all ops)
let mut batch = kernel.batch();
batch.add(&pairs);
batch.add(&[("foo", "bar"), ("test", "taste")]);
let results = batch.execute()?; // Vec<Vec<u32>>
Ok(())
}Available GPU kernels: GpuLevenshteinKernel, GpuJaroKernel, GpuNeedlemanAffineKernel, GpuDamerauKernel.
cd crates/fuzzgpu-wasm
wasm-pack build --target web --releaseimport init, {
levenshtein_distance, jaro_winkler, ratio, extract,
needleman_wunsch, needleman_wunsch_affine,
} from './pkg/fuzzgpu_wasm.js';
await init();
levenshtein_distance('kitten', 'sitting'); // 3
jaro_winkler('MARTHA', 'MARHTA', 0.1); // 0.9611...
// Needleman-Wunsch scores are i64 → JavaScript BigInt
needleman_wunsch('AGTACGCA', 'TATGC', 2n, -1n, -2n); // 1n
needleman_wunsch_affine('AGTACGCA', 'TATGC', 2n, -1n, -3n, -1n); // -2n ┌─────────────────────────┐
│ User Query / API │
└────────────┬────────────┘
│
Batch size / dataset assessment
│
┌──────────────────────┴──────────────────────┐
▼ ▼
Small batches (< threshold) Large batches (≥ threshold)
│ │
┌───────────────────┐ ┌─────────────────────────────┐
│ Rayon Parallel │ │ wgpu WebGPU Compute │
│ Myers bit-vector │ │ WGSL shaders │
│ AVX512/AVX2/NEON │ │ Metal / Vulkan / DX12 │
└───────────────────┘ └─────────────────────────────┘
-
Myers (1999) bit-vector — O(n) Levenshtein for patterns ≤ 64 chars, zero inner DP loop. Vectorized with AVX512 (8 texts/vector), AVX2 (4), NEON (2), portable scalar fallback. ISA detected at runtime via cached CPUID; override with
FUZZGPU_SIMD=portable|neon|avx2|avx512. - Unrestricted Damerau-Levenshtein — Full Lowrance-Wagner (1975) with non-adjacent transpositions. GPU shader keeps the full DP matrix in workgroup shared memory (≤ 32 chars ASCII).
- Gotoh (1982) affine gaps — 3-state recurrence, O(n) memory. GPU shader computes in f32; automatically routes to CPU when scoring parameters exceed f32 exact range (2²⁴).
-
WGSL shaders require no adapter features — bit-vectors implemented as u32×2 pairs (no
SHADER_INT64), works on every WebGPU backend including browsers and integrated GPUs. -
Metric-aware routing — iGPUs auto-route Jaro/Damerau to CPU (where AVX2 SIMD wins); discrete GPUs dispatch above a scaled threshold.
hardware_info()shows every routing decision. -
Dispatch lock — serializes GPU calls across threads to work around
gfx-rs/wgpu#10085(heap corruption under ≥3 concurrent dispatchers on Intel iGPUs). -
Zero-copy Python bindings —
Bound<PyString>pointers, noVec<String>copies;*_intoAPIs write directly into caller-supplied numpy arrays.
| Kernel | Algorithm | Max length | Notes |
|---|---|---|---|
levenshtein.wgsl |
Standard DP | 256 chars | General path |
levenshtein_short.wgsl |
SLM row DP | 64 chars | Transposed layout, no register spill |
levenshtein_myers.wgsl |
Myers bit-vector | 64 chars | Shared Peq per workgroup, 2×u32 bitmask |
levenshtein_cdist_myers.wgsl |
Row-wise Myers | 64 chars | One workgroup per matrix row |
levenshtein_matrix.wgsl |
2D DP grid | 256 chars | O(N+M) data upload |
jaro.wgsl |
Bitmap matcher | 128 chars | 128-bit registers, transposed layout |
jaro_matrix.wgsl |
2D Jaro grid | 128 chars | O(N+M) data upload |
damerau.wgsl |
Lowrance-Wagner | 32 chars ASCII | Full matrix in SLM, non-adjacent transpositions |
damerau_matrix.wgsl |
2D Damerau grid | 32 chars ASCII | Same |
needleman_affine.wgsl |
Gotoh serial | 128 chars | f32 scores, one thread per pair |
needleman_wavefront.wgsl |
Gotoh wavefront | 128 chars | Anti-diagonal parallel, O(m+n) steps |
fuzzgpu/
├── crates/
│ ├── fuzzgpu-core/ # Core Rust engine + GPU shaders
│ ├── fuzzgpu-python/ # PyO3 Python extension
│ └── fuzzgpu-wasm/ # wasm-bindgen WebAssembly module
├── python/fuzzgpu/ # Python package wrapper + type stubs
│ ├── distance/ # rapidfuzz.distance-compatible modules
│ ├── fuzz.py # rapidfuzz.fuzz-compatible scorers
│ └── process.py # rapidfuzz.process-compatible helpers
├── fuzz/ # libFuzzer targets + stable self-harness
├── benchmarks/ # Comparative benchmark scripts
├── tests/ # Python pytest suite (278 tests incl. rapidfuzz differential)
└── docs/GPU_TESTING.md # Fault injection & GPU test conventions
# Prerequisites: Rust 1.87+, Python 3.10+, maturin
git clone https://github.com/kuntal-devrat/fuzzgpu.git
cd fuzzgpu
maturin develop --release
pytest tests/ -v
cargo test --workspace| Variable | Effect |
|---|---|
FUZZGPU_USE_CPU |
Force CPU-only mode |
FUZZGPU_FORCE_GPU |
Error (not fallback) on GPU failure in Python |
FUZZGPU_DEBUG |
Log GPU→CPU fallback decisions |
FUZZGPU_SIMD |
Force ISA: portable|neon|avx2|avx512
|
FUZZGPU_READBACK_TIMEOUT_MS |
GPU readback timeout (default 10000 ms) |
FUZZGPU_SKIP_DISPATCH_LOCK |
Opt-in GPU dispatch serialization (safety valve for the rare gfx-rs/wgpu#10085 crash class on Intel D3D12; dispatch is fully concurrent by default) |
FUZZGPU_REQUIRE_GPU |
In tests: fail instead of skip when no GPU |
WGPU_BACKEND |
Force wgpu backend: vulkan|metal|dx12
|
PROPTEST_CASES |
Override proptest case count |