Skip to content

perf(entity-resolver): accelerate candidate scoring with rapidfuzz C++ SIMD - #3995

Open
Sanderhoff-alt wants to merge 1 commit into
vectorize-io:mainfrom
Sanderhoff-alt:perf/entity-resolver-rapidfuzz-opt
Open

perf(entity-resolver): accelerate candidate scoring with rapidfuzz C++ SIMD#3995
Sanderhoff-alt wants to merge 1 commit into
vectorize-io:mainfrom
Sanderhoff-alt:perf/entity-resolver-rapidfuzz-opt

Conversation

@Sanderhoff-alt

Copy link
Copy Markdown
Contributor

Summary

This PR replaces the pure-Python difflib.SequenceMatcher.ratio() in entity_resolver.py (_tokens_match and _resolve_from_candidates) with C++ SIMD-accelerated rapidfuzz.distance.Indel.normalized_similarity.

Under heavy entity resolution workloads (e.g. streaming retain batches with 50~250 new entities scored against up to 200 candidates each, or large-scale dataset imports), pure-Python dynamic programming in SequenceMatcher generated significant synchronous CPU latency on the main event-loop thread.

This optimization delivers an ~80x end-to-end CPU speedup (throughput increased from ~115k pairs/sec to ~9.3M pairs/sec), dropping 1,000 candidate scoring from 8.15ms to 0.10ms, the 50k streaming batch upper bound from 422ms to 5.0ms, and a 1M candidate stress run from 8.69s to 107ms, completely eliminating event-loop stalls while preserving 100% mathematical and merge decision equivalence.

Relates to #3211, #3991, #3107.


Performance & Microbenchmark Results

Benchmarked with ./scripts/benchmarks/run-entity-matcher-bench.sh --repeats 10 on macOS Darwin (Apple Silicon ARM64, Python 3.11):

Benchmark Summary Table

Workload Scenario Candidate Pairs (Pairs) Metric Baseline (SequenceMatcher) Production (rapidfuzz C++) Improvement / Speedup
typical_batch_50
(Typical streaming retain batch)
50 pairs Wall Time
CPU Time
Peak Heap
Throughput
0.404 ms
0.404 ms
9.50 KiB
123k pairs/s
0.006 ms
0.006 ms
1.83 KiB
7.64M pairs/s
🚀 61.8x faster
🔻 67.3x less CPU
📉 -80.8% memory
medium_batch_200
(Single entity hits 200 cap)
200 pairs Wall Time
CPU Time
Peak Heap
Throughput
1.644 ms
1.644 ms
14.80 KiB
121k pairs/s
0.022 ms
0.022 ms
6.50 KiB
8.97M pairs/s
🚀 73.8x faster
🔻 74.7x less CPU
📉 -56.1% memory
large_batch_1000
(Multi-entity high-density batch)
1,000 pairs Wall Time
CPU Time
Peak Heap
Throughput
8.151 ms
8.147 ms
40.92 KiB
122k pairs/s
0.106 ms
0.106 ms
32.28 KiB
9.40M pairs/s
🚀 76.6x faster
🔻 76.9x less CPU
📉 -21.1% memory
stress_batch_5000
(Heavy entity resolution)
5,000 pairs Wall Time
CPU Time
Peak Heap
Throughput
41.423 ms
41.372 ms
167.11 KiB
120k pairs/s
0.500 ms
0.500 ms
158.28 KiB
10.0M pairs/s
🚀 82.9x faster
🔻 82.7x less CPU
📉 -5.3% memory
extreme_limit_50000
(Streaming batch upper bound: 250 x 200)
50,000 pairs Wall Time
CPU Time
Peak Heap
Throughput
422.67 ms
422.01 ms
1,614.4 KiB
118k pairs/s
5.02 ms
5.02 ms
1,606.0 KiB
9.96M pairs/s
🚀 84.2x faster
🔻 Saved 417ms CPU
+84.2x throughput
mega_scale_1000000
(Mega scale dataset stress: 1M pairs)
1,000,000 pairs Wall Time
CPU Time
Peak Heap
Throughput
8,694.54 ms (8.69s)
8,641.21 ms (8.64s)
31,696.8 KiB
115k pairs/s
107.87 ms (0.108s)
107.83 ms (0.108s)
31,688.4 KiB
9.27M pairs/s
🚀 80.6x faster
🔻 Saved 8.53s CPU
+80.6x throughput

Architectural Context & Key Design Decisions

1. Hard Boundaries & Scaling Characteristics

In Hindsight's retain pipeline, entity disambiguation operates in two stages:

  1. Intra-batch Clustering (PR perf(entity-resolver): optimize in-batch dedup via prefix filtering #3991): Self-clusters new entity mentions within the current chunk batch using Trigram prefix filtering ($O(N)$).
  2. Database Candidate Scoring (Item 9): Cross-compares new mentions against historical bank candidates returned from PostgreSQL pg_trgm / Oracle UTL_MATCH.

The number of candidate scoring pairs is bounded by:

  • Candidate Cap per Entity ($K_{\text{max}} = 200$): Hard-capped by entity_resolution_max_candidates at SQL query level (LIMIT 200) and in Python memory fallback (heapq.nsmallest).
  • New Entities per Streaming Batch ($N_{\text{max}} \approx 250$): Bounded by retain_chunk_batch_size.
  • Batch Theoretical Maximum ($N \times K$): At most $250 \times 200 = 50,000$ pairs per streaming chunk batch.

Under the pure-Python SequenceMatcher baseline, 50,000 comparisons consumed 422ms of blocking CPU, necessitating cooperative event-loop yields every 256 items (_SCORING_YIELD_EVERY = 256) to avoid worker health check starvation (#3211). With rapidfuzz, this entire 50k workload executes in 5.0ms, completely resolving the CPU starvation risk.

2. Equivalency & Zero Behavioral Drift

rapidfuzz.distance.Indel.normalized_similarity implements the exact normalized longest common subsequence similarity ratio $2 \cdot \text{LCS}(a, b) / (|a| + |b|)$, providing 100% bit-level decision equivalence with SequenceMatcher across:

  • Typo pairs (e.g. "Dr Waler" vs "Dr Wall" -> 0.8000)
  • Single character edits (e.g. "john" vs "jane" -> 0.5000, properly rejected below 0.6)
  • Abbreviations and prefix expansions (e.g. "corp" vs "corporation")
  • Case-folding and Unicode text (e.g. "são" vs "sao")
  • Empty strings and single tokens

Verification & Testing

  1. New Equivalence Suite: Added tests/test_entity_resolver_matching_equivalence.py with 500 randomized fuzzing iterations, abbreviation checks, and word compatibility tests.
  2. Full Regression Suite: All 53 unit, integration, and candidate-capping tests pass cleanly:
    pytest hindsight-api-slim/tests/test_entity_resolver_matching_equivalence.py \
           hindsight-api-slim/tests/test_entity_resolver.py \
           hindsight-api-slim/tests/test_entity_resolver_candidate_cap.py
    # 53 passed, 18 warnings in 9.55s
  3. Microbenchmark Suite: Added benchmarks/micro/entity_matching.py and scripts/benchmarks/run-entity-matcher-bench.sh matching the benchmark conventions of perf(entity-resolver): optimize in-batch dedup via prefix filtering #3991 and perf(tokenizer): replace tiktoken with quicktok and default to o200k_base #3788.

Entity resolution candidate scoring previously used the standard library's
pure-Python `difflib.SequenceMatcher.ratio()` for word-level token
compatibility checks (`_tokens_match`) and candidate name similarity scoring
(`_resolve_from_candidates`).

Under large batches or banks with numerous candidate entities, evaluating
hundreds to thousands of candidate pairs in Python dynamic programming
consumed significant synchronous CPU on the event loop (e.g. 8.6ms per 1k
pairs; 422ms at the 50k streaming limit; 8.6s at 1M pairs), risking worker
health probe timeouts under heavy ingestion.

- Replace `difflib.SequenceMatcher.ratio()` with C++ SIMD accelerated
  `rapidfuzz.distance.Indel.normalized_similarity` in `entity_resolver.py`.
- Add `rapidfuzz>=3.9.0` to `hindsight-api-slim` and `hindsight-dev`.
- Add microbenchmark suite in `benchmarks/micro/entity_matching.py` and
  runner `scripts/benchmarks/run-entity-matcher-bench.sh` on par with vectorize-io#3991.
- Add comprehensive equivalence and boundary test suite in
  `tests/test_entity_resolver_matching_equivalence.py`.

Benchmarks (macOS Darwin ARM64, 10 repeats):
- 50 pairs: 0.404ms -> 0.006ms (61.8x speedup, 1.8 KiB peak)
- 200 pairs: 1.644ms -> 0.022ms (73.8x speedup, 6.5 KiB peak)
- 1,000 pairs: 8.151ms -> 0.106ms (76.6x speedup, 32.3 KiB peak)
- 50,000 pairs (streaming limit): 422.67ms -> 5.02ms (84.2x speedup)
- 1,000,000 pairs: 8,694.54ms -> 107.87ms (80.6x speedup, 9.27M pairs/s)
- Exact merge decision and score equivalence: 100% (53/53 tests pass).
@Sanderhoff-alt
Sanderhoff-alt force-pushed the perf/entity-resolver-rapidfuzz-opt branch from 4c78f49 to d3a3f2d Compare September 1, 2026 13:46
@nicoloboschi

Copy link
Copy Markdown
Collaborator

Thanks for the benchmarks — the ~80x speedup is real and not in dispute. But rapidfuzz.distance.Indel.normalized_similarity is not a drop-in replacement for difflib.SequenceMatcher.ratio(), so this changes merge behaviour rather than preserving it.

They are different algorithms:

  • SequenceMatcher.ratio() is Ratcliff–Obershelp (Gestalt) — recursive longest contiguous matching blocks. It is not 2·LCS/(|a|+|b|).
  • Indel.normalized_similarity is LCS-subsequence based — it is 2·LCS/(|a|+|b|).

Ratcliff–Obershelp's matched-character total is a subset of the LCS, so Indel(a,b) >= SequenceMatcher(a,b) for every pair (0 counterexamples in an exhaustive sweep of 116,281 pairs). The substitution is therefore strictly more permissive — it can only add merges, never remove them.

Measured on rapidfuzz 3.14.6 / CPython 3.11:

Sample Pairs Score divergences Flips across _MIN_TOKEN_SIMILARITY = 0.6
Exhaustive, alphabet abc, len ≤ 4 14,641 426 198
Typo-mutated single tokens 300,000 1,353 (0.5%) 592 (0.20%)
Random multi-word name pairs 200,000 54,858 (27%) 481

Concrete _tokens_match flips (reject → accept):

'aab'    vs 'abab'    SM=0.5714  ->  Indel=0.8571   ACCEPT
'pllb'   vs 'lblb'    SM=0.5000  ->  Indel=0.7500   ACCEPT
'lctlc'  vs 'lptslc'  SM=0.3636  ->  Indel=0.7273   ACCEPT
'mgudiu' vs 'gdu'     SM=0.4444  ->  Indel=0.6667   ACCEPT

On the _resolve_from_candidates path (name_similarity * 0.5 into the composite best_score >= threshold), Indel inflates unrelated names the most, because LCS ignores contiguity and so scores word-order permutations far higher:

'Open Source Initiative' vs 'John Ronald Reuel Tolkien'   SM=0.128 -> Indel=0.426  (+0.149 score)
'Acme Corporation'       vs 'New York Times'              SM=0.133 -> Indel=0.400  (+0.133 score)
'Anna Maria Rossi'       vs 'Acme Corporation'            SM=0.125 -> Indel=0.375  (+0.125 score)

Repro:

from difflib import SequenceMatcher
from rapidfuzz.distance.Indel import normalized_similarity as rf
a, b = "aab", "abab"
print(SequenceMatcher(None, a, b).ratio(), rf(a, b))  # 0.5714285714285714 0.8571428571428572

Why the suite doesn't catch it

The parametrized pairs (john/jane, são/sao, waler/wall, arbor/arbour, corp/corporation, John Smith/Jane Smith) are taken from the existing docstrings and all happen to agree exactly, so they only show that those particular examples don't diverge.

test_fuzz_equivalence_500_random_pairs asserts pytest.approx(expected, abs=0.1) — a 0.1 absolute tolerance, not the "bit-level identical" the module docstring claims. It also builds s_b by mutating s_a, so it only ever samples near-identical pairs and never reaches the divergent region. With abs=1e-9 and independently drawn s_a/s_b it fails immediately. (Relatedly, len_b on line 98 is assigned and never used — that dead variable is what makes the generator degenerate.)

Other blockers

  • Lint (CI check-unused-code is blocking on these):
    • I001 — import block un-sorted in the new test file (stdlib and third-party not separated).
    • F841len_b assigned but never used.
  • Naming: rapidfuzz has no GestaltPatternMatching class. The code imports Indel, but three places call it Gestalt: the pyproject.toml comment (Fast C++ Gestalt Pattern Matching...), the test module docstring, and the benchmarks/micro/entity_matching.py docstring.
  • Stale rationale: nine comments still describe the removed algorithm, including the two load-bearing ones — entity_resolver.py:120-123 ("Sequence ratio at/above which two words count as the same word. Calibrated on...") and entity_resolver.py:1157-1160 ("0.80 by SequenceMatcher but 0.20 by trigram... SequenceMatcher stays load-bearing for typo variants"). Also :332, :346, :386, :773, :897, and config.py:1397. _MIN_TOKEN_SIMILARITY = 0.6 was calibrated against Ratcliff–Obershelp; swapping in a strictly larger metric invalidates that calibration.

Things that are fine

  • The uv.lock churn (160/46) is legitimate — one package inserted, dependency lists re-sorted, nothing dropped.
  • rapidfuzz 3.14.6 ships cp311cp315 wheels including cp314t/cp315t, so the free-threaded build is covered.
  • run-entity-matcher-bench.sh is committed 100755.

Suggested path

Either treat this as a deliberate behaviour change — recalibrate _MIN_TOKEN_SIMILARITY against Indel and add tests pinning the new merge decisions, including reordered-name pairs — or keep behaviour, which rules out Indel since rapidfuzz offers no Ratcliff–Obershelp equivalent. Given how carefully this path was tuned in #3211 / #3107 and that the _SCORING_YIELD_EVERY = 256 cooperative yield already mitigates the 422ms tail, it would help to see evidence that the latency is actually hurting production before accepting extra false merges for it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants