Skip to content

Tracking: suffix array construction performance (10x in-memory, 15x external-memory on chr21) #7

Description

@BenjaminDEMAILLE

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:

chr21.0123 (N-free, 80 MB)     28.1 s / 2.11e9 steps =  13 ns/step
chr21.fa   (raw FASTA, 47 MB) 283.5 s / 1.21e9 steps = 222 ns/step

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.

Two pre-existing problems fixed along the way

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.
  • verify_sa, added in 1/4 perf: radix-seeded prefix doubling for the in-memory suffix array #3, checks a candidate in O(n) without re-running any
    construction algorithm and without depending on LCP length — usable at genome
    scale on exactly the repetitive inputs that are hardest to trust.
  • 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.

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions