Text performance - CyrilB1531/lodestar GitHub Wiki
Development build. This page describes
main, not a released package. The latest published Lodestar.Text is 0.7.0 — read its documentation.
What Lodestar.Text costs against the library a reader would otherwise reach for. How to read
a row, and what this page leaves out:
docs/guides/performance.md.
IndelPattern against FuzzySharp (issue #1130)
Full method, the corpora that cost the handle and the defect the agreement check found:
bench/README.md.
Machine: the AMD Ryzen 7 8700G named below, on 2026-09-24. BenchmarkDotNet 0.14.0, default job.
One query against 64 texts; length is the part that differs, the shared affixes are on top of it.
| the texts | length | Lodestar, held | Lodestar, pairwise | FuzzySharp 6.0.0 |
|---|---|---|---|---|
| unrelated | 4 | 347.7 ns | 763.9 ns | 3,104.7 ns |
| unrelated | 16 | 842.4 ns | 1,811.8 ns | 4,942.5 ns |
| unrelated | 32 | 1,443.0 ns | 3,318.8 ns | 7,853.3 ns |
| sharing 12 + 12 | 4 | 1,280.7 ns | 1,484.9 ns | 4,378.9 ns |
| sharing 12 + 12 | 16 | 1,746.5 ns | 2,472.9 ns | 5,627.5 ns |
| sharing 12 + 12 | 32 | 2,363.3 ns | 3,978.1 ns | 8,403.8 ns |
| sharing a 45 suffix | 4 | 2,098.5 ns | 2,289.7 ns | 4,669.3 ns |
| sharing a 45 suffix | 16 | 2,608.4 ns | 3,342.1 ns | 6,373.8 ns |
| sharing a 45 suffix | 32 | 4,654.0 ns | 4,834.3 ns | 9,057.9 ns |
| sharing 28 + 28 | 4 | 2,561.2 ns | 2,362.6 ns | 4,653.3 ns |
| sharing 28 + 28 | 16 | 3,776.9 ns | 3,328.8 ns | 6,726.6 ns |
| sharing 28 + 28 | 32 | 5,064.0 ns | 4,898.9 ns | 9,560.6 ns |
| sharing 45 + 45 | 4 | 3,949.0 ns | 3,344.1 ns | 5,581.9 ns |
| sharing 45 + 45 | 16 | 4,685.6 ns | 4,346.0 ns | 7,641.9 ns |
| sharing 45 + 45 | 32 | 6,135.0 ns | 5,901.3 ns | 10,186.3 ns |
| past the table, 70 + 70 | 4 | 4,580.0 ns | 4,601.9 ns | 7,001.1 ns |
| past the table, 70 + 70 | 16 | 5,504.4 ns | 5,728.7 ns | 8,989.9 ns |
| past the table, 70 + 70 | 32 | 6,803.1 ns | 7,181.3 ns | 11,815.0 ns |
Against the .NET incumbent, over all eighteen rows: the held form is 1.41× to 8.93× and the
pairwise loop 1.52× to 4.06×. Raffinert.FuzzySharp publishes no held form: its scorer takes
two strings, so its row is one call per text, which is the same absence the score matrix records.
Against this package's own pairwise loop it is 0.43 to 0.96 where the texts share little or share one end, and 1.03 to 1.18 where they share a long run at both. Those six rows are the cost of the design. The table spans the whole pattern, so it cannot drop a common prefix and suffix; where the pattern spans two words a sixteen-unit probe at either end sends the pair back to the pairwise call, which is what holds the 45 + 45 rows to 1.04–1.18 against 1.56–2.14 unguarded. Over one word the probe is not taken — scanning the affix costs about what the trim saves — and the 28 + 28 row at length 4, the one that would say otherwise, reads 1.08.
Allocation: 40 bytes against the incumbent's 5,120. The handle is one object and a pooled table; the incumbent materialises per call. The pairwise loop allocates nothing at all, which is the one column it wins.
Levenshtein.Distance against rapidfuzz
Levenshtein.Distance against rapidfuzz
3.14.6 on Python 3.12, through the cross-language harness over bench/corpus/pairs.json — the same
committed corpus and the same ns/pair methodology on both sides. Method, corpora and which bucket
reaches which kernel:
bench/README.md.
Machine: AMD Ryzen 7 8700G w/ Radeon 780M Graphics, 1 CPU, 16 logical and 8 physical cores (BenchmarkDotNet's own header), Ubuntu 26.04.1 LTS, .NET SDK 10.0.401, .NET 10.0.12 runtime, AVX-512. Window: one harness run per side on 2026-09-14.
| Alphabet, length | rapidfuzz | Lodestar | |
|---|---|---|---|
| Latin, 128 | 994.2 ns | 536.4 ns | 1.85× C# faster |
| Latin, 512 | 8,711.5 ns | 7,535.7 ns | 1.16× C# faster |
| CJK, 128 | 1,671.8 ns | 1,245.1 ns | 1.34× C# faster |
| CJK, 512 | 14,632.2 ns | 11,393.5 ns | 1.28× C# faster |
Ahead on both alphabets at both lengths. The Latin buckets take Myers' blocked kernel two words at a time; the CJK buckets leave Latin-1 once their affixes are trimmed — 993 of 1,000 pairs at 128 and all 1,000 at 512 — and take the row-major kernel with the side table that carries symbols above U+00FF. Buckets of 8 and 64 do not reach the blocked kernel at all; at those lengths rapidfuzz pays per-call interop overhead and this package is further ahead.
Indel is len(a) + len(b) - 2·LCS, so what runs is
Lcs.SubsequenceLength — and that is also
what fuzz.ratio, every process.extract and every blocking deduplication pass runs. Same corpus,
same harness and the same rapidfuzz 3.14.6 as the table above.
Machine: the AMD Ryzen 7 8700G named above. Window: the harness's compare-indel, one run per side on 2026-09-13, the C#
side published once over three rounds; the range below is the three rounds' spread.
| Alphabet, length | rapidfuzz | Lodestar | |
|---|---|---|---|
| Latin, 128 | 258.2 ns | 229.3 – 230.7 ns | 1.12× C# faster |
| Latin, 512 | 2,712.8 ns | 3,085 – 3,103 ns | 1.14× Python faster |
| CJK, 128 | 1,127.8 ns | 776 – 817 ns | 1.38× C# faster |
| CJK, 512 | 9,423.5 ns | 5,222 – 5,277 ns | 1.79× C# faster |
Latin 512 is the one row behind, and what it pays there is the four-word group's stored carry and the equality table's clear; the CJK rows, which take a different route, are ahead by more the longer the input gets.
TfidfVectorizer against ML.NET 5.0.0's FeaturizeText
Machine: AMD Ryzen 7 8700G w/ Radeon 780M Graphics, 1 CPU, 16 logical and 8 physical cores
(BenchmarkDotNet's own header), Ubuntu 26.04.1 LTS, .NET SDK 10.0.401, .NET 10.0.12 runtime,
AVX-512. Window: BenchmarkDotNet 0.14.0, default job, 2026-09-14, one run per class. The
class and its agreement check:
bench/README.md
section 15.
| Documents | Lodestar | FeaturizeText |
Ratio |
|---|---|---|---|
| 200 | 5.057 ms, 5.13 MB | 28.875 ms, 28.80 MB | 5.71 |
| 1,000 | 22.294 ms, 24.92 MB | 244.044 ms, 325.26 MB | 10.95 |
This is not like-for-like, and section 15 of bench/README.md counts the difference.
FeaturizeText adds character n-grams, so it produces 70,307 non-zero features at 200 documents
where this package stores 7,996, and 351,217 at 1,000 against 39,974. Per non-zero feature produced,
that is 0.63 μs against 0.41 μs at 200 documents, ML.NET ahead, and 0.56 μs against 0.69 μs at 1,000.
The claim is the sparse representation, not a faster kernel.
Bm25Index against LuceneSharp.Core 26.8.4415's
BM25Similarity, by Bm25Benchmarks: one query term, the top ten, over a seeded corpus of 500
terms and 40 tokens a document. Method:
bench/README.md
section 21.
Both sides return the same ten documents in the same order, checked at 1,000 and at 20,000
documents before either was timed. This package's K1 is 1.5 and Lucene's 1.2; with one term and
documents of equal length the ranking follows the term's frequency alone, whatever K1 is.
Machine: AMD Ryzen 7 8700G w/ Radeon 780M Graphics, 1 CPU, 16 logical and 8 physical cores
(BenchmarkDotNet's own header), Ubuntu 26.04.1 LTS, .NET SDK 10.0.401, .NET 10.0.12 runtime,
AVX-512. Window: BenchmarkDotNet 0.14.0, default job, 2026-09-14 — the query rows from the
run that measured both sides after #751
replaced Top's sort with a bounded heap, the text-to-ranking rows from the run before it, which
that change does not touch because the row is the vectorizer's.
| Documents | Row | Lodestar | LuceneSharp | Faster |
|---|---|---|---|---|
| 1,000 | query only | 1.223 μs, 424 B | 1.943 μs | Lodestar, 1.6× |
| 20,000 | query only | 15.166 μs, 424 B | 10.614 μs | Lucene, 1.4× |
| 1,000 | text to ranking | 9,280.8 μs, 21,362.8 KB | 4,451.2 μs, 1,346.0 KB | Lucene, 2.1× |
| 20,000 | text to ranking | 228,889 μs ± 23,916, 418,588.8 KB | 86,301 μs, 21,897.3 KB | Lucene, 2.7× |
The query splits by size. What is left at 20,000 documents is the pass over every document's score, 20,000 comparisons against the heap's root; Lucene never reads a document without the term, and skips postings blocks whose best score cannot enter its top ten, as its design has it. The query no longer allocates in proportion to the corpus.
The text-to-ranking row is the vectorizer's, not the index's. Each phase, split with a
Stopwatch on the same machine and corpus, lowest of several runs:
| Documents | CountVectorizer.FitTransform |
new Bm25Index(counts) |
Bm25Index.Score |
|---|---|---|---|
| 1,000 | 9.39 ms, 20.4 MB | 0.360 ms, 0.46 MB | 1.3 μs |
| 20,000 | 175.6 ms, 399.6 MB | 3.233 ms, 8.97 MB | 10.1 μs |
For a caller who already holds the matrix — one who has vectorized for something else, TF-IDF
or a classifier — building Bm25Index and answering q queries costs 0.36 ms + 1.22 μs·q at
1,000 documents against Lucene's 4.45 ms + 1.94 μs·q, cheaper at every q. At 20,000 documents
it is 3.23 ms + 15.2 μs·q against 86.3 ms + 10.6 μs·q, which crosses near 18,000 queries. From
text Lucene stays ahead from the first query. These crossings mix Stopwatch build figures with
BenchmarkDotNet query figures, so read them as orders of magnitude.
DamerauLevenshtein.Distance against F23.StringSimilarity (issue #1199)
DamerauLevenshtein.Distance
against F23.StringSimilarity 7.0.1's Damerau, the maintained .NET implementation of the
unrestricted distance, on one pair of each length sharing almost no affix, so the rows price the
recurrence rather than the trimming. Both arms are checked to agree before timing. Method:
bench/README.md.
Machine: AMD Ryzen 7 8700G w/ Radeon 780M Graphics, 1 CPU, 16 logical and 8 physical cores (BenchmarkDotNet's own header), Ubuntu 26.04.1 LTS, .NET SDK 10.0.401, .NET 10.0.12 runtime, AVX-512. Window: one BenchmarkDotNet run, default job, 2026-09-27, no other load.
| Length | F23.StringSimilarity | Lodestar | Allocated, F23 / Lodestar | |
|---|---|---|---|---|
| 12 | 1,123.2 ns | 481.9 ns | 2.33× C# faster | 1,520 B / 840 B |
| 120 | 89,160.2 ns | 36,239.3 ns | 2.46× C# faster | 61,064 B / 2,128 B |
| 1,000 | 7,577,094.9 ns | 2,326,732.7 ns | 3.26× C# faster | 4,018,545 B / 5,651 B |
The gap widens with length, and the memory gap is the finding. F23 allocates the full
(m + 2) × (n + 2) table per call, 4 MB at 1,000 characters a side; this keeps three rows of the
shorter side, rented from the pool, whatever the length.
No .NET package publishes a BK-tree, so the alternative is the loop a caller would otherwise
write: BkTreeBenchmarks.LengthFilteredScan, a linear scan that skips any word whose length
already rules it out, then calls
Levenshtein.Distance on what survives.
Both arms materialise and sort the same shape of result — a List<BkTreeMatch> ordered by
distance ascending — so neither pays a cost the other is exempt from. 20,000 words per shape, 200
queries drawn from the corpus. Building the tree runs in [GlobalSetup], which BenchmarkDotNet
excludes from every measured iteration: the scan has no structure to build, so a reader weighing
the k = 1 win should price the build in separately.
python3 bench/corpus/generate_dictionary.py
dotnet run -c Release --project bench/Lodestar.Text.Benchmarks -- --filter '*BkTree*'Machine: 4-core Intel Xeon Processor @ 2.10GHz (BenchmarkDotNet's own header; the physical CPU
behind this virtualized host is not otherwise identified), Ubuntu 24.04.4 LTS, .NET SDK 10.0.111,
.NET 10.0.11 runtime — a hosted session container, not a dedicated benchmark machine, so these
ratios are directional rather than exact. Window: one BenchmarkDotNet run, default job,
2026-09-02, no other load on the container, 6 min 16 s across 16 benchmarks.
| Method | Radius | Shape | Mean | Ratio | RatioSD | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|
LengthFilteredScan |
1 | clustered | 153.01 ms | 1.00 | 0.02 | 27.25 KB | 1.00 |
TreeWithinDistance |
1 | clustered | 90.91 ms | 0.59 | 0.02 | 103.75 KB | 3.81 |
LengthFilteredScan |
1 | uniform | 161.09 ms | 1.00 | 0.02 | 23.86 KB | 1.00 |
TreeWithinDistance |
1 | uniform | 84.03 ms | 0.52 | 0.02 | 116.56 KB | 4.88 |
LengthFilteredScan |
2 | clustered | 223.35 ms | 1.00 | 0.01 | 103.44 KB | 1.00 |
TreeWithinDistance |
2 | clustered | 352.15 ms | 1.58 | 0.02 | 260.13 KB | 2.51 |
LengthFilteredScan |
2 | uniform | 234.14 ms | 1.00 | 0.03 | 54.65 KB | 1.00 |
TreeWithinDistance |
2 | uniform | 315.79 ms | 1.35 | 0.06 | 193.09 KB | 3.53 |
LengthFilteredScan |
3 | clustered | 279.42 ms | 1.00 | 0.02 | 949.90 KB | 1.00 |
TreeWithinDistance |
3 | clustered | 489.30 ms | 1.75 | 0.04 | 1366.63 KB | 1.44 |
LengthFilteredScan |
3 | uniform | 286.40 ms | 1.00 | 0.02 | 741.56 KB | 1.00 |
TreeWithinDistance |
3 | uniform | 476.27 ms | 1.66 | 0.07 | 1153.80 KB | 1.56 |
LengthFilteredScan |
4 | clustered | 327.14 ms | 1.00 | 0.03 | 5113.56 KB | 1.00 |
TreeWithinDistance |
4 | clustered | 569.75 ms | 1.74 | 0.06 | 7216.20 KB | 1.41 |
LengthFilteredScan |
4 | uniform | 326.21 ms | 1.00 | 0.01 | 5514.13 KB | 1.00 |
TreeWithinDistance |
4 | uniform | 584.62 ms | 1.79 | 0.07 | 7964.50 KB | 1.44 |
Ratio is Mean(TreeWithinDistance) / Mean(LengthFilteredScan), BenchmarkDotNet's own baseline
column: below 1 the tree is faster, above 1 it is slower than never having built it. Only the
k = 1 rows are below 1, and the gap widens with radius rather than closing — 0.52/0.59
(uniform/clustered) at k = 1 against 1.79/1.74 at k = 4.
Pruning is not the whole cost. An instrumented run in the same window counted raw distance
computations, tree over scan, at 0.33/0.34 (k = 1), 0.79/0.86, 0.92/0.95 and 0.96/0.93 (k = 4):
the tree does compute fewer distances at every radius. What that count cannot see is per-node
traversal — a Dictionary<int, Node> lookup, a Stack<Node> push and a call through the stored
Func<string, string, int> metric delegate, against the scan's one array read and one integer
subtraction through a static method the JIT inlines. That fixed cost is cheap at k = 1, where the
tree visits a third as many nodes as the scan has candidates, and dominates from k = 2, where it
visits most of the corpus one node at a time instead of one array element at a time.
The allocation column carries the same story: the tree starts at 3.81× to 4.88× the scan at
k = 1, where a Stack<Node> costs more than a 20-word hit list, and falls to 1.41× to 1.44×
by k = 4 as the shared result list comes to dominate both sides.
docs/guides/dictionary-lookup.md carries the reader-facing version: the
tree is worth using at k = 1, and a length-filtered scan is the better answer past it.