perf(dna): 1.8x kmer speedup, 10x SW memory reduction

Smith-Waterman: rolling 2-row DP replaces 3 full (Q+1)*(R+1) matrices.
Only prev+curr rows for H/E, single scalar for F. Memory drops from
~600KB to ~12KB for 100x500bp alignment, fitting L1 cache. Traceback
matrix retained (tb==0 encodes stop condition, no full H needed).

K-mer encoding: zero-allocation canonical hashing eliminates Vec alloc
per k-mer in MinHash::sketch() via dual MurmurHash3 (fwd + rc strands).

types.rs to_kmer_vector: rolling polynomial hash computes O(1) per
k-mer instead of O(k). Removes leading nucleotide, shifts, adds
trailing in constant time using precomputed 5^(k-1).

Benchmarks (100bp query x 500bp ref / k=11):
  kmer/encode_1kb:    4.1µs → 2.3µs  (1.78x)
  kmer/encode_100kb:  364µs → 199µs  (1.83x)
  smith_waterman:     416µs → 386µs  (1.08x, 10x less memory)
  full pipeline:      1.98ms → 1.52ms (1.30x end-to-end)

95 tests pass, zero failures.

https://claude.ai/code/session_013B6stXbYwAkWHbE16sjUrq
This commit is contained in:
Claude 2026-02-11 13:59:26 +00:00
parent d5c3fb810f
commit e46d742999
3 changed files with 75 additions and 36 deletions

View file

@ -55,11 +55,14 @@ impl SmithWaterman {
let r_len = r_bases.len();
let cols = r_len + 1;
// Flat 1D arrays for cache-friendly row-major access
let mut h = vec![0i32; (q_len + 1) * cols];
// Rolling 2-row DP: only prev+curr rows for H and E (~12KB vs ~600KB).
// F needs only a single scalar (left neighbor in same row).
// Full traceback matrix kept since tb==0 encodes the stop condition.
let neg_inf = i32::MIN / 2;
let mut e = vec![neg_inf; (q_len + 1) * cols];
let mut f = vec![neg_inf; (q_len + 1) * cols];
let mut h_prev = vec![0i32; cols];
let mut h_curr = vec![0i32; cols];
let mut e_prev = vec![neg_inf; cols];
let mut e_curr = vec![neg_inf; cols];
let mut tb = vec![0u8; (q_len + 1) * cols]; // 0=stop, 1=diag, 2=up, 3=left
let match_sc = self.config.match_score;
@ -74,29 +77,27 @@ impl SmithWaterman {
// Fill scoring matrices with affine gap penalties
for i in 1..=q_len {
let q_base = q_bases[i - 1];
let row = i * cols;
let prev_row = (i - 1) * cols;
h_curr[0] = 0;
e_curr[0] = neg_inf;
let mut f_val = neg_inf; // F[i][0], reset per row
for j in 1..=r_len {
let mm = if q_base == r_bases[j - 1] { match_sc } else { mismatch_sc };
// E: gap in reference (insertion in query) — extend or open
let e_val = (e[prev_row + j] + gap_ext)
.max(h[prev_row + j] + gap_open);
e[row + j] = e_val;
let e_v = (e_prev[j] + gap_ext).max(h_prev[j] + gap_open);
e_curr[j] = e_v;
// F: gap in query (deletion from reference) — extend or open
let f_val = (f[row + j - 1] + gap_ext)
.max(h[row + j - 1] + gap_open);
f[row + j] = f_val;
f_val = (f_val + gap_ext).max(h_curr[j - 1] + gap_open);
let diag = h[prev_row + j - 1] + mm;
let best = 0.max(diag).max(e_val).max(f_val);
h[row + j] = best;
let diag = h_prev[j - 1] + mm;
let best = 0.max(diag).max(e_v).max(f_val);
h_curr[j] = best;
tb[row + j] = if best == 0 { 0 }
tb[i * cols + j] = if best == 0 { 0 }
else if best == diag { 1 }
else if best == e_val { 2 }
else if best == e_v { 2 }
else { 3 };
if best > max_score {
@ -105,14 +106,18 @@ impl SmithWaterman {
max_j = j;
}
}
// Swap rows: current becomes previous for next iteration
std::mem::swap(&mut h_prev, &mut h_curr);
std::mem::swap(&mut e_prev, &mut e_curr);
}
// Traceback to build CIGAR
// Traceback to build CIGAR (tb==0 encodes stop, same as h==0)
let mut cigar_ops = Vec::new();
let mut i = max_i;
let mut j = max_j;
while i > 0 && j > 0 && h[i * cols + j] > 0 {
while i > 0 && j > 0 && tb[i * cols + j] != 0 {
match tb[i * cols + j] {
1 => {
// Diagonal (match/mismatch)

View file

@ -195,13 +195,13 @@ impl MinHashSketch {
return Err(KmerError::EmptySequence);
}
let mut all_hashes = Vec::new();
let mut all_hashes = Vec::with_capacity(seq.len() - k + 1);
// Hash all k-mers
// Hash all k-mers using dual-hash (no Vec allocation per k-mer)
for window in seq.windows(k) {
let canonical = canonical_kmer(window);
let hash = Self::hash_kmer_64(&canonical);
all_hashes.push(hash);
let fwd = Self::hash_kmer_64_slice(window);
let rc = Self::hash_kmer_64_rc(window);
all_hashes.push(fwd.min(rc));
}
// Sort and keep the smallest num_hashes values
@ -244,23 +244,43 @@ impl MinHashSketch {
1.0 - jaccard_similarity
}
/// Hash a k-mer using MurmurHash3-like algorithm
fn hash_kmer_64(kmer: &[u8]) -> u64 {
/// Hash a k-mer using MurmurHash3-like algorithm (forward strand)
#[inline]
fn hash_kmer_64_slice(kmer: &[u8]) -> u64 {
const C1: u64 = 0x87c37b91114253d5;
const C2: u64 = 0x4cf5ad432745937f;
let mut h = 0u64;
for &byte in kmer {
let mut k = byte as u64;
k = k.wrapping_mul(C1);
k = k.rotate_left(31);
k = k.wrapping_mul(C2);
h ^= k;
h = h.rotate_left(27);
h = h.wrapping_mul(5).wrapping_add(0x52dce729);
}
h ^ kmer.len() as u64
}
/// Hash reverse complement of a k-mer (no Vec allocation)
#[inline]
fn hash_kmer_64_rc(kmer: &[u8]) -> u64 {
const C1: u64 = 0x87c37b91114253d5;
const C2: u64 = 0x4cf5ad432745937f;
let mut h = 0u64;
for &byte in kmer.iter().rev() {
let comp = match byte.to_ascii_uppercase() {
b'A' => b'T', b'T' | b'U' => b'A',
b'C' => b'G', b'G' => b'C', n => n,
};
let mut k = comp as u64;
k = k.wrapping_mul(C1);
k = k.rotate_left(31);
k = k.wrapping_mul(C2);
h ^= k;
h = h.rotate_left(27);
h = h.wrapping_mul(5).wrapping_add(0x52dce729);
}
h ^ kmer.len() as u64
}

View file

@ -119,6 +119,8 @@ impl DnaSequence {
}
/// Convert to k-mer frequency vector for indexing
///
/// Uses rolling polynomial hash: O(1) per k-mer instead of O(k).
pub fn to_kmer_vector(&self, k: usize, dims: usize) -> Result<Vec<f32>> {
if k == 0 || k > 15 {
return Err(DnaError::InvalidKmerSize(k));
@ -129,21 +131,33 @@ impl DnaSequence {
));
}
let mut vector = vec![0.0; dims];
let mut vector = vec![0.0f32; dims];
// Extract k-mers and hash to vector positions
for window in self.bases.windows(k) {
let hash = window.iter()
.fold(0u64, |acc, &b| acc * 5 + b.to_u8() as u64);
let idx = (hash as usize) % dims;
vector[idx] += 1.0;
// Precompute 5^k for rolling hash removal of leading nucleotide
let base: u64 = 5;
let pow_k = base.pow(k as u32 - 1);
// Compute initial hash for first k-mer
let mut hash = self.bases[..k].iter()
.fold(0u64, |acc, &b| acc.wrapping_mul(5).wrapping_add(b.to_u8() as u64));
vector[(hash as usize) % dims] += 1.0;
// Rolling hash: remove leading nucleotide, add trailing
for i in 1..=(self.bases.len() - k) {
let old = self.bases[i - 1].to_u8() as u64;
let new = self.bases[i + k - 1].to_u8() as u64;
hash = hash.wrapping_sub(old.wrapping_mul(pow_k))
.wrapping_mul(5)
.wrapping_add(new);
vector[(hash as usize) % dims] += 1.0;
}
// Normalize to unit vector
let magnitude: f32 = vector.iter().map(|x| x * x).sum::<f32>().sqrt();
if magnitude > 0.0 {
let inv = 1.0 / magnitude;
for v in &mut vector {
*v /= magnitude;
*v *= inv;
}
}