perf(entity-resolver): accelerate candidate scoring with rapidfuzz C++ SIMD - #3995
perf(entity-resolver): accelerate candidate scoring with rapidfuzz C++ SIMD#3995Sanderhoff-alt wants to merge 1 commit into
Conversation
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).
4c78f49 to
d3a3f2d
Compare
|
Thanks for the benchmarks — the ~80x speedup is real and not in dispute. But They are different algorithms:
Ratcliff–Obershelp's matched-character total is a subset of the LCS, so Measured on rapidfuzz 3.14.6 / CPython 3.11:
Concrete On the 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.8571428571428572Why the suite doesn't catch itThe parametrized pairs (
Other blockers
Things that are fine
Suggested pathEither treat this as a deliberate behaviour change — recalibrate |
Summary
This PR replaces the pure-Python
difflib.SequenceMatcher.ratio()inentity_resolver.py(_tokens_matchand_resolve_from_candidates) with C++ SIMD-acceleratedrapidfuzz.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
SequenceMatchergenerated 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 10on macOS Darwin (Apple Silicon ARM64, Python 3.11):Benchmark Summary Table
SequenceMatcher)rapidfuzzC++)typical_batch_50(Typical streaming retain batch)
CPU Time
Peak Heap
Throughput
0.404 ms
9.50 KiB
123k pairs/s
0.006 ms
1.83 KiB
7.64M pairs/s
🔻 67.3x less CPU
📉 -80.8% memory
medium_batch_200(Single entity hits 200 cap)
CPU Time
Peak Heap
Throughput
1.644 ms
14.80 KiB
121k pairs/s
0.022 ms
6.50 KiB
8.97M pairs/s
🔻 74.7x less CPU
📉 -56.1% memory
large_batch_1000(Multi-entity high-density batch)
CPU Time
Peak Heap
Throughput
8.147 ms
40.92 KiB
122k pairs/s
0.106 ms
32.28 KiB
9.40M pairs/s
🔻 76.9x less CPU
📉 -21.1% memory
stress_batch_5000(Heavy entity resolution)
CPU Time
Peak Heap
Throughput
41.372 ms
167.11 KiB
120k pairs/s
0.500 ms
158.28 KiB
10.0M pairs/s
🔻 82.7x less CPU
📉 -5.3% memory
extreme_limit_50000(Streaming batch upper bound: 250 x 200)
CPU Time
Peak Heap
Throughput
422.01 ms
1,614.4 KiB
118k pairs/s
5.02 ms
1,606.0 KiB
9.96M pairs/s
🔻 Saved 417ms CPU
⚡ +84.2x throughput
mega_scale_1000000(Mega scale dataset stress: 1M pairs)
CPU Time
Peak Heap
Throughput
8,641.21 ms (8.64s)
31,696.8 KiB
115k pairs/s
107.83 ms (0.108s)
31,688.4 KiB
9.27M pairs/s
🔻 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:
pg_trgm/ Oracle UTL_MATCH.The number of candidate scoring pairs is bounded by:
entity_resolution_max_candidatesat SQL query level (LIMIT 200) and in Python memory fallback (heapq.nsmallest).retain_chunk_batch_size.Under the pure-Python
SequenceMatcherbaseline, 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). Withrapidfuzz, this entire 50k workload executes in 5.0ms, completely resolving the CPU starvation risk.2. Equivalency & Zero Behavioral Drift
rapidfuzz.distance.Indel.normalized_similarityimplements the exact normalized longest common subsequence similarity ratioSequenceMatcheracross:"Dr Waler"vs"Dr Wall"->0.8000)"john"vs"jane"->0.5000, properly rejected below0.6)"corp"vs"corporation")"são"vs"sao")Verification & Testing
tests/test_entity_resolver_matching_equivalence.pywith 500 randomized fuzzing iterations, abbreviation checks, and word compatibility tests.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.55sbenchmarks/micro/entity_matching.pyandscripts/benchmarks/run-entity-matcher-bench.shmatching 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.