Skip to main content

fastx/
parallel.rs

1//! Parallel processing helpers (requires the `parallel` feature).
2//!
3//! Two strategies are available:
4//!
5//! * [`par_for_each`] and friends parse on one thread and hand batches of
6//!   records to a rayon pool. This works with any reader, including gzip and
7//!   standard input, and is the right default when the work per record is
8//!   non-trivial.
9//! * [`par_map_chunks_file`] splits an *uncompressed* file into byte ranges,
10//!   snaps each range to a record boundary and parses the ranges in parallel.
11//!   This is the only way to make parsing itself scale across cores.
12//!
13//! ```
14//! use fastx::{FastxReader, parallel};
15//!
16//! let data = b">a\nACGT\n>b\nGGCC\n>c\nAAAA\n";
17//! let gc: Vec<Option<f64>> =
18//!     parallel::par_map(&mut FastxReader::new(&data[..]), 64, |r| r.gc_content())?;
19//! assert_eq!(gc, vec![Some(0.5), Some(1.0), Some(0.0)]);
20//! # Ok::<(), fastx::Error>(())
21//! ```
22
23use std::fs::File;
24use std::io::{self, BufReader, Read, Seek, SeekFrom, Take};
25use std::ops::Range;
26use std::path::Path;
27
28use rayon::prelude::*;
29
30use crate::error::{Error, Result};
31use crate::format::Format;
32use crate::reader::FastxReader;
33use crate::record::Sequence;
34use crate::stats::SeqStats;
35
36/// Default number of records handed to the pool at a time.
37pub const DEFAULT_CHUNK_SIZE: usize = 4096;
38
39/// Reader type handed to each worker by [`par_map_chunks_file`].
40pub type ChunkReader = FastxReader<Take<File>>;
41
42/// A reusable batch of records, so that a long run does not reallocate.
43struct Batch {
44    records: Vec<Sequence>,
45    filled: usize,
46}
47
48impl Batch {
49    fn new(capacity: usize) -> Batch {
50        Batch {
51            records: Vec::with_capacity(capacity),
52            filled: 0,
53        }
54    }
55
56    /// Refill from `reader`, returning the number of records read.
57    fn refill<R: Read>(&mut self, reader: &mut FastxReader<R>, capacity: usize) -> Result<usize> {
58        self.filled = 0;
59        while self.filled < capacity {
60            if self.records.len() == self.filled {
61                self.records.push(Sequence::default());
62            }
63            if !reader.read_into(&mut self.records[self.filled])? {
64                break;
65            }
66            self.filled += 1;
67        }
68        Ok(self.filled)
69    }
70
71    fn as_slice(&self) -> &[Sequence] {
72        &self.records[..self.filled]
73    }
74}
75
76/// Apply `f` to every record, in parallel across batches.
77///
78/// Records within a batch are processed in an unspecified order. `f` must be
79/// `Sync` because it runs on several threads at once — use atomics or a `Mutex`
80/// if it needs to accumulate.
81pub fn par_for_each<R, F>(reader: &mut FastxReader<R>, chunk_size: usize, f: F) -> Result<()>
82where
83    R: Read,
84    F: Fn(&Sequence) -> Result<()> + Send + Sync,
85{
86    let chunk_size = chunk_size.max(1);
87    let mut batch = Batch::new(chunk_size);
88    while batch.refill(reader, chunk_size)? > 0 {
89        batch.as_slice().par_iter().try_for_each(&f)?;
90    }
91    Ok(())
92}
93
94/// Map every record through `f` in parallel, preserving input order.
95pub fn par_map<R, T, F>(reader: &mut FastxReader<R>, chunk_size: usize, f: F) -> Result<Vec<T>>
96where
97    R: Read,
98    T: Send,
99    F: Fn(&Sequence) -> T + Send + Sync,
100{
101    let chunk_size = chunk_size.max(1);
102    let mut batch = Batch::new(chunk_size);
103    let mut out = Vec::new();
104    while batch.refill(reader, chunk_size)? > 0 {
105        let mut mapped: Vec<T> = Vec::new();
106        batch
107            .as_slice()
108            .par_iter()
109            .map(&f)
110            .collect_into_vec(&mut mapped);
111        out.append(&mut mapped);
112    }
113    Ok(out)
114}
115
116/// Fold every record into a single value in parallel.
117///
118/// `identity` builds a fresh accumulator per thread, `fold` adds one record and
119/// `merge` combines two accumulators. This is the pattern for parallel counting
120/// without a lock.
121///
122/// ```
123/// use fastx::{FastxReader, parallel};
124///
125/// let data = b">a\nACGT\n>b\nGGCCGG\n";
126/// let bases = parallel::par_fold(
127///     &mut FastxReader::new(&data[..]),
128///     1024,
129///     || 0usize,
130///     |acc, record| acc + record.len(),
131///     |a, b| a + b,
132/// )?;
133/// assert_eq!(bases, 10);
134/// # Ok::<(), fastx::Error>(())
135/// ```
136pub fn par_fold<R, T, I, F, M>(
137    reader: &mut FastxReader<R>,
138    chunk_size: usize,
139    identity: I,
140    fold: F,
141    merge: M,
142) -> Result<T>
143where
144    R: Read,
145    T: Send,
146    I: Fn() -> T + Send + Sync,
147    F: Fn(T, &Sequence) -> T + Send + Sync,
148    M: Fn(T, T) -> T + Send + Sync,
149{
150    let chunk_size = chunk_size.max(1);
151    let mut batch = Batch::new(chunk_size);
152    let mut accumulator = identity();
153    while batch.refill(reader, chunk_size)? > 0 {
154        let folded = batch
155            .as_slice()
156            .par_iter()
157            .fold(&identity, &fold)
158            .reduce(&identity, &merge);
159        accumulator = merge(accumulator, folded);
160    }
161    Ok(accumulator)
162}
163
164/// Compute [`SeqStats`] using all cores.
165pub fn par_stats<R: Read>(reader: &mut FastxReader<R>, chunk_size: usize) -> Result<SeqStats> {
166    par_fold(
167        reader,
168        chunk_size,
169        SeqStats::new,
170        |mut stats, record| {
171            stats.push(record);
172            stats
173        },
174        |mut a, b| {
175            a.merge(&b);
176            a
177        },
178    )
179}
180
181/// Split an uncompressed FASTA/FASTQ file into `parts` byte ranges that each
182/// start exactly on a record boundary.
183///
184/// The returned ranges cover the whole file, are non-overlapping and in order.
185/// Fewer ranges than requested may come back when the file is small or its
186/// records are large.
187///
188/// ```no_run
189/// let ranges = fastx::parallel::split_file("reads.fq", 8)?;
190/// assert!(ranges.windows(2).all(|w| w[0].end == w[1].start));
191/// # Ok::<(), fastx::Error>(())
192/// ```
193pub fn split_file<P: AsRef<Path>>(path: P, parts: usize) -> Result<Vec<Range<u64>>> {
194    let path = path.as_ref();
195    if crate::format::Compression::from_path(path) != crate::format::Compression::None {
196        return Err(Error::Unsupported(
197            "compressed files cannot be split by byte range; use par_for_each instead",
198        ));
199    }
200    let mut file = File::open(path)
201        .map_err(|e| Error::Io(io::Error::new(e.kind(), format!("{}: {e}", path.display()))))?;
202    let size = file.metadata()?.len();
203    if size == 0 {
204        return Ok(Vec::new());
205    }
206    let format = sniff_format(&mut file)?;
207    let parts = parts.max(1);
208
209    let mut boundaries = vec![0u64];
210    for i in 1..parts {
211        let candidate = size * i as u64 / parts as u64;
212        if candidate <= *boundaries.last().unwrap() {
213            continue;
214        }
215        if let Some(start) = find_record_start(&mut file, candidate, size, format)? {
216            if start > *boundaries.last().unwrap() && start < size {
217                boundaries.push(start);
218            }
219        }
220    }
221    boundaries.push(size);
222    Ok(boundaries.windows(2).map(|w| w[0]..w[1]).collect())
223}
224
225/// Run `f` on a reader for each chunk of an uncompressed file, in parallel.
226///
227/// Each worker gets its own file handle limited to its byte range, so parsing
228/// itself is parallel. Results come back in file order.
229///
230/// ```no_run
231/// use fastx::parallel;
232///
233/// // Count records with real parallel parsing.
234/// let counts = parallel::par_map_chunks_file("reads.fq", 8, |reader| reader.count_records())?;
235/// let total: u64 = counts.iter().sum();
236/// # Ok::<(), fastx::Error>(())
237/// ```
238pub fn par_map_chunks_file<P, T, F>(path: P, parts: usize, f: F) -> Result<Vec<T>>
239where
240    P: AsRef<Path>,
241    T: Send,
242    F: Fn(&mut ChunkReader) -> Result<T> + Send + Sync,
243{
244    let path = path.as_ref();
245    let ranges = split_file(path, parts)?;
246    if ranges.is_empty() {
247        return Ok(Vec::new());
248    }
249    let format = sniff_format(&mut File::open(path)?)?;
250    ranges
251        .into_par_iter()
252        .map(|range| {
253            let mut file = File::open(path)?;
254            file.seek(SeekFrom::Start(range.start))?;
255            let mut reader = FastxReader::with_format(file.take(range.end - range.start), format);
256            f(&mut reader)
257        })
258        .collect()
259}
260
261/// Apply `f` to every record of an uncompressed file with parallel parsing.
262pub fn par_for_each_file<P, F>(path: P, parts: usize, f: F) -> Result<()>
263where
264    P: AsRef<Path>,
265    F: Fn(&Sequence) -> Result<()> + Send + Sync,
266{
267    par_map_chunks_file(path, parts, |reader| reader.for_each_record(&f))?;
268    Ok(())
269}
270
271/// Compute [`SeqStats`] for an uncompressed file with parallel parsing.
272pub fn par_stats_file<P: AsRef<Path>>(path: P, parts: usize) -> Result<SeqStats> {
273    let per_chunk = par_map_chunks_file(path, parts, |reader| {
274        let mut stats = SeqStats::new();
275        reader.for_each_record(|record| {
276            stats.push(record);
277            Ok(())
278        })?;
279        Ok(stats)
280    })?;
281    let mut total = SeqStats::new();
282    for stats in &per_chunk {
283        total.merge(stats);
284    }
285    Ok(total)
286}
287
288/// Read the first meaningful byte of a file to determine its format.
289fn sniff_format(file: &mut File) -> Result<Format> {
290    file.seek(SeekFrom::Start(0))?;
291    let mut byte = [0u8; 1];
292    loop {
293        let read = file.read(&mut byte)?;
294        if read == 0 {
295            return Err(Error::UnknownFormat {
296                hint: "file is empty".to_string(),
297            });
298        }
299        if byte[0].is_ascii_whitespace() {
300            continue;
301        }
302        return Format::from_first_byte(byte[0]).ok_or_else(|| Error::UnknownFormat {
303            hint: format!("first byte is {:?}", byte[0]),
304        });
305    }
306}
307
308/// Find the offset of the first record that starts at or after `from`.
309///
310/// For FASTQ a `@` at the start of a line is ambiguous, because `@` is also a
311/// quality character, so a candidate is only accepted when the following three
312/// lines look like a complete record.
313fn find_record_start(file: &mut File, from: u64, size: u64, format: Format) -> Result<Option<u64>> {
314    if from >= size {
315        return Ok(None);
316    }
317    file.seek(SeekFrom::Start(from))?;
318    let mut reader = BufReader::with_capacity(64 * 1024, file);
319    let mut offset = from;
320    let mut line = Vec::new();
321
322    // Unless we happen to start at byte 0, the first line is a partial one.
323    if from > 0 {
324        let read = read_line(&mut reader, &mut line)?;
325        if read == 0 {
326            return Ok(None);
327        }
328        offset += read as u64;
329    }
330
331    match format {
332        Format::Fasta => loop {
333            let read = read_line(&mut reader, &mut line)?;
334            if read == 0 {
335                return Ok(None);
336            }
337            if line.first() == Some(&b'>') {
338                return Ok(Some(offset));
339            }
340            offset += read as u64;
341        },
342        Format::Fastq => {
343            // Sliding window of four consecutive lines.
344            let mut window: Vec<(u64, Vec<u8>)> = Vec::with_capacity(4);
345            loop {
346                let read = read_line(&mut reader, &mut line)?;
347                if read == 0 {
348                    return Ok(None);
349                }
350                if window.len() == 4 {
351                    window.remove(0);
352                }
353                window.push((offset, trim_newline(&line).to_vec()));
354                offset += read as u64;
355                if window.len() == 4
356                    && window[0].1.first() == Some(&b'@')
357                    && window[2].1.first() == Some(&b'+')
358                    && window[1].1.len() == window[3].1.len()
359                {
360                    return Ok(Some(window[0].0));
361                }
362            }
363        }
364    }
365}
366
367fn read_line<R: Read>(reader: &mut BufReader<R>, line: &mut Vec<u8>) -> Result<usize> {
368    use std::io::BufRead;
369    line.clear();
370    Ok(reader.read_until(b'\n', line)?)
371}
372
373fn trim_newline(line: &[u8]) -> &[u8] {
374    let mut end = line.len();
375    if end > 0 && line[end - 1] == b'\n' {
376        end -= 1;
377    }
378    if end > 0 && line[end - 1] == b'\r' {
379        end -= 1;
380    }
381    &line[..end]
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use std::io::Write;
388
389    fn temp_dir(tag: &str) -> std::path::PathBuf {
390        let dir = std::env::temp_dir().join(format!("fastx-par-{}-{tag}", std::process::id()));
391        std::fs::create_dir_all(&dir).unwrap();
392        dir
393    }
394
395    fn write_fastq(path: &Path, records: usize) {
396        let mut file = std::fs::File::create(path).unwrap();
397        for i in 0..records {
398            // Quality deliberately contains '@' and '+' to confuse naive splitters.
399            writeln!(file, "@read{i} sample\nACGTACGTAC\n+\n@@++IIIIII").unwrap();
400        }
401    }
402
403    fn write_fasta(path: &Path, records: usize) {
404        let mut file = std::fs::File::create(path).unwrap();
405        for i in 0..records {
406            writeln!(file, ">contig{i}\nACGTACGTAC\nGGGG").unwrap();
407        }
408    }
409
410    #[test]
411    fn parallel_batches_preserve_order() {
412        let data = b">a\nAC\n>b\nACGT\n>c\nACGTAC\n";
413        let lengths = par_map(&mut FastxReader::new(&data[..]), 2, |r| r.len()).unwrap();
414        assert_eq!(lengths, vec![2, 4, 6]);
415    }
416
417    #[test]
418    fn parallel_fold_counts_bases() {
419        let data = b"@a\nACGT\n+\nIIII\n@b\nAC\n+\nII\n";
420        let total = par_fold(
421            &mut FastxReader::new(&data[..]),
422            1,
423            || 0usize,
424            |acc, r| acc + r.len(),
425            |a, b| a + b,
426        )
427        .unwrap();
428        assert_eq!(total, 6);
429    }
430
431    #[test]
432    fn parallel_for_each_propagates_errors() {
433        let data = b">a\nACGT\n";
434        let err = par_for_each(&mut FastxReader::new(&data[..]), 4, |_| {
435            Err(Error::Index("boom".into()))
436        });
437        assert!(err.is_err());
438    }
439
440    #[test]
441    fn parallel_stats_match_sequential() {
442        let data = b">a\nACGTACGTAC\n>b\nGGCC\n>c\nNNNN\n";
443        let parallel = par_stats(&mut FastxReader::new(&data[..]), 2).unwrap();
444        let mut sequential = SeqStats::new();
445        FastxReader::new(&data[..])
446            .for_each_record(|r| {
447                sequential.push(r);
448                Ok(())
449            })
450            .unwrap();
451        assert_eq!(parallel.count, sequential.count);
452        assert_eq!(parallel.total_length, sequential.total_length);
453        assert_eq!(parallel.n50(), sequential.n50());
454        assert_eq!(parallel.gc_content(), sequential.gc_content());
455    }
456
457    #[test]
458    fn splits_fastq_on_record_boundaries() {
459        let dir = temp_dir("fq");
460        let path = dir.join("reads.fq");
461        write_fastq(&path, 500);
462
463        for parts in [1, 2, 3, 7, 64, 4096] {
464            let ranges = split_file(&path, parts).unwrap();
465            assert_eq!(ranges.first().unwrap().start, 0);
466            assert_eq!(
467                ranges.last().unwrap().end,
468                std::fs::metadata(&path).unwrap().len()
469            );
470            assert!(
471                ranges.windows(2).all(|w| w[0].end == w[1].start),
472                "{ranges:?}"
473            );
474
475            let counts =
476                par_map_chunks_file(&path, parts, |reader| reader.count_records()).unwrap();
477            assert_eq!(counts.iter().sum::<u64>(), 500, "parts={parts}");
478        }
479        std::fs::remove_dir_all(&dir).ok();
480    }
481
482    #[test]
483    fn splits_fasta_on_record_boundaries() {
484        let dir = temp_dir("fa");
485        let path = dir.join("contigs.fa");
486        write_fasta(&path, 300);
487
488        for parts in [1, 4, 9, 128] {
489            let ids = par_map_chunks_file(&path, parts, |reader| {
490                let mut ids = Vec::new();
491                reader.for_each_record(|r| {
492                    ids.push(r.id.clone());
493                    Ok(())
494                })?;
495                Ok(ids)
496            })
497            .unwrap();
498            let flat: Vec<String> = ids.into_iter().flatten().collect();
499            assert_eq!(flat.len(), 300, "parts={parts}");
500            // Chunks come back in file order, so the ids do too.
501            assert_eq!(flat[0], "contig0");
502            assert_eq!(flat[299], "contig299");
503        }
504
505        let stats = par_stats_file(&path, 8).unwrap();
506        assert_eq!(stats.count, 300);
507        assert_eq!(stats.total_length, 300 * 14);
508        std::fs::remove_dir_all(&dir).ok();
509    }
510
511    #[test]
512    fn parallel_file_processing_sees_every_record() {
513        let dir = temp_dir("each");
514        let path = dir.join("reads.fq");
515        write_fastq(&path, 200);
516        let count = std::sync::atomic::AtomicUsize::new(0);
517        par_for_each_file(&path, 8, |_| {
518            count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
519            Ok(())
520        })
521        .unwrap();
522        assert_eq!(count.load(std::sync::atomic::Ordering::Relaxed), 200);
523        std::fs::remove_dir_all(&dir).ok();
524    }
525
526    #[test]
527    fn empty_file_splits_into_nothing() {
528        let dir = temp_dir("empty");
529        let path = dir.join("empty.fa");
530        std::fs::write(&path, b"").unwrap();
531        assert!(split_file(&path, 4).unwrap().is_empty());
532        assert!(par_map_chunks_file(&path, 4, |r| r.count_records())
533            .unwrap()
534            .is_empty());
535        std::fs::remove_dir_all(&dir).ok();
536    }
537
538    #[test]
539    fn refuses_to_split_gzip() {
540        assert!(split_file("x.fq.gz", 4).is_err());
541    }
542}