From 67e84e46bfd3e0b5529cc252d781ea8c69d2744a Mon Sep 17 00:00:00 2001 From: Benjamin Demaille Date: Thu, 13 Aug 2026 19:30:32 +0200 Subject: [PATCH 1/2] bench: replay a rustar-aligner shaped segmented build from a fixture rustar-aligner's `sa_build` is the workload that decides whether a change to this crate is worth having, and it does not look like a flat-text build: N-starting suffixes are never sorted, and the `LimitProvider` stops every comparison at a chromosome or junction-flank boundary. On a chr21-shaped fixture 98.3% of LCP calls resolve inside 16 bytes. Measuring a candidate on raw FASTA instead is how an optimization that regresses this workload gets proposed. This example replays the exact call shape `dispatch_caps_sa_segmented` makes (the spacer-bearing `u8` text verbatim, `SegmentedText::from_ends` over the spacer-run ends, STAR's boundary order, and the ACGT-only streaming filter through `build_ext_mem_for_filter_with`) from a fixture directory holding `text.bin` and `ends.u64`. It reports the caps-sa build wall time, the emitted entry count, and an order-sensitive checksum of the emitted position stream, so an A/B is correctness-gated by construction: identical checksum, identical suffix array. Co-Authored-By: Claude Opus 5 (1M context) --- examples/rustar_segmented_bench.rs | 210 +++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 examples/rustar_segmented_bench.rs diff --git a/examples/rustar_segmented_bench.rs b/examples/rustar_segmented_bench.rs new file mode 100644 index 0000000..d95ae47 --- /dev/null +++ b/examples/rustar_segmented_bench.rs @@ -0,0 +1,210 @@ +//! Replay of a **rustar-aligner shaped** segmented build, standalone. +//! +//! rustar-aligner's `sa_build` builds a generalized suffix array over +//! `T = forward || revcomp` of a spacer-padded, splice-junction-extended +//! genome. It never widens the alphabet: the text stays `u8` (bases +//! `0..=3`, `N = 4`, spacer `= 5`), and the segment structure is handed +//! to caps-sa as a [`SegmentedText`] whose `boundary_order` is flipped +//! to STAR's `spacer-as-largest` convention. Only ACGT positions are +//! sorted, through the streaming filter API. +//! +//! This example replays exactly that call shape from a dumped fixture, +//! so caps-sa-side changes can be measured on the real workload +//! without a full `genomeGenerate` run around them. +//! +//! Fixture layout (produced by rustar-aligner, see `bench/README.md`): +//! +//! - `text.bin`: the `2 * n_genome` byte text, verbatim. +//! - `ends.u64`: the cumulative segment ends, little-endian `u64[]`, +//! last entry equal to the text length. +//! +//! ```text +//! cargo run --release --example rustar_segmented_bench -- FIXTURE_DIR \ +//! [--threads N] [--repeat N] [--in-mem] [--work-dir DIR] +//! ``` +//! +//! Reports the wall time of the caps-sa build alone, plus the emitted +//! entry count and an order-sensitive checksum of the emitted position +//! stream. The checksum is the correctness gate when comparing +//! optimization variants: identical checksum, identical suffix array. + +use std::cmp::Ordering; +use std::env; +use std::fs; +use std::path::PathBuf; +use std::process; +use std::time::Instant; + +use caps_sa::{ + ExtMemOpts, LimitProvider, Opts, SegmentedText, build_ext_mem_for_filter_with, + build_in_memory_for_positions_with, +}; + +/// rustar-aligner's `StarSegmentedText`: `SegmentedText` limits with +/// STAR's boundary convention (longer remaining segment sorts first, +/// ascending position on ties). +struct StarSegmentedText { + inner: SegmentedText, +} + +impl LimitProvider for StarSegmentedText { + #[inline] + fn lim_at(&self, p: usize) -> usize { + self.inner.lim_at(p) + } + + #[inline] + fn boundary_order(&self, p_a: usize, lim_a: usize, p_b: usize, lim_b: usize) -> Ordering { + lim_b.cmp(&lim_a).then(p_a.cmp(&p_b)) + } +} + +struct Args { + fixture: PathBuf, + threads: Option, + repeat: usize, + in_mem: bool, + work_dir: Option, +} + +fn parse_args() -> Args { + let argv: Vec = env::args().collect(); + let mut positional: Vec = Vec::new(); + let mut threads = None; + let mut repeat = 1usize; + let mut in_mem = false; + let mut work_dir = None; + let mut i = 1; + while i < argv.len() { + match argv[i].as_str() { + "--threads" => { + threads = Some(argv[i + 1].parse().expect("--threads expects an integer")); + i += 2; + } + "--repeat" => { + repeat = argv[i + 1].parse().expect("--repeat expects an integer"); + i += 2; + } + "--in-mem" => { + in_mem = true; + i += 1; + } + "--work-dir" => { + work_dir = Some(PathBuf::from(&argv[i + 1])); + i += 2; + } + "--help" | "-h" => { + eprintln!( + "usage: rustar_segmented_bench FIXTURE_DIR [--threads N] \ + [--repeat N] [--in-mem] [--work-dir DIR]" + ); + process::exit(0); + } + _ => { + positional.push(argv[i].clone()); + i += 1; + } + } + } + if positional.len() != 1 { + eprintln!("error: expected 1 positional arg (fixture dir)"); + process::exit(2); + } + Args { + fixture: PathBuf::from(&positional[0]), + threads, + repeat, + in_mem, + work_dir, + } +} + +fn read_ends(path: &PathBuf) -> Vec { + let raw = fs::read(path).expect("read ends.u64"); + assert!( + raw.len().is_multiple_of(8), + "ends.u64 is not a whole u64 array" + ); + raw.chunks_exact(8) + .map(|c| u64::from_le_bytes(c.try_into().unwrap())) + .collect() +} + +fn main() { + let args = parse_args(); + + if let Some(t) = args.threads { + rayon::ThreadPoolBuilder::new() + .num_threads(t) + .build_global() + .expect("build rayon pool"); + } + + let text = fs::read(args.fixture.join("text.bin")).expect("read text.bin"); + let ends = read_ends(&args.fixture.join("ends.u64")); + let n = text.len(); + assert_eq!( + ends.last().copied(), + Some(n as u64), + "ends.u64 must close at the text length" + ); + + let n_kept = text.iter().filter(|&&b| b < 4).count(); + println!( + "fixture: text={n} bytes, {} segments, ACGT kept={n_kept} ({:.1}%), \ + path={}, threads={}", + ends.len(), + 100.0 * n_kept as f64 / n as f64, + if args.in_mem { "in-memory" } else { "ext-mem" }, + rayon::current_num_threads(), + ); + + let lp = StarSegmentedText { + inner: SegmentedText::from_ends(n, ends), + }; + + for round in 0..args.repeat { + // Order-sensitive checksum: any reordering of the emitted + // stream changes it, so two variants agreeing here produced + // the same suffix array. + let mut count: u64 = 0; + let mut checksum: u64 = 0; + let mut emit = |p: u64| -> std::io::Result<()> { + count += 1; + checksum = checksum + .rotate_left(7) + .wrapping_add(p.wrapping_mul(0x9E37_79B9_7F4A_7C15)) + .wrapping_add(count); + Ok(()) + }; + + let t0 = Instant::now(); + if args.in_mem { + let positions: Vec = (0..n as u64).filter(|&p| text[p as usize] < 4).collect(); + let sa = build_in_memory_for_positions_with(&text, positions, &lp, &Opts::default()); + for &p in &sa { + emit(p).expect("checksum sink never fails"); + } + } else { + let mut opts = ExtMemOpts::from_env(); + if let Some(dir) = &args.work_dir { + opts = opts.work_dir(dir); + } + let text_ref: &[u8] = &text; + build_ext_mem_for_filter_with( + &text, + |p| text_ref[p as usize] < 4, + &lp, + &opts, + &mut emit, + ) + .expect("caps-sa external-memory build"); + } + let elapsed = t0.elapsed(); + + println!( + "round {round}: {:.3} s entries={count} checksum=0x{checksum:016x}", + elapsed.as_secs_f64() + ); + } +} From 9814fbd8517ba86b5f45f9b21049eed06c44a5b8 Mon Sep 17 00:00:00 2001 From: rob-p Date: Thu, 13 Aug 2026 20:04:47 -0400 Subject: [PATCH 2/2] docs: make the ruSTAR replay harness reproducible --- bench/README.md | 54 ++++++++++++++++++++++ examples/rustar_segmented_bench.rs | 73 +++++++++++++++++++++--------- 2 files changed, 105 insertions(+), 22 deletions(-) diff --git a/bench/README.md b/bench/README.md index 45cc7f7..6ee901f 100644 --- a/bench/README.md +++ b/bench/README.md @@ -28,6 +28,60 @@ The harness runs four configurations on the same input: It reports wall time and peak RSS via `/usr/bin/time`. +## Replaying the ruSTAR segmented workload + +`examples/rustar_segmented_bench.rs` measures the caps-sa call shape used by +ruSTAR without timing the rest of `genomeGenerate`. It preserves the encoded +genome plus splice-junction text, segment limits, STAR boundary ordering, and +ACGT-start filter. + +The example reads a fixture directory containing: + +- `text.bin`: ruSTAR's complete `u8` text immediately before suffix-array + construction (`A=0`, `C=1`, `G=2`, `T=3`, `N=4`, spacer `=5`); +- `ends.u64`: the strictly increasing cumulative segment ends as little-endian + `u64` values, with the final value equal to `text.bin`'s length. + +To create a fixture from a particular rustar-aligner revision, temporarily add +the following immediately after `ends_orig` is computed in +`dispatch_caps_sa_segmented` and before it is moved into `SegmentedText`: + +```rust,ignore +let fixture_dir = std::path::Path::new("/path/to/fixture"); +std::fs::create_dir_all(fixture_dir)?; +std::fs::write(fixture_dir.join("text.bin"), original)?; + +let mut ends_file = std::io::BufWriter::new(std::fs::File::create( + fixture_dir.join("ends.u64"), +)?); +for &end in &ends_orig { + std::io::Write::write_all(&mut ends_file, &end.to_le_bytes())?; +} +std::io::Write::flush(&mut ends_file)?; +``` + +Run the normal rustar-aligner `genomeGenerate` command with the desired FASTA, +annotation, and `sjdbOverhang`; the dump therefore includes rustar-aligner's +actual parsing, junction preparation and deduplication, padding, junction +append, and forward/reverse-complement layout. Remove the temporary dump after +creating the fixture. + +Then build and run the standalone replay: + +```sh +cargo build --release --example rustar_segmented_bench + +CAPS_SA_PROFILE=1 taskset -c 0-31 \ + target/release/examples/rustar_segmented_bench /path/to/fixture \ + --threads 32 --repeat 3 --work-dir /path/to/fast/temp +``` + +Omit `taskset` on platforms where it is unavailable. Use `--in-mem` only for +fixtures that fit comfortably in RAM. Counts and 128-bit order-sensitive +checksums are useful regression signals, but hashes are not proofs of equality; +release validation should compare emitted position streams exactly or use a +direct suffix comparator on a smaller fixture. + ## Results Machine: 64-core x86_64 Linux node, 1 socket, AVX2 enabled. diff --git a/examples/rustar_segmented_bench.rs b/examples/rustar_segmented_bench.rs index d95ae47..3a55ff2 100644 --- a/examples/rustar_segmented_bench.rs +++ b/examples/rustar_segmented_bench.rs @@ -25,13 +25,14 @@ //! //! Reports the wall time of the caps-sa build alone, plus the emitted //! entry count and an order-sensitive checksum of the emitted position -//! stream. The checksum is the correctness gate when comparing -//! optimization variants: identical checksum, identical suffix array. +//! stream. Matching counts and checksums are a strong regression signal, +//! not a proof of equality; use an exact stream comparison when validating +//! a change before release. use std::cmp::Ordering; use std::env; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process; use std::time::Instant; @@ -67,6 +68,20 @@ struct Args { work_dir: Option, } +const USAGE: &str = "usage: rustar_segmented_bench FIXTURE_DIR [--threads N] \ + [--repeat N] [--in-mem] [--work-dir DIR]"; + +fn usage_error(message: &str) -> ! { + eprintln!("error: {message}\n{USAGE}"); + process::exit(2); +} + +fn option_value<'a>(argv: &'a [String], i: usize, option: &str) -> &'a str { + argv.get(i + 1) + .map(String::as_str) + .unwrap_or_else(|| usage_error(&format!("{option} requires a value"))) +} + fn parse_args() -> Args { let argv: Vec = env::args().collect(); let mut positional: Vec = Vec::new(); @@ -78,11 +93,22 @@ fn parse_args() -> Args { while i < argv.len() { match argv[i].as_str() { "--threads" => { - threads = Some(argv[i + 1].parse().expect("--threads expects an integer")); + let value = option_value(&argv, i, "--threads") + .parse::() + .unwrap_or_else(|_| usage_error("--threads expects a positive integer")); + if value == 0 { + usage_error("--threads expects a positive integer"); + } + threads = Some(value); i += 2; } "--repeat" => { - repeat = argv[i + 1].parse().expect("--repeat expects an integer"); + repeat = option_value(&argv, i, "--repeat") + .parse::() + .unwrap_or_else(|_| usage_error("--repeat expects a positive integer")); + if repeat == 0 { + usage_error("--repeat expects a positive integer"); + } i += 2; } "--in-mem" => { @@ -90,16 +116,16 @@ fn parse_args() -> Args { i += 1; } "--work-dir" => { - work_dir = Some(PathBuf::from(&argv[i + 1])); + work_dir = Some(PathBuf::from(option_value(&argv, i, "--work-dir"))); i += 2; } "--help" | "-h" => { - eprintln!( - "usage: rustar_segmented_bench FIXTURE_DIR [--threads N] \ - [--repeat N] [--in-mem] [--work-dir DIR]" - ); + eprintln!("{USAGE}"); process::exit(0); } + option if option.starts_with('-') => { + usage_error(&format!("unknown option: {option}")); + } _ => { positional.push(argv[i].clone()); i += 1; @@ -107,8 +133,7 @@ fn parse_args() -> Args { } } if positional.len() != 1 { - eprintln!("error: expected 1 positional arg (fixture dir)"); - process::exit(2); + usage_error("expected exactly one fixture directory"); } Args { fixture: PathBuf::from(&positional[0]), @@ -119,7 +144,7 @@ fn parse_args() -> Args { } } -fn read_ends(path: &PathBuf) -> Vec { +fn read_ends(path: &Path) -> Vec { let raw = fs::read(path).expect("read ends.u64"); assert!( raw.len().is_multiple_of(8), @@ -143,6 +168,11 @@ fn main() { let text = fs::read(args.fixture.join("text.bin")).expect("read text.bin"); let ends = read_ends(&args.fixture.join("ends.u64")); let n = text.len(); + assert!(n > 0, "text.bin must not be empty"); + assert!( + text.iter().all(|&symbol| symbol <= 5), + "text.bin contains a symbol outside ruSTAR's encoded alphabet 0..=5" + ); assert_eq!( ends.last().copied(), Some(n as u64), @@ -164,17 +194,16 @@ fn main() { }; for round in 0..args.repeat { - // Order-sensitive checksum: any reordering of the emitted - // stream changes it, so two variants agreeing here produced - // the same suffix array. + // A wide, order-sensitive checksum is a convenient regression signal. + // It is not a proof of equality; release validation should compare the + // emitted position streams directly. let mut count: u64 = 0; - let mut checksum: u64 = 0; + let mut checksum = 0x6c62_272e_07bb_0142_62b8_2175_6295_c58du128; let mut emit = |p: u64| -> std::io::Result<()> { count += 1; - checksum = checksum - .rotate_left(7) - .wrapping_add(p.wrapping_mul(0x9E37_79B9_7F4A_7C15)) - .wrapping_add(count); + checksum ^= p as u128; + checksum = checksum.wrapping_mul(0x0000_0000_0100_0000_0000_0000_0000_013b); + checksum ^= (p as u128) << 64; Ok(()) }; @@ -203,7 +232,7 @@ fn main() { let elapsed = t0.elapsed(); println!( - "round {round}: {:.3} s entries={count} checksum=0x{checksum:016x}", + "round {round}: {:.3} s entries={count} checksum=0x{checksum:032x}", elapsed.as_secs_f64() ); }