Skip to content

genomeGenerate: libsais suffix-array builder, selected by --limitGenomeGenerateRAM - #109

Open
BenjaminDEMAILLE wants to merge 9 commits into
scverse:mainfrom
BenjaminDEMAILLE:bd/index-libsais
Open

genomeGenerate: libsais suffix-array builder, selected by --limitGenomeGenerateRAM#109
BenjaminDEMAILLE wants to merge 9 commits into
scverse:mainfrom
BenjaminDEMAILLE:bd/index-libsais

Conversation

@BenjaminDEMAILLE

@BenjaminDEMAILLE BenjaminDEMAILLE commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

genomeGenerate: build the suffix array with libsais when it fits, selected by --limitGenomeGenerateRAM.

Corrections to the previous revision of this description

Three claims in the earlier body did not hold against the diff. They are fixed in the branch rather than in the prose.

--limitGenomeGenerateRAM did not select anything. The body said selection was driven by the flag and that this turned it "from warned-and-ignored into a real flag". It did not: build_libsais was called from nothing but its own tests, generate_streaming went to build_streaming unconditionally, and lib.rs still logged "accepted but not enforced". The builder was dead code and the flag was inert. It is wired now (0e34aab), and the warning is gone.

The streaming verification level did not exist. The body listed a check described as "build_libsais driven exactly as GenomeIndex::generate_streaming does, matching the caps-sa streaming byte stream (the path that ships, not a unit-test-only one)". The streaming test in the file compares caps-sa against caps-sa and says nothing about libsais. That test exists now (a9aaaa9), and it is only meaningful because the dispatch above makes it the shipping path.

The RAM estimate was half the truth. libsais_peak_bytes took n_genome and multiplied by 17, but the text libsais sorts is both strands. Any caller passing n_genome would have understated the requirement by a factor of two and handed a mammalian genome to the in-memory builder at the default limit. The argument is now n_entries, the call site doubles, and a test picks a limit that separates the two readings instead of one where they agree.

I also said the multi-genome benchmark was blocked on hardware I did not have. That was wrong; the machine has 128 GB and 16 cores. The full three-tier benchmark CONTRIBUTING asks for is below.

What changed

sa_build::build_libsais builds the suffix array with libsais instead of caps-sa, choosing the alphabet width automatically (u8/u16 for few segments, i32 large-alphabet for many-segment genomes).

GenomeIndex::generate_streaming picks between the two from --limitGenomeGenerateRAM: libsais when 17 × 2N fits the limit, caps-sa's external-memory path otherwise. --genomeSAsparseD above 1 always takes caps-sa, the only builder with a sparse arm. Both the chosen path and the two numbers are logged, so the decision is visible in the run rather than inferred:

Building suffix array with libsais (estimated peak 0.5 GB, --limitGenomeGenerateRAM 33.3 GB)...
Building suffix array with caps-sa external memory (libsais would need an estimated 106.7 GB, --limitGenomeGenerateRAM 33.3 GB)...

The new dependency

libsais 0.2.0, MIT OR Apache-2.0, wrapping IlyaGrebnov/libsais through feldroop/libsais-rs. The C is vendored in libsais-sys and compiled by cc: no system library to install, no bindgen, no libclang, no pkg-config. Declared default-features = false, which turns off the crate's default openmp feature, so the C builds single-threaded and needs nothing outside the vendored sources. Adds bytemuck and either, plus cc as a build dependency.

CONTRIBUTING.md asks for a non-Rust dependency to be raised in an issue before the PR. This predates that rule; the issue is #162, with the trade-offs and the alternatives laid out. That decision gates this PR, and if the answer is no it closes.

Why the output cannot change

A suffix array is unique for a given text. build_libsais sorts the same per-segment sentinel-transformed text the caps-sa sentinel arm uses, so its packed output is byte-for-byte identical.

Checked at four levels, the last two of which exercise the path that ships:

Level Test
unit, three fixtures build_libsais_matches_caps_sa_{single_chr,multi_chr_with_n,repetitive}
i32 large-alphabet arm forced build_libsais_i32_matches_caps_sa_{single_chr,multi_chr_with_n}
streamed bytes, as generate_streaming writes them libsais_streamed_matches_the_caps_sa_stream_byte_for_byte
real genomes the benchmark below, SA and SAindex compared with cmp

Benchmark

Three genomes, as CONTRIBUTING asks: yeast, chromosome-scale, mammalian-scale. Same machine (M-series, 16 cores, 128 GB), --runThreadN 8, warm cache, release build, peak RSS from /usr/bin/time -l. Every row had its SA and SAindex compared with cmp between the two builders.

genome size libsais caps-sa ratio
yeast R64-1-1 12 Mb 1.0 s / 0.39 GB 1.7 s / 0.22 GB 1.6x
human chr1 249 Mb 27.4 s / 6.5 GB 47.2 s / 5.6 GB 1.7x
GRCh38 primary 3.1 Gb 641.6 s / 86.8 GB 1040.4 s / 14.4 GB 1.6x

The ratio holds at 1.6-1.7x across three orders of magnitude, so this is not a small-genome artefact. The reason it holds even though default-features = false leaves libsais single-threaded is visible in the CPU time: on GRCh38, libsais spends 726 s of user time for 641 s of wall, while caps-sa spends 6135 s of user time for 1040 s across 8 threads. caps-sa buys its small footprint with roughly six times the total CPU work for the same array.

The cost is memory: 86.8 GB against 14.4 GB, a factor of six. The 17 x 2N estimate came out 19% above the measured peak, which is the right direction for a guard.

The default declines the mammalian row. At --limitGenomeGenerateRAM 31000000000 a GRCh38 build takes the caps-sa path; the libsais number above needed an explicit --limitGenomeGenerateRAM 0. So the speedup is available to anyone up to roughly chromosome scale without touching a flag, and above that only to a user who states they have the RAM.

Reproduce from the branch, one genome per invocation:

test/bench_sa_builders.sh "$FASTA" out_bench 14 8

It drives both arms through --limitGenomeGenerateRAM, so it exercises the dispatch rather than bypassing it, reports wall clock and peak RSS for each, and compares SA and SAindex with cmp.

A caveat on the table above, which the script now guards against. These runs were taken before the script existed, on a shared workstation, and I did not record the load average at the time. That machine runs unrelated multi-core jobs, and one landing on a single arm of a minutes-long build would move that arm without leaving any trace in the numbers. The 1.6-1.7x ratio is consistent across three genomes spanning three orders of magnitude, which is not what intermittent contention usually produces, and the byte-identity checks are unaffected either way. But the timings should be re-taken on an idle machine before anyone leans on the exact figures, and the script refuses to run above a load average of 2 so the next set cannot have this problem.

Gate

568 lib + 21 integration tests, cargo clippy --all-targets -- -D warnings, cargo fmt --check, MSRV 1.89. CI green on all five platforms including windows-x86_64. On-disk index format unchanged.

This PR previously also carried --genomeType SuperTranscriptome and --genomeTransformOutput SAM; those are now #161 and #160, per the one-theme rule in CONTRIBUTING.md.

@BenjaminDEMAILLE
BenjaminDEMAILLE marked this pull request as ready for review July 23, 2026 18:38
@BenjaminDEMAILLE BenjaminDEMAILLE changed the title feat(index): byte-identical suffix-array construction via libsais feat(index): suffix-array construction via libsais (byte-identical to caps-sa) Jul 23, 2026
@BenjaminDEMAILLE BenjaminDEMAILLE changed the title feat(index): suffix-array construction via libsais (byte-identical to caps-sa) genomeGenerate: libsais suffix-array builder, selected by --limitGenomeGenerateRAM Jul 28, 2026
@BenjaminDEMAILLE BenjaminDEMAILLE changed the title genomeGenerate: libsais suffix-array builder, selected by --limitGenomeGenerateRAM genomeGenerate: libsais builder selected by --limitGenomeGenerateRAM, and --genomeTransformOutput SAM Jul 29, 2026
@BenjaminDEMAILLE BenjaminDEMAILLE changed the title genomeGenerate: libsais builder selected by --limitGenomeGenerateRAM, and --genomeTransformOutput SAM genomeGenerate: libsais builder, --genomeType SuperTranscriptome, --genomeTransformOutput SAM Jul 29, 2026
BenjaminDEMAILLE and others added 3 commits July 29, 2026 11:42
Add `sa_build::build_libsais`, an alternative suffix-array constructor using
libsais (fast SA-IS) instead of caps-sa. It sorts the same per-segment
sentinel-transformed text the caps-sa sentinel arm uses; because a suffix array
is unique for a given text, the packed output is byte-for-byte identical to
`build()` (and hence to STAR 2.7.11b). Validated by the
`build_libsais_matches_caps_sa_*` tests over single-chr, multi-chr-with-N, and
repetitive fixtures.

- libsais is depended on with `default-features = false` (no OpenMP), so
  libsais-sys builds the C core via `cc` without an OpenMP toolchain (keeps the
  5-platform CI portable).
- Covers genomes whose per-segment sentinel alphabet fits u8/u16; larger segment
  counts fall back to `build()` until the libsais large-alphabet (i32) path is
  wired. Not yet swapped in as the default builder.

Roadmap PR-15 (index-construction speedup). Next: large-alphabet path + byte-
identical validation on the yeast and human genomes, then switch `build()` over
and benchmark. See docs-old/dev/porting-from-star-rs.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend build_libsais with the i32 large-alphabet arm so libsais covers genomes
whose per-segment sentinel alphabet exceeds u16 (many chromosomes + sjdb),
removing the previous caps-sa fallback for alphabet width. The width is
auto-selected (u8/u16/i32); an internal forceable width lets tests exercise the
i32 arm on small fixtures without a >64K-segment genome. Byte-identical to
build() (build_libsais_i32_matches_caps_sa_* tests).

libsais large-alphabet uses an i32 SA buffer, which bounds this arm to texts
shorter than 2^31; huge sequences (e.g. human) still use caps-sa's external-
memory path. Selecting libsais vs caps-sa by available memory, and switching
build()/build_streaming over, remain roadmap follow-ups.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds `libsais_peak_bytes` and `libsais_fits_in_ram`: the resident-memory
estimate for the in-memory SA-IS builder (~17 bytes per base — the suffix array
and libsais's working array at 8 bytes an entry, plus the text) and the gate
that compares it against --limitGenomeGenerateRAM.

This is the mechanism that turns --limitGenomeGenerateRAM into a real flag
rather than one that is accepted and warned about. Both builders produce
byte-identical output, so the choice is purely about resources: libsais is
roughly 3x faster but must hold everything resident, caps-sa's external-memory
path is slower and bounded.

Deliberately not wired into genomeGenerate yet: build_streaming has to learn to
emit from an in-memory SA before the choice can be acted on, and shipping a log
line that names a builder the code does not actually use would be worse than
shipping nothing. The gate is tested on its own here, including the GRCh38 case
where the 31 GB default correctly selects the external-memory path.

Replaces the RUSTAR_USE_LIBSAIS=1 environment gate from the original branch,
which read as debug scaffolding rather than a STAR-shaped interface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@BenjaminDEMAILLE BenjaminDEMAILLE changed the title genomeGenerate: libsais builder, --genomeType SuperTranscriptome, --genomeTransformOutput SAM genomeGenerate: libsais suffix-array builder, selected by --limitGenomeGenerateRAM Jul 29, 2026
…teRAM

build_libsais existed and was byte-identity tested, but nothing outside
its own tests ever called it: generate_streaming went to
sa_build::build_streaming unconditionally, and lib.rs still warned that
--limitGenomeGenerateRAM was "accepted but not enforced". The builder
was dead code and the flag was inert, which is not what this PR said it
did.

generate_streaming now picks the builder from the flag: libsais when its
estimated peak fits the limit, caps-sa's external-memory path otherwise,
with the chosen path and both numbers logged so the decision is visible
in the run. --genomeSAsparseD above 1 always takes caps-sa, which is the
only builder with a sparse arm. The stale warning is gone.

libsais_peak_bytes took "n_genome" and multiplied by 17, but the text it
sorts is both strands, so a caller passing n_genome would have
understated the requirement by half. Renamed to n_entries and documented
that callers double; the call site passes 2 * n_genome.

Verified on yeast R64-1-1: both paths produce byte-identical SA and
SAindex, libsais 1.04 s against caps-sa 1.65 s.
libsais_peak_bytes counts entries in the text it sorts, which for a
genome is both strands. generate_streaming doubles n_genome before
calling; if that doubling were dropped the estimate would be half the
truth and a mammalian genome would be handed to the in-memory builder at
the default limit. The test picks a limit that separates the two
readings rather than one where both happen to agree.
The PR body listed a "streaming" level of verification, described as
build_libsais driven exactly as GenomeIndex::generate_streaming does and
compared against the caps-sa streaming byte stream. No such test
existed; the streaming test in the file compares caps-sa streaming
against caps-sa in-memory, which says nothing about libsais.

It exists now, and it is only meaningful because generate_streaming
actually calls build_libsais as of 0e34aab: the SA is built with
libsais, pushed through the same PackedStreamWriter the caps-sa arm
emits into, and the resulting bytes compared. That pins the two builders
together at the level the file on disk is written, not only at the level
of the in-memory PackedArray.
The branch had no CHANGELOG entry at all, for a change that adds a
non-Rust dependency and turns an inert flag into a real one.

Both measured rows are runs on this branch with the commands in the PR;
the mammalian row is left out because at the 31 GB default GRCh38 is
declined by the heuristic, which the entry states instead of implying
the speedup generalises.
GRCh38 was left out as "declined at the default limit". It is measured
now, with --limitGenomeGenerateRAM 0 to force the in-memory builder:
641.6 s against caps-sa's 1040.4 s, at 86.8 GB against 14.4 GB, SA and
SAindex byte-identical.

The speedup was expected to fall away at that scale, since
default-features = false leaves libsais single-threaded. It does not:
1.6x, matching yeast and chr1. The CPU time says why, and the entry
says it rather than leaving the reader to assume threading explains it.
CONTRIBUTING asks for benchmark numbers reproducible from the branch with
a command anyone can run. The three-genome table in the PR was produced
by hand, so nobody else could rerun it.

The script drives the two arms through --limitGenomeGenerateRAM, which is
the flag that actually selects the builder, so the comparison exercises
the dispatch rather than bypassing it. It reports wall clock and peak RSS
and then compares SA and SAindex with cmp, failing if they differ.

It refuses to run above a load average of 2. A genomeGenerate run is
minutes long and uses every core, so an unrelated job does not add noise
evenly: it lands on whichever arm overlaps it, and nothing in the
resulting numbers shows that it happened. I made exactly that mistake
benchmarking the BAM backends on this machine today.
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.

1 participant