perf(build): enable geometric LCP memoization for genome indexing - #228
Open
rob-p wants to merge 9 commits into
Open
perf(build): enable geometric LCP memoization for genome indexing#228rob-p wants to merge 9 commits into
rob-p wants to merge 9 commits into
Conversation
rob-p
marked this pull request as ready for review
August 13, 2026 14:42
4 tasks
STAR ORs SAiMarkNmaskC onto the last-written SAindex entry at every level >= iL4 whenever a suffix's k-mer prefix hits an N (genomeSAindex.cpp:140-145). rustar never set the bit, leaving 58,474 byte diffs against STAR's SAindex on GRCh38. Implement the mark semantics in both builders: - build_parallel: collect mark targets in a shared bitset and OR them into the packed values after the gap-fill. Chunk workers seed their per-level ind0 state with a short backward scan over the preceding SA entries so cross-chunk marks land on the same slots as STAR's serial scan. - build (in-memory serial): follow the reference algorithm directly, and adopt STAR's absent-slot encoding (next-present | absent mask, tail-filled with nSA | absent mask) that build_parallel already used. Genome-only GRCh38 and yeast indexes are now byte-identical to STAR 2.7.11b's, SAindex included. On sjdb builds a handful of mark bits still differ by one slot because STAR places them against the pre-insertion base genome (see DIVERGENCE.md 3.1a). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HS4Mudeqj3Y1jk5p3fhUWt
Three divergences from STAR's GTF-junction pipeline left the inserted junction set 4 entries larger than STAR's on GRCh38 + GENCODE v49 (469,461 vs 469,457) and sjdbList.fromGTF.out.tab 143,860 lines longer: - junction/gtf.rs: keep 1-base introns. STAR only skips touching (exS <= exE+1, silent) and overlapping (exS <= exE, warning) exon pairs (GTF_transcriptGeneSJ.cpp:123-134); rustar also dropped length-1 introns, losing 2 junctions STAR keeps. - sjdb_insert.rs: implement STAR's first dedup pass over left-shifted coordinates, partitioned by the raw source strand (sjdbPrepare.cpp:75-123). Repeat-shifted duplicates of one splice event now collapse (canonical first, then smallest left shift); rustar previously kept 6 such duplicates. PreparedJunction gains a src_strand field because both of STAR's passes branch on the annotation strand, not the motif-derived one, and the cross-strand pass now mirrors sjdbPrepare.cpp:154-170 branch-for-branch. - transcriptome.rs: sort sjdbList.fromGTF.out.tab by (start, end) — funCompareUint2 compares two uint64s, not one — collapse rows the way GTF_transcriptGeneSJ.cpp:145-158 does, and emit gene lists as ascending sets. Genome, SA, sjdbInfo.txt, sjdbList.out.tab, and sjdbList.fromGTF.out.tab are now byte-identical to STAR 2.7.11b's on the full GRCh38 + GENCODE v49 build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HS4Mudeqj3Y1jk5p3fhUWt
- genomeParameters.txt: write the EFFECTIVE sjdbOverhang (STAR's mapGen.sjdbOverhang — 0 when the index has no sjdb) instead of the parameter value. A no-GTF index previously claimed overhang 100, which STAR's loader would trust at align time. The ### header now echoes the actual command line like STAR does, falling back to the old skeleton for API callers constructed without one. - Log.out: genomeGenerate now writes a STAR-shaped run log to the output prefix and copies it into the genome directory, matching STAR's manifest (genomeGenerate.cpp copies its log there). - index/mod.rs: thread the effective overhang into write_index_files; drop a stale doc reference to the removed on-the-fly SAindexBuilder flow. - DIVERGENCE.md: update 3.1 for the argv echo, add 3.1a documenting the remaining sjdb-build SAindex N-mark bits (STAR places them against the pre-insertion base genome; 2 slots out of 357,913,940 on GRCh38 + GENCODE v49) and 3.1b for Log.out content. With this and the two preceding commits, a full GRCh38 + GENCODE v49 sjdb index differs from STAR 2.7.11b's only in the ### argv line, Log.out content, and those 4 SAindex bytes; STAR aligns 100k read pairs byte-identically against either index. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HS4Mudeqj3Y1jk5p3fhUWt
The genomeDir Log.out was produced with fs::copy from <prefix>Log.out. Integration tests run genomeGenerate as concurrent processes in one working directory, so several of them raced on ./Log.out; on Windows, CopyFileEx opens its source without write sharing, and a concurrent File::create from another process fails with a sharing violation (os error 32). Three sjdb tests failed this way on windows-x86_64 CI. Write the genomeDir Log.out as a second independent create instead of copying — std's File::create uses share-all flags on Windows, so plain concurrent creates cannot collide. Also give every genomeGenerate invocation in the integration tests a per-test --outFileNamePrefix so test processes stop sharing ./Log.out at all (and stop littering the crate directory). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HS4Mudeqj3Y1jk5p3fhUWt
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.
Summary
This PR enables caps-sa's bounded geometric LCP memoization for ruSTAR's
production segmented external-memory genome-index construction.
caps-sa = "0.7"crates.io releaseLcpMemoizationPolicy::geometric()Why this is ruSTAR-specific
The production genome-plus-junction layout contains many long shared contexts
across the phase-4 partition cascades. Each cascade owns an independent bounded
memoization table, activates it lazily after learning enough long exact LCP
intervals, and compares a short prefix normally before consulting the table.
There is no shared-table write contention.
Full-genome benchmark
Complete ruSTAR-shaped GENCODE Human v50 fixture:
sjdbOverhang=100splice-junction flank generation
u64indices1.40 million segments
Memoization policy A/B
The isolated policy comparison that motivated this ruSTAR change was:
That is an 8.43% wall-time improvement and a 5.26% reduction in user CPU.
Both paths emitted the same count and streaming hash
(
e81c8f9881e322148741a23c92ae2000). An earlier independent pair measured295.048 s versus 270.063 s (8.47%).
Final caps-sa 0.7 release
The pre-release caps-sa audit then optimized the surrounding ruSTAR-shaped
construction as well: direct position/LCP array flow, task-local ping-pong
sorting, and a compact
SegmentedTextboundary directory for the 1.40 millionsegments.
The final published implementation is 35.4% faster than that original 0.7
baseline and uses 12.8% less peak RSS. It emitted the same full output hash.
On the focused chr21 backbone plus every annotation-derived flank, all
359,616,038 output positions also matched the stored baseline byte-for-byte.
Dependency and API
caps-sa 0.7.0is published on crates.io.Cargo.lockresolves the registrypackage with checksum
e46cbd8870dc17f488813e4c5499ff1bdc2feedb9a650ca2b597933943101394.The earlier temporary Git SHA pin has been removed.
ruSTAR uses the final concise API:
The policy remains explicit in ruSTAR; caps-sa continues to default to the
direct kernel for general inputs.
Validation
Validated locally against the published crates.io package recorded in
Cargo.lock:cargo check --all-targets --lockedcargo test --release --locked: 590 library tests plus all integration anddoc-test targets passed
cargo clippy --all-targets --locked -- -D warningscargo fmt --checkThe release gate is now satisfied: caps-sa 0.7.0 is published, caps-sa
mainand
v0.7.0resolve to the audited release commit, and this PR consumes theregistry release rather than a Git dependency.
Full-genome benchmark vs upstream STAR (GRCh38 + sjdb)
Measured on the final branch state (commits
ea1a359..814d7ceincluded):GRCh38 primary assembly plus GENCODE v49 basic annotation,
--sjdbOverhang 100,--runThreadN 32pinned to 32 physical cores(
taskset -c 0-31, 2× EPYC 9575F),x86-64-v4build, inputs prewarmed intothe page cache, runs sequential on an otherwise idle machine,
/usr/bin/time -v:ruSTAR builds the complete splice-aware human index 2.45× faster than
upstream STAR in less than half the memory.
STAR index compatibility: byte-identity evidence
We compared the two GRCh38+sjdb genome directories file by file and fixed
every divergence this PR could close:
SAindexN-mark flag (ea1a359): ruSTAR never set STAR'sSAiMarkNbit,leaving 58,474 byte diffs. Both SAindex builders now replicate
genomeSAindex.cpp's mark semantics; the parallel builder seeds per-chunkind0state with a backward scan so cross-chunk marks land on STAR'sslots, and the in-memory builder adopts STAR's absent-slot encoding.
d12bcff): ruSTAR dropped 1-base introns that STAR keepsand lacked STAR's first dedup pass over left-shifted coordinates, so
repeat-shifted duplicates survived.
sjdbList.fromGTF.out.tabalso used astart-only sort that broke its adjacency merge, leaving 143,860 excess
lines. All three paths now match
GTF_transcriptGeneSJ.cppandsjdbPrepare.cppbranch for branch.814d7ce):genomeParameters.txtnow records the effectivesjdbOverhang(0 for a no-sjdb index, where the old value of 100 couldmislead STAR's loader) and echoes the real command line, and
genomeGeneratewrites aLog.outinto the genome directory as STARdoes.
Byte-level comparison of the two GRCh38+sjdb genome directories after these
fixes:
Genome,SA(25.1 GB),chr*,sjdbInfo.txt,sjdbList.out.tab,sjdbList.fromGTF.out.tab,transcriptInfo.tab,exonInfo.tab,exonGeTrInfo.tab,geneInfo.tabSAindex(1.5 GB)genomeParameters.txt###argv comment lineLog.outIndexes built without a GTF are byte-identical across every file,
SAindexincluded; we verified this on GRCh38 and on yeast.
The 4 remaining
SAindexbytes are an artifact of STAR's two-phase build:it patches the base-genome SAindex during junction insertion
(
sjdbBuildIndex.cpp:228-231, 262-284) and never updates N-marks fork-mers that only became present via junction flanks. ruSTAR's single pass
over the final text marks the current slot instead. Both placements are
valid conservative markers, and
DIVERGENCE.md§3.1a documents thedifference.
Functional cross-check
Upstream STAR 2.7.11b aligned 100k SRR21186103 paired-end reads against the
ruSTAR-built index and against its own.
Aligned.out.sam(209,729 records)and
SJ.out.tabare byte-identical, excluding only the path-bearing@PG/@COheader lines; both runs report 89.50% uniquely mapped reads and106,768 splices. An index generated with ruSTAR is a drop-in replacement
for a STAR-generated one.
🤖 Generated with Claude Code
https://claude.ai/code/session_01HS4Mudeqj3Y1jk5p3fhUWt