From 785a5b9fe33fba43623dcff25eb3cf74a3b40a47 Mon Sep 17 00:00:00 2001 From: Benjamin Demaille Date: Thu, 27 Aug 2026 20:58:48 +0200 Subject: [PATCH] bench: add a divan harness and the first hot-path benchmarks Several open dependency questions (#162, #202, #205, #208) all start with "measure first", and each was about to invent its own measurement. This is the shared one. Divan rather than criterion: the questions that prompted a harness compare peak memory as much as speed, and divan reports allocation counts next to wall time without extra setup; a full run also takes seconds, so it stays usable in a pull request rather than only in a nightly job. Criterion is the better choice if the CI integration in #245 needs its report format, and swapping is a benches/ change rather than an API one. Three groups, chosen as the functions the open questions would replace: seed-extension scanning (`find_stop`, what a portable-SIMD crate would swap), the gene-overlap query and the annotation build (what an interval crate would swap). All are pure functions with no genome index to build, so a run takes seconds and a regression is attributable to one function. `find_stop` and the GTF record type are private to the crate; rather than widen the published API for a benchmark, both are exposed under a `bench` feature that only benches/ enables. Closes #204. --- Cargo.lock | 50 +++++++++++++ Cargo.toml | 15 ++++ benches/hot_paths.rs | 163 +++++++++++++++++++++++++++++++++++++++++++ src/align/mod.rs | 7 ++ src/junction/mod.rs | 6 ++ 5 files changed, 241 insertions(+) create mode 100644 benches/hot_paths.rs diff --git a/Cargo.lock b/Cargo.lock index 9f77c86d..729be5ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -237,6 +237,7 @@ dependencies = [ "anstyle", "clap_lex", "strsim", + "terminal_size", ] [[package]] @@ -263,6 +264,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "condtype" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf0a07a401f374238ab8e2f11a104d2851bf9ce711ec69804834de8af45c7af" + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -329,6 +336,31 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "divan" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a405457ec78b8fe08b0e32b4a3570ab5dff6dd16eb9e76a5ee0a9d9cbd898933" +dependencies = [ + "cfg-if", + "clap", + "condtype", + "divan-macros", + "libc", + "regex-lite", +] + +[[package]] +name = "divan-macros" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9556bc800956545d6420a640173e5ba7dfa82f38d3ea5a167eb555bc69ac3323" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "either" version = "1.15.0" @@ -959,6 +991,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.9" @@ -978,6 +1016,7 @@ dependencies = [ "chrono", "clap", "dashmap", + "divan", "env_logger", "flate2", "libdeflater", @@ -989,6 +1028,7 @@ dependencies = [ "noodles-bgzf", "predicates", "rayon", + "rustar-aligner", "rustc-hash", "shlex", "tempfile", @@ -1134,6 +1174,16 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys", +] + [[package]] name = "termtree" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 8a4638f9..ae0ad47f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,9 +66,24 @@ libmimalloc-sys = { version = "0.1.49", features = ["extended"] } # mi_option_se libdeflater = "1.25.2" noodles-bgzf = { version = "0.49", features = ["libdeflate"] } +[features] +# Exposes a few internals to `benches/` without widening the published API. +# Enabled automatically for `cargo bench` through dev-dependencies below. +bench = [] + [dev-dependencies] assert_cmd = "2" predicates = "3" +# Benchmark harness. Divan over criterion for the questions that prompted it +# (#204): it reports allocation counts next to wall time, which is what the +# suffix-array and interval evaluations are actually comparing, and a run +# takes seconds rather than minutes. See DEPENDENCIES.md. +divan = "0.1" +rustar-aligner = { path = ".", features = ["bench"] } + +[[bench]] +name = "hot_paths" +harness = false [build-dependencies] chrono = { version = "0.4", default-features = false, features = ["clock"] } diff --git a/benches/hot_paths.rs b/benches/hot_paths.rs new file mode 100644 index 00000000..a353da9c --- /dev/null +++ b/benches/hot_paths.rs @@ -0,0 +1,163 @@ +//! Micro-benchmarks for the paths the open "measure first" questions land on. +//! +//! Several dependency decisions (#162, #202, #205, #208) all begin with a +//! measurement, and each was about to invent its own. These are the shared +//! ones, deliberately small: pure functions with no genome index to build, so +//! a run takes seconds and a regression is attributable to one function. +//! +//! ```text +//! cargo bench # everything +//! cargo bench -- seed_scan # one group +//! cargo bench -- --sample-count 200 # more samples +//! ``` +//! +//! Divan reports allocation counts alongside wall time, which matters here: +//! two of the open questions (`sufr`/`libsais` against `caps-sa`) are about +//! peak RSS as much as speed. + +use std::collections::HashMap; + +use rustar_aligner::align::simd_scan::find_stop; +use rustar_aligner::genome::Genome; +use rustar_aligner::junction::gtf::GtfRecord; +use rustar_aligner::quant::GeneAnnotation; + +fn main() { + divan::main(); +} + +/// Deterministic pseudo-random bases (0..=3), the same generator the tests use. +fn lcg_bases(seed: u32, length: usize) -> Vec { + let mut state = seed; + (0..length) + .map(|_| { + state = state.wrapping_mul(1_103_515_245).wrapping_add(12345); + ((state >> 16) & 3) as u8 + }) + .collect() +} + +// ── Seed extension ────────────────────────────────────────────────────────── +// +// `find_stop` is the innermost loop of seed extension: it walks read against +// genome and returns the first mismatch or padding byte. It runs once per +// candidate extension, which is millions of times per million reads, and it is +// the function a portable-SIMD crate (#205) would replace. + +#[divan::bench(args = [50, 100, 250])] +fn seed_scan_full_match(bencher: divan::Bencher, len: usize) { + let read = lcg_bases(1, len); + let genome = read.clone(); + bencher.bench(|| find_stop(divan::black_box(&read), divan::black_box(&genome))); +} + +#[divan::bench(args = [50, 100, 250])] +fn seed_scan_stop_at_half(bencher: divan::Bencher, len: usize) { + // The common case in practice: a long run of matches, then a mismatch. + let read = lcg_bases(1, len); + let mut genome = read.clone(); + genome[len / 2] = (genome[len / 2] + 1) % 4; + bencher.bench(|| find_stop(divan::black_box(&read), divan::black_box(&genome))); +} + +// ── Gene overlap ──────────────────────────────────────────────────────────── +// +// The segment-tree overlap query runs per read (twice for `GeneFull`) and was +// the top solo hotspot before it replaced a linear scan. It is what the +// interval-crate question (#208) would replace. + +fn synthetic_annotation(n_genes: usize) -> (GeneAnnotation, Genome) { + let genome_len = (n_genes as u64 + 2) * 1_000; + let genome = Genome { + transform_blocks: None, + sequence: vec![0u8; genome_len as usize].into(), + n_genome: genome_len, + n_genome_real: genome_len, + n_chr_real: 1, + chr_start: vec![0, genome_len], + chr_length: vec![genome_len], + chr_name: vec!["chr1".to_string()], + }; + + let exons: Vec = (0..n_genes) + .flat_map(|g| { + // Two exons per gene, genes 1 kb apart with a little overlap + // between neighbours so the query cannot stop at the first hit. + let base = g as u64 * 1_000 + 100; + [(base, base + 400), (base + 600, base + 1_100)] + .into_iter() + .map(move |(s, e)| { + let mut attributes = HashMap::new(); + attributes.insert("gene_id".to_string(), format!("G{g}")); + attributes.insert("transcript_id".to_string(), format!("G{g}_T1")); + GtfRecord { + seqname: "chr1".to_string(), + feature: "exon".to_string(), + start: s + 1, + end: e, + strand: '+', + attributes, + } + }) + }) + .collect(); + + (GeneAnnotation::from_gtf_exons(&exons, &genome), genome) +} + +#[divan::bench(args = [100, 2_000, 20_000])] +fn gene_overlap_query(bencher: divan::Bencher, n_genes: usize) { + use rustar_aligner::align::transcript::{Exon, Transcript}; + + let (ann, _genome) = synthetic_annotation(n_genes); + // A read landing in the middle of the annotation, spanning two exons. + let mid = (n_genes as u64 / 2) * 1_000 + 150; + let transcript = Transcript { + chr_idx: 0, + genome_start: mid, + genome_end: mid + 800, + is_reverse: false, + exons: vec![ + Exon { + genome_start: mid, + genome_end: mid + 200, + read_start: 0, + read_end: 200, + i_frag: 0, + }, + Exon { + genome_start: mid + 600, + genome_end: mid + 800, + read_start: 200, + read_end: 400, + i_frag: 0, + }, + ], + cigar: Vec::new(), + score: 0, + n_mismatch: 0, + n_gap: 0, + n_junction: 1, + junction_motifs: Vec::new(), + junction_annotated: Vec::new(), + }; + + let mut out = Vec::new(); + bencher.bench_local(|| { + ann.overlapping_genes_into(divan::black_box(&transcript), &mut out); + out.len() + }); +} + +// ── Annotation build ──────────────────────────────────────────────────────── +// +// Building the annotation is once per run, but it is O(exons log exons) and +// shows up on the solo startup path; it is also the allocation-heavy half of +// the interval question. + +#[divan::bench(args = [2_000, 20_000])] +fn gene_annotation_build(bencher: divan::Bencher, n_genes: usize) { + bencher + .with_inputs(|| n_genes) + .bench_values(|n| synthetic_annotation(n).0.n_genes()); +} diff --git a/src/align/mod.rs b/src/align/mod.rs index 1c0351ed..c4e15747 100644 --- a/src/align/mod.rs +++ b/src/align/mod.rs @@ -2,7 +2,14 @@ pub mod pe_overlap; pub mod read_align; pub mod score; pub mod seed; +// Private to the crate, except under the `bench` feature: `benches/hot_paths.rs` +// measures `find_stop` directly, because it is the function a portable-SIMD +// crate would replace (#205) and the one whose cost is worth watching. +#[cfg(not(feature = "bench"))] mod simd_scan; +#[cfg(feature = "bench")] +#[doc(hidden)] +pub mod simd_scan; pub mod stitch; pub mod transcript; diff --git a/src/junction/mod.rs b/src/junction/mod.rs index 13776ce4..36f483c2 100644 --- a/src/junction/mod.rs +++ b/src/junction/mod.rs @@ -6,7 +6,13 @@ /// - Junction lookup during alignment (annotated vs novel) /// - Junction statistics collection for SJ.out.tab output pub(crate) mod chr_start_end; +#[cfg(not(feature = "bench"))] pub(crate) mod gtf; +// See `align::simd_scan`: exposed only for `benches/`, which builds a gene +// annotation from in-memory GTF records rather than from a file on disk. +#[cfg(feature = "bench")] +#[doc(hidden)] +pub mod gtf; mod sj_output; pub mod sjdb_insert;