difflib-fast 0.4.0__cp39-abi3-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- difflib_fast/__init__.py +51 -0
- difflib_fast/__init__.pyi +125 -0
- difflib_fast/_difflib_fast.pyd +0 -0
- difflib_fast/py.typed +0 -0
- difflib_fast-0.4.0.dist-info/METADATA +423 -0
- difflib_fast-0.4.0.dist-info/RECORD +8 -0
- difflib_fast-0.4.0.dist-info/WHEEL +4 -0
- difflib_fast-0.4.0.dist-info/sboms/difflib-fast.cyclonedx.json +771 -0
difflib_fast/__init__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""difflib-fast — fast, byte-for-byte exact difflib Ratcliff-Obershelp similarity + clustering.
|
|
2
|
+
|
|
3
|
+
A drop-in for ``difflib.SequenceMatcher(None, a, b, autojunk=False).ratio()``, computed with a suffix
|
|
4
|
+
automaton (Rust), plus exact single-linkage clustering of a corpus.
|
|
5
|
+
|
|
6
|
+
``ratio`` is overloaded: two strings → one float; a list of ``(a, b)`` pairs → a list of floats,
|
|
7
|
+
computed across all cores inside Rust (rayon, GIL released) — the contention-free way to score a batch.
|
|
8
|
+
|
|
9
|
+
``Rationer`` is the stateful handle: on a macOS wheel built with the ``gpu`` feature it runs
|
|
10
|
+
``cluster_canonicals`` on the Metal GPU (else CPU, identical output).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from typing import overload
|
|
14
|
+
|
|
15
|
+
from ._difflib_fast import (
|
|
16
|
+
CosineJoiner,
|
|
17
|
+
Rationer,
|
|
18
|
+
cluster_canonicals,
|
|
19
|
+
cluster_canonicals_lsh,
|
|
20
|
+
cosine_join,
|
|
21
|
+
ratio as _ratio,
|
|
22
|
+
ratio_many as _ratio_many,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"ratio",
|
|
27
|
+
"cluster_canonicals",
|
|
28
|
+
"cluster_canonicals_lsh",
|
|
29
|
+
"Rationer",
|
|
30
|
+
"cosine_join",
|
|
31
|
+
"CosineJoiner",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@overload
|
|
36
|
+
def ratio(a: str, b: str, /) -> float: ...
|
|
37
|
+
@overload
|
|
38
|
+
def ratio(pairs: list[tuple[str, str]], /, *, threads: int = 0) -> list[float]: ...
|
|
39
|
+
def ratio(a, b=None, /, *, threads=0):
|
|
40
|
+
"""Exact ``difflib.SequenceMatcher(None, a, b, autojunk=False).ratio()`` — byte-for-byte.
|
|
41
|
+
|
|
42
|
+
- ``ratio(a, b)`` → one float for the pair.
|
|
43
|
+
- ``ratio(pairs)`` → one float per ``(a, b)`` pair, computed in parallel across all cores inside
|
|
44
|
+
Rust (the GIL is released), so a batch saturates every core with no ``ThreadPoolExecutor`` and no
|
|
45
|
+
per-call overhead. ``ratio(pairs)[i] == ratio(*pairs[i])``, in order. Pass ``threads=N`` to cap
|
|
46
|
+
the pool to N workers for this call (``threads=0``, the default, uses every core — itself tunable
|
|
47
|
+
process-wide via the ``RAYON_NUM_THREADS`` environment variable).
|
|
48
|
+
"""
|
|
49
|
+
if b is None:
|
|
50
|
+
return _ratio_many(a, threads)
|
|
51
|
+
return _ratio(a, b)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Type stubs for difflib-fast — exact difflib Ratcliff-Obershelp similarity + clustering."""
|
|
2
|
+
|
|
3
|
+
from typing import overload
|
|
4
|
+
|
|
5
|
+
@overload
|
|
6
|
+
def ratio(a: str, b: str, /) -> float:
|
|
7
|
+
"""Exact ``difflib.SequenceMatcher(None, a, b, autojunk=False).ratio()`` — byte-for-byte.
|
|
8
|
+
|
|
9
|
+
The Ratcliff-Obershelp similarity ``2*M / (len(a) + len(b))``, identical to Python's ``difflib``
|
|
10
|
+
(including its argument-order asymmetry), computed via a suffix automaton so it stays linear on
|
|
11
|
+
long, repetitive inputs where ``difflib`` degrades.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
@overload
|
|
15
|
+
def ratio(pairs: list[tuple[str, str]], /, *, threads: int = 0) -> list[float]:
|
|
16
|
+
"""Exact :func:`ratio` for many ``(a, b)`` pairs at once, computed across all cores inside Rust.
|
|
17
|
+
|
|
18
|
+
``ratio(pairs)[i] == ratio(*pairs[i])``, bit-for-bit. The parallel fan-out happens in Rust with the
|
|
19
|
+
GIL released — the contention-free way to score a batch from Python: no ``ThreadPoolExecutor``, no
|
|
20
|
+
per-call overhead. Returns one float per input pair, in order. ``threads=N`` caps the pool to N
|
|
21
|
+
workers for this call; ``threads=0`` (default) uses every core (tunable via ``RAYON_NUM_THREADS``).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def cluster_canonicals(
|
|
25
|
+
canonicals: list[str], threshold: float, threads: int = 0
|
|
26
|
+
) -> list[tuple[list[int], float]]:
|
|
27
|
+
"""Exact single-linkage clustering of ``canonicals`` by RO similarity.
|
|
28
|
+
|
|
29
|
+
Two strings join a cluster when their exact ratio is ``>= threshold``. Returns one
|
|
30
|
+
``(member_indices, min_pairwise_ratio)`` per cluster of >= 2 members; ``member_indices`` index
|
|
31
|
+
into ``canonicals`` (sorted), ``min_pairwise_ratio`` is the cluster's exact minimum pairwise ratio.
|
|
32
|
+
The all-pairs join runs across all cores (GIL released); ``threads=N`` caps the pool to N for this
|
|
33
|
+
call (``threads=0``, default, uses every core — tunable via ``RAYON_NUM_THREADS``).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def cluster_canonicals_lsh(
|
|
37
|
+
canonicals: list[str], threshold: float, num_perm: int, band_rows: int, threads: int = 0
|
|
38
|
+
) -> list[tuple[list[int], float]]:
|
|
39
|
+
"""Scalable MinHash-LSH variant of :func:`cluster_canonicals` for very large corpora.
|
|
40
|
+
|
|
41
|
+
Generates candidate pairs via MinHash-LSH (``num_perm`` permutations, ``band_rows`` rows per band),
|
|
42
|
+
then verifies each candidate with the exact ratio. Clusters match the exact path modulo LSH recall
|
|
43
|
+
(tuned via ``band_rows``); use :func:`cluster_canonicals` when exact recall is required.
|
|
44
|
+
``threads=N`` caps the pool to N workers for this call (``threads=0``, default, uses every core).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def cosine_join(
|
|
48
|
+
docs: list[list[str]], threshold: float, concurrency: str = "cpu", threads: int = 0
|
|
49
|
+
) -> list[tuple[int, int, float]]:
|
|
50
|
+
"""Exact all-pairs weighted-cosine similarity join over token documents.
|
|
51
|
+
|
|
52
|
+
Each ``doc`` is a list of string tokens (e.g. a function's canonicalised lines); they're turned
|
|
53
|
+
into TF-IDF sparse vectors in Rust (dim = distinct token, weight = token-count × ``ln(n/df)``) and
|
|
54
|
+
every pair with cosine ``>= threshold`` is returned as ``(j, i, cos)`` with ``j < i``. Like
|
|
55
|
+
:func:`ratio`'s batch form, the work fans out across **all cores inside Rust with the GIL
|
|
56
|
+
released** — one call, full multicore, no ``ThreadPoolExecutor``. ``threads=N`` caps the pool to N
|
|
57
|
+
for this call; ``threads=0`` (default) uses every core (tunable via ``RAYON_NUM_THREADS``).
|
|
58
|
+
|
|
59
|
+
``concurrency`` ∈ ``"cpu" | "gpu" | "gpu+cpu"``: ``"cpu"`` is exact f64; ``"gpu+cpu"`` is the exact
|
|
60
|
+
f64 GPU-accelerated hybrid (byte-identical to ``"cpu"``); ``"gpu"`` runs the dot on the Metal GPU
|
|
61
|
+
in f32 (fastest; differs from exact only on pairs within ~1e-6 of the threshold). Off a macOS
|
|
62
|
+
``gpu`` wheel, or with no Metal device, the GPU modes quietly fall back to CPU. For repeated joins
|
|
63
|
+
on one corpus use :class:`CosineJoiner` (builds the corpus / uploads to the GPU once).
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
class CosineJoiner:
|
|
67
|
+
"""Stateful similarity-join handle: builds the TF-IDF corpus and (on a macOS ``gpu`` wheel)
|
|
68
|
+
acquires the Metal device + uploads the corpus once, then answers repeated joins reusing them.
|
|
69
|
+
|
|
70
|
+
Use it to sweep thresholds — the free :func:`cosine_join` rebuilds everything per call.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, docs: list[list[str]]) -> None:
|
|
74
|
+
"""Build a joiner over token documents (each a list of string tokens)."""
|
|
75
|
+
|
|
76
|
+
def __len__(self) -> int:
|
|
77
|
+
"""Number of documents in the corpus."""
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def has_gpu(self) -> bool:
|
|
81
|
+
"""Whether a Metal GPU backend was acquired (always ``False`` off a macOS ``gpu`` wheel)."""
|
|
82
|
+
|
|
83
|
+
def join(
|
|
84
|
+
self, threshold: float, concurrency: str = "cpu", threads: int = 0
|
|
85
|
+
) -> list[tuple[int, int, float]]:
|
|
86
|
+
"""Join at ``threshold`` under ``concurrency`` (``"cpu" | "gpu" | "gpu+cpu"``), reusing the
|
|
87
|
+
handle's resources. Returns ``(j, i, cos)`` pairs with ``j < i``; fans out across all cores
|
|
88
|
+
with the GIL released (``threads=0`` = every core)."""
|
|
89
|
+
|
|
90
|
+
class Rationer:
|
|
91
|
+
"""Stateful similarity/clustering handle that owns the backend resources once and reuses them.
|
|
92
|
+
|
|
93
|
+
On a macOS wheel built with the ``gpu`` feature, ``cluster_canonicals`` runs on the Metal GPU
|
|
94
|
+
when the group is large enough to amortize dispatch (~1.1-1.4x vs CPU on Apple Silicon); on every
|
|
95
|
+
other wheel, or with no Metal device, it transparently runs on CPU with byte-for-byte identical
|
|
96
|
+
output. ``ratio`` / ``ratio_many`` always run on CPU (the GPU offload measured slower there).
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(self, concurrency: str = "gpu+cpu", threads: int = 0, delta: float = 0.0) -> None:
|
|
100
|
+
"""Build a handle.
|
|
101
|
+
|
|
102
|
+
- ``concurrency``: ``"cpu"``, ``"gpu"``, or ``"gpu+cpu"`` (default). On a non-Metal build/host
|
|
103
|
+
``"gpu"``/``"gpu+cpu"`` quietly fall back to CPU.
|
|
104
|
+
- ``threads``: rayon worker count for CPU-side work; ``0`` (default) uses every core.
|
|
105
|
+
- ``delta``: approximate-RO knob; ``0.0`` (default) = exact, bit-identical to ``difflib``.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def concurrency(self) -> str:
|
|
110
|
+
"""Active backend after construction-time fallback: ``"cpu"``, ``"gpu"``, or ``"gpu+cpu"``."""
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def delta(self) -> float:
|
|
114
|
+
"""Active approximate-RO ``delta`` (``0.0`` = exact)."""
|
|
115
|
+
|
|
116
|
+
def ratio(self, a: str, b: str) -> float:
|
|
117
|
+
"""Single-pair exact ratio (always CPU)."""
|
|
118
|
+
|
|
119
|
+
def ratio_many(self, pairs: list[tuple[str, str]]) -> list[float]:
|
|
120
|
+
"""Batched exact ratio over ``(a, b)`` pairs, across all cores (GIL released). Always CPU."""
|
|
121
|
+
|
|
122
|
+
def cluster_canonicals(self, canonicals: list[str], threshold: float) -> list[tuple[list[int], float]]:
|
|
123
|
+
"""Exact single-linkage clustering at ``threshold``; GPU-accelerated where it wins (see class doc)."""
|
|
124
|
+
|
|
125
|
+
__all__: list[str]
|
|
Binary file
|
difflib_fast/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: difflib-fast
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Classifier: Programming Language :: Rust
|
|
5
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
6
|
+
Classifier: Topic :: Text Processing :: General
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Typing :: Typed
|
|
9
|
+
Summary: Fast, byte-for-byte exact difflib Ratcliff-Obershelp similarity + clustering.
|
|
10
|
+
Keywords: difflib,similarity,ratcliff-obershelp,fuzzy,diff
|
|
11
|
+
Author: prostomarkeloff
|
|
12
|
+
License: MIT
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
|
|
15
|
+
Project-URL: Repository, https://github.com/prostomarkeloff/difflib-fast
|
|
16
|
+
|
|
17
|
+
<div align="center">
|
|
18
|
+
|
|
19
|
+
# difflib-fast
|
|
20
|
+
|
|
21
|
+
**The exact `difflib` similarity ratio — up to `8,500×` faster.**
|
|
22
|
+
|
|
23
|
+
[](https://www.rust-lang.org/)
|
|
24
|
+
[](https://opensource.org/licenses/MIT)
|
|
25
|
+
[](https://docs.python.org/3/library/difflib.html)
|
|
26
|
+
[](#python-package)
|
|
27
|
+
|
|
28
|
+
The **same number** Python's `difflib` gives you — byte-for-byte, no `autojunk` approximation. A corpus
|
|
29
|
+
stdlib `difflib` would chew on for **~30 minutes** clusters in **~0.2 seconds**.
|
|
30
|
+
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
import difflib_fast
|
|
35
|
+
|
|
36
|
+
difflib_fast.ratio("the quick brown fox", "the quick brown dog") # 0.8947368421052632 (== difflib)
|
|
37
|
+
difflib_fast.ratio(pairs) # list[float] — computed across every core inside Rust, GIL released
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
<div align="center">
|
|
41
|
+
|
|
42
|
+
| from Python · real corpus · 12 cores | throughput | vs stdlib `difflib` |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| `ratio(a, b)` — one call | 2.4k pairs/s | **104×** |
|
|
45
|
+
| `ratio(pairs)` — batch, all cores | 15k pairs/s | **628×** |
|
|
46
|
+
| `cluster_canonicals(corpus)` — the real workload | 199k pairs/s | **8,541×** |
|
|
47
|
+
|
|
48
|
+
*23 pairs/s → 199,000 pairs/s on the same task. Same answer. ([how ↓](#how-it-works))*
|
|
49
|
+
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
And a pure-Rust crate, with **zero** Python dependency by default:
|
|
53
|
+
|
|
54
|
+
```rust
|
|
55
|
+
use difflib_fast::ratio;
|
|
56
|
+
// bit-for-bit identical to difflib.SequenceMatcher(None, a, b, autojunk=False).ratio()
|
|
57
|
+
assert_eq!(ratio("the quick brown fox", "the quick brown dog"), 0.8947368421052632);
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```toml
|
|
61
|
+
difflib-fast = "0.3"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## The idea
|
|
67
|
+
|
|
68
|
+
`ratio = 2·M / (len(a) + len(b))`, where `M` is the total size of the Ratcliff–Obershelp matching
|
|
69
|
+
blocks. The metric is exact and well-defined — including `difflib`'s tie-break **and its
|
|
70
|
+
argument-order asymmetry**, both of which this crate reproduces bit-for-bit.
|
|
71
|
+
|
|
72
|
+
The catch is how you compute `M`. `difflib` does it by re-scanning every occurrence of each character;
|
|
73
|
+
on long, small-alphabet text — canonicalized source code, log lines, DNA — a handful of popular
|
|
74
|
+
characters turn that into a quadratic crawl. `difflib-fast` computes the longest common substring with
|
|
75
|
+
a **suffix automaton** in `O(|a|+|b|)` regardless of how often a character repeats. Same answer. No
|
|
76
|
+
crawl.
|
|
77
|
+
|
|
78
|
+
> Two independent implementations of `M` live in this crate — the suffix automaton, and a from-scratch
|
|
79
|
+
> port of `difflib`'s own recursion. The test suite asserts they are **bit-identical**. That equality
|
|
80
|
+
> *is* the correctness guarantee.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## What it does
|
|
85
|
+
|
|
86
|
+
Two things, both exact:
|
|
87
|
+
|
|
88
|
+
```rust
|
|
89
|
+
use difflib_fast::{ratio, cluster_canonicals};
|
|
90
|
+
|
|
91
|
+
// 1. pairwise similarity — a drop-in for difflib's ratio
|
|
92
|
+
let r = ratio("def add(a, b): return a + b", "def add(x, y): return x + y");
|
|
93
|
+
|
|
94
|
+
// 2. cluster a whole corpus by similarity (single-linkage, exact min pairwise ratio per cluster)
|
|
95
|
+
let corpus = vec![
|
|
96
|
+
"def add(a, b): return a + b".to_string(),
|
|
97
|
+
"def add(x, y): return x + y".to_string(),
|
|
98
|
+
"totally unrelated".to_string(),
|
|
99
|
+
];
|
|
100
|
+
let clusters = cluster_canonicals(&corpus, 0.5); // → [([0, 1], 0.84…)]
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The clustering path is the one built for scale: each string's automaton is **prebuilt once** and
|
|
104
|
+
reused across the whole `n²` join, dissimilar pairs **early-exit** the moment the threshold is decided,
|
|
105
|
+
and the work is spread across cores with `rayon`. A cluster's reported `min_sim` is its exact minimum
|
|
106
|
+
pairwise ratio.
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## API
|
|
111
|
+
|
|
112
|
+
Two tiers. The **free functions** are the stateless CPU drop-in:
|
|
113
|
+
|
|
114
|
+
| function | what |
|
|
115
|
+
|---|---|
|
|
116
|
+
| `ratio(a, b) -> f64` | exact `difflib` ratio (dispatches automaton ⇄ `b2j` per input) |
|
|
117
|
+
| `ratio_many(&[(String, String)]) -> Vec<f64>` | exact `ratio` for a batch of pairs, in parallel (rayon) |
|
|
118
|
+
| `gestalt::gestalt_ratio(a, b) -> f64` | the suffix-automaton path directly |
|
|
119
|
+
| `cluster_canonicals(&[String], threshold) -> Vec<(Vec<usize>, f64)>` | exact single-linkage clusters + min pairwise ratio |
|
|
120
|
+
| `cluster_canonicals_lsh(&[String], threshold, num_perm, band_rows)` | scalable MinHash-LSH variant (candidate-gen + exact verify) for very large corpora |
|
|
121
|
+
|
|
122
|
+
And `Rationer` is the **stateful handle** that owns long-lived resources (rayon pool, and on
|
|
123
|
+
macOS the Metal device) once and reuses them across calls — same exact output, with an optional GPU
|
|
124
|
+
path:
|
|
125
|
+
|
|
126
|
+
```rust
|
|
127
|
+
use difflib_fast::Rationer;
|
|
128
|
+
|
|
129
|
+
let r = Rationer::new(); // builder().build(); default GpuPlusCpu
|
|
130
|
+
let clusters = r.cluster_canonicals(&corpus, 0.6); // GPU-accelerated on macOS (see below)
|
|
131
|
+
let ratios = r.ratio_many(&pairs); // CPU
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`Rationer::builder().concurrency(Concurrency::Cpu | Gpu | GpuPlusCpu).threads(n).delta(d).build()`
|
|
135
|
+
configures it; `PreparedRationer` (`r.prepare(&strings)`) amortizes SAM-build over many index-pair
|
|
136
|
+
queries.
|
|
137
|
+
|
|
138
|
+
### GPU (macOS + Metal)
|
|
139
|
+
|
|
140
|
+
Behind the `gpu` cargo feature on Apple Silicon, `Rationer::cluster_canonicals` offloads the
|
|
141
|
+
suffix-automaton `matching_stats` walk to a Metal compute kernel — **byte-for-byte identical output**,
|
|
142
|
+
**~1.1–1.4×** end-to-end vs the (already fast) CPU path on a single large group:
|
|
143
|
+
|
|
144
|
+
| corpus | `cluster_canonicals` GPU vs CPU |
|
|
145
|
+
|---|---|
|
|
146
|
+
| mypy | **1.38×** |
|
|
147
|
+
| ha | 1.25× |
|
|
148
|
+
| sympy | 1.17× |
|
|
149
|
+
| django | 1.14× |
|
|
150
|
+
| transformers | 1.08× |
|
|
151
|
+
|
|
152
|
+
It's a modest, honest win: the GPU only does `matching_stats` (~⅓ of the per-pair cost); the
|
|
153
|
+
`longest_in` recursion, filtering and assembly stay on CPU, so Amdahl caps it. `ratio_many` and
|
|
154
|
+
`cluster_canonicals_multi` measured *slower* on the GPU at every size tested, so they stay on CPU by
|
|
155
|
+
default (the GPU paths remain opt-in via `DFGPU_RATIO_MANY_THRESHOLD` / `DFGPU_MULTI_THRESHOLD`). With
|
|
156
|
+
the feature off, on non-macOS, or with no Metal device, every call quietly runs on CPU.
|
|
157
|
+
|
|
158
|
+
```toml
|
|
159
|
+
difflib-fast = { version = "0.3", features = ["gpu"] } # macOS only
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## Correctness
|
|
165
|
+
|
|
166
|
+
This crate's entire reason to exist is being *exactly* `difflib` — so correctness is enforced, not
|
|
167
|
+
hoped for:
|
|
168
|
+
|
|
169
|
+
- **two implementations, one answer** — the suffix-automaton path and the `b2j` reference port are
|
|
170
|
+
asserted bit-identical on thousands of fuzzed pairs (`fast_matches_reference`);
|
|
171
|
+
- **18k-assertion threshold gate** — every early-exit decision matches the full ratio
|
|
172
|
+
(`qualifies_matches_ratio_threshold`);
|
|
173
|
+
- **`difflib` reference values**, including non-ASCII.
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
cargo test
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## Benchmarks
|
|
182
|
+
|
|
183
|
+
Exact byte-for-byte RO on real canonicalized Python (top-level function bodies, `ast.dump`-shape),
|
|
184
|
+
Apple M3 Pro (6 P + 6 E cores). `pairs/s` = pairwise `ratio` decisions per second. Full methodology,
|
|
185
|
+
per-repo tables, and the C++ / Python harnesses are in [`benchmarks.md`](benchmarks.md).
|
|
186
|
+
|
|
187
|
+
**Clustering throughput** — the production path (prebuilt automaton + threshold early-exit + rayon),
|
|
188
|
+
`cargo run --release --features bench --bin bench`:
|
|
189
|
+
|
|
190
|
+
| repo | raw ratio, 1 thread | threshold @0.5, 1 thread | threshold @0.5, 12 threads |
|
|
191
|
+
|---|---|---|---|
|
|
192
|
+
| django | 24.3k pairs/s | 117k pairs/s | **936k pairs/s** |
|
|
193
|
+
| sympy | 23.6k | 97.9k | **828k** |
|
|
194
|
+
| ha | 21.5k | 69.6k | 468k |
|
|
195
|
+
| mypy | 14.1k | 60.6k | 271k |
|
|
196
|
+
| transformers | 3.5k¹ | 47.3k | 362k |
|
|
197
|
+
|
|
198
|
+
¹ transformers' model code has unusually long functions (per-pair RO is `O(L·log L)`), so raw
|
|
199
|
+
throughput is lower — the same reason `difflib` struggles there.
|
|
200
|
+
|
|
201
|
+
### Clustering, 0.4.0: 3.7× less CPU on the name-gated workload, every byte the same
|
|
202
|
+
|
|
203
|
+
`cluster_canonicals` is what [find-dup-defs](https://github.com/prostomarkeloff/find-dup-defs)
|
|
204
|
+
calls once per same-name group — thousands of calls per repository, most of them two or three
|
|
205
|
+
long canonical bodies, a few of them hundreds. Replaying every call of one such run (2 892 calls,
|
|
206
|
+
9 527 strings, 23 M characters, threshold 0.5) through the library alone, before and after:
|
|
207
|
+
|
|
208
|
+
| | 0.3.5 | 0.4.0 |
|
|
209
|
+
|---|---:|---:|
|
|
210
|
+
| single thread | 9.78 s | **2.66 s** |
|
|
211
|
+
| 12 threads (M3 Pro, 6 P + 6 E) | 1.30 s | **0.38 s** |
|
|
212
|
+
|
|
213
|
+
In the tool itself that pass went from 1.47 s to 0.55 s on that run (1.16× on the whole run,
|
|
214
|
+
whose other passes do not touch this crate). Ten output modes of that tool were diffed against
|
|
215
|
+
the 0.3.5 build on two corpora, and nothing moved; the library's own gates (`fast_matches_reference`, `qualifies_matches_ratio_threshold`,
|
|
216
|
+
the GPU parity test) pass unchanged. What changed, in the order it mattered:
|
|
217
|
+
|
|
218
|
+
- **Only the automata that are scanned get built.** A pair scans one string against the other's
|
|
219
|
+
automaton, so a string that is never the automaton side of a surviving pair never needs one —
|
|
220
|
+
in a group of two that is one build, not two, and the build is the most expensive step per string.
|
|
221
|
+
- **Only spanning edges are tested.** Single-linkage needs the connected components, not every
|
|
222
|
+
edge: candidates are visited most-similar-first in batches with a union-find between them, and a
|
|
223
|
+
pair already connected is not tested. A dense cluster of `k` strings costs about `k` edge tests
|
|
224
|
+
instead of `k²/2`, and its chained non-edges are never rejected the hard way.
|
|
225
|
+
- **The cluster minimum runs under a shared cap.** Every intra pair is computed against the
|
|
226
|
+
cluster's running minimum: a single common substring proves a pair above it before the
|
|
227
|
+
recursion starts (often before the scan — a common prefix or suffix is checked first), the
|
|
228
|
+
pair that *is* the minimum is always computed exactly, and the result is the exact minimum.
|
|
229
|
+
- **Narrow windows skip the automaton.** Half the recursion's windows have a `b` side of a
|
|
230
|
+
handful of characters; the chain walk climbs far there and mostly finds nothing, so those go
|
|
231
|
+
to a direct row-by-row comparison. Wider windows are taken largest-first, which closes both
|
|
232
|
+
early exits in fewer windows.
|
|
233
|
+
- **The automaton got cheaper to build and to walk:** a per-thread builder with four inline
|
|
234
|
+
transitions per state, endpos ranges laid out by two length-order passes instead of a tree walk,
|
|
235
|
+
sorted copies only for the few states with large endpos sets instead of a merge-sort tree over
|
|
236
|
+
every position, and a 32-byte scan slot whose inline transition continues along `b` so a match
|
|
237
|
+
that keeps extending costs one load per character.
|
|
238
|
+
|
|
239
|
+
Things that were measured and did not pay: a non-overlapping-interval bound on `fmatch` as an
|
|
240
|
+
in-scan reject (never fires on code — dissimilar functions still share long substrings), a
|
|
241
|
+
per-position window bound carried down the recursion, interleaving several scans to overlap
|
|
242
|
+
their cache misses (the scan is instruction-bound, not latency-bound), and a 128-bit character
|
|
243
|
+
set in the scan slot.
|
|
244
|
+
|
|
245
|
+
**vs other exact-RO implementations** — single thread, same metric, across the five repos above:
|
|
246
|
+
|
|
247
|
+
| competitor | difflib-fast speedup |
|
|
248
|
+
|---|---|
|
|
249
|
+
| Python stdlib `difflib` (pure Python — the original) | **245–1070×** |
|
|
250
|
+
| C++ `duckie/difflib` (well-optimized b2j) | **1.4–3.4×** |
|
|
251
|
+
| CyDifflib (Cython `difflib`) | **~3–10×** |
|
|
252
|
+
| `difflib` (Rust `difflib` crate) | 11–50× |
|
|
253
|
+
| `gestalt_ratio` (Rust Ratcliff–Obershelp crate) | 18–96× |
|
|
254
|
+
|
|
255
|
+
Against the **original** Python `difflib` it's a different universe: pure-Python `SequenceMatcher`
|
|
256
|
+
manages 15–77 exact-RO ratios/s on this code, so difflib-fast is **245–1070× faster single-threaded —
|
|
257
|
+
and ~1600–6600× on all 12 cores.** CyDifflib has no in-process parallelism (GIL-bound, no `nogil` /
|
|
258
|
+
batch API), so on all 12 cores — difflib-fast (rayon) vs CyDifflib (multiprocessing) on the same
|
|
259
|
+
qualifying-pairs task — the gap is **60–242×**. CyDifflib's *default* `autojunk=True` is faster but differs from the exact ratio on ~100%
|
|
260
|
+
of these pairs (mean |Δ| ≈ 0.22): a different metric, not a drop-in. The libraries that beat
|
|
261
|
+
difflib-fast on raw speed (RapidFuzz, strsim) likewise compute a *different* metric (Indel /
|
|
262
|
+
Levenshtein), not `difflib`'s.
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## Python package
|
|
267
|
+
|
|
268
|
+
Swap `difflib.SequenceMatcher(...).ratio()` for `difflib_fast.ratio(...)` and the **same number** comes
|
|
269
|
+
back — hundreds of times faster per call, and **thousands of times** faster when you score a whole
|
|
270
|
+
corpus. That's the entire change: `import difflib_fast`.
|
|
271
|
+
|
|
272
|
+
**No PyPI** — `pip install difflib-fast` won't work. Two ways to install:
|
|
273
|
+
|
|
274
|
+
**1. Prebuilt wheel — no Rust toolchain needed.** The wheels are `cp39-abi3`, so one wheel per platform
|
|
275
|
+
works on **every CPython ≥ 3.9, including 3.14**. GitHub Releases isn't a package index, so pip can't
|
|
276
|
+
pick the wheel for you — grab the one for your platform from the
|
|
277
|
+
[Releases page](https://github.com/prostomarkeloff/difflib-fast/releases/latest) and install it by URL:
|
|
278
|
+
|
|
279
|
+
```bash
|
|
280
|
+
# macOS Apple Silicon — swap the filename for your platform (see below):
|
|
281
|
+
pip install https://github.com/prostomarkeloff/difflib-fast/releases/download/v0.3.5/difflib_fast-0.3.5-cp39-abi3-macosx_11_0_arm64.whl
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
| platform | wheel suffix |
|
|
285
|
+
|---|---|
|
|
286
|
+
| macOS Apple Silicon | `…-macosx_11_0_arm64.whl` |
|
|
287
|
+
| macOS Intel | `…-macosx_10_12_x86_64.whl` |
|
|
288
|
+
| Linux x86_64 | `…-manylinux_2_17_x86_64.manylinux2014_x86_64.whl` |
|
|
289
|
+
| Linux aarch64 | `…-manylinux_2_17_aarch64.manylinux2014_aarch64.whl` |
|
|
290
|
+
| Windows x64 | `…-win_amd64.whl` |
|
|
291
|
+
|
|
292
|
+
**2. From source — needs a Rust toolchain** (pip drives maturin automatically, no manual build):
|
|
293
|
+
|
|
294
|
+
```bash
|
|
295
|
+
pip install git+https://github.com/prostomarkeloff/difflib-fast
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
```python
|
|
299
|
+
import difflib_fast
|
|
300
|
+
|
|
301
|
+
# one pair → one float, byte-for-byte difflib (autojunk=False)
|
|
302
|
+
difflib_fast.ratio("the quick brown fox", "the quick brown dog") # 0.8947368421052632
|
|
303
|
+
|
|
304
|
+
# cluster a corpus (single-linkage, exact min pairwise ratio per cluster)
|
|
305
|
+
difflib_fast.cluster_canonicals(["def f(a): ...", "def f(x): ...", "other"], 0.5)
|
|
306
|
+
# → [([0, 1], 0.86…)]
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### All cores, no contention
|
|
310
|
+
|
|
311
|
+
`ratio` is **overloaded**: hand it a *list of pairs* and it returns a list of ratios, computing them
|
|
312
|
+
**in parallel across every core inside Rust** — with the GIL released. You don't touch a
|
|
313
|
+
`ThreadPoolExecutor`, you don't fight the GIL; you just pass the batch:
|
|
314
|
+
|
|
315
|
+
```python
|
|
316
|
+
pairs = [(a, b) for a in corpus for b in corpus]
|
|
317
|
+
difflib_fast.ratio(pairs) # list[float], one per pair — fanned out over all cores
|
|
318
|
+
difflib_fast.ratio(pairs, threads=4) # …or cap it to 4 workers for this call
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
By default it uses every core; pass `threads=N` to any batch call (`ratio(pairs, …)`,
|
|
322
|
+
`cluster_canonicals(…)`) to cap the pool for that call, or set `RAYON_NUM_THREADS` to change the
|
|
323
|
+
process-wide default with no code. Thread count never changes the result — only the speed.
|
|
324
|
+
|
|
325
|
+
This matters because Python *can't* parallelize the stdlib version: `difflib` in a `ThreadPoolExecutor`
|
|
326
|
+
stays GIL-bound — **23 → 23 pairs/s, zero speedup**. The batch form sidesteps that entirely: the
|
|
327
|
+
parallelism lives in Rust, not in Python threads, so it just scales (numbers up top; full harness in
|
|
328
|
+
[`benchmarks/bench_python.py`](benchmarks/bench_python.py), measured on real canonicalized Python,
|
|
329
|
+
M3 Pro, 12 threads).
|
|
330
|
+
|
|
331
|
+
Clustering wins biggest because each string's automaton is built **once** and reused across the whole
|
|
332
|
+
`n²` join (dissimilar pairs early-exit) — it's not 12× the per-call speed, it's a different algorithm.
|
|
333
|
+
|
|
334
|
+
The package is **typed** (`py.typed` + `.pyi` stubs — pyright/mypy see the overloads), gated behind the
|
|
335
|
+
`python` cargo feature so the pure-Rust crate keeps **zero** Python dependency by default. Built with
|
|
336
|
+
[maturin](https://github.com/PyO3/maturin) (mixed layout: compiled `_difflib_fast` +
|
|
337
|
+
`python/difflib_fast/` package); build locally into a venv with `maturin develop --release --features python`.
|
|
338
|
+
|
|
339
|
+
### GPU from Python (macOS)
|
|
340
|
+
|
|
341
|
+
The **macOS wheels ship the Metal GPU path**. Use the `Rationer` handle — its `cluster_canonicals`
|
|
342
|
+
runs on the GPU when the group is large enough to pay for the dispatch, otherwise CPU; same
|
|
343
|
+
byte-for-byte answer either way:
|
|
344
|
+
|
|
345
|
+
```python
|
|
346
|
+
import difflib_fast as df
|
|
347
|
+
|
|
348
|
+
r = df.Rationer(concurrency="gpu+cpu") # "cpu" | "gpu" | "gpu+cpu" (default)
|
|
349
|
+
r.concurrency # "gpu+cpu" if Metal came up, else "cpu"
|
|
350
|
+
r.cluster_canonicals(corpus, 0.6) # GPU-accelerated (~1.1–1.4× on Apple Silicon)
|
|
351
|
+
r.ratio_many(pairs) # CPU (the GPU offload loses here)
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
On Linux/Windows wheels, or with no Metal device, a `Rationer` transparently runs everything on CPU.
|
|
355
|
+
Build a GPU wheel locally on macOS with
|
|
356
|
+
`maturin develop --release --features python,gpu` (the CLI `--features` **replaces** the pyproject
|
|
357
|
+
default, so list both).
|
|
358
|
+
|
|
359
|
+
---
|
|
360
|
+
|
|
361
|
+
## Also: exact cosine similarity join (`simjoin`)
|
|
362
|
+
|
|
363
|
+
The same "exact, or it's a bug" discipline, pointed at a different metric. **`simjoin`** is an exact
|
|
364
|
+
all-pairs **weighted-cosine** similarity join over sparse non-negative vectors — *every* pair with
|
|
365
|
+
`cos ≥ t`, no LSH, no approximation — on the provably-SOTA **L2AP** algorithm (inverted index +
|
|
366
|
+
Cauchy–Schwarz prefix pruning; Anastasiu & Karypis, ICDE'14). It's the principled exact replacement for
|
|
367
|
+
"shingle candidates → verify" near-duplicate detection: documents = functions, dimensions = canonical
|
|
368
|
+
lines, weights = IDF — i.e. **exact Type-3 code-clone detection**.
|
|
369
|
+
|
|
370
|
+
```python
|
|
371
|
+
import difflib_fast as df
|
|
372
|
+
|
|
373
|
+
# documents as token lists → TF-IDF in Rust → every pair with cosine ≥ 0.8
|
|
374
|
+
docs = [["def _fn(_v0):", "return _v0 + 1"],
|
|
375
|
+
["def _fn(_v0):", "return _v0 + 1"], # an exact clone of doc 0
|
|
376
|
+
["import os", "import sys"]]
|
|
377
|
+
df.cosine_join(docs, 0.8) # → [(0, 1, 1.0)] tuples are (j, i, cos), j < i
|
|
378
|
+
df.cosine_join(docs, 0.8, "gpu") # same join, the dot-products run on the Metal GPU
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
Three backends, one argument (`concurrency=`) — all auto-parallel across every core (rayon, GIL
|
|
382
|
+
released, exactly like `ratio`):
|
|
383
|
+
|
|
384
|
+
| `concurrency` | how | result |
|
|
385
|
+
|---|---|---|
|
|
386
|
+
| `"cpu"` | L2AP on all cores | exact `f64` |
|
|
387
|
+
| `"gpu+cpu"` | CPU prunes ~99% of candidates, GPU verifies the rest (f32 filter), CPU re-scores survivors exactly | **byte-identical to `"cpu"`** |
|
|
388
|
+
| `"gpu"` | CPU prunes, GPU verifies, emit the f32 score | ε-exact (≤ 1 differing pair per **millions**) |
|
|
389
|
+
|
|
390
|
+
On the real **top-300 PyPI** corpus (287,408 functions, 3.1M clone pairs found) the verify is
|
|
391
|
+
memory-**bandwidth**-bound, and the Apple GPU's memory-level parallelism wins it: **53 GB/s** of
|
|
392
|
+
random-gather sparse dot-products vs the CPU's 22 GB/s, so the GPU backends run the whole join
|
|
393
|
+
**~1.8–2× faster than the (already L2AP-tuned) CPU**, byte-for-byte. Brute force would be ~4·10¹⁰ pairs
|
|
394
|
+
(hours); this is seconds. `CosineJoiner(docs)` is the stateful handle (build corpus + GPU upload once,
|
|
395
|
+
sweep thresholds); full numbers in [`benchmarks.md`](benchmarks.md#6-similarity-join-simjoin).
|
|
396
|
+
|
|
397
|
+
In Rust: `difflib_fast::simjoin::{Corpus, cosine_join, cosine_join_with, CosineJoiner}` (GPU backends
|
|
398
|
+
behind the `gpu` feature). Same correctness gate as the rest of the crate — the indexed join is
|
|
399
|
+
asserted **bit-identical to an O(n²) brute-force oracle** on hundreds of fuzzed corpora.
|
|
400
|
+
|
|
401
|
+
---
|
|
402
|
+
|
|
403
|
+
## How it works
|
|
404
|
+
|
|
405
|
+
The metric is **Ratcliff–Obershelp** (Ratcliff & Obershelp, 1988) computed over a **suffix automaton**
|
|
406
|
+
(Blumer et al., 1985) via **matching statistics** (Chang & Lawler, 1994); short, diverse inputs take a
|
|
407
|
+
lighter `b2j` path instead, chosen per-input by a cheap work estimate. The composition — exact
|
|
408
|
+
byte-for-byte RO this way, tuned for an all-pairs clustering join — is original to this crate.
|
|
409
|
+
|
|
410
|
+
[`src/gestalt.rs`](src/gestalt.rs) is the engine: its module doc and inline comments cover the
|
|
411
|
+
automaton, the endpos range structure that lets one prebuilt automaton serve the whole RO recursion,
|
|
412
|
+
the threshold-engine early-exit math, and the measured performance floor.
|
|
413
|
+
|
|
414
|
+
---
|
|
415
|
+
|
|
416
|
+
<div align="center">
|
|
417
|
+
|
|
418
|
+
**Exact `difflib`. None of the wait.**
|
|
419
|
+
|
|
420
|
+
Made with ⚡ by [@prostomarkeloff](https://github.com/prostomarkeloff)
|
|
421
|
+
|
|
422
|
+
</div>
|
|
423
|
+
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
difflib_fast/__init__.py,sha256=yZJOYXa8slD_DX48TM36N_rrxklqHcDaiIY_YyVAamQ,1960
|
|
2
|
+
difflib_fast/__init__.pyi,sha256=U31nLrTFPWLsheynjFK5qmhAULNUsY0KiN0L2FEpimE,6819
|
|
3
|
+
difflib_fast/_difflib_fast.pyd,sha256=OvK1JEHaJ0F5Xzz3zUMtEBlwIyu2Fjfu0J7KdnVTaiY,906240
|
|
4
|
+
difflib_fast/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
difflib_fast-0.4.0.dist-info/METADATA,sha256=Kqsf6hat3fLOq3Rg3HP9C6Ve0C9A1sMDvDfQtkOllC4,20839
|
|
6
|
+
difflib_fast-0.4.0.dist-info/WHEEL,sha256=xe4_tbg8wYdeh4O7nLbnWckzIQtCR4nODDgwke_TKy8,95
|
|
7
|
+
difflib_fast-0.4.0.dist-info/sboms/difflib-fast.cyclonedx.json,sha256=dpDS3Mqde-oc9VarKoaP0utq6LTGCQe46PnrjlmQwVE,23481
|
|
8
|
+
difflib_fast-0.4.0.dist-info/RECORD,,
|