Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,11 @@ Sections commonly used: Features, Bug fixes, Other changes.
needs random access to the SA in RAM.

Initial release of Rust rewrite of STAR.
### Other changes

- The splice-motif check in the junction scan carries a sliding window
and looks the motif up in a table instead of re-reading four genome
bases and matching on them at every position. Output is unchanged
(SAM byte-identical on 200k reads); the scan itself goes from 8.5 ns
to 5.2 ns per iteration, measured by `examples/jrbench.rs`.

97 changes: 97 additions & 0 deletions examples/jrbench.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
//! Is the junction scan stalled on memory, and is it the acceptor stream?
//!
//! The stack sampler gives self time, not cache misses, so this asks the
//! question by construction instead: run the same scan over the same genome,
//! changing only how far the acceptor sits from the donor. Everything else
//! (iteration count, branch pattern, instruction mix) is held fixed, because
//! `del` enters the loop only as an address offset once it is inside the
//! intron-length range.
//!
//! If time per iteration is flat across `del`, the loop is not memory-bound on
//! the acceptor and prefetching it would be wasted work. If it climbs with
//! `del`, that stream is the cost.
//!
//! Run: cargo run --release --example jrbench

use rustar_aligner::align::score::AlignmentScorer;
use rustar_aligner::genome::{Genome, GenomeSeq};
use std::time::Instant;

/// Deterministic pseudo-random bases, so the run is reproducible and the motif
/// hit rate is the same at every `del`.
fn synthetic_genome(n: usize) -> Genome {
let mut state = 0x2545_F491_4F6C_DD1Du64;
let mut seq = Vec::with_capacity(n);
for _ in 0..n {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
seq.push((state >> 33) as u8 & 3);
}
Genome {
transform_blocks: None,
sequence: GenomeSeq::Owned(seq),
n_genome: n as u64,
n_genome_real: n as u64,
n_chr_real: 1,
chr_name: vec!["chr1".to_string()],
chr_length: vec![n as u64],
chr_start: vec![0, n as u64],
}
}

fn main() {
// Large enough that the donor and acceptor streams cannot both stay in
// cache when they are far apart.
const N: usize = 256 << 20; // 256 MB of bases, one byte each
const READ_LEN: usize = 150;
const SCANS: usize = 20_000;

let genome = synthetic_genome(N);
let mut scorer = AlignmentScorer::from_params_minimal();
scorer.align_intron_min = 20;
scorer.align_intron_max = u32::MAX;

let mut read = vec![0u8; READ_LEN];
let mut state = 0x9E37_79B9_7F4A_7C15u64;
for b in read.iter_mut() {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
*b = (state >> 33) as u8 & 3;
}

println!("{:>12} {:>10} {:>12}", "del", "wall", "ns/iter");
for del in [64u64, 1_024, 16_384, 262_144, 4_194_304, 67_108_864] {
// Spread the scans over the genome so no single window stays resident.
let stride = (N as u64 - del - 4096) / SCANS as u64;
let iters_per_scan = 100usize; // r_gap 0 + next_seed_len 100
let t0 = Instant::now();
let mut sink = 0i64;
for k in 0..SCANS {
let g_a_end = 2048 + k as u64 * stride;
let (jr, _motif, score, _l, _r) = scorer.find_best_junction_position(
&read,
75,
g_a_end,
0,
del as i64,
&genome,
false,
N as u64,
75,
iters_per_scan,
);
sink += jr as i64 + score as i64;
}
let dt = t0.elapsed();
let iters = (SCANS * iters_per_scan) as f64;
println!(
"{:>12} {:>9.3}s {:>11.2} (sink {})",
del,
dt.as_secs_f64(),
dt.as_secs_f64() * 1e9 / iters,
sink
);
}
}
179 changes: 163 additions & 16 deletions src/align/score.rs
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,18 @@ impl AlignmentScorer {
let mut best_motif = SpliceMotif::NonCanonical;
let mut best_motif_score = self.score_gap_noncan;

// `del` does not change across the scan, so whether the motif branch
// runs is decided once rather than per iteration.
let motif_in_range =
del >= self.align_intron_min as i64 && del <= self.align_intron_max as i64;

// The four motif bases sit at `donor`, `donor+1`, `donor+del-2` and
// `donor+del-1`, and `donor` moves exactly one base per iteration, so
// consecutive iterations share two of the four. Carrying the window
// instead of re-fetching it turns four genome reads per position into
// two. `window` is `None` until the first motif iteration primes it.
let mut window: Option<MotifWindow> = None;

loop {
let ri = r_a_end_inc as i64 + jr1 as i64;
if ri >= 0 && (ri as usize) < read_seq.len() {
Expand All @@ -378,7 +390,7 @@ impl AlignmentScorer {
}

// Check splice motif at this junction position
if del >= self.align_intron_min as i64 && del <= self.align_intron_max as i64 {
if motif_in_range {
// Donor position in SA space: one past the last donor-exon base
let donor_sa = (g_a_end_inc as i64 + jr1 as i64 + 1) as u64;
// Convert to forward genome coordinates for motif detection
Expand All @@ -387,7 +399,14 @@ impl AlignmentScorer {
} else {
donor_sa
};
let motif = self.detect_splice_motif(donor_fwd, del as u32, genome);
let w = match window.as_mut() {
Some(w) => {
w.slide_to(donor_fwd, del as u64, genome);
&*w
}
None => window.insert(MotifWindow::at(donor_fwd, del as u64, genome)),
};
let motif = w.motif();
let motif_score = self.score_splice_junction(motif);
let score2 = score1 + motif_score;

Expand Down Expand Up @@ -535,23 +554,110 @@ impl AlignmentScorer {
/// `donor_pos` is the 0-based position of the intron's first base on the
/// forward strand; `intron_len` is the intron length in bases.
pub fn detect_splice_motif(donor_pos: u64, intron_len: u32, genome: &Genome) -> SpliceMotif {
let d1 = genome.get_base(donor_pos);
let d2 = genome.get_base(donor_pos + 1);
let a1 = genome.get_base(donor_pos + intron_len as u64 - 2);
let a2 = genome.get_base(donor_pos + intron_len as u64 - 1);

// Base encoding: A=0, C=1, G=2, T=3.
match (d1, d2, a1, a2) {
(Some(2), Some(3), Some(0), Some(2)) => SpliceMotif::GtAg,
(Some(2), Some(1), Some(0), Some(2)) => SpliceMotif::GcAg,
(Some(0), Some(3), Some(0), Some(1)) => SpliceMotif::AtAc,
(Some(1), Some(3), Some(0), Some(1)) => SpliceMotif::CtAc,
(Some(1), Some(3), Some(2), Some(1)) => SpliceMotif::CtGc,
(Some(2), Some(3), Some(0), Some(3)) => SpliceMotif::GtAt,
_ => SpliceMotif::NonCanonical,
MotifWindow::at(donor_pos, intron_len as u64, genome).motif()
}

/// A position off the end of the genome. No motif arm matches it, so it falls
/// through to `NonCanonical` exactly as `get_base` returning `None` did.
const OUT_OF_RANGE: u8 = u8::MAX;

#[inline]
fn base_or_out_of_range(genome: &Genome, pos: u64) -> u8 {
genome.get_base(pos).unwrap_or(OUT_OF_RANGE)
}

/// The four bases that decide a splice motif: the intron's first two and last
/// two, on the forward strand.
///
/// Kept as a struct so the junction scan can slide it. Successive junction
/// positions differ by one base, so three of the four positions overlap the
/// previous window and only two bases have to be read from the genome.
#[derive(Clone, Copy)]
struct MotifWindow {
donor: u64,
d1: u8,
d2: u8,
a1: u8,
a2: u8,
}

impl MotifWindow {
#[inline]
fn at(donor: u64, intron_len: u64, genome: &Genome) -> Self {
Self {
donor,
d1: base_or_out_of_range(genome, donor),
d2: base_or_out_of_range(genome, donor + 1),
a1: base_or_out_of_range(genome, donor + intron_len - 2),
a2: base_or_out_of_range(genome, donor + intron_len - 1),
}
}

/// Move the window to `donor`, reusing what overlaps.
///
/// A step of exactly one base either way shares two of the four: moving
/// right, the old `d2`/`a2` become the new `d1`/`a1`; moving left, the old
/// `d1`/`a1` become the new `d2`/`a2`. Any other step is rare enough that
/// re-reading all four is the simpler answer.
#[inline]
fn slide_to(&mut self, donor: u64, intron_len: u64, genome: &Genome) {
if donor == self.donor + 1 {
self.d1 = self.d2;
self.a1 = self.a2;
self.d2 = base_or_out_of_range(genome, donor + 1);
self.a2 = base_or_out_of_range(genome, donor + intron_len - 1);
self.donor = donor;
} else if donor + 1 == self.donor {
self.d2 = self.d1;
self.a2 = self.a1;
self.d1 = base_or_out_of_range(genome, donor);
self.a1 = base_or_out_of_range(genome, donor + intron_len - 2);
self.donor = donor;
} else if donor != self.donor {
*self = Self::at(donor, intron_len, genome);
}
}

/// Base encoding: A=0, C=1, G=2, T=3.
///
/// A table lookup rather than a match on the four bases. The match compiled
/// to a chain of compares whose outcome is data-dependent and close to
/// unpredictable, which the scan pays at every junction position; the table
/// is one load at a computed index. `|` binds tighter than `>=` in Rust, so
/// the guard tests the union of the four bases and rejects anything that is
/// not `A`, `C`, `G` or `T` — `N`, the chromosome-boundary byte, and the
/// out-of-range sentinel all take that path, exactly as no match arm
/// covered them.
#[inline]
fn motif(&self) -> SpliceMotif {
let (d1, d2, a1, a2) = (self.d1, self.d2, self.a1, self.a2);
if d1 | d2 | a1 | a2 >= 4 {
return SpliceMotif::NonCanonical;
}
MOTIF_TABLE[motif_index(d1, d2, a1, a2)]
}
}

/// Every four-base combination, packed `d1 d2 a1 a2` at two bits each.
static MOTIF_TABLE: [SpliceMotif; 256] = build_motif_table();

/// Pack the four bases into the table index, two bits each.
const fn motif_index(d1: u8, d2: u8, a1: u8, a2: u8) -> usize {
((d1 << 6) | (d2 << 4) | (a1 << 2) | a2) as usize
}

const fn build_motif_table() -> [SpliceMotif; 256] {
// A=0, C=1, G=2, T=3.
let mut t = [SpliceMotif::NonCanonical; 256];
t[motif_index(2, 3, 0, 2)] = SpliceMotif::GtAg;
t[motif_index(2, 1, 0, 2)] = SpliceMotif::GcAg;
t[motif_index(0, 3, 0, 1)] = SpliceMotif::AtAc;
t[motif_index(1, 3, 0, 1)] = SpliceMotif::CtAc;
t[motif_index(1, 3, 2, 1)] = SpliceMotif::CtGc;
t[motif_index(2, 3, 0, 3)] = SpliceMotif::GtAt;
t
}

/// Splice junction motif types
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SpliceMotif {
Expand Down Expand Up @@ -624,6 +730,47 @@ mod tests {

use super::*;

/// The table has to agree with the match it replaced on every input the
/// scan can present, not merely on the six motifs. That includes `N` (4),
/// the chromosome-boundary byte (5) and the out-of-range sentinel, all of
/// which no match arm covered and which must therefore be `NonCanonical`.
#[test]
fn the_motif_table_agrees_with_the_original_match_on_every_input() {
fn original(d1: u8, d2: u8, a1: u8, a2: u8) -> SpliceMotif {
match (d1, d2, a1, a2) {
(2, 3, 0, 2) => SpliceMotif::GtAg,
(2, 1, 0, 2) => SpliceMotif::GcAg,
(0, 3, 0, 1) => SpliceMotif::AtAc,
(1, 3, 0, 1) => SpliceMotif::CtAc,
(1, 3, 2, 1) => SpliceMotif::CtGc,
(2, 3, 0, 3) => SpliceMotif::GtAt,
_ => SpliceMotif::NonCanonical,
}
}

let values = [0u8, 1, 2, 3, 4, 5, OUT_OF_RANGE];
for &d1 in &values {
for &d2 in &values {
for &a1 in &values {
for &a2 in &values {
let w = MotifWindow {
donor: 0,
d1,
d2,
a1,
a2,
};
assert_eq!(
w.motif(),
original(d1, d2, a1, a2),
"disagreement at ({d1}, {d2}, {a1}, {a2})"
);
}
}
}
}
}

fn make_test_genome(seq: &[u8]) -> Genome {
// Create simple genome with one chromosome
let n_genome = ((seq.len() as u64 + 1) / 64 + 1) * 64; // Pad to 64-byte boundary
Expand Down
Loading