Dev bqtools 0.5.10 - #190
Conversation
…atch to reduce contention
Feat/introduce qc command
…e-pattern fix: only use warning for 2+ patterns
Refactor/use hashbrown hashmap
There was a problem hiding this comment.
Code Review
This pull request introduces a new FastQC-inspired quality control command (bqtools qc) to the CLI, implementing parallelized modules for base/sequence quality, GC content, sequence length, duplication levels, and overrepresented sequences. It also updates some internal collections to use hashbrown and refactors grep pattern matching. The review feedback identifies several key issues: a bug in duplication sampling when using --span due to absolute record indexing, potential OOM risks in sequence length tracking using dense vectors, unidiomatic Rust patterns (such as .div() and bool::then for side effects), a potential panic from slicing non-UTF-8 character boundaries, and redundant computations of overrepresented sequences.
| if self.sample_size > 0 && record.index() as usize >= self.sample_size { | ||
| return; | ||
| } |
There was a problem hiding this comment.
When processing a specific range of records (e.g., using the --span CLI option), record.index() returns the absolute 0-based index of the record in the file, not the relative index within the processed span.
If the span starts at or after self.sample_size (which defaults to 100,000), record.index() as usize >= self.sample_size will be true for all records in the span, causing the duplication and overrepresented sequence modules to sample zero records and produce empty reports.
To fix this, we should pass the starting index of the processed range/span to the duplication module (e.g., via QcConfig::build_qc_modules(start_index)) and check against start_index + sample_size.
| #[derive(Debug, Clone, Default)] | ||
| pub struct SeqLenHistogram { | ||
| /// Indexed directly by sequence length | ||
| inner: Vec<usize>, | ||
| } | ||
| impl SeqLenHistogram { | ||
| fn is_empty(&self) -> bool { | ||
| self.inner.iter().copied().sum::<usize>() == 0 | ||
| } | ||
| fn len(&self) -> usize { | ||
| self.inner.len() | ||
| } | ||
| /// Track a single read's length | ||
| fn push(&mut self, len: usize) { | ||
| if len == 0 { | ||
| return; | ||
| } | ||
| if self.inner.len() <= len { | ||
| self.inner.resize(len + 1, 0); | ||
| } | ||
| self.inner[len] += 1; | ||
| } | ||
| fn ingest(&mut self, other: &mut Self) { | ||
| if self.len() < other.len() { | ||
| self.inner.resize(other.len(), 0); | ||
| } | ||
| self.inner | ||
| .iter_mut() | ||
| .zip(other.inner.iter_mut()) | ||
| .for_each(|(u, v)| { | ||
| *u += *v; | ||
| *v = 0; | ||
| }); | ||
| } | ||
| fn serialize_to<W: Write>(&self, wtr: &mut W) -> Result<()> { | ||
| if self.is_empty() { | ||
| return Ok(()); | ||
| } | ||
|
|
||
| let mut ser = csv::WriterBuilder::default() | ||
| .delimiter(b'\t') | ||
| .has_headers(true) | ||
| .from_writer(wtr); | ||
|
|
||
| self.inner | ||
| .iter() | ||
| .copied() | ||
| .enumerate() | ||
| .filter(|(_, count)| *count > 0) | ||
| .try_for_each(|(len, count)| -> Result<()> { | ||
| ser.serialize(&SeqLenRecord { len, count }) | ||
| .map_err(Into::into) | ||
| })?; | ||
|
|
||
| ser.flush().map_err(Into::into) | ||
| } | ||
|
|
||
| fn total(&self) -> usize { | ||
| self.inner.iter().sum() | ||
| } | ||
|
|
||
| fn min_len(&self) -> Option<usize> { | ||
| self.inner.iter().position(|&c| c > 0) | ||
| } | ||
|
|
||
| fn max_len(&self) -> Option<usize> { | ||
| self.inner.iter().rposition(|&c| c > 0) | ||
| } | ||
|
|
||
| fn mean(&self) -> f64 { | ||
| let total = self.total(); | ||
| if total == 0 { | ||
| 0.0 | ||
| } else { | ||
| let sum: usize = self.inner.iter().enumerate().map(|(len, &c)| len * c).sum(); | ||
| sum as f64 / total as f64 | ||
| } | ||
| } | ||
|
|
||
| fn mode(&self) -> usize { | ||
| self.inner | ||
| .iter() | ||
| .enumerate() | ||
| .max_by_key(|&(_, &c)| c) | ||
| .map_or(0, |(len, _)| len) | ||
| } | ||
|
|
||
| fn summary_table(&self) -> Option<String> { | ||
| if self.is_empty() { | ||
| return None; | ||
| } | ||
| Some(table( | ||
| &["Metric", "Value"], | ||
| &[ | ||
| vec!["Reads".into(), self.total().to_string()], | ||
| vec!["Min Length".into(), self.min_len().unwrap_or(0).to_string()], | ||
| vec!["Max Length".into(), self.max_len().unwrap_or(0).to_string()], | ||
| vec!["Mean Length".into(), format!("{:.2}", self.mean())], | ||
| vec!["Mode Length".into(), self.mode().to_string()], | ||
| ], | ||
| )) | ||
| } | ||
| } |
There was a problem hiding this comment.
Using a dense Vec<usize> for SeqLenHistogram can lead to extremely high memory usage or Out-Of-Memory (OOM) crashes if the input BINSEQ file contains very long sequences (e.g., assembly scaffolds or long reads) or if a record has a corrupted/huge length.
Using std::collections::BTreeMap<usize, usize> is much more robust, memory-efficient for sparse/large lengths, and naturally keeps the lengths sorted for serialization without needing to filter out zero counts.
#[derive(Debug, Clone, Default)]
pub struct SeqLenHistogram {
/// Map from sequence length to count
inner: std::collections::BTreeMap<usize, usize>,
}
impl SeqLenHistogram {
fn is_empty(&self) -> bool {
self.inner.is_empty()
}
/// Track a single read's length
fn push(&mut self, len: usize) {
if len == 0 {
return;
}
*self.inner.entry(len).or_insert(0) += 1;
}
fn ingest(&mut self, other: &mut Self) {
for (&len, &count) in &other.inner {
*self.inner.entry(len).or_insert(0) += count;
}
other.inner.clear();
}
fn serialize_to<W: Write>(&self, wtr: &mut W) -> Result<()> {
if self.is_empty() {
return Ok(());
}
let mut ser = csv::WriterBuilder::default()
.delimiter(b'\t')
.has_headers(true)
.from_writer(wtr);
self.inner
.iter()
.try_for_each(|(&len, &count)| -> Result<()> {
ser.serialize(&SeqLenRecord { len, count })
.map_err(Into::into)
})?;
ser.flush().map_err(Into::into)
}
fn total(&self) -> usize {
self.inner.values().sum()
}
fn min_len(&self) -> Option<usize> {
self.inner.keys().next().copied()
}
fn max_len(&self) -> Option<usize> {
self.inner.keys().next_back().copied()
}
fn mean(&self) -> f64 {
let total = self.total();
if total == 0 {
0.0
} else {
let sum: usize = self.inner.iter().map(|(&len, &c)| len * c).sum();
sum as f64 / total as f64
}
}
fn mode(&self) -> usize {
self.inner
.iter()
.max_by_key(|&(_, &c)| c)
.map_or(0, |(&len, _)| len)
}
fn summary_table(&self) -> Option<String> {
if self.is_empty() {
return None;
}
Some(table(
&["Metric", "Value"],
&[
vec!["Reads".into(), self.total().to_string()],
vec!["Min Length".into(), self.min_len().unwrap_or(0).to_string()],
vec!["Max Length".into(), self.max_len().unwrap_or(0).to_string()],
vec!["Mean Length".into(), format!("{:.2}", self.mean())],
vec!["Mode Length".into(), self.mode().to_string()],
],
))
}
}| .iter() | ||
| .map(|x| x.saturating_sub(PHRED_OFFSET) as usize) | ||
| .sum(); | ||
| let binned_mean = (total as f64).div(&(qual.len() as f64)).round() as usize; |
There was a problem hiding this comment.
| use binseq::BinseqRecord; | ||
| use parking_lot::Mutex; | ||
| use serde::Serialize; | ||
| use std::{io::Write, ops::Div, path::Path, sync::Arc}; |
| self.per_base_qual | ||
| .then(|| add_module(QcModuleType::new_base_quality())); | ||
| self.per_seq_qual | ||
| .then(|| add_module(QcModuleType::new_seq_quality())); | ||
| self.per_base_content | ||
| .then(|| add_module(QcModuleType::new_base_content())); | ||
| self.per_seq_gc | ||
| .then(|| add_module(QcModuleType::new_gc_content())); | ||
| self.seq_length | ||
| .then(|| add_module(QcModuleType::new_seq_length())); |
There was a problem hiding this comment.
Using bool::then purely for side effects (calling add_module) is unidiomatic in Rust. Standard if statements are much more readable and avoid creating unnecessary closures.
if self.per_base_qual {
add_module(QcModuleType::new_base_quality());
}
if self.per_seq_qual {
add_module(QcModuleType::new_seq_quality());
}
if self.per_base_content {
add_module(QcModuleType::new_base_content());
}
if self.per_seq_gc {
add_module(QcModuleType::new_gc_content());
}
if self.seq_length {
add_module(QcModuleType::new_seq_length());
}| fn truncate_sequence(seq: &[u8]) -> String { | ||
| const MAX_LEN: usize = 40; | ||
| let seq = String::from_utf8_lossy(seq); | ||
| if seq.len() > MAX_LEN { | ||
| format!("{}...", &seq[..MAX_LEN]) | ||
| } else { | ||
| seq.into_owned() | ||
| } | ||
| } |
There was a problem hiding this comment.
Slicing a str directly by a byte index (&seq[..MAX_LEN]) can panic if the index does not fall on a UTF-8 character boundary. While nucleotide sequences are typically ASCII, if there are any invalid UTF-8 bytes, String::from_utf8_lossy will insert the 3-byte replacement character ``, which could cause a panic if sliced in the middle.
It is safer to find the nearest valid character boundary or use character iterators.
| fn truncate_sequence(seq: &[u8]) -> String { | |
| const MAX_LEN: usize = 40; | |
| let seq = String::from_utf8_lossy(seq); | |
| if seq.len() > MAX_LEN { | |
| format!("{}...", &seq[..MAX_LEN]) | |
| } else { | |
| seq.into_owned() | |
| } | |
| } | |
| fn truncate_sequence(seq: &[u8]) -> String { | |
| const MAX_LEN: usize = 40; | |
| let seq = String::from_utf8_lossy(seq); | |
| if seq.len() > MAX_LEN { | |
| let mut end = MAX_LEN; | |
| while end > 0 && !seq.is_char_boundary(end) { | |
| end -= 1; | |
| } | |
| format!("{}...", &seq[..end]) | |
| } else { | |
| seq.into_owned() | |
| } | |
| } |
| if self.emit_overrepresented { | ||
| if counter | ||
| .overrepresented(self.overrepresented_threshold) | ||
| .is_empty() | ||
| { | ||
| trace!( | ||
| "No {label} sequences met the overrepresented threshold ({}%)", | ||
| self.overrepresented_threshold | ||
| ); | ||
| } else { | ||
| let mut handle = match_output(Some(outdir.as_ref().join(overrep_path)))?; | ||
| counter.serialize_overrepresented_to( | ||
| &mut handle, | ||
| self.overrepresented_threshold, | ||
| )?; | ||
| } | ||
| } |
There was a problem hiding this comment.
counter.overrepresented(...) is an expensive operation because it iterates over the entire HashMap, collects the elements, and sorts them. Calling it twice (once to check if it's empty, and once inside serialize_overrepresented_to) is inefficient.
We should compute it once and reuse the result.
if self.emit_overrepresented {
let overrepresented = counter.overrepresented(self.overrepresented_threshold);
if overrepresented.is_empty() {
trace!(
"No {label} sequences met the overrepresented threshold ({}%)",
self.overrepresented_threshold
);
} else {
let mut handle = match_output(Some(outdir.as_ref().join(overrep_path)))?;
let mut ser = csv::WriterBuilder::default()
.delimiter(b'\t')
.has_headers(true)
.from_writer(&mut handle);
overrepresented
.into_iter()
.try_for_each(|(seq, count, pct)| -> Result<()> {
ser.serialize(&OverrepresentedRecord {
sequence: String::from_utf8_lossy(seq).into_owned(),
count,
pct,
})
.map_err(Into::into)
})?;
ser.flush()?;
}
}
No description provided.