| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
Disclaimer: I guided Claude Code to generate the code, the benchmarks and the summary below. I felt it's safe to use AI for this issue because it is purely a logical module port and does not touch any of the Python core.
Add a C accelerator module _difflib for difflib.SequenceMatcher, modeled after the decimal/_pydecimal/_decimal pattern. Output is bit-identical to the existing pure-Python implementation (including the documented leftmost-maximal-match tie-break); the win comes from constant-factor optimizations (the algorithm is still Ratcliff-Obershelp).
Across six realistic workloads, the accelerator is 5–15× faster than pure Python on CPython main (3.16-dev), and faster than both existing PyPI accelerators (cdifflib, cydifflib) on every workload tested.
The pure-Python SequenceMatcher is preserved verbatim as a reference/fallback. The layout mirrors decimal/datetime/pickle:
When _difflib is unavailable, the shim is a no-op and SequenceMatcher stays the pure-Python class.
Each phase preserves the algorithm; each row shows what changed and why.
| phase | what changed | why |
|---|---|---|
| 1 | C port of find_longest_match; j2len replaced with paired int arrays + generation counter (no per-row clear) | Eliminates per-row dict allocation + per-j dict probe in the inner DP loop. |
| 2 | C port of chain_b; type-specialized iteration of b for str/list/tuple/bytes | chain_b became >50% of remaining cost after phase 1; ~25k setdefault+append in Python. |
| 3 | Full Ratcliff-Obershelp recursion in C; DP and extension passes operate on int32 label arrays (a_lbl, a_dp, b_lbl, junk_mask) | Hundreds of FLM calls per ratio() each cross the Python/C boundary; extension passes did PyObject_RichCompareBool per element. |
| 4 | Codepoint-keyed cp_full[]/cp_dp[] arrays for str b; str a fast-path reads codepoint and indexes the array | For str inputs we paid PyUnicode_FromOrdinal + dict-probe per element on both a and b. |
| 5 | Bytes fast-path (cp arrays of size 256), persistent DP scratch (no per-call alloca/memset), skip max-cp scan for UCS1 strings | Bytes paid PyLong_FromLong per element under autojunk; pure-Python's per-int allocator costs dominated for the bytes workload. |
Phase 1 is reproduced by monkey-patching _pydifflib.SequenceMatcher.find_longest_match with an early C port that still talks to the Python b2j dict; phase 2 also swaps in a C __chain_b; phase 3 disables the codepoint-keyed lookup tables on top of the current code; phase 4 disables only the phase-5-specific changes (bytes fast-path and the UCS1 max-codepoint shortcut). All five measurements run on the same release-mode in-tree CPython main build, with pyperf (20 processes × 3 values per workload, default warmup, system tuned via pyperf system tune). All times are pyperf mean ± stddev for SequenceMatcher(...).ratio() + .get_opcodes():
| case | pure-Py | phase 1 | phase 2 | phase 3 | phase 4 | phase 5 (final) |
|---|---|---|---|---|---|---|
| str_2k_similar | 908 ± 24 us | 391 ± 12 us | 268 ± 12 us | 228 ± 6 us | 132 ± 2 us | 133 ± 8 us |
| str_8k_similar | 3.73 ± 0.08 ms | 1.61 ± 0.05 ms | 1.12 ± 0.06 ms | 988 ± 38 us | 619 ± 23 us | 628 ± 18 us |
| lines_difflib | 5.64 ± 0.04 ms | 1.29 ± 0.02 ms | 1.25 ± 0.01 ms | 1.03 ± 0.01 ms | 1.09 ± 0.01 ms | 1.09 ± 0.01 ms |
| ints_3k | 16.1 ± 0.0 ms | 3.31 ± 0.01 ms | 3.12 ± 0.02 ms | 2.22 ± 0.01 ms | 2.18 ± 0.02 ms | 2.18 ± 0.02 ms |
| lines_big | 25.0 ± 0.1 ms | 4.77 ± 0.03 ms | 4.46 ± 0.05 ms | 2.87 ± 0.03 ms | 2.84 ± 0.01 ms | 2.84 ± 0.02 ms |
| bytes_8k | 213 ± 0 ms | 23.7 ± 0.2 ms | 23.2 ± 0.2 ms | 15.0 ± 0.1 ms | 14.8 ± 0.1 ms | 14.5 ± 0.1 ms |
Speedup over pure-Python (means only, derived from the row above):
| case | phase 1 | phase 2 | phase 3 | phase 4 | phase 5 (final) |
|---|---|---|---|---|---|
| str_2k_similar | 2.32× | 3.39× | 3.98× | 6.88× | 6.83× |
| str_8k_similar | 2.32× | 3.33× | 3.78× | 6.03× | 5.94× |
| lines_difflib | 4.37× | 4.51× | 5.48× | 5.17× | 5.17× |
| ints_3k | 4.86× | 5.16× | 7.25× | 7.39× | 7.39× |
| lines_big | 5.24× | 5.61× | 8.71× | 8.80× | 8.80× |
| bytes_8k | 8.99× | 9.18× | 14.20× | 14.39× | 14.69× |
Reading the table: each phase adds an optimization that monotonically helps the workloads it targets. Phase 3 (label-array DP + recursion-in-C) is the single largest jump on int/line workloads; phase 4 (codepoint-keyed tables for str) drives the doubling on str workloads; phase 5 contributes a measurable but smaller bytes win, and on the other workloads it's at the noise floor (within ±0.5 σ of phase 4). The pyperf standard deviations are <3% in every cell, so the small phase-4-vs-phase-5 swings on str_2k, str_8k, and lines_difflib are well inside measurement noise — not a regression.
All numbers in this issue were collected on the same machine and software configuration:
Six workloads, all comparing two sequences that are mostly identical with a small percentage of perturbations — the realistic case difflib is designed for:
The benchmark measures end-to-end SequenceMatcher(None, a, b) followed by s.ratio() and s.get_opcodes() with autojunk enabled (the default).
Measured on the in-tree CPython main (3.16-dev, release-mode, no --with-pydebug) where the patch lives, using pyperf (20 processes × 3 values per workload). Pure Python is _pydifflib.SequenceMatcher (the same code as the existing difflib.SequenceMatcher); the accelerated column is the C-backed difflib.SequenceMatcher after the shim picks up _difflib:
| case | pure-Py (mean ± std) | accel (mean ± std) | speedup |
|---|---|---|---|
| str_2k_similar | 908 ± 24 us | 133 ± 8 us | 6.83× |
| str_8k_similar | 3.73 ± 0.08 ms | 628 ± 18 us | 5.94× |
| lines_difflib | 5.64 ± 0.04 ms | 1.09 ± 0.01 ms | 5.17× |
| ints_3k | 16.1 ± 0.0 ms | 2.18 ± 0.02 ms | 7.39× |
| lines_big | 25.0 ± 0.1 ms | 2.84 ± 0.02 ms | 8.80× |
| bytes_8k | 213 ± 0 ms | 14.5 ± 0.1 ms | 14.69× |
The script below picks the implementation via the DIFFLIB_IMPL env var, so the same script measures any combination of py (stdlib difflib), pyours (the pure-Python _pydifflib), ours (the in-tree _difflib C accelerator), cdifflib, or cydifflib. For the table above I ran it twice:
# Pure-Python reference (does not need the C accelerator installed).
DIFFLIB_IMPL=pyours PYTHONPATH=/tmp ./python bench_pyperf.py \
-o results/main_pyours.json \
--inherit-environ=DIFFLIB_IMPL,PYTHONPATH
# C accelerator (drops _difflib.cpython-*.so into /tmp).
DIFFLIB_IMPL=ours PYTHONPATH=/tmp ./python bench_pyperf.py \
-o results/main_ours.json \
--inherit-environ=DIFFLIB_IMPL,PYTHONPATH
# Statistical comparison via t-test on per-process means:
./python -m pyperf compare_to results/main_pyours.json results/main_ours.json"""pyperf benchmark of difflib.SequenceMatcher implementations.
Selects the implementation via the DIFFLIB_IMPL environment variable
(one of: py, pyours, ours, cdifflib, cydifflib). This avoids needing
custom argparse arguments that pyperf would have to propagate to
worker processes.
"""
import os
import random
import string
import pyperf
def make_inputs(pydifflib_path):
cases = {}
rng = random.Random(42)
s1 = ''.join(rng.choices(string.ascii_lowercase, k=2000))
s2 = list(s1)
for _ in range(100):
s2[rng.randrange(len(s2))] = rng.choice(string.ascii_lowercase)
cases['str_2k_similar'] = (s1, ''.join(s2))
s1 = ''.join(rng.choices(string.ascii_lowercase, k=8000))
s2 = list(s1)
for _ in range(400):
s2[rng.randrange(len(s2))] = rng.choice(string.ascii_lowercase)
cases['str_8k_similar'] = (s1, ''.join(s2))
with open(pydifflib_path) as f:
la = f.readlines()
lb = la[:]
for i in range(0, len(lb), 50):
lb[i] = '# tweak\n'
cases['lines_difflib'] = (la, lb)
rng = random.Random(7)
la = [rng.randint(0, 500) for _ in range(3000)]
lb = la[:]
for _ in range(150):
lb[rng.randrange(len(lb))] = rng.randint(0, 500)
cases['ints_3k'] = (la, lb)
with open(pydifflib_path) as f:
la = f.readlines() * 3
lb = la[:]
for i in range(0, len(lb), 80):
lb[i] = '# x\n'
cases['lines_big'] = (la, lb)
rng = random.Random(99)
ba = bytes(rng.choices(range(256), k=8000))
bb = bytearray(ba)
for _ in range(400):
bb[rng.randrange(len(bb))] = rng.randrange(256)
cases['bytes_8k'] = (ba, bytes(bb))
return cases
def resolve_impl(name):
if name == 'py':
import difflib
return difflib.SequenceMatcher
if name == 'pyours':
import _pydifflib
return _pydifflib.SequenceMatcher
if name == 'ours':
import _difflib
return _difflib.SequenceMatcher
if name == 'cdifflib':
import cdifflib
return cdifflib.CSequenceMatcher
if name == 'cydifflib':
import cydifflib
return cydifflib.SequenceMatcher
raise ValueError(f"unknown impl {name!r}")
def make_workload(SM, a, b):
def workload():
s = SM(None, a, b)
s.ratio()
s.get_opcodes()
return workload
def main():
impl = os.environ['DIFFLIB_IMPL']
pydifflib_path = os.environ.get(
'PYDIFFLIB_PATH', '/path/to/cpython/Lib/_pydifflib.py')
SM = resolve_impl(impl)
cases = make_inputs(pydifflib_path)
runner = pyperf.Runner()
runner.metadata['difflib_impl'] = impl
runner.metadata['SM_class'] = f"{SM.__module__}.{SM.__qualname__}"
for name, (a, b) in cases.items():
runner.bench_func(name, make_workload(SM, a, b))
if __name__ == '__main__':
main()Two existing C accelerators on PyPI: cdifflib 1.2.9 (hand-written C) and cydifflib 1.2.0 (Cython-generated). The comparison is run on CPython 3.12 (not the in-tree 3.16-dev branch where the patch lives) because cydifflib's wheel build is CMake-based and currently fails to configure against 3.16-dev — it cannot be tested on that runtime. To remove "different Python version" as a confound, I rebuilt the new accelerator against the system Python 3.12 headers and installed all four implementations (pure-Python, cdifflib, cydifflib, ours) into a single Python 3.12 venv so every column below shares the same interpreter, GC, and pure-Python baseline. All measurements use pyperf (20 processes × 3 values per workload, system tuned via pyperf system tune).
Wall time (pyperf mean ± stddev), all on Python 3.12:
| case | pure-Py | cdifflib 1.2.9 | cydifflib 1.2.0 | ours |
|---|---|---|---|---|
| str_2k_similar | 989 ± 21 us | 529 ± 13 us | 901 ± 14 us | 120 ± 4 us |
| str_8k_similar | 3.86 ± 0.05 ms | 2.19 ± 0.07 ms | 33.5 ± 0.0 ms | 520 ± 25 us |
| lines_difflib | 6.15 ± 0.08 ms | 2.74 ± 0.04 ms | 1.88 ± 0.03 ms | 1.08 ± 0.01 ms |
| ints_3k | 16.5 ± 0.1 ms | 8.71 ± 0.13 ms | 4.31 ± 0.09 ms | 2.39 ± 0.01 ms |
| lines_big | 25.8 ± 0.2 ms | 11.6 ± 0.1 ms | 21.2 ± 0.0 ms | 2.99 ± 0.04 ms |
| bytes_8k | 205 ± 1 ms | 127 ± 1 ms | 60.8 ± 0.1 ms | 14.8 ± 0.1 ms |
Speedup over pure-Python (means only), all measured on Python 3.12:
| case | cdifflib 1.2.9 | cydifflib 1.2.0 | ours |
|---|---|---|---|
| str_2k_similar | 1.87× | 1.10× | 8.24× |
| str_8k_similar | 1.76× | 0.12× | 7.42× |
| lines_difflib | 2.24× | 3.27× | 5.69× |
| ints_3k | 1.89× | 3.83× | 6.90× |
| lines_big | 2.22× | 1.22× | 8.63× |
| bytes_8k | 1.61× | 3.37× | 13.85× |
Same bench_pyperf.py as above; in the 3.12 venv where all four implementations are importable, set DIFFLIB_IMPL to one of py, cdifflib, cydifflib, ours and run once per impl:
for impl in py cdifflib cydifflib ours; do
DIFFLIB_IMPL=$impl venv/bin/python bench_pyperf.py \
-o results/py312_$impl.json \
--inherit-environ=DIFFLIB_IMPL
done
# Compare any two with a t-test on the per-process means:
venv/bin/python -m pyperf compare_to results/py312_py.json results/py312_ours.jsonObservations:
cdifflib and cydifflib both keep the existing data shape: b2j is a dict[elt, list[int]], and the inner loop does PyDict_GetItem on Python-object keys per outer i. The biggest single change in this work is restructuring the state itself:
Neither cdifflib nor cydifflib does the integer-label transformation; that explains most of the remaining gap.
GNU diff is a different program (and a different algorithm — Myers diff, O((n+m)D) where D is the edit distance, not Ratcliff-Obershelp); the comparison only makes sense on line-level inputs because GNU diff doesn't natively handle character, integer, or byte sequences. The GNU diff column includes its full real-world cost: process spawn + reading both files from disk + writing the unified diff to /dev/null. The Python columns measure the in-process work difflib.unified_diff(a_lines, b_lines, lineterm='') does (the same work Tools/build/stable_abi.py, doctest's failure output, and unittest.assertMultiLineEqual do under the hood). All numbers are pyperf mean ± stddev:
| workload | GNU diff 3.10 (subprocess) | pure-Py difflib | ours (C accel) |
|---|---|---|---|
| lines_difflib (~2,100 lines) | 1.48 ± 0.01 ms | 6.16 ± 0.03 ms | 1.14 ± 0.01 ms |
| lines_big (~6,300 lines) | 2.50 ± 0.09 ms | 26.7 ± 0.2 ms | 2.46 ± 0.02 ms |
| lines_huge (~32,000 lines, 5%) | 13.5 ± 0.2 ms | 280 ± 1 ms | 24.6 ± 0.2 ms |
Reading the table:
Why the crossover, briefly: GNU diff uses Myers diff — for "mostly identical" inputs, edit distance D is small so its O((n+m)D) behaves like O(n+m) — linear in n. Ratcliff-Obershelp is super-linear in expected case. Holding the perturbation rate constant, scaling n by 15× (2k → 32k lines) grows ours' time by ~22× and GNU diff's algorithm time by ~9×. Combined with GNU diff's fixed ~1 ms subprocess overhead, the crossover lands around n ≈ 10–15k lines.
This is well above the sizes CPython callers actually hit (doctest failures, assertMultiLineEqual, the 2,833-line stable-ABI manifest), so for real workloads we're in the "GNU diff loses to us because of IO floor" zone. The discussion thread linked at the top covers the asymptotic angle (suffix-automaton work for autojunk=False) — that's the orthogonal path that would close the remaining gap on >10k-line inputs.
Bench script (pyperf, GNU diff vs difflib.unified_diff)The script selects the implementation via DIFFLIB_IMPL (gnudiff, pyours, or ours) so all three columns above share the same input generation and pyperf invocation pattern.
rm -f results/gnu_*.json
for impl in gnudiff pyours ours; do
DIFFLIB_IMPL=$impl PYTHONPATH=/tmp ./python bench_gnudiff.py \
-o results/gnu_$impl.json \
--inherit-environ=DIFFLIB_IMPL,PYTHONPATH
done
# Compare any two via t-test on the per-process means:
./python -m pyperf compare_to results/gnu_pyours.json results/gnu_ours.json"""Compare GNU diff(1) vs difflib.unified_diff on identical line inputs.
GNU diff uses Myers diff, not Ratcliff-Obershelp -- the outputs are
structurally different, but the wall-time comparison is informative for
the line-level workloads (the only case GNU diff handles natively).
Char-level / bytes / int sequences aren't included because GNU diff
doesn't have a meaningful native equivalent.
"""
import os
import random
import subprocess
import tempfile
import pyperf
def make_inputs(pydifflib_path):
cases = {}
with open(pydifflib_path) as f:
la = f.readlines()
lb = la[:]
for i in range(0, len(lb), 50):
lb[i] = '# tweak\n'
cases['lines_difflib'] = (la, lb)
with open(pydifflib_path) as f:
la = f.readlines() * 3
lb = la[:]
for i in range(0, len(lb), 80):
lb[i] = '# x\n'
cases['lines_big'] = (la, lb)
# ~32k lines, ~5% perturbation
with open(pydifflib_path) as f:
la = f.readlines() * 15
lb = la[:]
rng = random.Random(123)
for _ in range(len(lb) // 20):
i = rng.randrange(len(lb))
lb[i] = f"# changed line {i}\n"
cases['lines_huge'] = (la, lb)
return cases
def make_temp_files(a_lines, b_lines):
fa = tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.a')
fa.writelines(a_lines); fa.close()
fb = tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.b')
fb.writelines(b_lines); fb.close()
return fa.name, fb.name
def main():
impl = os.environ['DIFFLIB_IMPL'] # gnudiff | pyours | ours
pydifflib_path = os.environ.get(
'PYDIFFLIB_PATH', '/path/to/cpython/Lib/_pydifflib.py')
cases = make_inputs(pydifflib_path)
runner = pyperf.Runner()
runner.metadata['impl'] = impl
if impl == 'gnudiff':
# Write inputs once; bench the subprocess invocation. This
# deliberately includes process spawn + file IO + writing the
# diff to /dev/null -- the real cost of using GNU diff.
for name, (a, b) in cases.items():
path_a, path_b = make_temp_files(a, b)
def workload(_a=path_a, _b=path_b):
# GNU diff exits 1 when files differ; that's success.
subprocess.run(
['/usr/bin/diff', '-u', _a, _b],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
runner.bench_func(name, workload)
else:
if impl == 'pyours':
import _pydifflib as mod
elif impl == 'ours':
import difflib as mod # picks up _difflib via the shim
else:
raise SystemExit(f"unknown impl {impl!r}")
unified_diff = mod.unified_diff
for name, (a, b) in cases.items():
def workload(_a=a, _b=b, _ud=unified_diff):
# Materialize the generator to match the work GNU diff
# does (it writes the whole diff out).
list(_ud(_a, _b, lineterm=''))
runner.bench_func(name, workload)
if __name__ == '__main__':
main()All 3,611 tests pass across test_difflib, test_pyclbr, test_doctest, test_unittest, test_argparse, test_traceback.
For the CPython test suite as a whole, the impact is small — assertEqual only calls ndiff on failure, doctest only computes diffs on failure, argparse/traceback's get_close_matches filters out 99% of pairs via quick_ratio before reaching ratio(). On the difflib-using test subset (~33s):
| test | stock | accel | speedup |
|---|---|---|---|
| test_difflib | 1.41s | 0.49s | 2.9× |
| test_doctest | 3.55s | 3.29s | 1.1× |
| test_genericalias | 0.40s | 0.34s | 1.2× |
| (others within noise) |
Where users feel it is during development: a failed unittest.assertEqual on a 2000-item list goes from 115 ms → 30 ms; doctest's unified_diff on a long mismatch goes from ~50 ms → ~4 ms; Tools/build/stable_abi.py's ~2,800-line manifest diff goes from 43 ms → 4 ms (11×). Useful for iteration, not for green CI.
No response given
No response
| Back | FazBrowse Home | New Git URL |
Activity
picnixz commented on May 21, 2026
Sorry but this needs a DPO thread. In addition the speed up does not seem that great with the last examples. It looks also AI generated and the maintenance cost is nontrivial.