You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Tracking issue for the four-PR performance stack #3#4#5#6.
What prompted it
A report that caps-sa was ~18x slower than libsais on chr21. Reproducing it
turned up something more useful: the two numbers had been measured on
different inputs, and the gap was one specific pathology rather than general
slowness.
Dividing CPU time by n log2 n merge steps, same kernel, same machine:
16x apart, entirely scan length. Every leaf merge starts with m = 0, so a
tied comparison scans the whole shared prefix of two suffixes. Genome FASTA
carries ~6.6 Mb of N, and once 60-column line wrapping is included that is a
period-61 repeat, not a homopolymer. One comparison can scan millions of bytes.
On the N-free input caps-sa was never 18x off. That framing is worth keeping in
mind when reading the older numbers in bench/README.md.
Result
Apple M4 Max (12 P-cores), 12 threads. All outputs verified byte-identical to
the original merge kernel, on both the in-memory and external-memory paths.
input
in-memory
external-memory
chr21 fwd ++ revcomp, coded 0..3, 80 MB
6.08 s → 0.57 s
3.49 s → 1.69 s
same, ASCII ACGT, 80 MB
6.08 s → 0.61 s
3.49 s → 1.82 s
chr21 FASTA, 47.5 MB, 6.6 Mb of N
27.8 s → 1.12 s
24.19 s → 1.56 s
CPU time on the FASTA input drops from 283.5 s to ~5 s in memory and from 268 s
to ~17.5 s externally. Peak RSS for the external path goes from 214 MB to
285 MB, still an order of magnitude under the in-memory path, so the bound that
path exists to provide holds.
The stack
Merge in order. I do not have write access here, so the parts cannot use each
other as base branches and all four target main; every diff collapses to just
its own work as soon as the part before it merges.
No test covered the LCP array. The public entry points discard it, but the
next merge level consumes it in the three-case decision, so a single wrong
entry silently reorders suffixes one level up. Fixed first, in 1/4 perf: radix-seeded prefix doubling for the in-memory suffix array #3, before any
kernel change.
bench/README.md credited lto = "fat" to a parent workspace that is not
in this repository. From the commit that made the crate standalone until 1/4 perf: radix-seeded prefix doubling for the in-memory suffix array #3,
every build made here used lto = false, codegen-units = 16, so numbers taken
in that window are not comparable with numbers taken now.
Verification
87 unit tests (was 62), run in debug as well as release. Debug is not
incidental: it caught a u64 shift-by-64 in the SWAR key packing that release
masks to zero and silently gets right.
src/radix.rs checks every binary text to length 10 and every ternary text to
length 6 against brute force, so each key width is exercised at every
alignment and tail length.
Cross-check: the suffix array of the ASCII ACGT text is identical to that of
the 0..3-coded text, as it must be, the encodings being order-isomorphic.
Negative results, recorded so they are not retried
Grain-size threshold on parallel cascade levels measured worse on both
inputs (2.22 s and 1.63 s against 2.02 s and 1.55 s). Every level goes
parallel.
Unconditional partition re-sort by key regressed the N-heavy input badly
(1.45 s → 2.23 s): a long run is exactly where a fixed-depth key resolves
nothing. It is gated on the run table being empty.
Key-accelerated phase-3 pivot search was tried twice, including after the
key became ~5x cheaper, and gained nothing (0.402 s → 0.378 s, within noise).
Phase 3 is bound by the cache miss on records[mid] in the binary search, not
by the comparison. Reverted rather than kept.
Known limits
build_ext_mem keeps the merge kernel deliberately: prefix doubling needs a
rank for every text position, which would defeat its memory bound. It gets the
pathology fixed in the comparator instead (3/4 perf: external memory, 24.2 s -> 1.56 s on chr21 FASTA #5).
Run detection handles periods up to 64. A repeat whose shortest period exceeds
that is still scanned; the bound is a constant.
Segmented texts, custom boundary_order, finite max_context, and symbols
wider than u8 all stay on the merge kernel for ordering. Each is a soundness
condition, and each defaults to declining, so existing and third-party LimitProvider implementations are unaffected without changing a line.
Beyond this
The remaining step is a linear-time construction (SA-IS or DC3), which replaces
CaPS-SA rather than optimising it. Within the current algorithm the per-round
group-size distribution — now logged behind CAPS_SA_PROFILE — is the first
thing to look at.
Tracking issue for the four-PR performance stack #3 #4 #5 #6.
What prompted it
A report that caps-sa was ~18x slower than libsais on chr21. Reproducing it
turned up something more useful: the two numbers had been measured on
different inputs, and the gap was one specific pathology rather than general
slowness.
Dividing CPU time by
n log2 nmerge steps, same kernel, same machine:16x apart, entirely scan length. Every leaf merge starts with
m = 0, so atied comparison scans the whole shared prefix of two suffixes. Genome FASTA
carries ~6.6 Mb of
N, and once 60-column line wrapping is included that is aperiod-61 repeat, not a homopolymer. One comparison can scan millions of bytes.
On the N-free input caps-sa was never 18x off. That framing is worth keeping in
mind when reading the older numbers in
bench/README.md.Result
Apple M4 Max (12 P-cores), 12 threads. All outputs verified byte-identical to
the original merge kernel, on both the in-memory and external-memory paths.
0..3, 80 MBACGT, 80 MBNCPU time on the FASTA input drops from 283.5 s to ~5 s in memory and from 268 s
to ~17.5 s externally. Peak RSS for the external path goes from 214 MB to
285 MB, still an order of magnitude under the in-memory path, so the bound that
path exists to provide holds.
The stack
Merge in order. I do not have write access here, so the parts cannot use each
other as base branches and all four target
main; every diff collapses to justits own work as soon as the part before it merges.
verify_sa, LCP-array tests, Rust CI (8 commits)Two pre-existing problems fixed along the way
next merge level consumes it in the three-case decision, so a single wrong
entry silently reorders suffixes one level up. Fixed first, in 1/4 perf: radix-seeded prefix doubling for the in-memory suffix array #3, before any
kernel change.
bench/README.mdcreditedlto = "fat"to a parent workspace that is notin this repository. From the commit that made the crate standalone until 1/4 perf: radix-seeded prefix doubling for the in-memory suffix array #3,
every build made here used
lto = false, codegen-units = 16, so numbers takenin that window are not comparable with numbers taken now.
Verification
incidental: it caught a
u64shift-by-64 in the SWAR key packing that releasemasks to zero and silently gets right.
src/radix.rschecks every binary text to length 10 and every ternary text tolength 6 against brute force, so each key width is exercised at every
alignment and tail length.
verify_sa, added in 1/4 perf: radix-seeded prefix doubling for the in-memory suffix array #3, checks a candidate inO(n)without re-running anyconstruction algorithm and without depending on LCP length — usable at genome
scale on exactly the repetitive inputs that are hardest to trust.
ACGTtext is identical to that ofthe
0..3-coded text, as it must be, the encodings being order-isomorphic.Negative results, recorded so they are not retried
inputs (2.22 s and 1.63 s against 2.02 s and 1.55 s). Every level goes
parallel.
N-heavy input badly(1.45 s → 2.23 s): a long run is exactly where a fixed-depth key resolves
nothing. It is gated on the run table being empty.
key became ~5x cheaper, and gained nothing (0.402 s → 0.378 s, within noise).
Phase 3 is bound by the cache miss on
records[mid]in the binary search, notby the comparison. Reverted rather than kept.
Known limits
build_ext_memkeeps the merge kernel deliberately: prefix doubling needs arank for every text position, which would defeat its memory bound. It gets the
pathology fixed in the comparator instead (3/4 perf: external memory, 24.2 s -> 1.56 s on chr21 FASTA #5).
that is still scanned; the bound is a constant.
boundary_order, finitemax_context, and symbolswider than
u8all stay on the merge kernel for ordering. Each is a soundnesscondition, and each defaults to declining, so existing and third-party
LimitProviderimplementations are unaffected without changing a line.Beyond this
The remaining step is a linear-time construction (SA-IS or DC3), which replaces
CaPS-SA rather than optimising it. Within the current algorithm the per-round
group-size distribution — now logged behind
CAPS_SA_PROFILE— is the firstthing to look at.
🤖 Generated with Claude Code