perf(entity-resolver): optimize in-batch dedup via prefix filtering - #3991
Conversation
edf9d73 to
5de747c
Compare
Update on the Open Question: Safely Raising
|
| Batch Scale ($) | Stage 1: Intra-batch Clustering (#3991) | Stage 2: DB Candidate Scoring (#3995) | Total Combined CPU Latency |
|---|---|---|---|
| 1.60 ms | ~5.0 ms (50k pairs max) | ~6.6 ms | |
| 4.34 ms | ~10.0 ms (100k pairs max) | ~14.3 ms | |
| 12.65 ms | ~20.2 ms (200k pairs max) | ~32.8 ms |
Conclusion & Recommendation
Raising _INTRABATCH_MAX_NAMES to 500 (or exposing it via config) is now completely safe from an event-loop responsiveness perspective (
We can land #3991 and #3995 first, and follow up with a minor tune to lift _INTRABATCH_MAX_NAMES.
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).
`_find_intrabatch_similar_pairs` computes pairwise trigram Jaccard similarities between newly extracted entity names in the same retain batch to form alias clusters before database insertion. Previously, the pass used an O(N^2) pairwise double-loop over sets, incurring set intersection churn, and candidate scoring redundantly re-computed `_trigram_set` on candidate canonical names. This commit introduces four targeted optimizations: * Prefix Filtering Principle: replace naive O(N^2) pairwise iteration with an All-Pairs Set Similarity Join inverted index on frequency-sorted trigram prefixes, pruning >95% of non-candidate pairs in sub-linear time. * Fast set-length pruning: skip Jaccard intersection for candidate pairs failing the necessary length bound |B| >= theta * |A| before set verification. * Candidate trigram pre-computation: pre-calculate trigram sets for unique candidate canonical names at batch scope, eliminating repeated regex/slicing churn in the candidate scoring inner loop. * Bit-exact conformance: guarantee 100% equivalence with PostgreSQL pg_trgm Jaccard similarity across punctuation, case, accents, and CJK text. Benchmarked on Apple Silicon (threshold=0.5, 10 repeats, exact matching): 1. Production Retain Batches: * N = 20: Wall: 0.14 ms (was 0.09 ms), CPU: 0.14 ms, RAM: 76.1 KiB (+28.2 KiB) * N = 50: Wall: 0.27 ms (was 0.29 ms), CPU: 0.27 ms, RAM: 144.9 KiB (+49.6 KiB) * N = 250: Wall: 1.60 ms (was 4.72 ms), CPU: 1.60 ms, RAM: 672.2 KiB (+138.0 KiB) 2. Large Document & Bulk Import Stress: * N = 500: Wall: 4.34 ms (was 19.84 ms), CPU: 4.34 ms, RAM: 1552.8 KiB (+250.8 KiB) * N = 1000: Wall: 12.65 ms (was 78.25 ms), CPU: 12.65 ms, RAM: 3636.6 KiB (+466.1 KiB) Tests: all 41 test_entity_intrabatch_clustering and test_entity_resolver tests pass. Includes 500-batch adversarial fuzzing and a dedicated microbenchmark in hindsight-dev/benchmarks/micro/entity_resolver_bench.py.
Applies review feedback to the prefix-filtering in-batch dedup: - Pin the join against the O(N^2) loop it replaced. The existing 41 tests pass on either implementation, so nothing was holding the prefix bound in place; a bad bound shows up as a *missing* pair, which no fixed example catches. Adds a randomised equivalence test over 14 cutoffs (including exactly-achievable Jaccard ratios like 7/13, where a float `t * |A|` landing a hair above a whole number would shorten the prefix by one), plus the ordering, no-trigram and all-names-alike cases. An off-by-one in the prefix length now fails 14 of them. - Correct the stated invariant. Ascending set size is not what makes the pruning lossless — each prefix is cut from its own size, and the shorter set's is longer than the bound demands. The sort is there so the size filter has something to reject; descending order is equally correct and simply prunes nothing. - Drop "sub-linear" and the ">95%" headline from the docstring for the measured numbers, including the shape that defeats the filter entirely: 250 mutually similar names run ~1.2x slower than the double loop, 500 cost ~156ms of un-yielded CPU. That is the answer to whether _INTRABATCH_MAX_NAMES can be raised to 500 on these numbers: not yet. - Make the candidate trigram cache lazy. Precomputing it over every candidate in the batch did the work for label rows and mentions that resolve before scoring, and did it in one un-yielded block ahead of the _SCORING_YIELD_EVERY loop that exists to keep the event loop responsive (vectorize-ioGH-3211). The memo the scoring loop already had gets the same win for free. - Rebuild the benchmark workloads from distinct names. `make_batch` sampled 47 base names with replacement, so `bulk_import_1000` held ~280 distinct ones; the caller passes `rep_by_lower.values()`. On the real shape the win is larger (3.9x at 250, 8x at 1000) and the regressions are visible: two adversarial workloads and a lowered-cutoff one now appear in the table instead of only in review. - _PrefixIndexed dataclass in place of the 5-tuple.
Replays 9,525 per-document name batches harvested from LoCoMo and LongMemEval — the shape retain actually builds, median 58 distinct names, p95 156 — through both implementations. Identical pairs on every batch at six cutoffs; 2.5x faster over the corpus, with the whole win in the tail and a small loss on the ~46% of batches under 50 names.
…self run() took its conformance reference from whichever variant iterated first, which is prefix_filtering — so the "exact" column compared the optimisation against its own output and would have read exact however wrong it was. Pins the reference to _v_baseline_quadratic. Also rewrites the module docstring: it carried tracker ids from another repo, and described the baseline as "without candidate caching", which is a different change that this benchmark does not measure.
5de747c to
c24d1c2
Compare
Summary
Replaces the
O(N^2)pairwise loop in_find_intrabatch_similar_pairswith a prefix-filtering set-similarity join (AllPairs / PPJoin), so in-batch entity dedup verifies only the pairs that can clear the merge cutoff instead of all of them. Returns exactly the pairs the double loop returned — same Jaccard, same cutoff.Also memoises the candidate trigram sets in
_resolve_from_candidates, so a canonical name shared by several mentions in a batch is trigrammed once rather than once per mention.Relates to #3107, #3211, #3751.
How the pruning is lossless
Sort each name's trigrams rarest-first and index only the leading
|A| - ceil(t * |A|) + 1of them. Two sets sharing none of those tokens cannot overlap enough to cleart, so a pair that misses every indexed token can be skipped without computing its similarity.Each prefix is cut from its own size, which is what makes it safe: a qualifying pair needs an overlap of at least
ceil(t * max(|A|, |B|)), and the shorter set's prefix — cut for its own smaller size — is longer than that bound demands, so the pair cannot slip past both prefixes. Ascending set-size order is not load-bearing for correctness (descending is equally correct); it is there so the|B| >= t * |A|size filter has something to reject.Equivalence
tests/test_entity_intrabatch_clustering.pypins the join against the loop it replaced, over randomized batches at 14 cutoffs — including exactly-achievable Jaccard ratios like 7/13, where a floatt * |A|landing a hair above a whole number would shorten the prefix by one and silently drop a pair sitting on the cutoff. Plus the ordering, no-trigram and all-names-alike cases. An off-by-one in the prefix length fails 14 of these tests; dropping the size filter fails 15.Checked outside the suite as well:
a/bforb <= 24) — 0 mismatches.Performance
./scripts/benchmarks/run-entity-resolver-bench.sh --repeats 10. Workloads are built from distinct names, because the caller passesrep_by_lower.values()— one entry per distinct new name in the batch.micro_batch_20small_batch_50cap_batch_250cap_batch_250_low_cutoff(t=0.2)adversarial_250_alikeabove_cap_500above_cap_500_alikebulk_import_1000Peak memory rises by 151 KiB at the 250-name cap and 483 KiB at 1000 — the inverted index and the prefix lists, all local to the function.
What this looks like on real batches
The synthetic numbers above are the ceiling, not the expectation. Replaying the 9,525 real per-document batches (median 58 distinct names, p95 156, max 438):
2.47x over the whole corpus (9,136 ms → 3,705 ms), with the win entirely in the tail: roughly 46% of real batches are under 50 names and get slightly slower, by ~40 µs each. That is well below the noise floor of a retain that spends 10–100 ms on DB and LLM, and a size threshold would buy those microseconds back at the cost of a second code path — so there isn't one.
Two shapes that are slower
Acme Corporation Subsidiary 0001probe into every bucket, so the filter prunes nothing and the index is pure overhead: ~1.2x slower than the double loop. Bounded, and_INTRABATCH_MAX_NAMES = 250keeps it around 29 ms. Both shapes are now workloads in the benchmark rather than footnotes.On raising
_INTRABATCH_MAX_NAMESLeft at 250. The distinct-name numbers make 500 look free, but the mutually-similar shape at 500 names costs ~156 ms of CPU with no
awaitinside the join — worse than the ~80 ms at N=1000 that the cap was originally chosen against. Real batches also top out at 438 names and sit at 58 median, so the cap is rarely the binding constraint. Worth revisiting against a real bulk-import corpus (catalogs, SKUs), not on these numbers.Also in this PR
entity-resolver-benchmicrobenchmark +./scripts/benchmarks/run-entity-resolver-bench.sh. Its conformance column is measured against_v_baseline_quadraticspecifically, not against whichever variant runs first — otherwise it would compare the optimisation to its own output._PrefixIndexeddataclass rather than a 5-tuple._SCORING_YIELD_EVERYyield points (resolver: synchronous candidate-scoring loop can block the worker event loop for minutes on large candidate sets #3211).