Skip to content

perf: choose pivots before sorting, and fuse phase 3 into phase 1 - #9

Closed
BenjaminDEMAILLE wants to merge 41 commits into
COMBINE-lab:mainfrom
BenjaminDEMAILLE:perf/fuse-phase1-phase3
Closed

perf: choose pivots before sorting, and fuse phase 3 into phase 1#9
BenjaminDEMAILLE wants to merge 41 commits into
COMBINE-lab:mainfrom
BenjaminDEMAILLE:perf/fuse-phase1-phase3

Conversation

@BenjaminDEMAILLE

Copy link
Copy Markdown
Contributor

Depends on #8. Removes phase 3 from the external-memory build entirely.

Why

Phase 3 was the worst-scaling part of the build:

  threads      phase 1     phase 3     phase 4     total
        1       2.088 s     0.756 s     6.413 s    9.288 s
       12       0.289 s     0.361 s     0.954 s    1.640 s
  speedup         7.2x        2.1x        6.7x       5.7x

It saturates by six threads and gains 14% from six to twelve. Two explanations were tested and both are wrong, recorded so they are not retried:

  • Physical file contention. Raising CAPS_SA_N_PHYS from 12 through 192 does not help and mildly hurts (0.356 s to 0.420 s).
  • Search cost. Galloping cut its pivot comparisons roughly eightfold and bought 8%; an earlier packed-key attempt at the same searches measured nothing.

What remains is the write path. Phase 3 read every record back and wrote every record out again — ~1.3 GB at this input size — through a page cache that does not parallelise with threads. 3.5 GB/s is far below this machine's DRAM, which points at the kernel rather than memory.

What

Phase 3 existed only because pivots were sampled from the already-sorted subarrays, which forced the order: sort everything and spill it, choose pivots, read all of it back to distribute.

Pivots do not have to come from sorted data. Any splitters produce a correct sample sort; only the balance of the partitions changes. That is what makes this restructuring far safer than it looks — correctness does not depend on the sample being good.

So a cheap pre-pass over the raw positions supplies them, and phase 1 sorts and distributes in one go. The subarrays are never materialised.

Sampling is by strided blocks of 64 rather than strided singletons: a PositionSource fills a contiguous run cheaply but pays per call, and the Filtered variant especially so. Coverage is the same and splitter quality is not sensitive to the difference.

Two spilled copies of every record disappear — one write and one read — and with them an entire bucket file pool, since there are no subarray buckets left to hold.

Measured

chr21 forward ++ revcomp (80 MB), 12 threads, interleaved A/B, three pairs, fused winning every pair:

  before   1.975  1.733  1.790 s   mean 1.833
  after    1.671  1.527  1.609 s   mean 1.602   -12.6%

Scaling is the real result:

               1 thread   12 threads   speedup
  before        9.288 s     1.640 s      5.7x
  after         9.563 s     1.352 s      7.1x

The phase 1 + phase 3 pair went from a combined ~3.9x to 6.3x for the fused phase alone, so the gain grows with core count. @rob-p, this should show up more strongly at 32 or 64 cores than it does at 12.

Verified

  • Byte-identical output against the in-memory suffix array on N-free DNA and on raw FASTA.
  • Byte-identical against the previous ext-mem path on the ACGT-filtered ruSTAR-shaped fixture (80,177,238 retained positions).
  • --verify clean on the segmented 698,597-junction splice-junction fixture under STAR's boundary order.
  • 94 tests in debug and release; fmt, Clippy with warnings denied, doc tests, rustdoc.

Note

Single-thread time is slightly worse (9.288 s to 9.563 s): the pre-sample is extra work that the old flow got for free from data it was sorting anyway. It pays for itself by two threads and grows from there.

🤖 Generated with Claude Code

BenjaminDEMAILLE and others added 30 commits August 11, 2026 21:33
Two prerequisites for the performance work that follows, neither of
which changes behaviour.

The crate had no test covering the LCP array. That is the riskiest
possible gap for this algorithm: the public entry points discard the
array, but the *next* merge level consumes it in the three-case
decision, so a single wrong LCP entry silently reorders suffixes one
level up and the SA comes out subtly wrong. Add four tests that check
`lcp[0] == 0` and `lcp[i] == lcp(text[sa[i-1]..], text[sa[i]..])`
against a naive oracle, over fixtures, random texts across four
alphabet sizes, long runs and periodic text, and finite `max_context`.

`bench/README.md` claims the published numbers were taken with fat LTO
and one codegen unit, supplied by a parent workspace. That workspace is
not in this repo, so every build made from it since the crate went
standalone has used `lto = false, codegen-units = 16`. Pin the profile
here. In a library crate `[profile.release]` applies only when this
crate is the workspace root, so it affects this repo's own tests,
examples and benches and is invisible to downstream consumers.

Measured on Apple M4 Max, 12 threads, chr21 FASTA (47.5 MB):
27.8 s -> 24.0 s wall. Neutral on N-free DNA.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The CaPS-SA merge kernel stays the general path, but it is not the right
algorithm for the most common request: the standard lexicographic suffix
array of a byte text, unsegmented, with no context bound. Add
`src/radix.rs` and route that case through it.

Profiling separated two distinct costs, and the merge kernel pays both:

* Step count. `n log n` merge steps, most resolved by a symbol
  comparison at a random text address. 80 MB of N-free DNA is ~2.1e9
  steps at ~13 ns each.
* Scan length. Every leaf merge starts at `m = 0`, so two suffixes
  sharing a long prefix cost a scan proportional to that prefix. Genome
  FASTA carries megabyte-scale runs of `N` (period-61 once line wrapping
  is included), where one comparison scans millions of bytes. That drives
  the cost per merge step from 13 ns to 222 ns, a 16x penalty which is
  entirely scan time. This is what made real chr21 20x slower than
  N-free DNA of comparable size.

The new path removes both. It sorts by a packed fixed-depth key, then
resolves the remainder by prefix doubling on ranks. The packing picks the
narrowest field width in {1,2,4,8} bits that holds the alphabet, so DNA
over {0,1,2,3} resolves 32 symbols per key rather than the 8 a raw byte
key gives. After the seed no comparison reads the text again, so a
megabyte run of `N` costs exactly what random DNA costs.

The seed sorts by `(key, min(n - p, k))`. The second component is
required, not cosmetic: zero-padding makes a short suffix share a key
with any suffix continuing in zeros, and `0` is a real symbol in every
DNA encoding. Ordering by visible length puts the proper prefix first,
which is the crate's shorter-is-smaller convention. Without it `[0, 0]`
leaves two positions permanently tied and doubling cannot terminate.

Guards are soundness conditions, not heuristics, and all three default
to declining:

* `max_context` must be unbounded; a finite bound makes the merge's
  comparator fall through to `boundary_order`, which compares lengths,
  so it is not lexicographic.
* `LimitProvider::plain_lex_len` must report the full text. New method,
  defaulting to `None`, overridden only by `PlainText`. An impl that
  delegates `lim_at` to `PlainText` but overrides `boundary_order` for a
  different convention (STAR's spacer-as-largest) inherits `None` and
  stays on the merge kernel without changing a line.
* `S` must be exactly `u8`. Packing wider symbols into an order-
  preserving key is endianness-dependent: for `u16` on a little-endian
  host `0x0100 > 0x0001` as values but their byte views compare the
  other way. The rest of the crate avoids this only because
  `LcpDispatch` resolves equality over bytes and recovers ordering
  through `S: Ord`.

Tests: exhaustive over every binary text to length 10 and every ternary
text to length 6, random texts across seven alphabet widths, texts where
a real `0` collides with padding, long runs, periodic text, and the
wrapped-FASTA `N`-block shape.

Measured on Apple M4 Max, 12 threads, against the previous kernel, with
byte-identical suffix arrays on both real inputs:

  chr21 fwd+revcomp, N-free, 80 MB   6.08 s -> 1.14 s   CPU 28.1 s -> 5.0 s
  chr21 FASTA, 47.5 MB               27.8 s -> 1.16 s   CPU 283.5 s -> 5.2 s

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Checking a new construction path against the old one only shows the two
agree. Checking adjacent suffixes directly is O(n · lcp), which is
unusable on exactly the repetitive inputs that need checking most: on a
chr21 FASTA a single adjacent pair can share megabytes.

Use the fixpoint characterisation instead. With `rank` the inverse of
`sa` and `f(p) = (text[p], rank[p + 1])`, taking `rank[n]` as less than
every real rank, a permutation of `0..n` is the suffix array of `text`
if and only if `f` is strictly increasing along it. That is one pass to
invert plus one pass to compare, independent of any construction
algorithm and independent of LCP length.

Exposed as `caps_sa::verify_sa` and wired to a `--verify` flag on the
bench CLI, off by default so it never contaminates a timing run.

Full-scale results on Apple M4 Max, 12 threads:

  chr21 fwd+revcomp, N-free, 80,177,238 entries   verify OK in 1.14 s
  chr21 FASTA, 47,488,540 entries                 verify OK in 0.57 s

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase timings (`CAPS_SA_PROFILE=1`) showed the two rank-scatter passes
were fully sequential and had become the largest single cost after the
seed sort: 0.34 s of a 1.17 s build on 80 MB of DNA.

Both scatter through a permutation -- the target index is `sa[i]`, not
`i` -- so the writes are not expressible as disjoint sub-slices and
`split_at_mut` does not apply. They are nonetheless disjoint: `sa` is a
permutation and the ranges being processed partition its index space, so
every slot is written exactly once. Introduce a small `Scatter` wrapper
that encodes precisely that contract in its `unsafe fn set`, and drive
both passes with rayon.

Grouping now has each index decide for itself whether it starts a group;
the index that does owns the group, walks it to find the end, and writes
its members' ranks. Exactly one owner per group, and `collect` on an
indexed parallel iterator preserves order, so the group list still comes
out sorted, which is what `split_disjoint` relies on.

Also materialise the successor ranks before sorting each group.
`sort_unstable_by_key` re-evaluates its key function O(len log len)
times and every evaluation was a random probe into `rank`; paying once
per element makes the sort's memory traffic sequential.

Apple M4 Max, 12 threads, suffix arrays byte-identical to the previous
kernel and independently `--verify`-checked:

  chr21 fwd+revcomp, N-free, 80 MB   grouping 0.341 s -> 0.075 s
                                     total    1.17 s  -> 0.89 s
  chr21 FASTA, 47.5 MB               grouping 0.172 s -> 0.037 s
                                     total    1.10 s  -> 1.04 s

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The repository had no Rust CI at all: the only workflow deployed the
docs site, while 73 tests sat in the tree with nothing running them.
That is not a safe baseline for changing the sorting kernel.

Covers both architectures that matter here, since the LCP kernel and the
pooled external-memory bucket path are the parts that diverge per
platform: macOS is aarch64/NEON, Ubuntu is x86_64/AVX2. Runs the tests
in debug as well as release, because debug is what exercises the
`debug_assert`s guarding the unchecked scatter in `radix.rs` and the
buffer-length invariants in the merge kernel. Adds fmt, clippy with
warnings denied, a check against the declared 1.89 MSRV, and rustdoc
with broken links denied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`bench/run.sh` compares against upstream C++ and needs both binaries
prebuilt. Add a self-contained script for the case that prompted this
work: it fetches hg38 chr21 and prepares *both* inputs, which is the
distinction that made the original slowdown report hard to interpret.

  chr21.0123  forward ++ revcomp, one byte per base, codes 0..=3,
              ambiguous bases dropped. ~80 MB, alphabet 4, no long runs.
              The input libsais is normally benchmarked on.
  chr21.fa    the raw FASTA, still carrying its ~6.6 Mb of `N`. Wrapped
              at 60 columns, so the `N` blocks are a period-61 repeat
              rather than a plain run.

Benchmarking one implementation on the first and another on the second
compares two different problems. The second is the realistic input and
the one that used to be pathological.

Builds with `-C target-cpu=native` and runs each case through
`--verify`, so the harness reports correctness alongside timing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds to README: the in-memory fast path with measured numbers, a
"Choosing a path" table giving the three soundness conditions and why
each one exists, and `verify_sa` usage.

Adds to bench/README: the chr21 section, including the per-merge-step
arithmetic that separates the two inputs (13 ns/step on N-free DNA
against 222 ns/step on the FASTA, same kernel and same machine), and the
phase breakdown of the fast path.

Corrects two claims that were misleading:

The build paragraph attributed `lto = "fat"` and `codegen-units = 1` to
a parent workspace. No such workspace is in the repository, so from the
commit that made the crate standalone until the profile was added, every
build made from this repo used `lto = false, codegen-units = 16`.
Numbers taken in that window are not comparable with numbers taken now.

The 97.54% `lcp_u8_avx2` profile was read as "LCP scanning is
expensive", which motivated widening the scan through AVX2, AVX-512 and
the hybrid. The LCP kernel is also where the two random text loads
happen, so on short-LCP input those samples are load stalls and a wider
vector cannot help. The existing AVX-512 ablation already showed this:
the 64-byte-only variant was 16% slower on rand100m and only the
long-LCP human slice gained. Both readings are now stated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Public documentation linked to `FilteredSource`, which is private, so
`cargo doc` fails under `RUSTDOCFLAGS=-D warnings`. Pre-existing, but it
blocks the rustdoc job added in the previous commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The seed was a `par_sort_unstable` over a materialised
`Vec<(u64, u32, I)>`, which was both the largest remaining cost and the
peak-memory driver.

Three things change. The source is never materialised: keys are
recomputed from `text` in the histogram pass and again in the scatter,
which trades a random read of an n-element key array for a sequential
read of the text. Peak memory drops to the two destination buffers, 12
bytes per position at `I = u32` against the 16 a `(u64, u32, I)` record
costs. And the top-level partition becomes a counting pass, which
parallelises evenly, where a parallel comparison sort's first
partitioning steps are close to serial.

11 bits (2048 buckets) keeps the write-combining state near 512 KB and
inside a core's private cache. 16 bits would need 16 MB of open write
lines and thrash the TLB instead.

The visible-length tie-break no longer needs storing. Only the last
`k - 1` positions can have a visible length below `k`, so it is a
function of the position alone and is applied in the per-bucket sort and
in the group scan.

Apple M4 Max, 12 threads, chr21 fwd+revcomp 80 MB, output unchanged and
`--verify` clean:

  peak RSS   2.83 GB -> 2.21 GB   (-22%)
  seed       0.341 s -> 0.289 s
  total      0.892 s -> 0.844 s

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes two of the three gaps listed as limitations when the fast path
landed.

**Subsets.** Doubling cannot be restricted to a subset directly, because
a round compares `rank[p + d]` and that successor is generally not in
the subset, so ranks must be defined for every position in the text.
Building the whole array and filtering it sidesteps that, and the filter
is one O(n) pass since the full array is already ordered. Worth it when
the subset is a real fraction of the text, which is what this API exists
for: STAR-style indexing keeps every ACGT position and drops only
spacers. Below one eighth of the text it declines, because O(n) to build
and discard would dwarf the O(m log m) the merge kernel needs. That
ratio is a performance heuristic; the guards it sits behind remain
correctness conditions.

It also declines on duplicate or out-of-range positions. The output is a
permutation of the input *multiset*, which a membership filter cannot
reproduce.

**In-memory sample sort.** `build_in_memory_sample_sort` exists to sort
in RAM, so where the doubling path applies it is strictly better: same
output, no bucket machinery, and none of the scan cost on repeat-heavy
text.

`build_ext_mem` deliberately does *not* get this. Its purpose is to
bound peak memory, and routing it through an in-memory algorithm would
defeat exactly that. It stays on the merge kernel, and the remaining
limitation is now stated as a deliberate choice rather than an omission.

Apple M4 Max, 12 threads, chr21 fwd+revcomp 80 MB. `--in-mem-ss` output
verified identical to the in-memory path's:

  --in-mem-ss   3.41 s -> 1.18 s wall,  34.5 s -> 7.2 s CPU

Tests: duplicate positions keep their multiplicity, a tiny subset of a
large text still matches brute force through the merge kernel, and
out-of-range positions still panic rather than silently returning a
wrong answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Records the counting-sort seed (peak RSS 2.83 GB -> 2.21 GB, total
0.89 s -> 0.84 s on the 80 MB input), adds the `--in-mem-ss` row, and
replaces the "everything else uses the merge kernel" line with what is
now actually true: subsets and in-memory sample sort are covered, and
`build_ext_mem` stays on the merge kernel deliberately, because bounding
peak memory is the whole point of that path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`build_ext_mem` was left on the merge kernel because bounding peak
memory is its purpose and prefix doubling needs a rank for every
position in the text. That left it paying the full scan cost on
repeat-heavy input. Profiling put 94% of an ext-mem run on chr21 FASTA
in phase 1:

  chr21.0123 (N-free, 80 MB)   phase1  1.15 s   of  3.49 s total
  chr21.fa   (47.5 MB)         phase1 22.85 s   of 24.19 s total

Normalised that is 0.014 s/MB against 0.48 s/MB, the same 34x scan
penalty the in-memory path had, and for the same reason: two suffixes
inside a long repeat agree for as far as it continues, so one comparison
scans megabytes.

Fix it in the comparator rather than the algorithm, which keeps the
memory bound intact. If `text[s..e)` has period `q` and two suffixes
start at `a < b` inside it with `(b - a) % q == 0`, they agree until the
later one reaches `e`, so `lcp(a, b) >= e - b` is known in O(1) from the
run's bounds with no scanning. When the phase does not match, the two
must differ within `q` symbols and the ordinary scan is already short.
The scan is additionally bounded so it stops at a run's start rather
than traversing it.

Detecting only single-symbol runs would have missed the case that
actually occurs: in wrapped FASTA an `N` block is 60 `N`s then a
newline, which is period 61, not period 1. Periods up to 64 are
considered.

Detection is two-stage so texts without runs pay almost nothing. A
sampling pass looks for any periodic window and collects the periods
that occur; the full scan runs only for those. On N-free DNA the sample
finds nothing, the table is empty, and every query short-circuits on a
slice-empty check. The table itself is a few dozen entries, so the
memory bound is untouched.

`Cmp` bundles the SIMD dispatch with the run table and replaces the bare
`LcpDispatch` threaded through the merge kernel, so phase 1, the phase-3
pivot searches and the phase-4 cascade all benefit. It stays `Copy` and
still travels through the recursion in registers.

Apple M4 Max, 12 threads. Ext-mem output verified identical to the
in-memory suffix array on both inputs:

  chr21.fa    24.19 s -> 2.48 s wall,  268 s -> 23.3 s CPU
              phase 1 22.85 s -> 0.95 s
              peak RSS 147 MB -> 151 MB
  chr21.0123   3.49 s -> 3.55 s wall (unchanged; no runs to find)

Tests: the run-aware LCP is checked against a byte-at-a-time oracle over
sampled position pairs on homopolymers, wrapped-FASTA blocks and
multi-period texts, plus detection shape (sorted, disjoint, period claim
actually holds) and `max_context` behaviour inside a run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the ext-mem phase breakdown that located the cost (94% of the
chr21 FASTA run in phase 1), the before/after table, and the detail
worth keeping: a homopolymer detector would not have worked, because in
60-column wrapped FASTA the longest single-byte run is 60. Each line of
`N`s ends in a newline, so the real structure is a period-61 repeat
spanning 6.6 Mb.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 4 had become the largest ext-mem cost, and its merge CPU (19.2 s
on 80 MB of DNA) exceeded the entire CPU of the in-memory path. The
merge is latency-bound: the tied branch dereferences the text at two
random addresses, and the address for step i+1 is not known until step i
retires, so there is no memory-level parallelism and the hardware
prefetcher cannot see the pattern.

The candidate positions themselves live in the two index arrays, which
are sequential and already in cache, so the addresses several steps
ahead are known even though the dependent loads are not. Issue them as
prefetches, offset by the current boundary LCP `m`, which estimates
where the next scans start.

This is not the prefetch recorded as a negative result in `lcp.rs`. That
one sat inside the strided scan loop, which the hardware prefetcher
already covers. This one targets the random access, which it cannot.

Apple M4 Max, 12 threads, ext-mem, output verified identical to the
in-memory suffix array on both inputs:

  chr21.0123, 80 MB    phase4 merge CPU 19.20 s -> 12.42 s
                       phase4 wall       2.17 s ->  1.46 s
                       total             3.55 s ->  2.90 s   CPU 33.8 -> 27.0 s
  chr21.fa, 47.5 MB    phase4 merge CPU  7.99 s ->  6.76 s
                       total             2.56 s ->  1.99 s   CPU 23.3 -> 18.0 s

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sweeping `p` directly showed the previous default sitting on the wrong
side of a flat region. Total work is `n log n` either way, since a
smaller `p` moves levels out of phase 4's per-partition cascade and into
phase 1's merge sort, but the constants differ: phase 3 shrinks
quadratically in `p`, and phase 4's cascade does a full pass over its
partition per level.

Peak RSS is set by phase 4 holding `4 x threads` partitions of `n / p`
records at once, so it only starts growing once `p` is small enough for
that product to rival the text. Measured on chr21 forward ++ revcomp
(80 MB), 12 threads:

    p     total     peak RSS
     48   2.58 s      987 MB
     96   2.49 s      538 MB
    306   2.85 s      282 MB
    612   2.80 s      202 MB     <- 131072
   1224   3.05 s      205 MB     <- 65536 (previous default)

128Ki is the largest step that costs nothing in memory: same peak RSS,
~8% less wall. Going further trades real memory for speed, which is the
opposite of what this path is for, so it stays available through
`ExtMemOpts::subproblem_count` rather than becoming the default.

At genome scale this changes nothing. `PHASE1_MAX_PARTITIONS` already
binds for any n above ~1 GB, so GRCh38 still gets p = 8192.

Output verified identical to the in-memory suffix array on both inputs.

  chr21.0123, 80 MB    3.01 s -> 2.77 s
  chr21.fa, 47.5 MB    2.06 s -> 1.86 s

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 1 had become the largest external-memory phase (1.20 s of 2.54 s
on 80 MB of DNA) and was already parallelising at ~97%, so the way
forward was less work rather than more threads.

It was sorting every subarray from singletons, which is the case the
merge kernel handles worst: each leaf merge starts at `m = 0` and orders
two suffixes by scanning the text at two random addresses. Sorting by
the packed key first resolves the leading `k` symbols with no text
access at all (32 symbols for DNA at 2 bits each), and yields the LCP
between adjacent runs for free from `(key_a ^ key_b).leading_zeros()`.
Only suffixes agreeing through all `k` symbols reach the merge kernel,
on the short slice they occupy.

This is the bounded-memory counterpart to the prefix doubling in
`build_in_memory`. Doubling itself is not available here: it needs a
rank for every position in the text, which is exactly the memory this
path refuses to spend. A fixed-depth key needs none.

The cross-run LCP is capped by both suffixes' lengths. Padding can agree
with a real `0` symbol past the end of the shorter suffix, so the raw
`leading_zeros` count can overstate it, and a wrong LCP would silently
corrupt the order at the next merge level.

Gated on the same conditions as the other fast paths (`u8` symbols,
plain lexicographic comparator, unbounded `max_context`) and falls back
to `merge_sort` otherwise. The alphabet scan that picks the field width
runs once per build, not once per subarray.

Apple M4 Max, 12 threads, output verified identical to the in-memory
suffix array on both inputs:

  chr21.0123, 80 MB    phase1 1.201 s -> 0.316 s   total 2.54 s -> 2.11 s
  chr21.fa, 47.5 MB    phase1 0.898 s -> 0.415 s   total 1.86 s -> 1.65 s

Peak RSS 205 MB -> 233 MB: the key vector is 16 bytes per record over
one subarray per worker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`CascadeWorkspace::merge_one_level` walked its run pairs sequentially,
even though the pairs at a level are independent and write to disjoint
destination ranges. The only thing forcing the order was the running
`src_off` / `dst_off`, and both are prefix sums, so they can be computed
up front and each pair handed its own sub-slices.

This is what capped phase 4's parallel efficiency. Partition-level
parallelism (`4 x threads` at once) hides it while there are many
partitions in flight, but each partition's cascade ends in a single
2-way merge over the whole partition, and those tails serialise.

Apple M4 Max, 12 threads, output verified identical to the in-memory
suffix array on both inputs:

  chr21.0123, 80 MB    2.11 s -> 2.02 s
  chr21.fa, 47.5 MB    1.65 s -> 1.55 s

A grain-size threshold was tried, on the theory that the small early
levels would not pay for their rayon tasks. It measured worse on both
inputs (2.22 s and 1.63 s), so the pairs are merged in parallel at every
level.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Emitting is Theta(n) and single-threaded by construction: the caller's
closure is `FnMut` and its ordering is the point. But it also did not
overlap anything, so once per chunk every worker sat idle while the main
thread drained the merged results.

A scoped producer now merges chunk c+1, itself rayon-parallel, while the
main thread emits chunk c. The channel bound of one keeps at most two
chunks resident, so the transient cost is one extra chunk of merged
positions rather than the unbounded queue an unsynchronised producer
would build.

This is not the existing `ordered_phase4_emit` path, which coordinates
at *partition* granularity through an mpsc channel and a `BTreeMap` and
measured slower than plain collect-then-emit. Here the producer hands
over whole chunks that are already in order, so the consumer only
drains them and no reordering structure is needed. That path is left
untouched behind its opt-in flag.

Apple M4 Max, 12 threads, output verified identical to the in-memory
suffix array on both inputs:

  chr21.0123, 80 MB    2.02 s -> 1.82 s   peak RSS 233 -> 240 MB
  chr21.fa, 47.5 MB    1.55 s -> 1.45 s   peak RSS 182 -> 194 MB

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A partition arrives as `p` sorted sub-subarrays, and the cascade merged
them pairwise in `log2(p)` levels, each a full LCP-enhanced pass. That
was the largest single cost left in the ext-mem build: 15.4 CPU-seconds
of a 15.1-second run on 80 MB of DNA.

When a packed key applies, discarding that sortedness and re-sorting the
partition outright is much cheaper. One key sort resolves the leading
`k` symbols with no text access, and only suffixes agreeing through all
of them reach the merge kernel, so `log2(p)` passes collapse into one.

Gated on the run table being empty, which is the point worth recording.
A long periodic run is exactly a stretch where a fixed-depth key
resolves nothing, since every suffix inside it shares the whole key, so
the re-sort would hand the merge kernel one enormous tied group and
throw away ordering phase 1 had already established. Measured
unconditionally it was a large regression on the `N`-heavy input:

                       cascade    key re-sort
  chr21.0123 (no runs)   1.95 s      1.65 s
  chr21.fa (6.6 Mb N)    1.45 s      2.23 s

So the run table, which already exists to make scans cheap, doubles as
the predicate for whether a fixed-depth key can be expected to pay off.

Apple M4 Max, 12 threads, output verified identical to the in-memory
suffix array on both inputs:

  chr21.0123, 80 MB    1.85 s -> 1.65 s   phase4 merge CPU 15.4 -> 7.8 s
  chr21.fa, 47.5 MB    1.45 s -> 1.47 s   (unchanged; takes the cascade)

Peak RSS on the run-free input rises 240 MB -> 285 MB: the key vector
and the sort buffers are live for each of the `4 x threads` partitions
in flight. Still an order of magnitude under the in-memory path's
2.2 GB, which is the comparison that matters for choosing this path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The field width was chosen from the largest byte value in the text
rather than from how many distinct symbols it uses, and for the most
natural input format those are very different numbers. A plain ACGT
sequence has four symbols, but its largest byte is `'T'` (84), so the
packer used 8-bit fields and fit 8 symbols per key instead of the 32
that four symbols allow.

Rank the bytes that actually occur onto a dense code range first. The
map is monotone by construction, since codes are assigned in ascending
byte order, so a packed key stays order-preserving and the zero-padding
argument carries over unchanged: code 0 is still the minimum.

Apple M4 Max, 12 threads, chr21 forward ++ revcomp written as ASCII
ACGT (80 MB, four symbols, largest byte 84):

                 before     after
  seed sort      0.480 s    0.281 s
  doubling       0.867 s    0.488 s
  total          1.404 s    0.827 s     -41%

The two inputs benchmarked so far both happened to hide this. One is
pre-encoded to codes 0..3 and is already dense; the other is dominated
by its `N` runs, where key depth is irrelevant because every suffix
inside a run shares the whole key. A FASTA-derived ACGT text is the
common case that neither covered.

Cross-check: the suffix array of the ASCII text is identical to the
suffix array of the 0..3-coded text, as it must be, the two encodings
being order-isomorphic. Both the in-memory and ext-mem paths agree.

Tests: the field width follows alphabet size rather than byte value
across four alphabets, and the remap is asserted monotone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Building a key was a chain of `k` dependent shift-or-lookup steps, 32 of
them for 2-bit DNA fields, and that chain set the cost of the seed sort
in every path that uses a packed key.

Two changes remove it. The alphabet map is applied to the whole text
once, up front, so the inner loop no longer carries a dependent table
load. And the fields are gathered by a binary-tree SWAR shuffle: each
step folds neighbouring fields together and halves the stride, so eight
symbols cost three shift-or-mask pairs rather than eight dependent
steps. The load is big-endian, which puts the text's first byte in the
result's most significant field, the order the key already needed.

The ranked copy is only materialised when the identity map does not
already rank the text. A `0..3` DNA encoding is already dense and pays
nothing; an ASCII text pays one byte per symbol, which is what buys the
narrower fields in the first place.

Note the group concatenation assigns the first group rather than
shifting it in. With 8-bit fields there is exactly one group and the
shift distance would be 64, which is not legal for `u64`: release builds
mask it to zero and happen to produce the right answer, debug builds
panic. This was caught by running the tests in debug, which is why the
CI workflow does.

Apple M4 Max, 12 threads, 80 MB chr21 forward ++ revcomp, output
verified identical on all three encodings:

                       seed sort        total
  ASCII ACGT       0.281 s -> 0.177 s   0.827 s -> 0.702 s
  coded 0..3       0.278 s -> 0.168 s   0.802 s -> 0.719 s

Peak RSS is 2.21 GB for the coded input, unchanged, and 2.29 GB for the
ASCII one, the difference being the ranked copy.

Tests: a new case checks the SWAR gather against the obvious shift-or
loop for every field width, every alignment and every tail length, and
the monotonicity of the alphabet map is now asserted through `key_at`
rather than through the internal table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Instrumenting the rounds (`CAPS_SA_PROFILE=1` now prints one line each)
showed where the time actually went, and it was not where I assumed.
On 80 MB of DNA the first round has 3.5 million tied groups averaging
**four elements**:

  round depth=32:  14193102 tied in 3526079 groups (avg 4.0)
  round depth=64:   9929726 tied in 2924078 groups (avg 3.4)
  round depth=128:  7480152 tied in 2543160 groups (avg 2.9)

At that size neither the sort nor the rank probes dominate. The
bookkeeping around them does: one heap allocation per group for the key
vector, and a sequential `split_at_mut` chain over all 3.5 million
groups to hand each one its sub-slices.

Both go. `Scatter` already encodes "disjoint ranges, one owner each",
which is exactly the property the groups have, so a group takes its own
sub-slices directly and the sequential prepass disappears. Groups up to
32 elements build their key vector in a stack buffer, so nearly every
group avoids the allocator. `flat_map_iter` replaces collecting a
`Vec<Vec<_>>` of mostly-empty vectors.

Apple M4 Max, 12 threads, output verified identical on all three
encodings and both paths:

                       doubling           total
  chr21.0123      0.502 s -> 0.234 s   0.733 s -> 0.566 s
  ASCII ACGT                            0.764 s -> 0.611 s

The first round alone goes from 0.124 s to 0.053 s. The `N`-heavy FASTA
is unchanged: its groups are large, so it was never paying the
per-group overhead.

The per-round log stays in, behind `CAPS_SA_PROFILE`. It is what located
this, and the group-size distribution is the thing worth looking at
first for any further work here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Addresses the blockers @rob-p raised in COMBINE-lab#7.

**Signed symbols.** `seed_params` accepted any `Symbol` one byte wide and
reinterpreted it as `u8`, but `Symbol` is implemented for `i8` too and a
packed key orders its fields as unsigned: `-1` has byte `0xFF` and sorts
above `1`. The in-memory doubling guard already required exact `u8`; the
packed-key paths now match it. Rob's reproducer
`[-1, 0, -1, 1, -2, 0, 1, -1, 0, -2, 1, 0]` is in the tests, alongside
external-memory and sample-sort cases at several partition counts and an
`i8::MIN`/`i8::MAX` case. Reverting the guard fails exactly the two
end-to-end tests and leaves the in-memory one passing, matching the
report that only the packed-key paths were affected.

`runs.rs` deliberately keeps its one-byte-wide check: a run table is
built from byte *equality*, which coincides with value equality for
`i8`, and ordering is recovered by the caller through `S: Ord`.

**Run skipping taxed the common path.** Consulting the table cost up to
three binary searches before any comparison, and nearly every LCP call
in real sequence mismatches within a few symbols and never reaches a
run. Probe with the ordinary bounded scan first, and consult the table
only once a match has survived 256 symbols. The probe is not wasted:
whatever it matches counts towards the answer.

Measured on a fixture matching the one in COMBINE-lab#7 (parsed chr21 forward ++
revcomp, `N` kept as a symbol, ACGT-start filter, 80,177,238 retained
positions, 12 threads), with byte-identical output in all three cases:

  run table disabled          1.97 s
  table consulted eagerly     2.40 s   (+22%)
  probe first                 2.02 s   (+2.5%, within noise)

And it keeps the benefit where runs matter: the raw FASTA takes 43.8 s
with the table disabled against ~1.5 s with it.

**Packer built before its guards.** `Packer::new` may materialise a
ranked copy of the whole text, and on a segmented or context-bounded
build that copy can never be used, since every packed-key path declines
those. It is now constructed only after the `plain_lex_len` and
`max_context` guards pass.

**Docs.** The claim that periods up to 64 also cover satellite arrays
was too broad: the canonical alpha-satellite monomer is 171 bases and
measures at parity with no detection. Both the module docs and the
README now state the measured coverage and its limit.

Also adds `--filter-acgt` to the bench CLI, so the STAR-shaped
filtered build this all turns on can be benchmarked directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The packed-key paths declined every segmented build, which is the
comparator a splice-junction index actually uses, so none of this work
reached it. The obstacle was that zero-padding a short suffix encodes
shorter-is-smaller, while STAR's `boundary_order` is
`lim_b.cmp(&lim_a).then(p_a.cmp(&p_b))` — longer-is-smaller.

That is expressible in the key once the provider says which side it
wants. `LimitProvider::boundary_rank` returns `ShorterFirst` or
`LongerFirst`, defaulting to `None` so existing implementations keep
today's behaviour. A segmented key then packs `min(k, lim_at(p))`
symbols — never reading into the next segment — and pads with a reserved
sentinel below every real code under `ShorterFirst`, above every real
code under `LongerFirst`.

The position tie-break does not need to be in the key. Keys that tie
defer to `boundary_order` itself, so the key only has to avoid
contradicting the convention, never to reproduce it.

The sentinel needs one code above the alphabet, so a segmented build
sizes its field to hold `alphabet` rather than `alphabet - 1`, and
`Packer::new` takes that as a parameter: a plain build must not pay for
it, since widening the field halves the symbols a key carries. Where no
spare code fits (256 distinct symbols in 8-bit fields) the path declines.

Tests compare against the provider's own comparator over random
segmented texts on the ruSTAR alphabet, under both conventions, with and
without the ACGT-start filter, at three partition counts.

Two notes on how those tests are written, both learned the hard way:

- They assert the *property* (permutation, and no adjacent pair out of
  order) rather than equality with a canonical answer. `SegmentedText`'s
  default `boundary_order` returns `Equal` for suffixes that end together
  with equal content, so their relative order is genuinely free and a
  stable-sort oracle is not a valid reference.
- Sensitivity was checked by breaking `key_at_bounded` deliberately. A
  *constant* wrong key still passes, because it collapses everything into
  one tied group that the fallback sorts correctly — so that check proves
  nothing. An order-*inverting* key does fail the test, which is what
  establishes the fast path is both taken and verified.

Not benchmarked end to end on an annotation-shaped fixture: that needs
rustar-aligner's junction pipeline, which I cannot run here. @rob-p, if
the GENCODE harness from COMBINE-lab#7 is available this is the change that should
finally move phase 1 on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BenjaminDEMAILLE and others added 11 commits August 12, 2026 08:14
… branch

Measures the case @rob-p correctly identified as the one none of this
work reached: segmented text, STAR's spacer-as-largest `boundary_order`,
ACGT-start filter, external-memory construction.

`bench/gsj_fixture.py` builds the fixture from a FASTA and a GTF the way
a STAR index is laid out — genome sequence, then one 2*overhang flank
per distinct junction, then the reverse complement — and `gsj_bench`
runs it through `build_ext_mem_for_positions_with` under a
`LongerFirst` provider.

Apple M4 Max, 12 threads, chr21 with its GENCODE annotation: 95,409,966
symbols, 9,952 segments, 82,167,238 retained ACGT-start positions.

  segmented keys declined (previous behaviour)   3.84 s
  segmented keys enabled                         2.57 s   -33%

Output order verified directly against the provider's comparator.

This is a *shape* reproduction, not a size one. A genome-wide annotation
yields far more junctions than a single chromosome's: this fixture has
4,975 junctions and 9,952 segments against the 698,597 and 1,397,196 of
the GENCODE v50 primary-assembly fixture in COMBINE-lab#7. The segmented-key path
should matter more, not less, as the segment count grows, but that is a
prediction and not something these numbers establish.

Also runs CI on every branch rather than only `main`. A pull request
opened from a fork by a first-time contributor does not run workflows
until a maintainer approves them, which is why the API showed no check
runs at the pinned tips. Building on push makes the fork produce
evidence that can be linked from the PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rob-p asked, correctly, what happens to *memory* once a realistically
sized junction library is included, and suspected an interaction with
prefix doubling and radix sorting scaling in the number of distinct
elements rather than in `n`. The answer is that there is one, and it
cuts against making this a default.

Fixture: chr21 plus a 698,597-junction library, 372,858,766 symbols,
1,397,196 segments, 320,856,244 retained ACGT-start positions. That is
the same junction and segment count as the GENCODE v50 primary-assembly
fixture in COMBINE-lab#7, and within 0.4% of its symbol count.

Apple M4 Max, 12 threads:

                         segmented keys off    segmented keys on
  phase 1                      9.544 s              3.313 s
  phase 4                     11.616 s             11.421 s
  total                       22.718 s             16.256 s   -28%
  peak RSS                     3.45 GB              5.33 GB   +54%

So the segmented key does reach the splice-junction case and does cut
phase 1 by 65%. But it buys that with half again as much resident
memory, from the ranked text copy and the per-subarray key vectors, both
proportional to the input. For a constructor whose argument against
libsais and against STAR is memory, that is not a trade to make
unconditionally.

This is the same shape of objection raised against #4's dense-subset
heuristic, and it wants the same answer: a memory budget the caller sets,
not a silent default. Not implemented here — recording the measurement
first so the decision is made against a number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…on target

The RSS number I reported was wrong, and it was wrong in the direction
that changes the recommendation, so correcting it first.

I compared one run against one run. Replicating both sides three times
shows peak memory is unchanged:

                         segmented keys off    segmented keys on
  total                    23.5-24.0 s          15.8-16.2 s   -33%
  peak RSS                  3.22-3.94 GB         3.21-3.23 GB

The earlier "3.45 GB -> 5.33 GB, +54%" was one outlier measured against
another. There is no memory cost to weigh against the speedup, so the
argument I made for keeping segmented keys opt-in on memory grounds does
not hold.

Also sweeps the partition count on the same segmented fixture, which is
the re-evaluation the review asked for: the 128 Ki target was tuned on a
plain workload and needed checking on a segmented one.

  p =  2845 (128 Ki target, current default)   15.26 s   3.22 GB
  p =  5690 (64 Ki target, previous default)   19.09 s   3.71 GB
  p = 11380                                    26.97 s   5.19 GB

It holds up: the current default is both faster and smaller than the one
it replaced, and the trend continues in the same direction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The review objected that at `subset >= text / 8` this silently
constructed the complete suffix array plus an `n`-element membership
structure and full rank arrays, on the caller's behalf and without
saying so. The objection is right: those arrays are sized by the
*text*, not by the subset, so the rule made a large allocation
decision that only the caller can actually make.

Replaced by `Opts::subset_full_sa_budget`, the peak extra bytes the
subset path may spend. `None` is the default and never takes the trade;
`Some(budget)` allows it when the estimated footprint fits, which the
docs spell out as `n * (3 * size_of::<I>() + 9)`: three index-wide
arrays for the suffix array, the ranks and the round scratch, eight
bytes of key per position, and a one-byte membership flag.

Tested at three text sizes with the budget set exactly to the estimate,
one byte below it, and left at the default: all three produce the same
answer, so the gate changes resource use and never correctness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
First step towards the subsumed-LCP idea @rob-p described, and an
honest one: the mechanism is right and the measured effect is small.

The merge already exploits the LCP triangle inequality
(`lcp(a,c) = min(lcp(a,b), lcp(b,c))` when the two differ) for
*adjacent* pairs — that is exactly its three-case rule, which resolves
two of three cases with no text access. What it did not exploit is a
known LCP arriving from outside the merge: a tied group coming out of a
packed-key sort agrees on at least `k` symbols by construction, and the
fallback merge rescanned them from zero.

`merge_from` takes that as a `base`. It costs one substitution in the
existing invariant: the three-case rule is stated against the
last-output element, initialised to the empty string, and is now
initialised to the length-`base` prefix every element shares.
`lcp(B, s) = base` for all `s`, `B` precedes every element, and
`lcp_x[0] = base` is what the first iteration reads. The loop body is
untouched, because every case in it argues about relative offsets and
none mentions zero. `merge` stays as a `base = 0` shim, so the cascade
and phase-1 call sites are unchanged.

Measured, 12 threads, output byte-identical:

  chr21 coded, ext-mem            1.69 s -> 1.61 s
  ruSTAR-shaped filtered          2.02 s -> 2.05 s  (no change)

Small, and worth saying why: after a 32-symbol key sort the tied groups
are short, and the merge's own three-case rule already starts subsequent
scans at the right offset. Only the first comparison in each group saves
the rescan. The gain would grow with the key depth relative to typical
LCPs.

The value here is the mechanism rather than the number. A general
subsumed-LCP cache — inferring `lcp(a,c)` from cached `lcp(a,b)` and
`lcp(b,c)` for non-adjacent pairs — needs exactly this: a merge that can
be told a lower bound it did not derive itself. Run skipping is the
other special case already in the tree, deriving its bound from periodic
structure instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes the structural gap behind the subsumed-LCP discussion, and comes
with a finding that changes what is left to do.

**The finding.** In a 2-way merge the LCP triangle inequality is already
saturated. After computing `lcp(a_i, b_j) = L` and advancing A, the next
pair is `(a_{i+1}, b_j)`, and `lcp(a_i, a_{i+1})` is already in the
source LCP array; combining them is exactly what the three-case rule
does. There is no residual inference to harvest inside a merge. The
places a cached LCP can still pay are *outside* one: a bound arriving
from elsewhere, which is what `merge_from`'s `base` and run skipping
both supply.

**The gap.** Prefix doubling answers comparisons from ranks and never
computes an LCP, which is the structural reason the external-memory path
could not be routed through it — that path needs the array the merge
kernel yields as a byproduct. And the merge's array was internal, so no
caller could obtain one at all.

Kasai's algorithm closes both: one linear pass from the suffix array.
The bound is the point, and it is exactly what the scanning merge lacks
— `h` falls by at most one per position and rises only while matching,
so symbol comparisons total at most `2n` however repetitive the text is.

Tested against a naive per-pair scan on the inputs that make the naive
version expensive: long runs, period-3 and period-61 text, three
alphabet widths, plus the empty and single-symbol cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wall times on the external-memory path drift 30% between sessions on the
same machine and commit. Diagnosed after a phantom regression that
bisected to a commit predating it: 32 GB of benchmark *output* had
accumulated in the working directory, and each build streams several GB
of bucket data through the page cache. Deleting the outputs restored the
original numbers.

  with 32 GB resident   20.5-21.1 s
  after deleting them   15.8-17.5 s, 154-166 s CPU

The temp files are not implicated and do not leak: `$TMPDIR` measured
454 MB and 801 entries both before and after a run, so the pooled
anonymous buckets are released as intended.

Writes down the four rules that follow: send output to /dev/null unless
checking correctness, interleave A against B rather than batching each,
report CPU alongside wall, and treat a single reading as a hypothesis.

That last one is not abstract. Two figures published in this file came
from unreplicated single runs and both were wrong: a claimed +54% RSS
that replication showed to be flat, and an 11.4 s phase-4 time that was
15-16 s on repetition. The interleaved comparison of the segmented-key
change gave the correct 0.67 ratio even on a session where neither
side's absolute number was right.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 3 asks for `p` upper bounds per subarray, once per pivot, and the
pivots are sorted, so the answers are non-decreasing. Each search
ignored that and bisected the whole array: at `p = 612` over 131 K-record
subarrays that is ~17 suffix comparisons per pivot no matter how close
the answer is to the previous one. Galloping forward from the previous
split costs one comparison when the bracket is empty, which it usually
is once `p` is large enough that consecutive pivots land within a few
records of each other.

Measured, 12 threads, output verified identical to the in-memory suffix
array: phase 3 0.394 s -> 0.362 s on chr21.

That is 8%, not the 8x the comparison count alone would predict, and the
gap is the useful part: phase 3 is bound by data movement, not by
searching. It writes every one of the n records into its partition
bucket — 640 MB at this input size — and 0.36 s for that is roughly
3.5 GB/s, which is the real floor. An earlier attempt to accelerate the
same searches with packed keys was reverted for measuring nothing, and
this explains why: both were optimising the part that was not the cost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cket count

Adds per-pass timing to the seed sort behind `CAPS_SA_PROFILE`, which
locates its cost precisely on 80 MB of DNA:

  histogram   0.058 s
  prefix sum  0.001 s
  scatter     0.080 s
  bucket sort 0.127 s   <- 48%

The per-bucket comparison sort dominates, so the obvious lever is the
bucket count: more buckets, smaller and more cache-resident sorts. The
11-bit choice had been reasoned about (write-combining state stays
inside a core's private cache) but never measured.

Measured now, and it makes no difference. A first sweep suggested 13
bits was 18% better, but that was single runs. Interleaving three pairs
per the methodology this repo now documents:

  11 bits   0.582  0.654  0.613   mean 0.616 s
  13 bits   0.640  0.623  0.595   mean 0.619 s

So the bucket sort is 48% of the seed sort and is not reachable by
changing how many buckets there are. Left at 11 bits, and recorded here
so the sweep is not repeated.

This is the fifth time in this branch that a single reading pointed the
wrong way. The interleaved comparison has been right every time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Per-phase thread scaling, 1 to 12 threads on 80 MB of DNA:

  threads      phase 1     phase 3     phase 4     total
        1       2.088 s     0.756 s     6.413 s    9.288 s
       12       0.289 s     0.361 s     0.954 s    1.640 s
  speedup         7.2x        2.1x        6.7x       5.7x

Phases 1 and 4 scale acceptably. Phase 3 saturates by six threads and
gains 14% from six to twelve. It is 22% of the wall here and would be a
larger share on a wider machine, which makes it the thing to fix rather
than the seed sort or the cascade.

Two hypotheses tested, both wrong, both recorded so they are not
retried. Physical file contention: raising `CAPS_SA_N_PHYS` from 12
through 192 does not help and mildly hurts (0.356 s to 0.420 s). Search
cost: galloping cut pivot comparisons roughly eightfold for 8%, and an
earlier packed-key attempt at the same searches measured nothing.

What remains is the write path. Phase 3 reads every record back and
writes every record out again, ~1.3 GB here, through the page cache,
which does not parallelise with threads. 3.5 GB/s is far below this
machine's DRAM, which points at the kernel path rather than memory.

So the fix is not to speed phase 3 up but to not run it: fuse it into
phase 1, which needs pivots before phase 1 rather than after. A cheap
pre-sampling pass over the raw positions supplies them, and correctness
does not depend on their quality — any splitters give a correct sample
sort, only the balance changes. That removes one full write-and-read
round trip of every record. Not attempted here: it restructures the
ext-mem driver and the filtered and positions variants with it, and
that wants its own change with its own verification.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 3 was the worst-scaling part of the external-memory build: 2.1x
on twelve threads against 7.2x and 6.7x for phases 1 and 4, saturating
by six threads and gaining 14% from six to twelve. Two explanations were
tested and both were wrong — raising the physical file count from 12 to
192 does not help, and galloping cut its pivot comparisons roughly
eightfold for 8%. What was left is the write path: phase 3 read every
record back and wrote every record out again, ~1.3 GB at this input
size, through a page cache that does not parallelise with threads.

So do not make phase 3 faster; remove it.

It existed only because pivots were sampled from the already-sorted
subarrays, which forced the order: sort everything and spill it, choose
pivots, read all of it back to distribute. But **pivots do not have to
come from sorted data**. Any splitters produce a correct sample sort;
only the balance of the partitions changes. A cheap pre-pass over the
raw positions supplies them, and phase 1 can then sort and distribute in
one go, never materialising the subarrays at all.

Sampling is by strided blocks of 64 rather than strided singletons: a
`PositionSource` fills a contiguous run cheaply but pays per call, and
the `Filtered` variant especially so. Coverage of the position space is
the same and splitter quality is not sensitive to the difference.

Two spilled copies of every record disappear — one write and one read —
and with them an entire bucket file pool, since there are no subarray
buckets left to hold.

Apple M4 Max, chr21 forward ++ revcomp (80 MB), interleaved A/B, three
pairs, fused winning every pair:

  before   1.975  1.733  1.790 s   mean 1.833
  after    1.671  1.527  1.609 s   mean 1.602   -12.6%

Scaling improves as well, which is the point rather than the wall time:

               1 thread   12 threads   speedup
  before        9.288 s     1.640 s      5.7x
  after         9.563 s     1.352 s      7.1x

The phase 1 + phase 3 pair went from a combined ~3.9x to 6.3x for the
fused phase alone.

Verified: byte-identical output against the in-memory suffix array on
N-free DNA and on raw FASTA, against the previous ext-mem path on the
ACGT-filtered ruSTAR-shaped fixture, and `--verify` clean on the
segmented 698,597-junction splice-junction fixture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rob-p

rob-p commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Thanks @BenjaminDEMAILLE — the core restructuring is sound, and it produced a clear benefit on the complete ruSTAR input. I integrated it onto main as ac0a911 (merge ba29c3b).

I did not merge this PR head verbatim because it contains 40 prerequisite commits from #8. Doing so would have merged unreviewed work and subsumed #10, #11, and #13 before their requested independent reviews. Instead I ported only the phase-0 presampling and fused external-memory sort/distribution design onto the pre-review main commit and validated that clean integration.

Full ruSTAR fixture

GENCODE human v50, GRCh38 primary-assembly FASTA plus comprehensive primary-assembly GTF, using ruSTAR's actual FASTA/GTF parsing, genome bin padding, junction preparation/deduplication, sjdbOverhang=100, Gsj append, forward/reverse-complement layout, ACGT-start filter, StarSegmentedText boundary order, external-memory API, and u64 indices:

  • 6,557,611,930 text symbols
  • 6,176,694,310 retained suffixes
  • 698,597 junctions
  • 1,397,582 segments
  • 8,192 partitions
  • 32 physical cores on AMD EPYC 9555

One warm-up, then three interleaved measured pairs:

pair main clean fused change improvement
1 396.944 s 365.675 s 31.269 s
2 (reversed order) 397.891 s 366.767 s 31.124 s
3 397.050 s 365.268 s 31.782 s
mean ± sample SD 397.295 ± 0.519 s 365.903 ± 0.775 s 31.392 s / 7.90%

The preprocessing portion fell from a 107.426 s mean to 102.787 s. The deterministic presample also improved final partition balance: phase 4 fell from 280.993 to 258.097 s. Temporary filesystem writes fell from about 193 GB to 96.5 GB, system CPU time from 102.01 to 55.12 s, and mean peak RSS from about 10.47 to 10.15 GiB.

Every full run emitted 6,176,694,310 positions with hash e81c8f9881e322148741a23c92ae2000. On the focused 374,255,962-symbol annotation fixture, the clean integration also matched all 359,616,038 stored main positions byte-for-byte. Debug and release tests, doc tests, formatting, Clippy with warnings denied, and git diff --check passed.

For completeness, the isolated change regressed the smaller focused fixture (14.43 s historical main mean versus 22.72 s), but the requested complete human genome+annotation workload showed a large, highly repeatable benefit. The 8,192-partition u64 regime is therefore important to the disposition.

Closing this contributor PR as superseded by the clean main integration rather than merged verbatim.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants