genomeGenerate: libsais suffix-array builder, selected by --limitGenomeGenerateRAM - #109
Open
BenjaminDEMAILLE wants to merge 9 commits into
Open
genomeGenerate: libsais suffix-array builder, selected by --limitGenomeGenerateRAM#109BenjaminDEMAILLE wants to merge 9 commits into
BenjaminDEMAILLE wants to merge 9 commits into
Conversation
BenjaminDEMAILLE
marked this pull request as ready for review
July 23, 2026 18:38
BenjaminDEMAILLE
force-pushed
the
bd/index-libsais
branch
from
July 28, 2026 20:35
f9191a9 to
3f0d3a6
Compare
This was referenced Jul 28, 2026
This was referenced Jul 29, 2026
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
force-pushed
the
bd/index-libsais
branch
from
July 29, 2026 09:43
3beb5da to
5266fad
Compare
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
genomeGenerate: build the suffix array withlibsaiswhen 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.
--limitGenomeGenerateRAMdid 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_libsaiswas called from nothing but its own tests,generate_streamingwent tobuild_streamingunconditionally, andlib.rsstill 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_libsaisdriven exactly asGenomeIndex::generate_streamingdoes, 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_bytestookn_genomeand multiplied by 17, but the text libsais sorts is both strands. Any caller passingn_genomewould 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 nown_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_libsaisbuilds the suffix array with libsais instead of caps-sa, choosing the alphabet width automatically (u8/u16for few segments,i32large-alphabet for many-segment genomes).GenomeIndex::generate_streamingpicks between the two from--limitGenomeGenerateRAM: libsais when17 × 2Nfits the limit, caps-sa's external-memory path otherwise.--genomeSAsparseDabove 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:The new dependency
libsais0.2.0,MIT OR Apache-2.0, wrapping IlyaGrebnov/libsais through feldroop/libsais-rs. The C is vendored inlibsais-sysand compiled bycc: no system library to install, nobindgen, nolibclang, nopkg-config. Declareddefault-features = false, which turns off the crate's defaultopenmpfeature, so the C builds single-threaded and needs nothing outside the vendored sources. Addsbytemuckandeither, plusccas 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_libsaissorts 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:
build_libsais_matches_caps_sa_{single_chr,multi_chr_with_n,repetitive}i32large-alphabet arm forcedbuild_libsais_i32_matches_caps_sa_{single_chr,multi_chr_with_n}generate_streamingwrites themlibsais_streamed_matches_the_caps_sa_stream_byte_for_byteSAandSAindexcompared withcmpBenchmark
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 itsSAandSAindexcompared withcmpbetween the two builders.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 = falseleaves 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 2Nestimate came out 19% above the measured peak, which is the right direction for a guard.The default declines the mammalian row. At
--limitGenomeGenerateRAM 31000000000a 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 8It drives both arms through
--limitGenomeGenerateRAM, so it exercises the dispatch rather than bypassing it, reports wall clock and peak RSS for each, and comparesSAandSAindexwithcmp.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 includingwindows-x86_64. On-disk index format unchanged.This PR previously also carried
--genomeType SuperTranscriptomeand--genomeTransformOutput SAM; those are now #161 and #160, per the one-theme rule in CONTRIBUTING.md.