-
Notifications
You must be signed in to change notification settings - Fork 0
0004 levenshtein myers backlog
Status: accepted · Date: 2026-08-01 · Revised: 2026-08-19
The cross-language bench (bench/) shows that the initial implementation of
Levenshtein.Distance — a rolling-row DP O(n·m) — is markedly slower than
rapidfuzz on long strings (≈ 37× at 512 characters), while being faster on short
strings (no call overhead). rapidfuzz owes that advantage to the bit-parallel
Myers algorithm (1999), in O(n·⌈m/w⌉) with w = 64 bits per machine word.
Since performance is the project's central argument, this algorithmic gap must be closed for medium/long strings.
Both the single-word and the blocked (multi-word) path shipped, the second in the 2026-08-05 revision below.
-
Single-word Myers shipped (
src/DataNet.Text/Distances/Myers.cs), wired as the fast path ofDistanceon thecharpath for a pattern of length 16–64 in Latin-1; falls back to the DP otherwise. Zero allocation (Peqtable instackalloc). Validated with no extra test code by the BMP oracle cases (Distance_default_utf16_matches_rapidfuzz_for_bmp_cases). -
Blocked (multi-word) Myers shipped. The bit vectors span
⌈m/64⌉words and the horizontal deltas carry from each word into the next; only the last word's bit at(m-1) mod 64moves the score. The 64-character cap on the fast path is gone.Length Python (rapidfuzz) before after 128 2 693 ns 36 178 ns 1 777 ns 512 21 688 ns 683 581 ns 20 555 ns 33× at 512 characters, which turns a 31× deficit into a slight lead.
Micro-optimising the DP was measured first and rejected: a char-specialised version with bounds checks elided via refs came out slower than the generic
Dp<char>(3.97 vs 3.50 ns/cell). A scalar rolling-row DP was already at its floor, and rapidfuzz's 0.08 ns/cell-equivalent is unreachable without doing 64 cells per word operation. The gap was never micro-architectural.
#208 update: the first item is done for the code-point mode, and the second has changed character rather than been solved.
The code-point mode takes the fast path by renaming rather than by a second kernel: the pattern's distinct code points are numbered
0..k-1, every text symbol the pattern lacks is renamed to one reserved slot, and the existingcharkernels run unchanged over the result. Measured in../guides/performance.md: 2× at 32 code points, 15× at 128, 32×–36× at 512, zero allocation.That buys a ceiling instead of a restriction. 255 distinct symbols fit the renamed alphabet and a pattern holding more falls back to the DP, which the benchmark measures rather than hides — the failed attempt costs ≈1%.
The second item is now tractable and deliberately not taken. The same renaming would lift the Latin-1 restriction on the
charpath, which is what "a sparse or hashed table would generalise it" was reaching for. It is not done here because that path is the hot one — this file's own note that helper calls cost measurably is about it — so it needs its own measurement and its own branch, not a change smuggled in beside a code-point lot.Indeland the length-32 bucket remain untouched; #208 is an umbrella and this was one of its three lots.
MyersMinPatternLengthstays at 16, now measured rather than inherited: at a pattern of exactly that length the code-point fast path is 1.5× ahead of the DP. Whether a lower gate would win more is untested and would move the character path too, the constant being shared.
- Extend the fast path to the code-point mode (
Distance<int>) and toIndel.
#273 update: the
Indelhalf is done, and it was never Indel's.Indelislen(a) + len(b) - 2·LCS, so the kernel belongs inLcs.SubsequenceLength— whichfuzz.ratio,process.extractand blocking deduplication all run. Hyyrö's bit-parallel LLCS recurrence over the same alphabet-table machinery, since Myers' own recurrence carries substitution and LCS does not.Two separable wins, and the second was not on this list:
Lcswas not trimming the common prefix and suffix thatLevenshteinalready stripped. Measured in../guides/performance.md: the 128 and 512 buckets go from 95–98× behind rapidfuzz to 2.07× and 2.15×, a 46× and 44× improvement, of which trimming alone accounts for 1.94× and 1.10×.The corpus reached none of it. Of 1 522 oracle cases, 97 reach the kernel and zero reach the blocked path, every pair fitting one machine word once trimmed — the failure this file records for #52, one lot later. Property tests against the dynamic program cover it now, and a mutation is what proved they had power: they were vacuous at first because with two arguments C# resolves
Lcs.SubsequenceLengthto the generic overload, so both sides ran the DP.
BitParallelMinPatternLengthstays at 16, measured this time rather than inherited: at that band the kernel is already 2.85× ahead of the DP. It is likely conservative — the kernel's floor is ~149 ns and the DP costs 161 ns at band 8 — which wants a sweep below 8 rather than a guess.
-
Lift the Latin-1 restriction. The equality table is 256 entries, so a pattern containing CJK or emoji still falls back to the DP — the figures above do not describe those inputs. A sparse or hashed table would generalise it.
-
The length-32 bucket sits at 1.4× behind rapidfuzz. It already takes the single-word path, so the cause differs from the one fixed here and needs its own measurement.
#208 update: closed, and this bullet had the cause wrong. "It already takes the single-word path" was true of 53% of the bucket and false of the rest. Split on the dispatch's own criterion, the pairs falling under the gate were 47% of the bucket and 70% of its cost — 650 ns/pair against 255 for the ones taking Myers, on a band a quarter the size. The kernel was never the problem; the gate was 16 where the curves cross at 8.
That also retires both "stays at 16" above. Neither was wrong when written — each measured a pattern at 16 and found the kernel ahead, which says the gate is not too low and says nothing about it being too high. Sweeping the constant against the committed corpus is what answered the other half, and it is the only way to: a constant the dispatch consults cannot be swept from inside the dispatch.
MyersMinPatternLengthis 8,BitParallelMinPatternLengthis 8, and the length-32 bucket went 427.6 → 204.8 ns/pair on Levenshtein and 318.7 → 145.6 on Indel — 1.46× and 1.31× ahead of rapidfuzz where it had been 1.4× behind.The shared constant is now two. "Whether a lower gate would win more is untested and would move the character path too, the constant being shared" was the right worry: it would have, and in the wrong direction. The code-point path renames both operands through a 512-entry probe table first, so it carries the larger fixed cost and crosses at 10, not 8 — at a pattern of 8 the DP is 11% ahead of it.
MyersMinCodePointPatternLengthis its own constant for that reason, measured in../guides/performance.md.A second finding, independent of the gate. Both single-word kernels called
Clear()on a 256-entry equality table thatstackallochad already zeroed — a second 2 KB memset on a call whose work isO(n). This file's own note that the probe table needs no fill, because "stackalloc zeroes and nothing here disables localsinit", sat three constants above the line that cleared. Worth 12% of the bucket on Levenshtein and 17% on Indel, on top of the gate. Right-sizing the table to the pattern's own alphabet is still open, and is still the same change as lifting the Latin-1 restriction above.
The blocked path shipped with zero coverage from the existing corpus, and the
full suite passed regardless. The long family draws from BMP ranges, so every
one of its 85 long cases contains CJK, fails the Latin-1 check and falls back to
the DP — the new code was never executed.
Two long_ascii/long_latin families were appended to build_pairs to fix that;
89 cases now genuinely exercise it. Appending rather than inserting keeps the RNG
stream intact, so every pre-existing case keeps its id and value.
The lesson generalises: a green suite proves nothing until you have checked that the new path is reached. Coverage of a file is not coverage of a branch.
#208 update: it generalised, to the same corpus, one plane up. Measured before writing the code-point path: of 1425 cases, 283 reached the length gate and 194 of those held a character above U+00FF — a real net — but none held a supplementary character, because the
supplementaryfamily draws 2–10 characters and the gate opens at 16. Surrogate decoding was the only genuinely new part of the change and had no case long enough to exercise it.A
long_supplementaryfamily was appended, again last, again leaving every pre-existing id and value untouched. Its 97 cases split 19 on the single-word kernel, 60 on the blocked one, and 18 above the 255-symbol ceiling on the fallback — so all three outcomes are covered, including the refusal.
- Myers manipulates per-alphabet-character bit masks; implementing it cleanly for
an arbitrary Unicode alphabet needs a
char -> bitmasktable (dictionary or array depending on range). Start with the common ASCII/BMP path. - Allowed inspiration source: Myers' published paper, "A Fast Bit-Vector Algorithm
for Approximate String Matching Based on Dynamic Programming" (JACM 1999) —
published pseudo-code, no transcription of copyleft source (cf.
0003-provenance-and-licensing.md).
- 0001-target-framework
- 0002-unicode-comparison-unit
- 0003-provenance-and-licensing
- 0004-levenshtein-myers-backlog
- 0005-hamming-jellyfish-divergence
- 0006-ratcliff-autojunk
- 0007-metaphone-scope
- 0008-italian-enza-nltk-divergence
- 0009-sample-consumes-a-local-feed
- 0010-stop-word-list-provenance
- 0011-persistence-format
- 0012-per-package-versioning
- 0013-sentencepiece-parity-scope
- 0014-precompiled-normalizer
- 0015-sonar-rules-in-the-build
- 0016-metrics-package-placement
- 0017-bpe-parity-scope
- 0018-multiclass-roc-auc-parallelism-is-opt-in
- 0019-the-net-analysers-run-in-the-build-too
- 0020-normalize-is-a-projection-not-a-parameter
- 0021-multioutput-is-a-method-not-an-enum
- 0022-added-token-matching-flags
- 0023-byte-level-decode-substitutes
- 0024-weighted-median-averages-within-scikit-learns-epsilon
- 0025-quickselect-replaces-a-full-sort-for-the-median
- 0026-r2-and-explainedvariance-split-their-undefined-cases-differently
- 0027-r2-and-explainedvariance-vectorize-only-a-single-output
- 0028-log1p-is-kahans-identity-not-math-log-1-plus-x
- 0029-balanced-accuracy-adjusted-is-left-to-ieee-754-at-the-edge
- 0030-cohen-kappa-keeps-scikit-learns-expected-matrix-orientation
- 0031-nosamplecorrect-mirrors-numpys-float64-upcast
- 0032-fbeta-substitutes-tp-predicted-and-support-algebraically
- 0033-compensated-sum-is-neumaiers-variant
- 0034-dropout-is-refused-for-want-of-a-user
- 0035-a-null-pre-split-is-removed-with-invert-not-isolated
- 0036-a-member-may-ship-without-an-oracle-if-it-says-so
- 0037-the-guards-run-before-the-commit
- 0038-the-gate-confronts-an-exception-tag-with-the-page-that-documents-it
- 0039-mutual-information-returns-zero-on-an-empty-input
- 0040-a-curve-is-a-sealed-class-per-curve
- 0041-one-sample-file-per-public-class
- 0042-phonetic-encoders-refuse-a-null-word
- 0043-the-equality-table-is-sized-to-the-pattern
- 0044-compression-belongs-to-the-caller
- 0045-a-console-call-carries-its-reason-on-the-line
- 0046-check-adr-immutable-runs-in-ci-only
- 0047-one-gate-per-kernel-not-one-per-alphabet
- 0048-the-gate-depends-on-the-kernel-and-the-alphabet
- 0049-two-gates-per-kernel-tested-where-the-width-is-known
- 0050-the-sentencepiece-bpe-lineage-stays-a-bpe-model
- 0051-the-save-paths-cost-is-the-buffer-not-the-encoding
- 0052-pre-sizing-the-artifact-file-buys-nothing-on-a-delayed-allocation-filesystem
- benchmark_latest
- decisions
- equivalence
- matplotlib
- migration
- nightly_run
- numpy
- pandas
- performance
- pytorch
- seaborn
- sklearn
- statsmodels