From f50cc8cc623bcade36d3c7fbf2eb3c5b0c78f25c Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sat, 7 Nov 2026 15:09:22 +0800 Subject: [PATCH] perf(search): skip SequenceMatcher when the ratio cannot reach the threshold The LoRAs page search ran difflib.SequenceMatcher over every token of every cached entry. Matching entries exit at the substring check, so the cost was paid entirely by the entries that do not match - exactly the state the list is in while a word is still being typed. At 100k entries one non-matching query took 1.7-3.2s, and the handler runs that work on the event loop. Gate the matcher with two exact necessary conditions instead of a heuristic: 2*min(len_a,len_b)/(len_a+len_b) is an upper bound on the ratio, and so is twice the character-multiset intersection (a matching block can only use characters present in both strings). Pairs that fail either bound provably cannot reach the threshold, so every result is unchanged. A non-matching query now takes 0.28-0.36s at 100k entries (6-9x faster) and 54-72ms at 20k (was 0.32-0.62s). Add a Hypothesis equivalence test against the previous implementation, a regression test proving obvious misses never reach the matcher, and a library-scale benchmark. Fixes #1179 --- py/utils/utils.py | 27 ++++ tests/performance/test_cache_performance.py | 45 +++++++ tests/utils/test_fuzzy_match_prefilter.py | 133 ++++++++++++++++++++ 3 files changed, 205 insertions(+) create mode 100644 tests/utils/test_fuzzy_match_prefilter.py diff --git a/py/utils/utils.py b/py/utils/utils.py index 2f02badd..6f5ea209 100644 --- a/py/utils/utils.py +++ b/py/utils/utils.py @@ -1,3 +1,4 @@ +from collections import Counter from difflib import SequenceMatcher import functools import logging @@ -409,6 +410,12 @@ def fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool: """ Check if text matches pattern using fuzzy matching. Returns True if similarity ratio is above threshold. + + Two cheap necessary conditions gate ``SequenceMatcher``: its ratio + ``2*M/(len_a+len_b)`` is bounded both by the length ratio and by the number + of characters the two strings have in common. Skipping pairs that cannot + reach ``threshold`` keeps per-keystroke searches responsive on large + libraries without changing a single result (see #1179). """ if not pattern or not text: return False @@ -428,8 +435,28 @@ def fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool: # If not found as substring, try fuzzy matching # Check if any part of the text matches this word + word_len = len(word) + word_char_counts = list(Counter(word).items()) found_match = False for text_part in text.split(): + part_len = len(text_part) + total_len = word_len + part_len + min_len = part_len if part_len < word_len else word_len + + # No pairing can beat 2*min_len/total_len, so a text part whose + # length is far off the query word is discarded outright. + if 2 * min_len < threshold * total_len: + continue + + # Matching blocks can only use characters present in both strings, + # so the character-multiset intersection bounds the matched chars. + common = 0 + for char, count in word_char_counts: + part_count = text_part.count(char) + common += count if count < part_count else part_count + if 2 * common < threshold * total_len: + continue + ratio = SequenceMatcher(None, text_part, word).ratio() if ratio >= threshold: found_match = True diff --git a/tests/performance/test_cache_performance.py b/tests/performance/test_cache_performance.py index 65a74d7b..a9927a4c 100644 --- a/tests/performance/test_cache_performance.py +++ b/tests/performance/test_cache_performance.py @@ -150,6 +150,51 @@ class TestFuzzyMatchPerformance: benchmark(match) +class TestFuzzyMatchLibraryScale: + """Benchmark one search query over a large library (issue #1179). + + Before the prefilters, a non-matching query at this size spent well over + 100ms in ``SequenceMatcher`` because every token of every non-matching + entry reached the matcher. + """ + + LIBRARY_SIZE = 20_000 + + @staticmethod + def _build_library(size: int) -> list[str]: + vocabulary = ( + "anime style character realistic portrait detail lineart chinese dark " + "magic girl pixel art photo cinematic illustration concept fantasy armor " + "cyberpunk watercolor sketch vintage retro neon glow soft light shadow " + "chibi cute mecha samurai landscape nature city night flower dragon knight" + ).split() + folders = ("", "anime/", "realistic/", "styles/", "characters/") + rng = random.Random(1179) + library = [] + for _ in range(size): + words = rng.sample(vocabulary, k=rng.randint(2, 4)) + library.append(f"{rng.choice(folders)}{'_'.join(words)}.safetensors") + return library + + def test_non_matching_query(self, benchmark): + """Worst case: nothing matches, so every entry is fully scanned.""" + library = self._build_library(self.LIBRARY_SIZE) + + def search(): + return sum(1 for name in library if fuzzy_match(name, "zzzqq")) + + assert benchmark(search) == 0 + + def test_typo_query(self, benchmark): + """A typo shares characters with many tokens, so it does the most work.""" + library = self._build_library(self.LIBRARY_SIZE) + + def search(): + return sum(1 for name in library if fuzzy_match(name, "anmei")) + + benchmark(search) + + class TestRecipeFingerprintPerformance: """Performance benchmarks for recipe fingerprint calculation.""" diff --git a/tests/utils/test_fuzzy_match_prefilter.py b/tests/utils/test_fuzzy_match_prefilter.py new file mode 100644 index 00000000..2a62ad4e --- /dev/null +++ b/tests/utils/test_fuzzy_match_prefilter.py @@ -0,0 +1,133 @@ +"""Equivalence tests for the ``fuzzy_match`` prefilters added for #1179. + +``fuzzy_match`` now skips ``SequenceMatcher`` when its ratio provably cannot +reach ``threshold``. These tests pin the two properties that make that legal: +the optimised implementation agrees with the pre-optimisation one on every +input, and obvious misses never reach the matcher at all. +""" + +from __future__ import annotations + +from difflib import SequenceMatcher + +import pytest +from hypothesis import given, settings, strategies as st + +import py.utils.utils as utils_module +from py.utils.utils import fuzzy_match + + +def reference_fuzzy_match(text: str, pattern: str, threshold: float = 0.85) -> bool: + """The implementation from before #1179, kept as the oracle.""" + if not pattern or not text: + return False + + text = text.lower() + pattern = pattern.lower() + + for word in pattern.split(): + if word in text: + continue + + found_match = False + for text_part in text.split(): + if SequenceMatcher(None, text_part, word).ratio() >= threshold: + found_match = True + break + + if not found_match: + return False + + return True + + +ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789 -." +TEXT = st.text(alphabet=ALPHABET, max_size=60) +PATTERN = st.text(alphabet=ALPHABET, max_size=24) +THRESHOLDS = st.sampled_from([0.0, 0.5, 0.75, 0.8, 0.85, 0.9, 1.0]) + + +@st.composite +def similar_pairs(draw): + """Two strings sharing a stem, to exercise the threshold boundary.""" + stem = draw(st.text(alphabet="abcdefghijklmnopqrstuvwxyz_", min_size=1, max_size=10)) + tail_a = draw(st.text(alphabet="abcdefghijklmnopqrstuvwxyz", max_size=3)) + tail_b = draw(st.text(alphabet="abcdefghijklmnopqrstuvwxyz", max_size=3)) + return stem + tail_a, stem + tail_b + + +@settings(max_examples=300, deadline=None) +@given(TEXT, PATTERN, THRESHOLDS) +def test_matches_reference_on_arbitrary_input(text, pattern, threshold): + assert fuzzy_match(text, pattern, threshold) == reference_fuzzy_match( + text, pattern, threshold + ) + + +@settings(max_examples=300, deadline=None) +@given(similar_pairs(), THRESHOLDS) +def test_matches_reference_near_threshold(pair, threshold): + text, pattern = pair + assert fuzzy_match(text, pattern, threshold) == reference_fuzzy_match( + text, pattern, threshold + ) + # Both argument orders: the prefilter is not allowed to be directional. + assert fuzzy_match(pattern, text, threshold) == reference_fuzzy_match( + pattern, text, threshold + ) + + +class _CountingSequenceMatcher(SequenceMatcher): + """SequenceMatcher that records how often ``ratio()`` is evaluated.""" + + calls = 0 + + def ratio(self) -> float: + type(self).calls += 1 + return super().ratio() + + +@pytest.fixture +def counting_matcher(monkeypatch): + _CountingSequenceMatcher.calls = 0 + monkeypatch.setattr(utils_module, "SequenceMatcher", _CountingSequenceMatcher) + return _CountingSequenceMatcher + + +def test_obvious_miss_never_reaches_sequence_matcher(counting_matcher): + assert fuzzy_match("photorealistic_anime_v3_fp16", "zzzqq") is False + assert counting_matcher.calls == 0 + + +def test_length_mismatch_never_reaches_sequence_matcher(counting_matcher): + # 2 * 3 / (3 + 18) is far below the 0.85 threshold. + assert fuzzy_match("high_detail_anime_style", "abc") is False + assert counting_matcher.calls == 0 + + +def test_near_match_still_consults_sequence_matcher(counting_matcher): + # "animee" is not a substring of "anime", so only the matcher can accept it. + assert fuzzy_match("anime", "animee") is True + assert counting_matcher.calls >= 1 + + +@pytest.mark.parametrize( + ("text", "pattern", "expected"), + [ + # A leading insertion changes the first character: a prefix/first-char + # prefilter would wrongly reject this, the applied bounds do not. + ("anime", "nanime", True), + ("anime", "animee", True), + # A transposition scores 0.8, below the default threshold. + ("anime", "anmei", False), + ("anime", "animee_", False), + ("Anime", "anime", True), + ("anime style", "anime styl", True), + ("anime", "anime style", False), + ("anime_style_v2", "zzzqq", False), + ("", "anime", False), + ("anime", "", False), + ], +) +def test_threshold_boundaries_are_preserved(text, pattern, expected): + assert fuzzy_match(text, pattern) is expected