Skip to content

perf(Levenshtein): speed up Python distance -- 34% less time - #504

Open
dexhunter wants to merge 1 commit into
rapidfuzz:mainfrom
dexhunter:perf/python-levenshtein-distance
Open

dexhunter wants to merge 1 commit into
rapidfuzz:mainfrom
dexhunter:perf/python-levenshtein-distance

Conversation

@dexhunter

@dexhunter dexhunter commented Nov 4, 2026 •

Copy link
Copy Markdown

TL;DR

  • Speed up _uniform_distance, the Python Levenshtein path for unit edit costs.
  • Use final bit counts and avoid repeated score updates.
  • Unicode comparison batch: 125.35 ms -> 82.93 ms, 33.8% less time.
  • Native validation: 348 tests passed across process, fuzz, and distance suites.

What & Why

The old loop updated the distance after every character, although callers only need the final value. The new code sums the stored row differences once to get that value.

Plain strings also return early when equal and put the shorter string in the character loop. These changes preserve the edit-distance formula and keep the computation shared across input types.

Results

CPython 3.12.13 on Linux x86_64, with one isolated CPU core. The table uses random ASCII strings, seed 18, and 1,000 calls per length. Each value is the mean of two runs; each run uses the best of seven repeats from bench.common.benchmark.

Situation Before After Change
Empty strings; existing early return 0.181 µs 0.180 µs Unchanged within timing noise
16-character strings 6.62 µs 4.54 µs 31.4% less time
64-character strings 41.10 µs 31.64 µs 23.0% less time
256-character strings 223.25 µs 176.33 µs 21.0% less time
512-character strings 535.13 µs 432.73 µs 19.1% less time

Validation

The batch result above uses the median times from three runs. It combines Python Levenshtein distance and normalized similarity on 50 Unicode pairs, Indel controls, and sequence boundary cases. All returned values and exceptions matched the original version.

Native test command:

PYTHONPATH=src python -m pytest \
  tests/test_process.py tests/test_hypothesis.py tests/test_fuzz.py \
  tests/distance/test_init.py tests/distance/test_Levenshtein.py \
  tests/distance/test_levenshtein_zero_deletion.py

Black 25.1.0 and Ruff 0.11.5 pass with the repository configuration.

To reproduce the size checks, install packaging and tqdm, then run this on each checkout. It prints seconds per call.

PYTHONPATH=src:. python - <<'PY'
from bench.common import benchmark

setup = """
from rapidfuzz.distance.Levenshtein_py import distance
import random
import string
random.seed(18)
characters = string.ascii_letters + string.digits + string.whitespace + string.punctuation
a = ''.join(random.choice(characters) for _ in range({0}))
choices = [''.join(random.choice(characters) for _ in range({0})) for _ in range({1})]
"""
print(benchmark('Python Levenshtein', '[distance(a, b) for b in choices]',
                setup, [0, 16, 64, 256, 512], 1000))
PY

Why this is safe and easy to maintain

The change stays inside one function and keeps one readable distance loop. Public results, cutoffs, weights, and exceptions are unchanged. Hypothesis and Levenshtein tests cover Unicode, integer sequences, and empty inputs. Non-string inputs allocate a temporary mask list to preserve the number of items actually consumed.

AI assistance

This PR was prepared with AI assistance from Aiden. Candidates were tracked with Weco Observe.

Weco share link: https://dashboard.weco.ai/share/IBeNYNHxcI8YFj4EM3nBE_bPTn9T6oZz

Compute the final distance from the bit vectors and reduce work in the
shared character loop. Keep string equality and sequence ordering checks
limited to plain strings.

Co-Authored-By: Aiden <aiden@weco.ai>

This branch has not been deployed

No deployments
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.

1 participant