-
Notifications
You must be signed in to change notification settings - Fork 0
performance
Performance is the selling point against Python, so it is measured from Lot 1 with BenchmarkDotNet, not estimated.
dotnet run -c Release --project bench/Lodestar.Text.Benchmarks -- --filter '*Levenshtein*'
dotnet run -c Release --project bench/Lodestar.Text.Benchmarks -- --filter '*VectorizerBenchmarks*' '*FuzzBenchmarks*'-
ReadOnlySpan<char>everywhere. Inputs are never copied;stringliterals convert with no allocation. -
ArrayPool<int>for the dynamic-programming matrices. The DP row is rented then returned: zero managed allocation per call, so no GC pressure even under heavy load. -
Rolling DP row on the shorter operand →
O(min(n, m))memory. - Common prefix/suffix trimming → collapses the DP band on near-equal inputs (the common case in record matching).
Short-job measurement (reduced iterations: means are noisy, but the allocation column is reliable). Re-run with a full job before quoting.
| Method | Length | Mean | Allocated |
|---|---|---|---|
Distance (UTF-16) |
8 | ~35 ns | 0 B |
Distance (code point) |
8 | ~208 ns | 0 B |
Distance (UTF-16) |
64 | ~7.0 µs | 0 B |
Distance (UTF-16) |
512 | ~21 µs | 0 B |
Zero allocation at every size is the structural result. On very short inputs
the code-point mode costs ~5× the UTF-16 mode (the decode pass dominates when the
computation itself is tiny); from 64+ characters the gap closes. Hence the choice:
UTF-16 by default, CodePoint on demand.
Cross-language bench with identical methodology on both sides (same committed
ASCII corpus, ns/pair throughput, auto-scaling, best-of-5). See
bench/README.md.
Indicative measurement (rapidfuzz 3.14.5 / Python 3.12; Lodestar.Text / .NET 10 on an Intel i7-4770S; dev machine — non-authoritative), after adding the blocked (multi-word) Myers fast path:
| Length | Python (rapidfuzz) | C# (Lodestar.Text) | Ratio | C# path |
|---|---|---|---|---|
| 8 | 183 ns/pair | 36 ns/pair | 5.1× C# faster | DP |
| 32 | 324 ns/pair | 453 ns/pair | 1.4× Python | Myers (single word) |
| 128 | 2 693 ns/pair | 1 777 ns/pair | 1.5× C# faster | Myers (blocked) |
| 512 | 21 688 ns/pair | 20 555 ns/pair | 1.06× C# faster | Myers (blocked) |
- Short strings (≤ ~40) — the typical name/identifier matching case: C# is ahead, largely because rapidfuzz pays per-call interop overhead there.
- Long strings — previously 13–31× behind rapidfuzz, because patterns over 64 characters fell back to the DP. Blocked Myers closed that: the 512 bucket went from 684 µs to 21 µs, a 33× improvement, and now edges ahead. It was never a language problem, only an algorithmic one.
- The length-32 bucket was the remaining gap, at 1.4× behind. It is closed, and the cause was not the one this line assumed — see the window below.
-
Scope. The figures above are the UTF-16 mode, measured on a Latin corpus,
so the table describes Latin inputs. The bit-parallel path no longer requires
them: a pattern above U+00FF used to send
Distanceback to the DP, and a side table now carries those symbols on both the single-word and the blocked route, so CJK and emoji take the kernel too (decision 0043). The code-point mode lost the same restriction earlier (#208). What each kernel costs on a wide alphabet, and where it crosses the DP there, is the banded sweep below — which found the then-shipped gate wrong on three of its four crossings (decision 0048) and led to the two-gate shape each kernel carries now (decision 0049).
Intel i7-4770S, .NET 10.0.10, rapidfuzz 3.14.5 / Python 3.12.3; dev machine,
non-authoritative. The issue that opened this said the gap was "inside the
kernel, not in the dispatch". It was the dispatch, and the first measurement is
the one that says so: BucketRouteDiagnostics splits the committed length-32
bucket on the dispatch's own criterion — the shorter operand after Affixes.Trim,
which is the pattern Myers is handed — and times each half.
| route at the gate of 16 | pairs | median pattern | total | per pair |
|---|---|---|---|---|
| DP (pattern < 16) | 474 | 10 | 308.3 µs | 650.4 ns |
| Myers (pattern ≥ 16) | 526 | 22 | 134.6 µs | 255.9 ns |
The two sum to 442.9 ns/pair against the harness's 440.6, so the split accounts for the bucket. 47% of the pairs were carrying 70% of its cost, and they were the ones on the smaller problem: the DP was 2.5× slower than Myers on a band a quarter the size. Nothing was wrong inside either kernel — the gate was simply far above where the two curves actually cross.
A constant the dispatch consults cannot be swept from inside the dispatch, so it was swept directly, rebuilding between points and reading the committed corpus end to end. Single runs, ns/pair:
| gate | Levenshtein len 8 | Levenshtein len 32 | Indel len 8 | Indel len 32 |
|---|---|---|---|---|
| 16 | 31.2 | 451.7 | 37.4 | 316.4 |
| 12 | 31.4 | 311.2 | 37.2 | 217.5 |
| 10 | 31.9 | 266.0 | 37.2 | 189.8 |
| 8 | 32.0 | 239.4 | 37.9 | 177.6 |
| 6 | 31.8 | 232.4 | 37.4 | 170.0 |
| 4 | 31.6 | 233.0 | 37.6 | 164.8 |
| 2 | 31.2 | 233.1 | 37.0 | 166.9 |
| 1 | 51.2 | 236.6 | — | — |
-
8, and not lower, is a choice about text length rather than pattern length.
Myers costs
setup + O(n)where the DP costsO(m·n), so they cross atm ≈ 1 + setup/n— which falls as the text grows. There is no single right gate, only a right one per regime, and the gate sees onlym. Calibrating on the shortest texts is calibrating on the case where being wrong costs the most, and the gate-of-1 row is that case: the length-8 bucket loses 64% because 32% of its pairs have a pattern of exactly 1 and pay for a 256-entry table to compare one character. - The corpus cannot resolve 2 to 7. Its length-8 bucket trims to a pattern of 0 or 1 and its length-32 bucket is the only one holding patterns in between, so every row above between 2 and 6 rests on one bucket. The flat region there is worth 6 ns and is not worth the exposure.
The second finding is in both kernels and is independent of the gate: stackalloc
zeroes, nothing in the assembly disables localsinit, and both single-word
kernels then called Clear() on the 256-entry equality table anyway — a second
2 KB memset on a call whose real work is O(n). Myers already relied on that
zeroing for its probe table and said so, three constants above the line that
cleared. Removing it is worth 12% of the length-32 bucket on Levenshtein and 17%
on Indel, on top of the gate.
Both changes together, median of 3, before and after interleaved in one window so machine drift lands on both columns:
| Length | Levenshtein before | after | Indel before | after | |
|---|---|---|---|---|---|
| 8 | 31.9 ns | 30.7 ns | 37.7 ns | 36.5 ns | |
| 32 | 427.6 ns | 204.8 ns — 2.09× | 318.7 ns | 145.6 ns — 2.19× | |
| 128 | 1 722.5 ns | 1 700.8 ns | 1 193.6 ns | 1 174.3 ns | |
| 512 | 19 926.8 ns | 19 267.3 ns | 13 562.6 ns | 13 388.5 ns |
Every bucket but 32 is within noise, which is the expected shape: 128 and 512
already took the kernel and keep their Clear() on the blocked path, and 8 never
reaches the gate at all.
Against rapidfuzz, both sides re-run in one window on a machine under load (one-minute average 11.3 falling to 8.0 across it, so the C# column sits above the quiet-machine medians above; the ratios are what the shared load makes comparable):
| Length | rapidfuzz Lev | C# Lev | rapidfuzz Indel | C# Indel |
|---|---|---|---|---|
| 8 | 188.1 ns | 33.9 ns — 5.55× | 130.6 ns | 39.2 ns — 3.33× |
| 32 | 323.9 ns | 221.9 ns — 1.46× | 206.9 ns | 158.3 ns — 1.31× |
| 128 | 2 590.6 ns | 1 889.0 ns — 1.37× | 637.9 ns | 1 342.3 ns — 2.10× behind |
| 512 | 20 863.7 ns | 19 896.4 ns — 1.05× | 7 335.8 ns | 15 144.0 ns — 2.06× behind |
The 32 bucket is ahead rather than 1.4× behind, which is the door this lot was opened to close. Indel's 128 and 512 are the 2.07×/2.15× the section below already records; they take the blocked path and this lot did not touch it.
MyersMinPatternLength was shared with the code-point path, which ADR 0004 flagged
as untested. It is: that path renames both operands through a 512-entry probe table
before the kernel sees them, so it carries the larger fixed cost and crosses later.
LevenshteinCodePointBenchmarks at the two candidate gates, Length = 16 acting as
a control because both gates route it to the kernel:
| Pattern | DP (gate 16) | Kernel (gate 8) | |
|---|---|---|---|
| 8 | 376.6 ns | 419.3 ns | DP ahead by 11% |
| 10 | 428.4 ns | 434.1 ns | the tie |
| 12 | 534.9 ns | 482.6 ns | kernel ahead by 10% |
| 16 | 550.1 ns | 532.4 ns | control — 3%, the noise floor |
So MyersMinCodePointPatternLength is 10 and MyersMinPatternLength is 8. One
constant would have to give up 11% on one path or the other, and the character
path is the one fuzz.ratio and every process.extract run.
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 and same methodology as the Levenshtein
table above, on the same machine (Intel i7-4770S, .NET 10.0.10, rapidfuzz 3.14.5
/ Python 3.12), one-minute load average 5.2 to 5.9 across the window; dev
machine, non-authoritative, and comparable to the Levenshtein figures because it
was taken under the same conditions rather than an ideal one.
Three states, each measured in its own window with Levenshtein re-run beside it as a control:
| Length | rapidfuzz | before | + trimming | + kernel | total |
|---|---|---|---|---|---|
| 8 | 132 ns | 222 ns | 30 ns | 37 ns | 6.0× |
| 32 | 205 ns | 3 775 ns | 1 084 ns | 326 ns | 11.6× |
| 128 | 590 ns | 58 066 ns | 29 996 ns | 1 252 ns | 46.4× |
| 512 | 6 582 ns | 627 297 ns | 569 521 ns | 14 363 ns | 43.7× |
Against rapidfuzz that moves the 128 and 512 buckets from 95–98× behind to 2.07× and 2.15× behind, the 32 bucket from 18.4× to 1.57×, and leaves the 8 bucket 3.66× ahead.
-
Trimming came first and is not the kernel.
Levenshtein.Distancealready stripped the common prefix and suffix;Lcsdid not. On a corpus that is a string and a copy with one mutation per ten, that is worth 7.5× at length 8 — where one position differs, so seven characters of eight never reach the band — and 1.10× at 512, where fifty-one scattered mutations leave almost nothing to strip. It is a best case for record matching and worth nothing on unrelated text, which is the shape of input the corpus does not contain. - The kernel is what closes the long buckets, 46× at 128 and 44× at 512.
- Still 2× behind at 128 and 512. (#320 revisits this: the gap is algorithmic, and the sentence below was wrong about where it sits — see "The blocked LCS kernel" further down.) The gap is no longer algorithmic; see the gate table below for where it now sits.
IndelBenchmarks cannot answer this — its operands trim down to an accidental
band — so LcsGateBenchmarks parameterises the differing middle directly and
runs both routes in one process with the dynamic program as baseline:
| Band | DP | Kernel | Ratio |
|---|---|---|---|
| 8 | 161 ns | 182 ns | 1.13 |
| 14 | 347 ns | 402 ns | 1.16 |
| 16 | 425 ns | 149 ns | 0.35 |
| 32 | 1 707 ns | 185 ns | 0.11 |
| 64 | 6 926 ns | 264 ns | 0.04 |
| 96 | 15 836 ns | 1 504 ns | 0.09 |
- The gate at 16 is right, and probably conservative. The kernel's floor is about 149 ns and the DP already costs 161 ns at band 8, so a lower gate may win — but that needs measuring below 8 rather than assuming. It was, in #208, and the guess was right: the gate is 8 and the length-32 bucket halved. The window above has the sweep.
-
The kernel's cost is nearly flat from 16 to 64 — 149, 154, 160, 168, 185,
234, 264 ns while the work quadruples. That is a fixed cost dominating: the
256-entry equality table is cleared on every call. Half of that clearing was
redundant and #208 removed it —
stackallochad already zeroed the table — which is worth 17% of the length-32 bucket here. #301 then stopped zeroing it at all for patterns of 32 or fewer, worth a further 13% of that bucket; right-sizing the table is still open. Band 96 makes it plain, crossing into the blocked path for 281 → 1 504 ns on one and a half times the work, the table having doubled. Right-sizing that table to the pattern's own alphabet is where the remaining 2× is most likely to be, and it is the same change ADR 0004 lists as lifting the Latin-1 restriction. -
The character route carries a fixed 25–60 ns overhead over the generic one,
visible below the gate where both take the DP and gone by band 96 where the
work dwarfs it. It is not a missing inlining — an
AggressiveInliningattempt moved the ratios by less than the error bars — and it is not yet explained.
Same machine and corpus, own window: Intel i7-4770S, .NET 10.0.110, one-minute load average 3.2 to 4.6 across it; dev machine, non-authoritative.
Both single-word kernels built their 256-entry table with stackalloc, which
localsinit zeroes on every call — 2 KB of memset for a table of which at most 64
entries are used, on work that is O(n). #208 removed the redundant second
memset; this is the one that remained, and no Clear() removal reaches it
(AllowUnsafeBlocks=false rules out [SkipLocalsInit]).
The prize, sized before anything was built. A table held in a [ThreadStatic]
array and restored by walking the pattern again costs O(m) stores rather than
O(256). An isolated harness put the memset it replaces at a flat ~25 ns per call
at every pattern length — the signature of a fixed cost, and consistent with the
flatness across bands above. That harness also said the change won in both
kernels, which the corpus then contradicted for one of them: a 64-string working
set never evicts a held table, and a walk over the corpus does.
Swept over the pair corpus — longest held pattern at 0 (never held), 16, 32 and 64 — length-32 bucket, ns/pair, best of two runs interleaved across the four builds:
| longest held pattern | 0 | 16 | 32 | 64 |
|---|---|---|---|---|
compare-indel (LCS kernel) |
152.2 | 138.5 | 132.1 | 134.2 |
compare (Myers) |
215.7 | 222.0 | 233.1 | 231.1 |
So the table is held in one kernel and not in the other, which is the same split the two gates already carry and for a related reason. The LCS recurrence is four operations per text character where Myers' is a dozen, so the identical fixed cost is a far larger share of what an LCS call does — and the held table's own costs (a thread-static access, a restore loop, and a table a corpus walk evicts between calls where a stack frame stays hot) are what Myers cannot cover.
Before and after, three runs each, interleaved across the boundary:
| Length | before | after | |
|---|---|---|---|
| 8 | 38.0 ns/pair | 37.4 ns/pair | +1.6% |
| 32 | 151.2 ns/pair | 131.6 ns/pair | +13.0% |
| 128 | 1 241 ns/pair | 1 219 ns/pair | +1.8% |
| 512 | 14 166 ns/pair | 14 319 ns/pair | −1.1% |
The 128 and 512 buckets take the blocked path, which this did not touch, so their movement is the run-to-run spread — as is the 8 bucket, whose pairs trim to a pattern below the gate. Levenshtein was re-run beside it as the control and moved by less than that at every bucket: 32.4 → 32.5, 215.6 → 214.6, 1 800 → 1 803.
The gate was re-swept afterwards and did not move. A cheaper kernel should be
worth entering at a shorter pattern, and the corpus says so — the length-32 bucket
reads 132.5 ns/pair at a gate of 8, 124.1 at 6 and 120.8 at 4, with the length-8
bucket flat at 37–38 throughout. That is the same relationship #208 measured
(177.6 at 8 against 164.8 at 4) and declined to act on, for a reason this lot does
not change: below 8 the corpus has a hole, the length-8 bucket trims to a pattern
of 0 or 1, and every row therefore rests on the one bucket. BitParallelMinPatternLength
stays at 8 until a corpus that can answer it exists.
The 256-entry equality table is indexed by the character, so a pattern above U+00FF could not be represented in it and both single-word kernels refused — the tables above therefore did not describe those inputs. The dense table now keeps the common characters and an open-addressed side table carries the rare ones, built only when the pattern holds one, in a method of its own so a Latin-1 pattern is never charged its 1.25 KB.
What had to be established first is that the Latin-1 path — fuzz.ratio's — does
not pay for the reach. Same machine and corpus, four runs per side interleaved,
one-minute load average 6.4 to 8.2; dev machine, non-authoritative. The corpus is
ASCII throughout, so every bucket of it measures exactly that question:
| Length | path | Levenshtein | Indel |
|---|---|---|---|
| 8 | single-word (changed) | +0.9% | −1.3% |
| 32 | single-word (changed) | +1.2% | −3.2% |
| 128 | blocked (unchanged) | −4.9% | −0.1% |
| 512 | blocked (unchanged) | −6.4% | −3.5% |
The last two rows are the reading that matters: they run code this change does not touch, so their movement is the measurement's own noise floor — up to 6.4% under this load. Every changed bucket moves less than that, which bounds the cost at about 5% and no better.
A timing bound was the wrong instrument, though, and the exact answer is free. The claim being tested — that the Latin-1 callers pass an empty side table, so the extra test folds away where the kernel is inlined — is a statement about generated code, not about elapsed time. Read it instead of timing it:
DOTNET_JitDisasm='SubsequenceLengthChars' DOTNET_TieredCompilation=0 dotnet run -c Releaseagainst a driver that runs the Latin-1 single-word path until the JIT settles, on this branch and on the parent commit. Normalise the addresses and the two listings are identical, all 83 instructions. The Latin-1 path's machine code is unchanged, so the branch costs exactly nothing there — a claim no machine load can weaken, and one the corpus timing could only ever have bounded.
The blocked path was widened the same way in #382, so no pattern falls back to the dynamic program for holding a character above U+00FF.
Both halves are recorded in decision 0043, which amends 0004's two bullets rather than editing that record.
What the reach is worth is not measured by this corpus, which is ASCII by construction. It took a band of its own, below.
Until this section the gain rested on the code-point mode's standing figure for what
falling back costs — 2.80 ms at Length = 512 against 22 µs — which is an argument
about a different mode, not a measurement of this change. MyersGateBenchmarks and
LcsGateBenchmarks now run each band twice, over 27 Latin symbols and over 27 CJK
ones, so the alphabet is the only difference between the two readings and both are
taken in one process against the same dynamic program.
Intel i7-4770S, .NET 10.0.11, X64 RyuJIT AVX2, BenchmarkDotNet short job
(IterationCount=3, WarmupCount=3), [MemoryDiagnoser]; dev machine,
non-authoritative. Three runs per class, all shown, the first two interleaved
LCS → Myers → LCS → Myers so drift lands on every column, at a one-minute load
average of 1.22 to 4.31. The third is a control taken after the operand
construction moved to a shared base class, on a busier machine — 4.56 to 6.38 —
which is why it reads uniformly slower and why the ratio column, computed inside
each run, is the one to read across the three. Nothing on any row allocates.
| Band | DP | kernel | DP (CJK) | kernel (CJK) | CJK kernel ÷ CJK DP |
|---|---|---|---|---|---|
| 8 | 147 / 160 / 149 ns | 72 / 74 / 70 ns | 149 / 149 / 195 ns | 118 / 140 / 125 ns | 0.79 / 0.94 / 0.64 |
| 12 | 249 / 247 / 294 ns | 75 / 81 / 78 ns | 247 / 263 / 267 ns | 142 / 150 / 156 ns | 0.57 / 0.57 / 0.58 |
| 16 | 422 / 377 / 417 ns | 96 / 87 / 93 ns | 409 / 388 / 410 ns | 147 / 146 / 165 ns | 0.36 / 0.38 / 0.40 |
| 32 | 1 558 / 1 685 / 1 652 ns | 121 / 126 / 132 ns | 1 566 / 1 592 / 1 720 ns | 211 / 239 / 235 ns | 0.13 / 0.15 / 0.14 |
| 64 | 6 245 / 6 614 / 6 671 ns | 188 / 205 / 209 ns | 6 344 / 6 378 / 6 724 ns | 350 / 380 / 389 ns | 0.06 / 0.06 / 0.06 |
| 96 | 13 962 / 15 729 / 16 768 ns | 840 / 861 / 876 ns | 14 752 / 15 079 / 15 571 ns | 1 146 / 1 251 / 1 232 ns | 0.08 / 0.08 / 0.08 |
LcsGateBenchmarks, and the edit-distance twin below:
| Band | DP | kernel | DP (CJK) | kernel (CJK) | CJK kernel ÷ CJK DP |
|---|---|---|---|---|---|
| 6 | 127 / 125 / 121 ns | 121 / 122 / 128 ns | 123 / 124 / 122 ns | 119 / 124 / 122 ns | 0.97 / 1.00 / 1.00 |
| 8 | 164 / 168 / 165 ns | 111 / 107 / 106 ns | 166 / 176 / 169 ns | 168 / 162 / 161 ns | 1.02 / 0.92 / 0.95 |
| 10 | 231 / 218 / 240 ns | 113 / 115 / 127 ns | 218 / 228 / 235 ns | 183 / 181 / 194 ns | 0.84 / 0.79 / 0.83 |
| 12 | 290 / 320 / 317 ns | 120 / 148 / 135 ns | 294 / 367 / 291 ns | 202 / 223 / 186 ns | 0.69 / 0.61 / 0.64 |
| 16 | 452 / 464 / 456 ns | 139 / 141 / 138 ns | 449 / 497 / 451 ns | 216 / 233 / 215 ns | 0.48 / 0.47 / 0.48 |
| 32 | 1 905 / 2 030 / 1 700 ns | 209 / 235 / 212 ns | 1 616 / 1 951 / 1 910 ns | 299 / 308 / 301 ns | 0.19 / 0.16 / 0.16 |
| 96 | 20 090 / 20 806 / 19 478 ns | 1 441 / 1 403 / 1 468 ns | 19 502 / 20 866 / 18 742 ns | 1 750 / 1 751 / 1 772 ns | 0.09 / 0.08 / 0.09 |
- What the refusal cost, measured rather than argued. A CJK band of 32 takes about 1 650 ns on the LCS dynamic program and 230 on the kernel; at 64, 6 500 against 370. Myers at 96 reads roughly 19 700 against 1 760. Between 7× and 18× wherever a band is past the crossing, and that is the whole of what #302 and #382 buy.
-
The two
DPcolumns are the same measurement twice, which is why both are here. The dynamic program compares characters and should not care where in the BMP they sit; it does not — the two agree to under 8% on most rows, 31% at worst on the shortest band. That spread is this job's noise floor, measured rather than asserted, and it is what every reading below has to clear. - The side table is not free: 1.6× to 1.9× on the LCS kernel, 1.2× to 1.5× on Myers'. A Latin band of 32 runs the LCS kernel in about 125 ns and a CJK one in 230. Both are far under the 1 650 ns the DP costs, so the reach is overwhelmingly worth having — but the fixed cost roughly doubles, which is the probe and the second table, and no earlier section could have said so.
- The gate of 8 is right for one of the two kernels on this input. LCS is under the DP at band 8 in all three runs (0.79, 0.94, 0.64), so its crossing is at or below the gate — though that row is the table's noisiest and cannot size the win. Myers is not: three readings of 1.02, 0.92 and 0.95 straddle parity, and it does not clearly win until band 10. The constant is shared, was swept over an ASCII corpus in #208, and on wide input the two kernels no longer cross at the same place. Splitting it is its own change and needs that sweep, not this band.
Every bucket was ASCII until now, so no number on this page described what either
side does above Latin-1. bench/corpus/pairs.json carries eight buckets since #406:
the same four lengths drawn from 27 Latin symbols, and four more drawn from 27 CJK
ones. Both alphabets stay inside the BMP on purpose — UTF-16 units and code points
coincide there, so the two sides still measure the same quantity, which a
supplementary character would break.
The wide buckets reach the kernel, which is checked rather than assumed.
BucketRouteDiagnostics splits a length-32 bucket on the dispatch's own criterion
and reads 833 of 1 000 CJK pairs on the bit-parallel route against 861 Latin. A
bucket whose pairs trimmed below the gate would have measured the dynamic program
under a wide-sounding name.
Intel i7-4770S, .NET 10.0.11, rapidfuzz 3.14.5 / Python 3.12.3; dev machine, non-authoritative. Each pair of sides back to back, Python first, one-minute load average 3.91 falling to 1.71 across the window.
| alphabet | length | Python ns/pair | C# ns/pair | speedup (py/C#) |
|---|---|---|---|---|
| latin | 8 | 164.7 | 29.3 | 5.62x C# faster |
| latin | 32 | 291.2 | 189.2 | 1.54x C# faster |
| latin | 128 | 2255.6 | 1778.8 | 1.27x C# faster |
| latin | 512 | 18113.7 | 22308.3 | 1.23x Py faster |
| cjk | 8 | 162.8 | 29.5 | 5.51x C# faster |
| cjk | 32 | 404.9 | 311.1 | 1.30x C# faster |
| cjk | 128 | 3333.7 | 2863.6 | 1.16x C# faster |
| cjk | 512 | 27670.7 | 26315.1 | 1.05x C# faster |
Levenshtein above, Indel below:
| alphabet | length | Python ns/pair | C# ns/pair | speedup (py/C#) |
|---|---|---|---|---|
| latin | 8 | 115.1 | 34.3 | 3.35x C# faster |
| latin | 32 | 183.9 | 118.5 | 1.55x C# faster |
| latin | 128 | 561.5 | 883.7 | 1.57x Py faster |
| latin | 512 | 6037.7 | 10046.4 | 1.66x Py faster |
| cjk | 8 | 136.3 | 34.6 | 3.94x C# faster |
| cjk | 32 | 337.5 | 261.2 | 1.29x C# faster |
| cjk | 128 | 1896.6 | 1501.9 | 1.26x C# faster |
| cjk | 512 | 15901.1 | 11931.5 | 1.33x C# faster |
- Read the speedup column last, and only after the two it is made of. On Indel the ranking flips with the alphabet — Lodestar is 1.66× behind on Latin at 512 and 1.33× ahead on CJK — and it is not Lodestar that got faster. rapidfuzz goes from 6 038 ns to 15 901 on the same lengths, up 163%; Lodestar goes from 10 046 to 11 932, up 19%. The column moved because the other side moved.
- What each side pays for is different, and both were measured. Lodestar's cost is the side table #302 added, and it is the same 1.2×–1.6× the gate benchmarks price on synthetic bands. rapidfuzz's is not about the alphabet at all: it tracks CPython's internal string kind. A control over three 27-symbol alphabets at length 512 reads 19 894 ns on ASCII, 19 445 on accented Latin-1 — stored one byte a character, like ASCII — and 29 522 on CJK, stored two. Above U+00FF the interpreter widens the string and rapidfuzz pays for it.
- Both implementations draw their line at U+00FF, for unrelated reasons. One indexes a 256-entry equality table, the other switches storage kind. Nothing arranged that, and it is what makes the wide buckets a fair test rather than a flattering one: the same threshold is crossed on both sides at the same character.
- The Latin buckets are byte-identical to the ones this page already published. One seeded stream feeds the generator and the CJK draws come strictly after the Latin ones, so every figure above the CJK rows still measures what it always did.
Since #302 and #382 one constant per kernel governs two regimes, and #383 measured the two kernels no longer crossing in the same place on wide input. A gate benchmark cannot place a gate — below it the dispatch sends both rows to the DP, so the ratio is 1 exactly where the crossing would be read. What answers it is #208's method, which #406 finally made reachable: edit the constant, rebuild, and read the committed corpus end to end at each value.
Intel i7-4770S, .NET 10.0.11; dev machine, non-authoritative. Six values per kernel, two passes in opposite order so drift between successive builds lands on both ends of the range rather than on one; one-minute load average 1.69 to 3.80 across the window. The Python side is fixed and was not re-run. The two passes agree to 5.3%.
Bucket 32 only, because it is the only bucket whose patterns straddle any candidate gate, and both passes are shown:
| gate | 4 | 6 | 8 | 10 | 12 | 16 |
|---|---|---|---|---|---|---|
| Levenshtein, latin | 178 / 180 | 181 / 184 | 189 / 191 | 216 / 220 | 253 / 257 | 381 / 380 |
| Levenshtein, cjk | 301 / 317 | 305 / 305 | 313 / 317 | 327 / 330 | 354 / 360 | 469 / 462 |
| Indel, latin | 108 / 108 | 113 / 113 | 121 / 120 | 143 / 141 | 172 / 167 | 262 / 272 |
| Indel, cjk | 257 / 245 | 271 / 258 | 275 / 267 | 282 / 282 | 296 / 301 | 382 / 374 |
The same, as a ratio against each row's own gate-4 reading, which is what the decision turns on:
| gate | 6 | 8 | 10 | 12 | 16 |
|---|---|---|---|---|---|
| Levenshtein, latin | 1.02× | 1.06× | 1.22× | 1.42× | 2.13× |
| Levenshtein, cjk | 0.99× | 1.02× | 1.06× | 1.16× | 1.51× |
| Indel, latin | 1.05× | 1.11× | 1.31× | 1.57× | 2.47× |
| Indel, cjk | 1.05× | 1.08× | 1.12× | 1.19× | 1.50× |
- No alphabet wants a different gate. All four curves rise with it, and the wide regime is consistently the less sensitive one: raising the gate from 4 to 16 costs Latin 2.13× and 2.47×, against 1.51× and 1.50× on CJK. That is #383's finding read from the other end — a kernel that wins by less against the DP also loses by less when the gate hands pairs back to it. A gate per alphabet would give precision to the regime that asks for least, and the test it needs would be paid by the Latin-1 path. Decision 0047 has the shapes refused.
-
The value question is still unanswerable, and the wide buckets do not lift it.
Both alphabets prefer 4 to 8, and that is one bucket saying so. Buckets 128 and 512
trim to median patterns of 110 and 493, far above every candidate; bucket 8 trims to a
median of 0, since 10% of 8 characters mutated leaves nothing after
Affixes.Trim— and CJK's length-8 bucket does exactly the same, the edit rate producing the hole rather than the alphabet. #208's objection stands verbatim. - Do not sum ns/pair across buckets. The 512 bucket is roughly 95% of any total and the gate cannot touch it, so a sum reports that bucket's run-to-run noise as though it were a result about the constant — and it picks a different winner than the row that can actually see the gate.
- The corpus is the more precise instrument, by a factor of two. The gate benchmarks' bands reproduce to about 12% on a short job; two corpus passes taken in opposite order agree to 5.3%. Where the two disagree, the corpus is the one reading input that ships: #383 read Myers at parity on band 8, yet moving the gate from 8 to 10 costs the CJK bucket 4.4% here.
The sweep above answered the alphabet question from bucket 32, the only bucket whose patterns straddled any candidate gate — and its median trimmed pattern is 16, with 70% of its pairs at or above 12. That is above the range where the two alphabets separate, so the answer it gave was bounded by where it could look.
Issue #409 gave the corpus twenty banded buckets, whose pattern after Affixes.Trim is
exactly the band named — checked pair by pair, 500 of 500 at every band — spanning 2
to 16 in both alphabets. With them the question stops needing a sweep: at a gate of 2
every band takes the kernel and at 17 every band takes the dynamic program, so one pair
of runs prices both routes over the same pairs and the crossing is where the ratio
reaches 1.
Intel i7-4770S, .NET 10.0.11; dev machine, non-authoritative. Two passes per kernel in opposite order, both shown, one-minute load 2.03 to 5.65. Bold means the kernel is under the dynamic program in both passes.
| band | LCS latin | LCS cjk | Myers latin | Myers cjk |
|---|---|---|---|---|
| 2 | 0.76 / 0.72 | 1.28 / 1.36 | 1.07 / 1.22 | 1.53 / 1.75 |
| 3 | 0.74 / 0.66 | 1.28 / 1.13 | 1.01 / 1.14 | 1.43 / 1.69 |
| 4 | 0.65 / 0.58 | 1.14 / 1.01 | 0.94 / 1.08 | 1.33 / 1.59 |
| 5 | 0.58 / 0.53 | 1.04 / 0.97 | 0.85 / 0.96 | 1.23 / 1.46 |
| 6 | 0.50 / 0.45 | 0.95 / 0.86 | 0.76 / 0.87 | 1.15 / 1.32 |
| 7 | 0.44 / 0.38 | 0.86 / 0.74 | 0.69 / 0.80 | 1.06 / 1.22 |
| 8 | 0.39 / 0.33 | 0.78 / 0.65 | 0.62 / 0.72 | 1.00 / 1.08 |
| 10 | 0.30 / 0.25 | 0.67 / 0.56 | 0.48 / 0.55 | 0.81 / 0.88 |
| 12 | 0.24 / 0.19 | 0.60 / 0.50 | 0.37 / 0.43 | 0.68 / 0.75 |
| 16 | 0.16 / 0.13 | 0.46 / 0.39 | 0.26 / 0.30 | 0.52 / 0.56 |
- Four crossings, not one. LCS crosses at or below band 2 on Latin and at 6 on CJK; Myers at 5 and at 10. The shipped constants are 8 and 8, so they are three bands too high for Myers on Latin, at least six too high for LCS on Latin, two too high for LCS on CJK — and two too low for Myers on CJK.
- Two dimensions, independent and both real. LCS crosses about three bands before Myers, its recurrence being four operations per text character against Myers' dozen — the asymmetry decision 0043 measured when only one of the two kernels was worth holding a table for. And CJK crosses about four bands after Latin in both, the side table raising the kernel's floor while leaving the dynamic program's cost untouched.
-
The largest error is on the hottest path.
Lcs.SubsequenceLengthCharsis whatfuzz.ratio,process.extractand blocking deduplication run. At band 8 its kernel costs 91.6 ns against the dynamic program's 236.2 — 2.6× — and the gate refuses it everything below, where it is still 24% to 56% cheaper. - This reverses what the sweep above concluded, and decision 0048 records both the reversal and why the earlier reading was bounded rather than wrong. What replaces the shared constant is a change to the hot path and is not decided there.
- The ratio is taken across two processes. Unlike the gate benchmarks, whose DP baseline runs in the same process, the two readings come from separate builds, so drift enters the ratio — about 10% between passes, which is why band 4 on Myers Latin and band 5 on LCS CJK flip between them. Four bands of separation between alphabets is an order above that; a boundary band is not.
The four crossings above are answered by two constants per kernel rather than one: the dispatch keeps its test, valued at the Latin-1 crossing, and a second refusal sits where each kernel has just discovered that the pattern leaves Latin-1. Decision 0049 has the three shapes it refused.
| dense gate | wide gate | was | |
|---|---|---|---|
Lcs |
2 | 6 | 8 |
Levenshtein |
5 | 10 | 8 |
What the Latin-1 path pays is nothing, and that is read rather than timed. Diffing
the JIT's output as #302 did — SubsequenceLengthChars, DistanceChars and both
single-word kernels, six methods, 492 instructions against 534 — every loop is unchanged
instruction for instruction. Two once-per-call instructions use a different register, the
allocator having reshuffled around the new blocks. The 45 added instructions are the
three refusals, their early-return epilogues and the stack checks those need; none sits
on a path a Latin-1 pattern reaches.
Intel i7-4770S, .NET 10.0.11; dev machine, non-authoritative. Each state published once and only the runs alternated — nothing compiles inside the window — three rounds in alternating order, medians shown, with the spread of the three after runs beside each row as that row's own noise floor. Scattered buckets only: the banded ones are what placed the gate, not what it is judged on.
| bucket | before | after | change | spread |
|---|---|---|---|---|
| Indel latin 8 | 33.4 ns | 33.9 ns | +1.6% | ±10% |
| Indel latin 32 | 126.3 ns | 106.9 ns | −15.4% | ±11% |
| Indel latin 128 | 913.9 ns | 888.8 ns | −2.7% | ±11% |
| Indel latin 512 | 10 518.8 ns | 10 481.7 ns | −0.4% | ±10% |
| Indel cjk 32 | 278.9 ns | 256.4 ns | −8.1% | ±9% |
| Indel cjk 512 | 12 656.3 ns | 12 398.3 ns | −2.0% | ±6% |
| Levenshtein latin 8 | 32.4 ns | 35.2 ns | +8.6% | ±11% |
| Levenshtein latin 32 | 188.3 ns | 197.5 ns | +4.9% | ±9% |
| Levenshtein latin 512 | 21 474.1 ns | 21 258.8 ns | −1.0% | ±23% |
| Levenshtein cjk 32 | 310.0 ns | 326.0 ns | +5.1% | ±10% |
-
One row clears its own noise, and it is the hot path's.
Indelat length 32 reads −11.9%, −16.3% and −13.7% across the three rounds — negative every time. That bucket is where the gate bites, andLcs.SubsequenceLengthCharsis whatfuzz.ratio,process.extractand blocking deduplication run. - Levenshtein shows nothing, and the corpus explains it rather than excusing it. Its sign flips between rounds (+5.2%, +4.6%, −4.1%). The length-32 bucket has 94% of its pairs above band 4 and 86% above 8, so moving Myers from 8 to 5 touches the 8% that sit between, at 15–30% each — one to two percent overall, under the floor. The change rests on the banded measurement above, not on this table, and 0049 records that distinction.
-
The first attempt at this table was thrown away, and why is worth keeping. It
rebuilt between every measurement, and
VBCSCompilerkept burning a quarter of a core into the window that followed: two runs of identical code differed by up to 15.2%, which is the size of the effect being looked for. Publishing each state once and alternating only the runs is what made the rows above readable.
#302 sized the blocked routes' side rows from the pattern's length rather than from
its characters above Latin-1, so the table went from 256 × ⌈m/64⌉ words to about
m²/32 — for every pattern, an ASCII one included. Past m ≈ 262 000 the product
wrapped in int, and Rent either threw out of a distance function or returned a table
too small to index. No path produced a wrong distance; the failure was an exception.
The table is arithmetic, so its size is stated rather than measured:
| pattern | before | after |
|---|---|---|
| 10 000 ASCII | 41.5 MB | 0.3 MB |
| 65 536 ASCII | 2 149 MB | 2.1 MB |
| 262 144 ASCII | wrapped | 8.4 MB |
| 10 000 CJK | 41.5 MB | 41.5 MB |
A pattern that genuinely holds wide characters is unchanged, which is the honest half: the count is of occurrences, not of distinct symbols, because distinct costs a set where occurrences cost the pass the fill loop makes anyway. A repetitive CJK pattern is therefore still sized for an alphabet it does not have.
Intel i7-4770S, .NET 10.0.11, BenchmarkDotNet short job; dev machine, non-authoritative.
BlockedTableBenchmarks, each state published once:
| before | after | ||
|---|---|---|---|
| Latin 10 000 | 8 296 µs | 6 281 µs | −24% |
| Latin 65 536 | 357 690 µs | 239 203 µs | −33% |
| CJK 10 000 | 9 658 µs | 9 030 µs | −7%, within noise |
-
Allocatedreads zero on every row, before and after, and always will.ArrayPool.Rentamortises the buffer across invocations, so nothing is attributed to the benchmark body — the instrument the issue proposed cannot see this claim. What the rows show is theClear()of whatever was rented, and the magnitudes agree with the arithmetic: clearing 2.1 GB costs about 200 ms, and 118 of them are what the ASCII row gives back at 65 536. - The CJK row moving least is the check that the reading is real. Its table is the one thing this change does not touch, so a row that improved there would have meant the measurement was reading something else.
The table above is the UTF-16 mode over an ASCII corpus. The code-point mode is a
different question and needed its own corpus: LevenshteinCodePointBenchmarks
draws both operands from U+1F300..U+1FAFF, so every character is a surrogate pair
and the two readings genuinely differ — which is the case
../decisions/0002-unicode-comparison-unit.md
points a caller at.
Intel i7-4770S, .NET 10, BenchmarkDotNet short job, [MemoryDiagnoser]. Two
runs per side, both shown, interleaved after → before → after → before so that
any drift in machine state lands on both columns. Distinct is how many distinct
code points the operands are drawn from.
Both operands differ at their first and last code point, so Trim strips nothing
and Length is the pattern the threshold actually sees. That is not a detail: an
earlier corpus mutated only scattered positions, and at Length = 16 a single
mutation left a pattern of two or three symbols — the fast path was never
entered, and the row compared the DP against itself while appearing to say the
threshold cost nothing.
| Length | Distinct | before | after | change |
|---|---|---|---|---|
| 16 | 32 | 793 / 773 ns | 509 / 516 ns | 1.53× faster |
| 16 | 512 | 765 / 774 ns | 515 / 515 ns | 1.49× faster |
| 24 | 32 | 1.61 / 1.55 µs | 612 / 608 ns | 2.59× faster |
| 24 | 512 | 1.48 / 1.43 µs | 624 / 648 ns | 2.29× faster |
| 32 | 32 | 3.50 / 3.57 µs | 725 / 725 ns | 4.87× faster |
| 32 | 512 | 3.38 / 3.44 µs | 753 / 736 ns | 4.58× faster |
| 40 | 32 | 4.13 / 3.92 µs | 860 / 871 ns | 4.65× faster |
| 40 | 512 | 4.96 / 4.97 µs | 863 / 870 ns | 5.73× faster |
| 128 | 32 | 48.4 / 50.6 µs | 3.18 / 3.14 µs | 15.7× faster |
| 128 | 512 | 40.4 / 40.4 µs | 3.10 / 3.08 µs | 13.1× faster |
| 512 | 32 | 777 / 818 µs | 23.2 / 23.2 µs | 34.4× faster |
| 512 | 512 | 658 / 660 µs | 683 / 632 µs | unchanged — see below |
Zero allocation on both sides, at every size: the renaming borrows its two
buffers from ArrayPool and the probe table is stackalloc.
-
The last row is the ceiling, not a disappointment. A pattern is renamed
into a dense alphabet of 255 symbols, and one holding more falls back to the
DP. 512 random draws from 512 symbols hold ~330 distinct, so that row measures
the fallback — and measures what the failed attempt costs, which is the number
worth having: within noise of doing nothing, ≈1%. At
Length = 128the same 512-symbol alphabet yields ~110 distinct, under the ceiling, and the row is 12.6× faster; the ceiling is about the pattern, not the alphabet. -
The gate holds at 16, and that is measured rather than inherited.
MyersMinPatternLengthwas tuned for the character path, and this one pays for a probe table on top of the kernel's equality table, so it needed its own answer. At a pattern of exactly 16 code points the fast path is already 1.5× faster, so the threshold is not too low. Whether it is too high is a different question and is untested: below 16 both sides take the DP, and the constant is shared with the character path, so lowering it would change the hot path and wants its own measurement. -
The UTF-16 mode on this same corpus is the DP, because every character is a
surrogate and the Latin-1 check refuses them: 2.80 ms at
Length = 512against the code-point mode's 22 µs. That is a comparison between two different questions and not a reason to switch modes — but it is the measurement that makes the remaining backlog item concrete.
Short-job measurement, [MemoryDiagnoser] (dev machine — indicative).
Vectorizers, fit+transform over a synthetic corpus:
| Method | 200 docs | 1000 docs |
|---|---|---|
CountVectorizer |
~4.2 ms | ~9.0 ms |
TfidfVectorizer |
~4.3 ms | ~9.1 ms |
CountVectorizer (bigrams) |
~4.8 ms | ~14.7 ms |
HashingVectorizer |
~3.6 ms | ~8.6 ms |
Fuzzy ratios, on a ~43-character sentence pair:
| Method | Mean | Allocated |
|---|---|---|
Fuzz.Ratio |
~2.5 µs | 0 B |
Fuzz.TokenSortRatio |
~5.3 µs | 1.3 KB |
Fuzz.TokenSetRatio |
~15 µs | 5.6 KB |
Fuzz.WRatio |
~25 µs | 7.0 KB |
Fuzz.PartialRatio |
~460 µs | 0 B |
PartialRatiois markedly slower: the current sliding-window scan isO(n·m²)(a full Indel per window). It is correct and zero-alloc, but a bit-parallel or block-based optimization is a clear backlog item for long inputs.
dotnet run -c Release --project bench/Lodestar.Text.Benchmarks -- --filter '*BatchEmbedding*' --inProcessRead this before quoting the ratio. The model is tiny_embedder.onnx: one
Gather node over a 64 × 4 table, because weights are never committed
(CONTRIBUTING.md) and a real encoder is a hundred megabytes. Its arithmetic is
free. So what is measured is the per-sequence cost that batching removes — graph
dispatch, thread-pool wake-up, tensor wrapping — and none of the matrix
multiplication a real encoder adds to both sides. This is an upper bound on the
speed-up, not the speed-up.
Full job, [MemoryDiagnoser], InProcessEmitToolchain, Intel Core i7-4770S
(Haswell, 4 physical cores), Ubuntu 24.04, .NET 10.0.10, X64 RyuJIT AVX2. Corpus
of 1 to 61 words per text, sub-batch 8. UnitLoop is the baseline — one Embed
call per text, which is what the guide's three lines amounted to before
EmbedBatch existed. Two runs, both shown, because BenchmarkDotNet's ±
describes dispersion inside one process and not reproducibility across processes.
| Texts | UnitLoop |
EmbedBatch |
ratio | EmbedBatchBucketed |
ratio | allocated vs baseline |
|---|---|---|---|---|---|---|
| 1 | 9.9 / 11.0 µs | 10.6 / 10.8 µs | 1.06 / 0.99 | 10.0 / 11.1 µs | 1.00 / 1.01 | 1.12 |
| 8 | 168 / 180 µs | 105 / 107 µs | 0.62 / 0.60 | 101 / 106 µs | 0.60 / 0.59 | 0.95 |
| 32 | 628 / 672 µs | 360 / 381 µs | 0.57 / 0.57 | 346 / 349 µs | 0.55 / 0.52 | 0.94 / 0.91 |
| 128 | 2 439 / 2 602 µs | 1 482 / 1 460 µs | 0.61 / 0.56 | 1 370 / 1 356 µs | 0.56 / 0.52 | 0.94 / 0.91 |
Batching removes about 40 % of the wall clock from 8 texts upward and stays there — 0.56–0.62 across every pairing — as the per-call overhead is amortized over the whole sub-batch. At a single text there is nothing to amortize and the two paths are a wash: 1.06 in one run, 0.99 in the next, which is a way of saying this benchmark cannot tell them apart there rather than that either wins.
Bucketing is a different story, and the honest answer is smaller. It engages only when the corpus spans more than one sub-batch, so the rows at 1 and 8 above run the identical code in both columns — they are the control, and what they differ by is this harness's noise floor: 1–3 % on seven of the eight control measurements taken here, with one outlier at 5.7 %. At 128 texts bucketing is ahead by 5.8–7.5 % in all four pairings, and ahead at 32 in all four as well. The sign is consistent where the magnitude alone would not be decisive. What is decisive is the allocation column, which is counted rather than sampled: 1 764 KB → 1 697 KB at 128 texts, in every run. That is padding genuinely not written. On a model doing real work that padding would be matrix multiplication not performed, and the time would follow; this model cannot show it, so the claim stops here.
The two builds. The same benchmark against the netstandard2.0 assemblies
does not measure a penalty. On EmbedBatch the netstandard2.0 side comes in
1.4–5.7 % ahead of net10 in all four pairings (355 / 365 µs against 360 / 381
at 32 texts; 1 418 / 1 419 against 1 482 / 1 460 at 128), which is inside this
harness's noise but consistent in direction. This guide previously reported the
opposite — "3–5 % behind" — from a pair of commands that did not share a
BenchmarkDotNet toolchain. That comparison is withdrawn rather than reversed
(issue #87).
Withdrawn, not disproved. The figures above cannot be set against the old ones,
for a reason that has nothing to do with either harness: the old table predates
the bump of Microsoft.ML.OnnxRuntime to 1.28.0, and this benchmark is almost
entirely that library's dispatch cost. Add to that a machine carrying twice the
load, and no difference between the two sets is attributable to anything. What
the old pair of commands can be faulted for is visible without measuring — its
two columns came from two toolchains.
Within this window the harness is not the explanation either. Running the
net10 side out-of-process here, the same mismatched shape, moves this tier by
2 % at most (EmbedBatch 356 µs at 32 texts and 1 441 at 128, against 360 / 381
and 1 482 / 1 460 in-process) and still shows no netstandard2.0 penalty. So
unlike VectorMath, where the mismatch moves the ratio by up to 0.5×, on this
path the toolchain barely registers.
No penalty is the structurally correct answer here, and the earlier figure
should have been read as suspicious for that reason. Pooling guards its
Vector<T> branch with accumulator.Length >= Vector<float>.Count;
tiny_embedder.onnx has a hidden size of 4 (EMBEDDING_DIM in
tools/build_tiny_models.py) and Vector<float>.Count is 8 under AVX2, so on
net10 the guard is false and the code falls into the same scalar tail loop
netstandard2.0 runs unconditionally. The two builds execute identical pooling on
this benchmark. It cannot measure the difference between them, and now reports
that instead of a number. Where the vector path does engage, it is worth 4×–7×
(VectorMath over 384–1024 dimensions, section 2 of
bench/README.md).
The one difference this benchmark does resolve is counted, not timed: the unit-loop path
allocates 0.6 % more on netstandard2.0 (1 887 KB against 1 875 at 128 texts),
identically in both runs, while the two batch paths allocate byte for byte the
same on both targets.
dotnet run -c Release --project bench/Lodestar.NetStandard.Benchmarks -- --filter '*BatchEmbedding*'--inProcess on the first command, and not on the second, is the point. The
netstandard2.0 project pins InProcessEmitToolchain in its Program.cs — it
has to, or BenchmarkDotNet's generated project re-resolves the
ProjectReference and silently restores the net10.0 build — so the flag is what
puts the net10 side on the same toolchain. Without it the two commands measure
the same code two different ways.
Conditions. The four runs behind the table were taken back to back in one window, alternating net10 and netstandard2.0, with the one-minute load average between 5.1 and 5.9 on 8 logical cores — the editor's language servers and the session driving the runs are part of that load and cannot be excluded from inside it. Both columns pay it equally, so the table is internally comparable; it is not comparable to figures taken on this machine in a quieter state, and the ratios travel between such sets while the absolute microseconds do not.
Intel Core i7-4770S @ 3.10 GHz, .NET 10.0.110, Release. Median of 21 runs (n <= 10 000) and 5 runs
(n = 100 000), one process, two random labellings of n samples over k clusters.
| n | k | FowlkesMallows |
AdjustedMutualInformation |
AdjustedRand |
|---|---|---|---|---|
| 1 000 | 5 | 0.36 ms | 0.42 ms | 0.27 ms |
| 10 000 | 5 | 1.66 ms | 2.84 ms | 2.44 ms |
| 10 000 | 50 | 8.07 ms | 14.17 ms | 1.73 ms |
| 100 000 | 10 | 5.46 ms | 26.83 ms | 5.60 ms |
| 100 000 | 100 | 37.76 ms | 254.89 ms | 39.63 ms |
Fowlkes-Mallows costs about what AdjustedRand costs, which is the expected result: it is a
second reader of the contingency table the other already builds, and it adds one pass over the
cells.
Adjusted mutual information is the expensive one, and the cost grows with the number of
clusters rather than only with the samples. The correction sums over the hypergeometric
distribution of every (class, cluster) cell the marginals allow, so the work is bounded by
classes x clusters x n and not by n alone: at 100 000 samples it is 4.8x AdjustedRand at 10
clusters and 6.4x at 100. That is inherent to the quantity — scikit-learn sums the same terms —
and it is the reason to reach for
NormalizedMutualInformation.Score
instead when the two labellings being compared have the same number of clusters and the chance
correction is not buying anything.
Nothing here was optimised. These are the numbers as first written, published so the next change has a baseline rather than an impression to argue against.
Same machine and method, median of 31 runs at n = 100 000. All three new members read
Internal/Contingency, the structure the table above's metrics already build.
| n | k | RandIndex |
MutualInformation |
PairConfusionMatrix.Compute |
AdjustedRand (reference) |
|---|---|---|---|---|---|
| 100 000 | 10 | 7.22 ms | 6.40 ms | 6.03 ms | 6.00 ms |
| 100 000 | 100 | 41.04 ms | 40.23 ms | 39.98 ms | 41.26 ms |
All four land in the same band as AdjustedRand at both cluster counts, which is what the code
says should happen: RandIndex.Score calls PairConfusionMatrix.Compute and adds two numbers,
so it cannot cost meaningfully more than the call it wraps. None of the three introduces the
classes x clusters x n shape AdjustedMutualInformation has above — there is no hypergeometric
sum here, only the pair counts and the mutual-information term the sequential agreement metrics
already pay for.
One measurement is worth naming rather than hiding: an earlier pass at 11 runs read RandIndex as
24.6 ms against PairConfusionMatrix.Compute's 6.4 ms at n = 100 000, k = 10 — four times its
own dependency, which is not a shape the code can produce. Raising the run count to 31 collapsed
the gap to the numbers above. This machine runs guarded dotnet commands behind a lock shared
with other sessions (see CONTRIBUTING.md), and a contended run landing inside a short sample is
the likely cause. Reported here because a number that does not survive more samples is exactly
what a perf/-style measurement is supposed to catch before it reaches a PR description, not
after.
Intel Core i7-4770S @ 3.10 GHz, 8 logical cores, .NET 10.0.110, Release. Median of
11 runs (n = 1 000 000) and 41 runs (n = 100 000) of RocAuc.Score, one process,
same binary — only BinaryRoc.RadixThreshold differs between the columns, so the
two rows of a pair measure nothing but the sort.
| n | equal scores | Array.Sort |
radix | gain |
|---|---|---|---|---|
| 100 000 | all distinct | 8.15 ms | 5.56 ms | 1.47x |
| 100 000 | ~100 per score | 6.22 ms | 3.94 ms | 1.58x |
| 1 000 000 | all distinct | 97.44 ms | 76.91 ms | 1.27x |
| 1 000 000 | ~100 per score | 78.87 ms | 59.97 ms | 1.32x |
The 97.44 ms baseline agrees with the 95.219 ms roc_auc_binary_n1000000_k2
below, measured a different way on the same machine.
Why the sort at all. Profiled on the same machine, one binary curve at n = 1 000 000 splits 6 ms building the points, 91 ms sorting, 3 ms accumulating — the sort is 91% of it, and at n = 100 000 it is effectively all of it. Nothing else in the curve is worth touching until it is.
Why a radix rather than the alternatives. Sorting an int index array against
the scores — the cheaper-items idea — was measured and rejected: 6.88 ms against
7.47 ms at n = 100 000, but 98.23 ms against 86.96 ms at a million, where the
gather costs more than the smaller items save. Eight-bit digits beat sixteen-bit
ones below ~16 000 and lose above (77.67 ms against 66.51 ms at a million).
Why the threshold is 8 192. The radix carries four passes and a 64 K histogram whatever the input, so it loses on small ones: 0.85x at n = 6 000, 0.98x at 8 000, 1.21x at 10 000, 1.55x at 16 000. Below the threshold the curve still sorts by comparison, and the extra buffers are not even rented.
No parallelism was added. #86 left this sort as the parallelisable remainder; measured, it did not need to be. A parallel sort here would nest inside the region #86 already parallelises on the multiclass path, and 1.3x sequential is the cheaper answer.
python bench/corpus/generate_metrics.py # writes bench/corpus/metrics/, git-ignored
. .venv-oracles/bin/activate && python bench/python/bench_metrics.py
dotnet run -c Release --project bench/Lodestar.Text.Benchmarks -- compare-metrics
python bench/compare.py metricsSix operations — confusion_matrix, accuracy, precision_recall_f1_macro,
classification_report, roc_auc_binary, roc_auc_ovr_macro — over six shapes
(1 000 / 100 000 / 1 000 000 samples, 2 or 10 classes), on the same corpus files
on both sides. This is the merge gate for the branch, on processor time: every
row must be ≥ 1×, and it is.
Lodestar.Metrics on .NET 10.0.10 against scikit-learn 1.9.0 / NumPy 2.5.1 on
Python 3.12.3, Intel i7-4770S. Both sides measured back to back, Python first,
on a machine left to settle (one-minute load 1.52 at the Python start — below
this workstation's 1.9–2.3 floor, itself a permanent ~30–40 % background from
the desktop client, an editor and a browser). The C# side started 49 seconds
later, in the Python run's own wake, so its figures are the ones taken on the
busier machine and every ratio below is conservative rather than flattering;
bench/README.md records the full conditions.
| Operation | Lodestar ms | Python ms | wall | Lodestar cpu ms | Python cpu ms | cpu |
|---|---|---|---|---|---|---|
confusion_matrix_n1000_k2 |
0.009 | 1.028 | 117.98x | 0.009 | 1.028 | 117.97x |
accuracy_n1000_k2 |
0.001 | 0.546 | 618.32x | 0.001 | 0.546 | 618.33x |
precision_recall_f1_macro_n1000_k2 |
0.008 | 1.793 | 226.58x | 0.008 | 1.793 | 226.58x |
classification_report_n1000_k2 |
0.011 | 6.692 | 623.31x | 0.011 | 6.691 | 623.25x |
roc_auc_binary_n1000_k2 |
0.029 | 2.008 | 70.12x | 0.029 | 2.008 | 70.12x |
confusion_matrix_n1000_k10 |
0.009 | 1.051 | 120.64x | 0.009 | 1.051 | 120.64x |
accuracy_n1000_k10 |
0.001 | 0.541 | 622.03x | 0.001 | 0.541 | 622.08x |
precision_recall_f1_macro_n1000_k10 |
0.010 | 1.855 | 192.49x | 0.010 | 1.855 | 192.48x |
classification_report_n1000_k10 |
0.017 | 7.011 | 422.54x | 0.017 | 7.010 | 422.53x |
roc_auc_ovr_macro_n1000_k10 |
0.550 | 10.526 | 19.13x | 0.550 | 10.525 | 19.13x |
confusion_matrix_n100000_k2 |
0.964 | 15.791 | 16.39x | 0.964 | 15.791 | 16.39x |
accuracy_n100000_k2 |
0.190 | 5.519 | 29.01x | 0.190 | 5.518 | 29.01x |
precision_recall_f1_macro_n100000_k2 |
0.844 | 17.786 | 21.07x | 0.844 | 17.785 | 21.07x |
classification_report_n100000_k2 |
0.848 | 36.233 | 42.75x | 0.847 | 36.231 | 42.75x |
roc_auc_binary_n100000_k2 |
7.977 | 35.024 | 4.39x | 8.092 | 35.023 | 4.33x |
confusion_matrix_n100000_k10 |
1.059 | 16.109 | 15.20x | 1.059 | 16.108 | 15.21x |
accuracy_n100000_k10 |
0.296 | 5.519 | 18.66x | 0.296 | 5.519 | 18.66x |
precision_recall_f1_macro_n100000_k10 |
0.979 | 18.524 | 18.92x | 0.979 | 18.523 | 18.92x |
classification_report_n100000_k10 |
0.979 | 40.139 | 41.00x | 0.979 | 40.137 | 41.00x |
roc_auc_ovr_macro_n100000_k10 |
88.385 | 250.400 | 2.83x | 91.396 | 250.402 | 2.74x |
confusion_matrix_n1000000_k2 |
8.750 | 156.920 | 17.93x | 8.749 | 156.823 | 17.92x |
accuracy_n1000000_k2 |
2.045 | 51.599 | 25.23x | 2.045 | 51.596 | 25.23x |
precision_recall_f1_macro_n1000000_k2 |
8.701 | 164.332 | 18.89x | 8.701 | 164.330 | 18.89x |
classification_report_n1000000_k2 |
8.719 | 314.805 | 36.11x | 8.718 | 314.782 | 36.11x |
roc_auc_binary_n1000000_k2 |
95.219 | 364.420 | 3.83x | 95.684 | 364.384 | 3.81x |
confusion_matrix_n1000000_k10 |
9.916 | 156.707 | 15.80x | 9.915 | 156.699 | 15.80x |
accuracy_n1000000_k10 |
3.122 | 51.877 | 16.61x | 3.122 | 51.874 | 16.61x |
precision_recall_f1_macro_n1000000_k10 |
10.001 | 173.128 | 17.31x | 10.000 | 173.121 | 17.31x |
classification_report_n1000000_k10 |
9.865 | 352.364 | 35.72x | 9.864 | 352.349 | 35.72x |
Gate result: 29/29 operations at or above 1× on processor time. The
narrowest margin is 2.74×, on roc_auc_ovr_macro at n=100 000, k=10 — the
row the design brief flagged as the one most likely to need a radix-sort
rewrite of BinaryRoc. It did not: even the heaviest sort-bound row clears the
gate by a comfortable margin, so no algorithmic change was needed on this
branch.
Read this before quoting a single ratio. The rows at n=1 000 (70×–620×) are dominated by CPython's per-call interpreter overhead, not by the computation — a confusion matrix over 1 000 samples is sub-microsecond work on either side. The rows that carry the argument are the ones at n=100 000 and n=1 000 000, where the ratios settle to a more modest but still decisive 2.7×–43×.
Unlike the persistence comparison, wall and processor time agree here to within about 1% on every row (up to 3.4% on the single heaviest-cpu row): these metrics allocate little enough per call that .NET's background collector is never a factor, so there is no gap between the two columns to explain away.
Full breakdown, including the intra-C# and net10-vs-netstandard2.0 tiers and
where the two language sides do not do identical work, in
bench/README.md.
Balanced accuracy, Matthews correlation and Cohen's kappa (issue #93, Tasks
3–5) add three operations — balanced_accuracy, matthews, cohen_kappa —
run over all six shapes above, unweighted and with default label handling on
both sides, matching scikit-learn's balanced_accuracy_score,
matthews_corrcoef and cohen_kappa_score. Same corpus files, same harnesses,
same methodology as the table above — but measured in a separate window
from the original 29 rows, with its own load: uptime's one-minute average
was 19.70 just before the Python side started and 7.65 by the time
compare.py printed the numbers below (fifteen-minute average 13.2–14.9
throughout that window). That is nowhere near the 1.52 one-minute load the
paragraph above states for the original run, so these 18 rows should not be
read as sharing that sentence's conditions — only their own, given here.
| Operation | Lodestar ms | Python ms | wall | Lodestar cpu ms | Python cpu ms | cpu |
|---|---|---|---|---|---|---|
balanced_accuracy_n1000_k2 |
0.016 | 1.194 | 76.44x | 0.011 | 1.194 | 105.24x |
matthews_n1000_k2 |
0.017 | 2.216 | 134.27x | 0.012 | 2.216 | 192.06x |
cohen_kappa_n1000_k2 |
0.018 | 1.240 | 67.89x | 0.012 | 1.240 | 105.23x |
balanced_accuracy_n1000_k10 |
0.008 | 1.225 | 152.93x | 0.008 | 1.225 | 152.96x |
matthews_n1000_k10 |
0.008 | 2.258 | 282.30x | 0.008 | 2.258 | 282.33x |
cohen_kappa_n1000_k10 |
0.009 | 1.399 | 157.84x | 0.009 | 1.399 | 157.84x |
balanced_accuracy_n100000_k2 |
0.887 | 17.287 | 19.49x | 0.887 | 17.282 | 19.48x |
matthews_n100000_k2 |
0.884 | 34.733 | 39.28x | 0.884 | 34.712 | 39.26x |
cohen_kappa_n100000_k2 |
0.880 | 18.133 | 20.61x | 0.880 | 18.103 | 20.58x |
balanced_accuracy_n100000_k10 |
1.001 | 17.326 | 17.31x | 1.001 | 17.320 | 17.31x |
matthews_n100000_k10 |
0.996 | 36.312 | 36.46x | 0.996 | 36.307 | 36.45x |
cohen_kappa_n100000_k10 |
0.980 | 17.130 | 17.49x | 0.979 | 17.129 | 17.49x |
balanced_accuracy_n1000000_k2 |
9.087 | 166.698 | 18.35x | 9.085 | 166.690 | 18.35x |
matthews_n1000000_k2 |
9.003 | 350.953 | 38.98x | 9.003 | 350.762 | 38.96x |
cohen_kappa_n1000000_k2 |
9.032 | 186.455 | 20.64x | 9.032 | 185.697 | 20.56x |
balanced_accuracy_n1000000_k10 |
10.103 | 167.552 | 16.58x | 10.102 | 167.550 | 16.59x |
matthews_n1000000_k10 |
10.262 | 340.992 | 33.23x | 10.261 | 340.854 | 33.22x |
cohen_kappa_n1000000_k10 |
10.352 | 174.623 | 16.87x | 10.352 | 174.619 | 16.87x |
18/18 at or above 1× on processor time — the gate holds for these three
metrics too. The two narrowest are balanced_accuracy_n1000000_k10 at
16.59× and cohen_kappa_n1000000_k10 at 16.87×; every other row
clears 17×. As with the original 29, the busier the machine gets, the more
conservative (not flattering) a ratio above 1× is — and this window's load
average was roughly 5–13× the original run's, so these margins are, if
anything, understated relative to a quiet machine.
The eleven regression metrics landed for issue #92 add four benchmark
operations — mse, mae, median_ae, r2 — covering the four distinct cost
shapes among them: a squared mean, an absolute mean, a sort, and a two-pass
centred sum. The other seven metrics are one of those four with a different
arithmetic kernel and are not separately timed. They run over
y_true_real/y_pred_real, continuous targets drawn by a separate seeded
random generator and attached to each of the six existing corpus shapes,
independent of the classification columns those shapes already carry. The
generator inserting these draws would otherwise have shifted every
classification array after the insertion point, invalidating the 29 and 18
rows above. A before/after comparison of y_true[:10] on the regenerated
corpus confirmed it did not. Same corpus files, same harnesses, same
methodology as the tables above — but measured in yet another separate
window, with its own load: uptime's one-minute average was 8.05 just
before the Python side started (five/fifteen-minute: 11.95 / 14.25) and
6.05 by the time compare.py printed the numbers below (five/fifteen-minute:
7.15 / 11.07). That is well below the 16–23 one-minute load this session saw
at dispatch and while the code changes were being made, but still noticeably
busier than the 1.52 one-minute load recorded for the original 29 rows. So
these 24 rows should be read only under their own conditions, given here —
except the six median_ae rows marked †. Those come from a later
window described below, after MedianAbsoluteError's unweighted path was
rewritten.
Read the k suffix as a corpus file name, not as a workload. The
regression arrays are drawn from SeededRandom(SEED + 1_000 + n), which
depends on the sample count and not on the class count, so metrics_n1000_k2
and metrics_n1000_k10 carry byte-identical y_true_real. That is deliberate
— all four operations here are single-output, and k is a property of the
classification columns those files also hold — but it means the 24 rows below
are 12 distinct workloads, each measured twice. The pairs are useful for
exactly that: they bound the run-to-run spread. At n=1 000 000 the two members
agree to within 0.04× (mse 1.04× / 1.00×), while at n=1 000 the same
identical array gives 98.88× and 141.17× — a 43 % spread, which is what a
sub-millisecond mse measurement is worth on a machine at this load, and the
reason no conclusion on this page rests on an n=1 000 row.
| Operation | Lodestar ms | Python ms | wall | Lodestar cpu ms | Python cpu ms | cpu |
|---|---|---|---|---|---|---|
mse_n1000_k2 |
0.005 | 0.486 | 104.89x | 0.005 | 0.458 | 98.88x |
mae_n1000_k2 |
0.005 | 0.358 | 77.79x | 0.005 | 0.358 | 77.70x |
median_ae_n1000_k2† |
0.011 | 0.818 | 77.81x | 0.011 | 0.625 | 59.45x |
r2_n1000_k2 |
0.008 | 0.443 | 57.72x | 0.008 | 0.442 | 57.66x |
mse_n1000_k10 |
0.005 | 1.003 | 219.23x | 0.005 | 0.646 | 141.17x |
mae_n1000_k10 |
0.005 | 0.541 | 119.33x | 0.005 | 0.507 | 111.80x |
median_ae_n1000_k10† |
0.011 | 0.367 | 34.83x | 0.011 | 0.367 | 34.84x |
r2_n1000_k10 |
0.008 | 0.447 | 55.95x | 0.008 | 0.447 | 55.86x |
mse_n100000_k2 |
0.452 | 0.645 | 1.43x | 0.452 | 0.645 | 1.43x |
mae_n100000_k2 |
0.466 | 1.588 | 3.41x | 0.466 | 1.295 | 2.78x |
median_ae_n100000_k2† |
1.967 | 1.781 | 0.91x | 2.045 | 1.781 | 0.87x |
r2_n100000_k2‡ |
0.759 | 0.991 | 1.31x | 0.759 | 0.991 | 1.31x |
mse_n100000_k10 |
0.455 | 0.628 | 1.38x | 0.454 | 0.628 | 1.38x |
mae_n100000_k10 |
0.458 | 0.673 | 1.47x | 0.458 | 0.672 | 1.47x |
median_ae_n100000_k10† |
2.142 | 1.796 | 0.84x | 2.241 | 1.795 | 0.80x |
r2_n100000_k10‡ |
0.743 | 0.950 | 1.28x | 0.743 | 0.950 | 1.28x |
mse_n1000000_k2 |
5.013 | 5.226 | 1.04x | 5.008 | 5.220 | 1.04x |
mae_n1000000_k2 |
5.054 | 5.635 | 1.12x | 5.036 | 5.633 | 1.12x |
median_ae_n1000000_k2† |
18.365 | 16.375 | 0.89x | 18.708 | 16.360 | 0.87x |
r2_n1000000_k2‡ |
8.093 | 9.205 | 1.14x | 8.083 | 9.204 | 1.14x |
mse_n1000000_k10 |
4.983 | 4.989 | 1.00x | 4.982 | 4.983 | 1.00x |
mae_n1000000_k10 |
5.040 | 5.712 | 1.13x | 5.035 | 5.711 | 1.13x |
median_ae_n1000000_k10† |
18.094 | 16.282 | 0.90x | 18.163 | 16.259 | 0.90x |
r2_n1000000_k10‡ |
7.807 | 9.687 | 1.24x | 7.807 | 9.686 | 1.24x |
† All six median_ae rows were re-measured after the quickselect rewrite
described below, in a separate window from the other eighteen rows in this
table. Every other cell is the original, unrewritten-algorithm measurement.
They are themselves superseded: issue #140 took a further 38% off
median_ae at n = 1 000 000, so every row marked † measures a partition this
package no longer ships. See "Branchless partitioning (issue #140)" below.
‡ These four r2 rows predate issue #127: they measure the original
sequential-sum R2, not the Neumaier-compensated one this package ships
today, and are stale for that reason — kept here only so the pairing this
table relies on (each workload measured twice, at k=2 and k=10) stays
intact. The compensated-and-optimised numbers are in
"Compensated summation (issue #127)" below, in the same window as its own
numpy column, which is not directly comparable to the Python ms column
here. mse and mae are not marked at n = 1 000 000: that section's own
numbers put them within this page's noise band there (0.6% and 1.8% away
from the figures printed above). That does not hold at n = 100 000, where
the same comparison is 5.7% and 5.4% — wider than the 1.8% this page treats
as the top of the band — so the claim below is scoped to the n = 1 000 000
rows only.
20/24 rows at or above 1× on processor time when this table was first
measured — median_ae was the finding, not a fluke to rerun away. That is
20 of 24 rows, which under the pairing above is 10 of 12 distinct
workloads; the four that failed were two workloads, each measured twice, and
they failed both times. All four median_ae rows at n=100 000 and
n=1 000 000 landed below the gate —
0.36×, 0.25×, 0.19× and 0.19× — meaning Python was 3× to over
5× faster there, the only rows on this page where that was true. The cause
was the algorithm, not the run: scikit-learn's median_absolute_error calls
NumPy's median, which selects via introselect/quickselect in expected
O(n); Lodestar's MedianAbsoluteError sorted the whole residual array,
which is O(n log n), and the gap widened with n exactly as that
complexity difference predicted (0.36× at 100 000 rows, 0.19× at
1 000 000). mse_n1000000_k10 was the narrowest passing row at 1.00×
— a squared mean over a million rows, near enough to parity that a busier or
quieter machine could tip it either way; every other passing row cleared
1.12×.
What changed. WeightedPercentile's unweighted branch (the follow-up
this branch was created for) no longer sorts the whole array: it selects the
one or two order statistics the median needs with a median-of-three
quickselect, falling back to Array.Sort on the remaining range once
partitioning has run more than a budget proportional to log2(n) — the same
introselect guarantee NumPy's own median relies on, so the worst case
stays O(n log n) instead of degrading to O(n²) on adversarial input. The
weighted branch, which genuinely needs sorted order for its cumulative-weight
walk, was not touched.
Re-measured under load deliberately comparable to the original run, not a
quieter one. All six median_ae rows above, marked †, were re-measured
after that rewrite in one pass over the full 24-operation harness, with the
same corpus and harnesses as the rest of this section — the two n=1 000
rows were already below the gate's radar (neither the original nor the
rewritten algorithm is close to failing there), but re-measuring them
alongside the four that mattered keeps every median_ae row honest about
which implementation it describes, rather than leaving two rows silently
mixed in with the pre-rewrite ones. Re-running on an idle machine would have
folded "the machine got quieter" into "the algorithm got faster," and a
reader could not have told the two apart — so the measurement was
deliberately taken while the one-minute load sat in the same 6–10 band as
the original run's 8.05 → 6.05, rather than waiting for a quieter machine.
uptime's one-minute average was 6.62 just before the Python side
started (five/fifteen-minute: 15.34 / 14.79) and 6.52 by the time
compare.py printed the numbers above (five/fifteen-minute: 7.37 / 9.96).
The same fresh run put mse_n1000000_k10 — untouched by this rewrite,
recorded above at its original 1.00× — at 0.99×: that row sits at
parity either way, so which side of the gate it lands on is scheduling luck
between runs, not a change in the code, and the table above keeps its
original value rather than being edited to match this aside.
The four rows are faster but still below the gate — that is the finding,
not a reason to keep iterating on the algorithm. In absolute terms
Lodestar's own time dropped by roughly 4×–4.8× (7.358 ms → 1.967 ms at
n=100 000, k=2; 88.792 ms → 18.365 ms at n=1 000 000, k=2), and the
processor-time ratio against scikit-learn rose from 0.36× to 0.87×
(n=100 000, k=2), 0.25× to 0.80× (n=100 000, k=10), 0.19× to
0.87× (n=1 000 000, k=2) and 0.19× to 0.90× (n=1 000 000, k=10).
Those are four rows over two workloads, not four independent measurements —
the k=2 and k=10 members of each pair run on the same array — so read
them as two recoveries each confirmed twice: 0.36×/0.25× → 0.87×/0.80× at
n=100 000, and 0.19×/0.19× → 0.87×/0.90× at n=1 000 000. The agreement
within each pair is what makes the recovery credible; a 4× swing on one row
alone would not be.
NumPy's introselect and this quickselect now do the same order of work —
O(n) expected, O(n log n) worst case — so the remaining gap reads as
constant overhead (managed bounds checks, the Lomuto partition's extra
writes, no SIMD-accelerated comparison loop) rather than an algorithmic
difference, and is recorded here as measured rather than chased further.
An ill-conditioned target — a large offset over a small spread — showed that
R2, ExplainedVariance and the shared kernel walk behind mse/mae and
friends were accumulating with a plain sequential sum, where numpy sums
pairwise. On such a target the two round differently: R2's two passes
landed 357× outside the oracle's 1e-9 tolerance (issue #127's fixture:
offset = 1e9, spread = 1e-2, n = 200 000). Three sites now sum with
Neumaier compensation instead — R2's two passes, ExplainedVariance's five
accumulations, and Outputs.WeightedMean, which MeanSquaredError,
RootMeanSquaredError, MeanAbsoluteError, MeanAbsolutePercentageError,
MeanSquaredLogError, RootMeanSquaredLogError and PinballLoss all walk
through. median_ae (which sorts) and max_error (which never sums) are
untouched and stand in as this round's control.
The naive fix was not free, and was not accepted at its first cost.
Measured before any recovery work, compensating cost 1.38×–1.46× the
uncompensated loop on mse/mae and 1.56×–1.63× on r2 — which pays the
compensated sum twice per row, once per pass — against a median_ae control
that moved at most 1.04×. Two changes recovered it: an unweighted fast path
that skips the weight multiply and the weight sum at all three sites when no
sampleWeight is supplied (a sum of n ones is exactly n below 2^53, no
compensation needed), and a Vector<double> reduction — one Neumaier partial
sum per SIMD lane — for R2 and ExplainedVariance's single-output
unweighted case on net10.0 (the scalar loop is unchanged on
netstandard2.0 and for multi-output; see
docs/decisions/0001). A third
lever, a branchless 2Sum in place of Neumaier's magnitude-compared branch,
was measured and reverted: it was measurably slower on this workload,
most visibly on r2 (+5.2%, outside both groups' own spread in either
measurement order). No performance counters were read, so branch prediction
is offered as the likely explanation, not as something observed — what was
measured is that the "branchless is faster" hypothesis this lever tested
came out falsified, not confirmed.
Before (the original sequential sum) against after (compensated and
optimised) — same corpus, same harness as the table above, k=2 shape
only:
| Operation | before (ms) | after (ms) | cost vs before | numpy (ms) |
|---|---|---|---|---|
mse_n100000_k2 |
0.445 | 0.478 | 1.07x | 0.568 |
mse_n1000000_k2 |
4.757 | 5.042 | 1.06x | 4.743 |
mae_n100000_k2 |
0.449 | 0.491 | 1.09x | 0.579 |
mae_n1000000_k2 |
4.769 | 5.146 | 1.08x | 5.330 |
r2_n100000_k2 |
0.740 | 0.358 | 0.48x | 0.867 |
r2_n1000000_k2 |
7.820 | 4.280 | 0.55x | 9.193 |
median_ae_n100000_k2 (control)§ |
1.681 | 1.672 | 0.99x | 1.716 |
median_ae_n1000000_k2 (control)§ |
15.718 | 15.994 | 1.02x | 15.696 |
§ Both control rows predate issue #140, which took 38% off median_ae at
n = 1 000 000. They are the right control for this round — nothing here
touched the median — but they are not the current cost of the operation.
r2 is this round's confirmed result: faster than the uncompensated loop
it replaced, not merely recovered back to it, and 2.15× faster than numpy
at n = 1 000 000 — the SIMD lane-wise reduction pays for the compensation and
then some, an asymmetry only available here because the uncompensated
baseline was itself a scalar loop with room to vectorize.
Outputs.WeightedMean could not take the same route: it calls a kernel
through a generic interface per element, so a lane-wise accumulator around a
scalar-called kernel would buy nothing without vectorizing all five kernels
in their own right, which was assessed and left out of scope.
Load: uptime's one-minute average was 9.8 at the start of this round (just
under the wait threshold this project measures against), settling to
4.9–5.5 for the rest of it; read via uptime before each run, and none
exceeded 10 once the wait loop cleared.
mse and mae's 1.06×–1.09× are not a second confirmed result — they are
inside this round's own noise. The control (median_ae, untouched by any
of this) moved 1.8% at n = 1 000 000 in this same round — within the band
this page accepts, but at the top of it. r2's 45–52% swing survives that
easily; mse and mae's cost is only three to four times the control's own
drift, which is not enough separation from the noise floor to publish as a
settled number. Read them as "roughly unchanged by the optimisation, within
this round's noise" rather than as a precise cost. A cleaner window that
measured the unweighted fast path alone, before the SIMD lever, put both
nearer 1.02×–1.07× with the control moving at most 1.4% — closer to the true
cost of that lever, but not this round's number, and not what is published
above as the current state.
MedianAbsoluteError was the most expensive regression metric by a factor of
three, and the issue opened believing a sort was the reason and threads were
the answer. Instrumenting Compute per phase at n = 1 000 000 said otherwise:
| phase | time | share |
|---|---|---|
| allocating the 8 MB residual column | 0.6 ms | 4% |
| filling it with abs(y − ŷ) | 2.6 ms | 16% |
QuickSelect |
10.8 ms | 68% |
| validation, reduction, harness | 2.0 ms | 12% |
That killed two hypotheses in one measurement. ArrayPool for the column
would have bought 0.6 ms, not a third. And the fill is cheaper than mse's
loop, because it only subtracts and takes an absolute value where mse adds a
compensated sum per element. Parallelism was measured separately and not
taken: partitioning Outputs.WeightedMean over fixed ranges reached 1.56× at
four workers and 1.65× at eight, and only with pinned pointers —
ReadOnlySpan<double> cannot be captured in a lambda, so a shipped version
needs either unsafe, which this repository sets to false explicitly, or
the 16 MB copy MultiClassRoc.CopyForWorkers makes, which costs more than the
parallelism saves.
The diagnosis, and the experiment it had to avoid. QuickSelect was
spending about 5 ns per element on sequential access, which is not bandwidth.
The suspect was the one data-dependent branch in the Lomuto partition's inner
loop, taken about half the time on unsorted residuals — the worst case for a
predictor. The natural test, timing random input against sorted input, is
confounded: on sorted input the median-of-three pivot lands on the true
median, so one partition pass suffices where random data needs several, and
the time would collapse because the number of passes collapsed. So the
experiment counted inner-loop iterations and compared nanoseconds per element
touched, on three shapes:
| shape | ns per element touched |
|---|---|
| random | 4.39 |
| sorted ascending | 2.15 |
| alternating 0 / 1 | 2.65 |
Both predictable shapes sit together at 2.15 and 2.65 and only the coin-flip shape costs 4.4. That is the signature of misprediction rather than of memory or of pass count — and it also corrects the guess this experiment started from, which expected the alternating shape to sit with random. A period-2 pattern is trivial for a modern predictor; "varies" and "hard to predict" are not the same property.
The change is three lines: swap unconditionally, then advance the store
index by the comparison rather than branching on it. It is correct for the
same reason the branchy version is — when the element does not belong left,
the store index points at another element that also does not, so the two are
interchangeable and the swap is harmless. The comparison must use the value
read before the swap. That the JIT then emits it without a branch was
checked rather than assumed, with DOTNET_JitDisasm on a verbatim copy of the
loop in a scratch project — Partition is internal, so it cannot be called
from one:
vucomisd xmm0, xmm1
seta r8b
movzx r8, r8b
add esi, r8d ; storeIndex += (value < pivot)
RyuJIT on x64. The netstandard2.0 assembly also ships to .NET Framework,
Mono and IL2CPP, where this is unverified — the correctness of the loop does
not depend on it, only the gain does. The trade is two stores every iteration
instead of two only when the swap fires: more memory traffic bought fewer
mispredictions, and only a measurement can say whether that pays.
Before against after, four interleaved campaigns in one window, same
corpus on both sides, mse and mae as controls because this change cannot
touch them:
| Operation, n = 1 000 000 | before (ms) | after (ms) | change |
|---|---|---|---|
median_ae_n1000000_k2 |
16.201 | 9.941 | −38.6% |
mse_n1000000_k2 (control)
|
5.051 | 5.037 | −0.3% |
mae_n1000000_k2 (control)
|
5.080 | 5.034 | −0.9% |
Medians of four campaigns each, run after/before/after/before inside a single
window rather than campaign by campaign — the machine drifts more between
campaigns than the effect being measured. Spread within each side was at most
0.22 ms, and the controls moved under 1%, so the window is clean. The bar was
fixed before the number was known: 20% on median_ae or the change reverts.
It cleared it by a factor of nearly two, taking the selection phase from
10.8 ms to roughly 4.5 and median_ae from three times mse to twice.
Machine: Intel i7-4770S, four physical cores, uptime's one-minute average
7.9 at the start of the window and 5.5 at the end — a desktop session ran
throughout, which is what the interleaving and the controls are for.
Reproduce it with the operation filter the same issue added, which measures three rows instead of seventy-eight and turns an eight-minute-twenty campaign into a twenty-one-second one:
dotnet run -c Release --project bench/Lodestar.Text.Benchmarks -- \
compare-metrics --only median_ae,mse,mae --shapes 1000000x2A filtered run writes the same bench/results/csharp-metrics.json holding
fewer rows, so it stamps filtered into the file's metadata and
bench/compare.py refuses it. The merge gate above needs the whole matrix, and
a three-row file would otherwise have printed as a green one.
R2 and ExplainedVariance got a Vector<double> accumulation in #127, gated by
decision 0027
on outputCount == 1 && Vector.IsHardwareAccelerated. The shared walk under
Outputs.WeightedMean — which mse, mae and, through
MeanSquaredError.PerOutput,
RootMeanSquaredError all take —
did not, and stayed a scalar loop.
The table above shows the consequence without naming it: at n = 1 000 000, r2 does
two passes over the data and cost less than mse doing one.
Same machine and method as the table above (Intel i7-4770S, .NET 10.0.10), median of
3, before and after in the same window, with r2 re-run beside them as a control
because nothing in this change touches it:
| Operation | before | after | change |
|---|---|---|---|
mse_n1000000_k2 |
5.385 ms | 3.261 ms | 1.65× faster |
mae_n1000000_k2 |
5.419 ms | 3.393 ms | 1.60× faster |
mse_n1000000_k10 |
5.380 ms | 3.234 ms | 1.66× faster |
mae_n1000000_k10 |
5.484 ms | 3.395 ms | 1.62× faster |
r2_n1000000_k2 — control |
4.428 ms | 4.476 ms | unchanged |
r2_n1000000_k10 — control |
4.397 ms | 4.410 ms | unchanged |
-
The control is the point of the table.
r2moving by 1% across the same window is what says the other four rows are the change and not the machine. -
mseis now cheaper thanr2, which is the ordering the work justifies: one pass against two. -
This machine is not where the gap was found. The
nightly run reported
mseandmaeat 0.60× against numpy on a Xeon 6973P-C with AVX-512, below the gate this page sets, where the rows above have them at 1.04× and 1.12× on Haswell — the regression is visible only where numpy's wider registers pay off and a scalar loop's do not. The ratio there is not measured here, and 1.65× is the floor rather than the estimate:Vector<double>holds four lanes on AVX2 and eight on AVX-512, so the runner should gain more. The next nightly is what confirms that, not this table. -
Four kernels deliberately keep the scalar loop. The Tweedie deviances reach
Math.Powand the log errorsMath.Log, neither of whichVector<T>offers;MeanAbsolutePercentageErrordivides and clamps and was not measured. Only the two kernels that are arithmetic all the way down implement the lane-wise form.
The nightly reported embedding_index_save at 0.27× cpu against numpy.save
where bench/README.md
had published 1.13×, Lodestar ahead. Neither figure was wrong: the C# side moved
by 6% between the two machines and numpy's by a factor of four, because numpy.save
writes a raw block — bandwidth-bound work a newer machine speeds up almost linearly —
where this artifact base64-encodes into JSON, whose cost per byte barely moves.
Under that, Base64Numbers.WriteSingles allocated a full copy of the vector block
and memcpy'd into it, so that an endianness swap could run in place. That swap is a
no-op on every platform .NET runs on, and the comment beside it said so. On a
little-endian machine the bytes to encode are the ones already in the span.
Intel i7-4770S, .NET 10.0.10, median of 3, before and after in one window, on a
machine under load from a parallel session — which is why these absolutes sit above
bench/README.md's. embedding_index_load is re-run as the control: it reads the
same artifact through the same harness and this change does not touch it.
| Operation | before | after | change |
|---|---|---|---|
embedding_index_save, wall |
15.807 ms | 10.579 ms | 1.49× faster |
embedding_index_save, cpu |
17.139 ms | 11.729 ms | 1.46× faster |
embedding_index_load, wall — control |
13.715 ms | 13.506 ms | unchanged |
embedding_index_load, cpu — control |
15.739 ms | 15.527 ms | unchanged |
- One allocation and one copy, on the largest block any artifact holds. At the benchmark's size that is 15 360 000 bytes allocated on the large-object heap and copied, per save, discarded immediately after.
-
The bytes on the wire are unchanged, and that is now pinned rather than
argued: the little-endian path hands
WriteBase64Stringthe same span the copy used to hold, andBase64NumbersTestsasserts the exact base64 of a known vector, computed from the IEEE-754 bits rather than captured from this build. - This does not settle the ratio the nightly reported. Encoding is still the dominant cost and still scales with the machine differently from a raw block write; what it removes is the part that was never encoding at all. The load direction, further behind and for a decided reason, is #324.
The load direction is the furthest behind Python anything here publishes, and
ADR 0011 priced it: base64 inside JSON
against numpy.load's raw block. So the obvious question was whether to pay for a
second format. The profile says the format is not where the time goes.
EmbeddingIndex.Load instrumented
phase by phase, Intel i7-4770S, .NET 10.0.10,
median of the artifact's own 20 589 007 bytes:
| phase | cost | share |
|---|---|---|
| reading the payload into a buffer | ~4.5 ms | ~29% |
| vector block — allocation and base64 decode | ~10.8 ms | ~50% |
| finite scan, SIMD | ~1.6 ms | ~9% |
| 10 000 ids | ~0.7 ms | ~5% |
-
Base64 decoding is close to free. Measured by replacing the decode with a
memcpyof the same byte count and re-running: ~1.3 ms, which is what the decode costs over moving the bytes at all. The other ~9.5 ms of that row is the allocation. - So the answer to ADR 0011's open question is that its door is not the one to open. A binary format would remove 5.2 MB of base64 expansion from a path spending its time on allocation and page commit, not on decoding.
What the allocation was doing that it needed not: the runtime zeroes an array before handing it over, and both large buffers here are overwritten in full immediately — the payload by the stream, the vector block by the decoder. That is 36 MB of large-object-heap writes per load that nothing reads.
Median of 3, before and after interleaved in one window, with tfidf_save as the
control since it writes and does not read:
| Operation | before | after | change |
|---|---|---|---|
embedding_index_load, wall |
14.729 ms | 12.510 ms | 1.18× faster |
embedding_index_load, cpu |
16.868 ms | 14.341 ms | 1.18× faster |
tfidf_load, cpu |
7.320 ms | 7.654 ms | 0.96× |
tfidf_save, cpu — control |
2.380 ms | 2.363 ms | unchanged |
-
tfidf_loaddoes not gain, and is not expected to. Its buffers are far below the large-object heap, whereGC.AllocateUninitializedArrayskips no zeroing at all. The 0.96× sits inside the spread that row shows between windows — it has read 6.9 to 7.9 ms across this page's runs — so it is reported rather than claimed as a regression. - 1.18× is well under what the allocation phase costs, and that gap is the finding to carry forward: removing the zeroing returns only part of it, so most of that phase is the operating system committing pages on first touch, which no allocation strategy avoids. Moving fewer bytes is the remaining lever, which is #336 — and its own measurement narrows that further.
The nightly put Indel 2.40× behind rapidfuzz at 512 while Levenshtein sat at
1.08×, on the same run and the same corpus. Both take the blocked bit-parallel path
and both pay the same equality table, so a cost they share cannot explain a gap only
one of them has. The asymmetry is sharper still read the other way: rapidfuzz's Indel
is 3.03× faster than its own Levenshtein where ours was 1.37× faster than
ours. The cheaper recurrence was not being cashed in.
BitParallelLcs.TryBlocked called Advance(…) once per text character.
Myers.TryBlocked writes its block loop out by hand, and says why in the file's own
header — "It is also the hot path: helper calls here cost measurably." The LCS
kernel, written later in #273, did not inherit that.
Two changes, both local:
-
AdvanceisAggressiveInlining, which is whatMyers.TryBlockedachieves by not having a helper at all; - the
peqBase >= 0test leaves the inner loop. A text character outside Latin-1 makes everyuzero, sosum == value,difference == value, carry and borrow stay clear andv[b] = value | value. The whole pass is a no-op, so the call is skipped rather than made with an empty row.
Intel i7-4770S, .NET 10.0.10, before and after interleaved, four replications,
with Levenshtein as the control — it takes Myers, which nothing here touches.
Baseline is post-#334, not the nightly's: that run predates both #299 and #334 and
was taken on a different machine.
| before | after | change | |
|---|---|---|---|
Indel, length 512 |
13 926.5 ns | 12 631.4 ns | 1.10× faster |
Indel, length 128 |
1 210.7 ns | 1 215.8 ns | unchanged |
Levenshtein, 512 — control |
19 925.5 ns | 20 333.9 ns | 0.98× |
Levenshtein, 128 — control |
1 781.2 ns | 1 814.1 ns | 0.98× |
- The medians understate what the runs show. At 512 the four before values are 13 856.8 / 13 911.1 / 13 941.9 / 14 259.2 and the after values 12 572.2 / 12 599.0 / 12 663.8 / 15 235.6. Three of four sit below every before value with no overlap; the fourth is a contaminated replication, which also carries the only outlier at 128. It is left in the median rather than dropped.
- The control drifts 2% the wrong way, so if the alternation biases anything it biases against the change.
- Nothing at 128, and that is the mechanism. A 110-character pattern spans two 64-bit blocks against eight at 512, so inlining a loop of two iterations saves little.
- This does not close the gap, and the remainder is not incidental. At 12.6 µs against rapidfuzz's 2.9 the kernel is still ~4.3× behind. The blocked path's rented table is cleared per call — 16 KB at this size, about 500 ns of 12 600, or 4% — so that is not where the rest is either. What remains is algorithmic and wants its own measurement before its own lot.
#320 took the mechanical half of the blocked LCS kernel — a helper called once per text character — and left the residual gap open as #357, recording that the table's per-call clear was only 4% of the call and that what remained was algorithmic. It was, and it was one line.
Advance threaded two chains between words: the addition's carry and the
subtraction's borrow. The borrow was provably always zero. u is v & peq, so its
set bits are a subset of v's, and subtracting a bit-subset never borrows —
v - u is exactly v & ~u. Checked against 200 000 random 64-bit draws before a line
was changed, and the property tests #273 added against the dynamic program are what
would have caught the reasoning being wrong.
The asymmetry is the LCS recurrence's own. Myers carries substitution and its
subtraction has no such shape, which is why its blocked loop legitimately keeps both
chains — and why our blocked Myers sat at parity with rapidfuzz's while our blocked
LCS did not. One serial dependency too many, in the loop that runs text.Length × blocks times.
Intel i7-4770S, four replications, before and after interleaved, Levenshtein as the
control since it takes Myers:
| before | after | change | |
|---|---|---|---|
Indel, 128 |
1 293.6 ns | 906.2 ns | 1.43× faster |
Indel, 512 |
13 811.1 ns | 8 837.0 ns | 1.56× faster |
Levenshtein, 128 — control |
1 778.5 ns | 1 768.7 ns | 1.006× |
Levenshtein, 512 — control |
20 422.0 ns | 20 218.6 ns | 1.010× |
No overlap between the two series, on either bucket. At 512 the before values are 12 403.5 / 12 491.5 / 15 130.7 / 15 210.7 and the after values 8 704.3 / 8 832.8 / 8 841.1 / 8 881.9 — and the after values sit inside 2% of each other where the before ones spread over 23%. Removing a serial dependency steadies the measurement as well as shortening it, which is what the mechanism predicts.
Unlike #320 this moves the 128 bucket too, and for the reason #320 could not: that lot amortised a call over the blocks it covered, so two blocks gained little where eight gained more. This removes a dependency from the loop itself, so it pays wherever the loop runs.
UInt128 for the carry was tried and is slower — do not retry it. What remains
between this kernel and rapidfuzz is about 1.8 cycles per block update (6.69 against
4.92 on an i7-4770S at length 512), and the shape of the loop suggests the carry: C++
reaches _addcarry_u64 and emits one ADC where this reconstructs the carry with two
comparisons and an or. Writing it as UInt128 wide = (UInt128)value + u + carry,
which reads better and should let the JIT emit ADD/ADC, measured 0.914× at 512
and 0.957× at 128 — four replications interleaved, control steady, no overlap
between the series. The conversions and the shift cost more than the comparisons they
replace. The gap is what the JIT will not emit, not something the source is holding
wrong.
Against rapidfuzz 3.14.5, both sides re-run in one window after the change:
| length | rapidfuzz | Lodestar | |
|---|---|---|---|
| 8 | 128.5 ns | 37.2 ns | 3.45× C# faster |
| 32 | 203.2 ns | 129.3 ns | 1.57× C# faster |
| 128 | 585.1 ns | 895.4 ns | 1.53× Python faster |
| 512 | 6 504.9 ns | 8 969.7 ns | 1.38× Python faster |
The two long buckets were 2.03× and about 2.1× behind before this lot. What is left is no longer a factor of two. Whether the remainder is worth a third lot is a question for a measurement, not for this page.
The artifact is base64 inside JSON, which spends eight bits to carry six, so it is about 1.33x the raw block. Deflate takes that back almost exactly — base64 is the one expansion a general-purpose coder undoes perfectly. The question was never whether the size comes back. It was what the time costs, on a path #323, #324, #336 and #377 spent four lots making fast.
A synthetic 4 000 × 384 index through the real Save and Load, Intel i7-4770S,
.NET 10.0.10 — warmed, median of 7, the five modes interleaved in one window so the
rows are comparable to each other:
| bytes | × size | save | × save | load | × load | |
|---|---|---|---|---|---|---|
| plain | 8 231 006 | 1.000 | 10.2 ms | 1.00 | 7.9 ms | 1.00 |
gzip Fastest
|
6 257 079 | 0.760 | 270.8 ms | 26.67× | 56.5 ms | 7.19× |
gzip Optimal
|
6 151 764 | 0.747 | 382.1 ms | 37.62× | 46.6 ms | 5.92× |
brotli Fastest
|
6 074 449 | 0.738 | 37.4 ms | 3.68× | 40.8 ms | 5.19× |
brotli Optimal
|
6 069 780 | 0.737 | 122.1 ms | 12.02× | 38.3 ms | 4.87× |
- The size claim holds exactly. 0.747 × 1.333 = 0.996 of the raw block, which is the floor #378 predicted from the other direction. Level 9 gives the same bytes as level 6; there is nothing to tune.
-
Deflate is dominated on all three axes. brotli
Fastestis smaller than gzip at any level, seven times cheaper to write and cheaper to read.BrotliStreamdoes not exist onnetstandard2.0, though, which is why the documented recipe is gzip: a recipe that works on one of two target frameworks is not one this project can publish. - And the price is the whole answer. The cheapest compression available multiplies the load by 5.19 — spending, several times over, what four lots returned — to buy 26% of a disk.
So the library does not compress, and the caller can. Wrapping the stream works
on both sides today and costs no API:
the embeddings guide has the recipe,
ADR 0044 the decision and
its loser. bench/compare-persistence now carries embedding_index_save_gzip and
embedding_index_load_gzip beside the plain rows, against numpy's
savez_compressed, so the trade is re-measured rather than remembered.
The corpus is harsher than the synthetic index, and says so. The nightly's own rows, 10 000 × 384 on a hosted runner — ratios only, per that page's warning:
| operation | C# cpu | bytes | Python cpu | Python bytes |
|---|---|---|---|---|
embedding_index_save |
5.949 ms | 20 589 007 | 1.337 ms | 15 360 128 |
embedding_index_save_gzip |
456.995 ms | 15 251 458 | 638.992 ms | 14 022 374 |
embedding_index_load |
5.519 ms | 20 589 007 | 1.327 ms | 15 360 128 |
embedding_index_load_gzip |
81.774 ms | 15 251 458 | 72.368 ms | 14 022 374 |
0.741× the size for 76.8× the save and 14.8× the load, against 37.62× and 5.92×
for Optimal on the 8 MB synthetic index above. The price grows with the artifact,
which is the opposite of what would make it worth paying — the indexes big enough for
26% of a disk to matter are the ones where compressing costs the most.
Two things the pair says that the plain rows cannot:
-
Compressed, we are ahead of numpy on the write and level with it on the read —
1.40× on
savez_compressed, 0.88× coming back. The deflate coder is the same on both sides, so what is being compared is what each side hands it. - Compression closes most of the format gap. Uncompressed, the artifact is 1.34× numpy's block, the expansion ADR 0011 priced. Compressed, it is 1.09×. The base64 is nearly all of the difference, and a binary format would buy back an eighth of what a general-purpose coder already does.
Bytes are a published number now. Harness.Measure recorded time only, so
#100's size figures were taken
by hand and could not be re-checked. Every persistence row carries artifact_bytes
on both sides, and the comparison prints it next to the time — which is the only way
a compressed row reads honestly.
dotnet run -c Release --project bench/Lodestar.Text.Benchmarks -- roc-parallelThe axis here is elapsed time, and processor time rises. That is what
spending cores means rather than a fault in the measurement, so both columns are
printed side by side for every cell and neither is dropped. Concretely, on
ovr_n100000_k10 at eight workers: processor time goes from 75.993 ms to
142.224 ms while elapsed time falls from 75.991 ms to 26.648 ms. The work got
larger and the wait got shorter. A caller who is paying for CPU seconds should
read the second column and may well conclude the default is right for them.
The table immediately above this one is on a different axis, and the two must not be paired. Issue #61's comparison against scikit-learn is a processor time gate — every row at or above 1×, which is the property that survives being run on a busy machine — and it stays one. This table asks a different question of the same code: how long the caller waits. Setting a processor-time ratio beside an elapsed-time ratio and reading down the page is the easiest available way to mislead a reader, so the axes are named at both tables rather than inferred from the column headers.
One pair of numbers is on the same axis, and it is the one to watch. Issue #61's
table reports roc_auc_ovr_macro_n100000_k10 at 88.385 ms wall (91.396 ms
processor); the table further down this page reports the same operation at dop=1
as 75.991 / 75.779 ms elapsed. Both are elapsed milliseconds, so the axis
warning just given does not cover them — and they are still not the same
measurement. Three things differ, and all three matter:
-
Different input. Issue #61 scores the committed corpus file
bench/corpus/metrics/metrics_n100000_k10.json; this bench generates a seeded separable problem in process (Random(86), rows normalised). How separable the problem is decides how many equal scoresBinaryRocgroups into one threshold, which changes the work per curve. - Different machine load. Issue #61's pass was taken at a one-minute load of 1.52. These figures were taken between 2.31 and 4.01 — the Conditions section below gives the three readings.
-
Different code on the sequential path. The sequential multiclass drivers were
rewritten for this issue: the per-curve buffers now come from one pooled
BinaryRoc.Scratchreused across every class, where Issue #61's build allocated two fresh arrays per class insideBinaryRoc.Score. Issue #61's 88.385 ms and this table's 75.991 ms were not produced by the same implementation, and nothing here measures how much of the gap that accounts for.
So the arithmetic a reader is most likely to reach for is the one nobody should
publish: dividing this table's best ovr_n100000_k10 cell (26.648 ms elapsed) into
Issue #61's Python column for that row (250.400 ms wall) yields a tidy-looking
"9.4× faster than scikit-learn" that no single run measured — two windows, two
inputs, two builds. A cross-language figure for the parallel path would have to be
measured as one pass with both sides on the same input, and it has not been.
Intel i7-4770S, 4 physical cores / 8 logical threads, Ubuntu 24.04,
.NET 10, one sitting. Inputs generated in-process from a fixed seed (Random(86),
rows normalised to sum to 1) — no shared corpus, because both sides of this
comparison are C#. Each cell is the best of five repeats over a 0.5 s minimum,
with wall and processor time taken from the same repeat.
Two back-to-back passes over all 24 cells, both published. One-minute load
average, from uptime: 2.31 before the first pass, 3.32 between them, 4.01
after the second. The rise across the window is the runs themselves — this
workstation does not reach that on its own — and it is why both passes are shown
instead of a mean, following bench/README.md's "read the pairs, not the means"
rule. The two passes are unusually close here: the sequential baselines of the
two heaviest cells agree to 0.28% and 0.45%, against the up-to-15% dispersion
that same section documents for this machine. The widest disagreement anywhere in
the 24 pairs is 6.45%, on ovo_n1000_k10 at four workers, where the whole cell
is under 0.3 ms.
Because the load climbed from 2.31 to 4.01 while these figures were taken, the table is comparable to itself — one window, all four worker counts of a shape measured next to each other — and not to the scikit-learn table above, which was taken at a one-minute load of 1.52. Those absolute milliseconds and these were not measured on the same machine state.
Elapsed and processor milliseconds per operation, as pass 1 / pass 2. The
cpu ÷ elapsed column is what the harness prints as (…x cores) — how many
cores' worth of work the call consumed. The speed-up column is elapsed time
divided into the same pass's own dop=1 row.
| Operation | dop | elapsed ms | processor ms | cpu ÷ elapsed | speed-up vs dop=1 |
|---|---|---|---|---|---|
ovr_n1000_k10 |
1 | 0.466 / 0.470 | 0.467 / 0.470 | 1.00 / 1.00 | 1.00 / 1.00 |
ovr_n1000_k10 |
2 | 0.294 / 0.301 | 0.647 / 0.672 | 2.20 / 2.23 | 1.59 / 1.56 |
ovr_n1000_k10 |
4 | 0.240 / 0.238 | 0.958 / 0.944 | 3.98 / 3.96 | 1.94 / 1.97 |
ovr_n1000_k10 |
8 | 0.249 / 0.244 | 1.066 / 1.036 | 4.28 / 4.24 | 1.87 / 1.93 |
ovo_n1000_k10 |
1 | 0.745 / 0.741 | 0.745 / 0.741 | 1.00 / 1.00 | 1.00 / 1.00 |
ovo_n1000_k10 |
2 | 0.438 / 0.439 | 0.957 / 0.943 | 2.19 / 2.15 | 1.70 / 1.69 |
ovo_n1000_k10 |
4 | 0.297 / 0.279 | 1.215 / 1.169 | 4.09 / 4.19 | 2.51 / 2.66 |
ovo_n1000_k10 |
8 | 0.423 / 0.421 | 1.627 / 1.603 | 3.84 / 3.81 | 1.76 / 1.76 |
ovr_n100000_k5 |
1 | 36.770 / 36.633 | 36.770 / 36.635 | 1.00 / 1.00 | 1.00 / 1.00 |
ovr_n100000_k5 |
2 | 23.535 / 23.444 | 38.852 / 38.905 | 1.65 / 1.66 | 1.56 / 1.56 |
ovr_n100000_k5 |
4 | 17.777 / 17.656 | 45.709 / 46.358 | 2.57 / 2.63 | 2.07 / 2.07 |
ovr_n100000_k5 |
8 | 14.380 / 14.405 | 56.961 / 57.354 | 3.96 / 3.98 | 2.56 / 2.54 |
ovo_n100000_k5 |
1 | 55.121 / 54.865 | 55.117 / 54.860 | 1.00 / 1.00 | 1.00 / 1.00 |
ovo_n100000_k5 |
2 | 29.305 / 29.674 | 56.743 / 57.214 | 1.94 / 1.93 | 1.88 / 1.85 |
ovo_n100000_k5 |
4 | 24.177 / 24.151 | 60.830 / 60.723 | 2.52 / 2.51 | 2.28 / 2.27 |
ovo_n100000_k5 |
8 | 17.331 / 17.535 | 91.430 / 91.555 | 5.28 / 5.22 | 3.18 / 3.13 |
ovr_n100000_k10 |
1 | 75.991 / 75.779 | 75.993 / 75.785 | 1.00 / 1.00 | 1.00 / 1.00 |
ovr_n100000_k10 |
2 | 40.242 / 40.163 | 77.532 / 77.425 | 1.93 / 1.93 | 1.89 / 1.89 |
ovr_n100000_k10 |
4 | 34.997 / 35.644 | 94.273 / 91.616 | 2.69 / 2.57 | 2.17 / 2.13 |
ovr_n100000_k10 |
8 | 26.648 / 26.379 | 142.224 / 140.712 | 5.34 / 5.33 | 2.85 / 2.87 |
ovo_n100000_k10 |
1 | 127.375 / 126.810 | 127.377 / 126.801 | 1.00 / 1.00 | 1.00 / 1.00 |
ovo_n100000_k10 |
2 | 64.317 / 64.250 | 123.433 / 123.341 | 1.92 / 1.92 | 1.98 / 1.97 |
ovo_n100000_k10 |
4 | 37.162 / 36.875 | 133.183 / 131.681 | 3.58 / 3.57 | 3.43 / 3.44 |
ovo_n100000_k10 |
8 | 37.284 / 37.457 | 187.662 / 184.325 | 5.03 / 4.92 | 3.42 / 3.39 |
Bold marks the fastest worker count for each shape in elapsed time. Nothing was skipped: one-vs-one at n=100 000, k=10 is 45 pairs and 90 curves, the heaviest cell in the matrix, and a single sequential call is about 127 ms — well inside the bench's 60-second patience for that cell.
At k=10 and n=100 000 the opt-in is worth 2.85×–2.87× on one-vs-rest and 3.43×–3.44× on one-vs-one, on four physical cores. One-vs-rest wants all eight logical threads to get there (26.6 / 26.4 ms); one-vs-one gets there at four (37.2 / 36.9 ms) and eight buys it nothing.
At k=5 the ceiling is lower, and part of it is arithmetic. One-vs-rest tops out at 2.56× / 2.54×: five classes are five independent units of work, so the per-index loop is clamped to five workers however many the caller asks for, and five pieces cannot be spread evenly over four cores — one core does two while three do one. One-vs-one at the same shape has ten pairs to hand out and reaches 3.18× / 3.13×, the best ratio in the table at n=100 000.
At n=1000 the opt-in is a gain, not a cost — which is not what was expected. The design brief assumed the small-input row would be a dispatch overhead to justify. It is a doubling: one-vs-rest 0.466 / 0.470 ms → 0.240 / 0.238 at four workers (1.94× / 1.97×), one-vs-one 0.745 / 0.741 → 0.297 / 0.279 (2.51× / 2.66×). Ten classes are ten independent sorts even when each sorts only a thousand values, and the copy the parallel path pays for is 1000 × 10 doubles — 80 KB, which fits in L2. This is why the option has no internal size threshold: a crossover constant calibrated for "small inputs" would have thrown this away.
dop=8 loses to dop=4 on three of the six shapes, in both passes.
ovr_n1000_k10 (0.249 / 0.244 against 0.240 / 0.238), ovo_n1000_k10 (0.423 /
0.421 against 0.297 / 0.279 — 42% / 51% slower) and ovo_n100000_k10 (37.284 /
37.457 against 37.162 / 36.875). This machine has 4 physical cores and 8
logical threads: past four workers the extra ones share execution units with a
sibling rather than adding any, while still adding scheduling and cache
pressure. The ovo_n1000_k10 row is the clearest case, and it is a large
regression, not a rounding error.
The practical consequence is worth stating plainly, because
MaxDegreeOfParallelism's own documentation offers Environment.ProcessorCount
as the way to ask for every core: on a hyperthreaded machine that property can
be the wrong number, and slower than half of it. ProcessorCount is 8 here.
Four is the better setting on half these shapes and never much worse on the rest.
There is no recommended value in the library because there is no value that is
right on every machine — measure your shape on your hardware, which is what this
bench mode exists for.
Where the ceiling comes from. Nothing here approaches 4× on four cores, and
three things account for it, in decreasing order of confidence. ValidateRowSums
walks all samples × classes scores on the calling thread before any dispatch —
it stays sequential so its message can name the first bad row — and no worker
count shortens it. The parallel path copies its inputs, samples × classes × 8
bytes for the transposed score matrix, about 8 MB at n=100 000, k=10. And the
per-index loop can only be as parallel as it has indices, which is what caps
one-vs-rest at k=5. This run does not time those three separately, so the split
between them is not quantified here; what is measured is the total, and it is in
the table.
The reasoning behind the opt-in default, the absent -1 sentinel and the absent
threshold is in
../decisions/0018-multiclass-roc-auc-parallelism-is-opt-in.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
- benchmark_latest
- decisions
- equivalence
- matplotlib
- migration
- nightly_run
- numpy
- pandas
- performance
- pytorch
- seaborn
- sklearn
- statsmodels