From 43c328515a5e313ed6e4570ea98665328930dec8 Mon Sep 17 00:00:00 2001 From: Benjamin Demaille Date: Fri, 24 Jul 2026 10:06:57 +0200 Subject: [PATCH] feat: migrate multimapper-tiebreak RNG to in-tree splitmix64 Replace the `rand` crate (StdRng) with STAR-rs's splitmix64-based deterministic generator for the two RNG consumers ported so far: `shuffle_tied_prefix` (SE/PE `--outMultimapperOrder Random` tie-break) and `pick_primary_and_mapq` (transcriptome-space primary pick). Per docs-old/dev/porting-from-star-rs.md's RNG decision: this must land before STARsolo, whose UMI dedup depends on deterministic read order. Keeps the existing per-read seed (run_rng_seed + read-name hash, already thread-invariant); only the underlying generator changes, matching STAR-rs's own choice of an in-tree splitmix64 over a general-purpose PRNG crate (documented in STAR-rs's DIVERGENCES.md D16: STAR's own mt19937 output is itself thread-count-dependent, so no external crate's output is any more "correct" to match). Also migrates the test-only packed_stream.rs fuzz generator off `rand`, so the dependency can be dropped from Cargo.toml entirely. Gate green: cargo build --all-targets, clippy -D warnings, fmt, 455 tests. --- Cargo.lock | 39 -------------------------------------- Cargo.toml | 1 - src/align/read_align.rs | 32 +++++++++++++++++++++++++++++-- src/index/packed_stream.rs | 7 ++++--- src/lib.rs | 7 +++---- 5 files changed, 37 insertions(+), 49 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a48e901..dd7e75d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -173,17 +173,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "chacha20" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" -dependencies = [ - "cfg-if", - "cpufeatures", - "rand_core", -] - [[package]] name = "chrono" version = "0.4.45" @@ -249,15 +238,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - [[package]] name = "crc32fast" version = "1.5.0" @@ -412,7 +392,6 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "rand_core", "wasip2", "wasip3", ] @@ -870,23 +849,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" -dependencies = [ - "chacha20", - "getrandom", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - [[package]] name = "rayon" version = "1.12.0" @@ -965,7 +927,6 @@ dependencies = [ "mimalloc", "noodles", "predicates", - "rand", "rayon", "shlex", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 338a551..b4447f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,7 +39,6 @@ flate2 = "1" rayon = "1" dashmap = "6" chrono = "0.4" -rand = "0.10" tempfile = "3" bitflags = { version = "2.12.1", features = ["std"] } shlex = "2.0.1" diff --git a/src/align/read_align.rs b/src/align/read_align.rs index f8c7275..a9dee3b 100644 --- a/src/align/read_align.rs +++ b/src/align/read_align.rs @@ -10,7 +10,6 @@ use crate::error::Error; use crate::index::GenomeIndex; use crate::params::{IntronMotifFilter, IntronStrandFilter, MultimapperOrder, Parameters}; use crate::stats::UnmappedReason; -use rand::{SeedableRng, rngs::StdRng, seq::SliceRandom}; use std::hash::{DefaultHasher, Hash, Hasher}; /// Derive a deterministic per-read RNG seed from `run_rng_seed` + the read name. @@ -26,6 +25,35 @@ pub(crate) fn per_read_seed(run_rng_seed: u64, read_name: &str) -> u64 { run_rng_seed.wrapping_mul(hasher.finish().wrapping_add(1)) } +/// A deterministic `splitmix64` step, ported from STAR-rs (which uses it for its own +/// thread-invariant `--outMultimapperOrder Random` per-read shuffle; see +/// `docs-old/dev/porting-from-star-rs.md`'s RNG decision). Replaces a general-purpose +/// PRNG crate with a small in-tree generator, matching the project's dependency-hygiene +/// preference for self-contained determinism-critical code. +pub(crate) fn splitmix64(state: &mut u64) -> u64 { + *state = state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = *state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} + +/// A deterministic Fisher-Yates shuffle of `order` seeded by `seed`, using `splitmix64`. +fn shuffle_deterministic(order: &mut [T], seed: u64) { + let mut s = seed ^ 0xA076_1D64_78BD_642F; + for k in (1..order.len()).rev() { + let j = (splitmix64(&mut s) % (k as u64 + 1)) as usize; + order.swap(k, j); + } +} + +/// A single deterministic draw from `0..n`, using the same `splitmix64` mixing as +/// `shuffle_deterministic`. +pub(crate) fn deterministic_index(seed: u64, n: usize) -> usize { + let mut s = seed ^ 0xA076_1D64_78BD_642F; + (splitmix64(&mut s) % n as u64) as usize +} + /// Shuffle the prefix of `items` whose `score_fn` equals the first element's score. /// /// Mirrors STAR's `ReadAlign_multMapSelect` / `funPrimaryAlignMark`: best-scoring @@ -40,7 +68,7 @@ fn shuffle_tied_prefix(items: &mut [T], score_fn: impl Fn(&T) -> i32, seed: u if tied < 2 { return; } - items[..tied].shuffle(&mut StdRng::seed_from_u64(seed)); + shuffle_deterministic(&mut items[..tied], seed); } /// Result of aligning a single read: (transcripts, chimeric_alignments, n_for_mapq, unmapped_reason) diff --git a/src/index/packed_stream.rs b/src/index/packed_stream.rs index 2508be8..1b66595 100644 --- a/src/index/packed_stream.rs +++ b/src/index/packed_stream.rs @@ -211,12 +211,13 @@ mod tests { // bit), so this pre-existing `PackedArray` limitation is // never hit in practice. The streaming writer uses `u128` // and is correct for all widths 1..=64. - use rand::{RngExt, SeedableRng}; - let mut rng = rand::rngs::StdRng::seed_from_u64(0x5A_C0DE); + let mut state = 0x5A_C0DEu64; for &wl in &[1u32, 5, 12, 32, 33, 35, 48, 57] { for &n in &[0usize, 1, 7, 8, 9, 127, 128, 129, 1000] { let mask = if wl == 64 { u64::MAX } else { (1u64 << wl) - 1 }; - let values: Vec = (0..n).map(|_| rng.random::() & mask).collect(); + let values: Vec = (0..n) + .map(|_| crate::align::read_align::splitmix64(&mut state) & mask) + .collect(); assert_matches_packed_array(wl, &values); } } diff --git a/src/lib.rs b/src/lib.rs index c9a3aa9..fd866a3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -623,12 +623,11 @@ fn pick_primary_and_mapq( read_name: &str, params: &Parameters, ) -> (usize, u8) { - use crate::align::read_align::per_read_seed; + use crate::align::read_align::{deterministic_index, per_read_seed}; use crate::mapq::calculate_mapq; - use rand::{RngExt, SeedableRng, rngs::StdRng}; - let mut rng = StdRng::seed_from_u64(per_read_seed(params.run_rng_seed, read_name)); - let primary_hit = rng.random_range(0..n_alignments); + let primary_hit = + deterministic_index(per_read_seed(params.run_rng_seed, read_name), n_alignments); let mapq = calculate_mapq(n_alignments.max(n_for_mapq), params.out_sam_mapq_unique); (primary_hit, mapq) }