marcelm / marcelm/cutadapt

Improving cutadapt speed with functional programming tools map and filter

Open
#652 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Python
Stars
587
Forks
144
Avg merge
2h 37m
Merged PRs (30d)
1

Description

Hi,

I did a performance profiling of cutadapt:

python -m cProfile -s tottime -m cutadapt -a AGATCGGAAGAGCACACGTCTGAACTCCAGTCA ~/test/big2.fastq -o /dev/null --quiet | head -n 50
         70943657 function calls (70942386 primitive calls) in 42.604 seconds

   Ordered by: internal time

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
  5000000   22.428    0.000   22.428    0.000 {method 'locate' of 'cutadapt._align.Aligner' objects}
        1    5.872    5.872   42.566   42.566 pipeline.py:454(process_reads)
  5000000    2.228    0.000    6.290    0.000 steps.py:278(__call__)
  5000000    1.964    0.000   29.084    0.000 modifiers.py:219(__call__)
  5000000    1.517    0.000   26.655    0.000 modifiers.py:270(_match_and_trim_once_action_trim)
  5000000    1.489    0.000    2.643    0.000 writers.py:149(_write)
  5000000    1.328    0.000   25.082    0.000 adapters.py:1096(match_to)
  5000000    1.269    0.000   23.754    0.000 adapters.py:749(match_to)
  5000000    1.111    0.000    1.419    0.000 statistics.py:16(update)
  5000000    1.040    0.000    1.040    0.000 modifiers.py:42(__init__)
  5000000    0.698    0.000    0.698    0.000 {method 'write' of '_io.BufferedWriter' objects}
10151293/10151082    0.599    0.000    0.599    0.000 {built-in method builtins.len}
  5000000    0.456    0.000    0.456    0.000 {method 'fastq_bytes' of 'dnaio._core.SequenceRecord' objects}
5000104/5000103    0.282    0.000    0.282    0.000 {method 'extend' of 'list' objects}
   148033    0.104    0.000    0.182    0.000 adapters.py:177(add_match)

So what stands out to me is the time needed for the process_reads function.

    def process_reads(
        self, progress: Progress = None
    ) -> Tuple[int, int, Optional[int]]:
        """Run the pipeline. Return statistics"""
        n = 0  # no. of processed reads
        total_bp = 0
        for read in self._reader:
            n += 1
            if n % 10000 == 0 and progress is not None:
                progress.update(10000)
            total_bp += len(read)
            info = ModificationInfo(read)
            for modifier in self._modifiers:
                read = modifier(read, info)
            for filter_ in self._steps:
                if filter_(read, info):
                    break
        if progress is not None:
            progress.update(n % 10000)
        return (n, total_bp, None)

So what happens here is that:

  • stats are counted
  • modifiers are applied
  • filters are applied, with the process stopping if a filter returns true.

This python code is expensive. An alternative option is to:

  • Make a StatsCounter class with a call method. This can be implemented in cython.
  • Rewrite the filters to return False when filtering. (Set global DISCARD to False)
  • Utilize the following pseudocode:
def process_reads(self, progress: Progress = None):
    iterator = iter(self._reader)
    stats_counter = StatsCounter(progress)
    iterator = map(stats_counter, iterator)
    for modifier in self._modifiers:
        iterator = map(modifier, iterator)
    for filter_ in self._steps:
        iterator = filter(filter_, iterator)
    write_reads_to_fastq(iterator)
    return stats_counter.n, stats_counter.total_bp, None

This will remove quite a lot of python overhead from the pipeline. I applied this on my fastq-filter program. Admittedly, that one is a bit over optimized (with every filter being written in C), but it works quite well. (see https://github.com/LUMC/fastq-filter/blob/ac6173fadd0d802deecc60cbcf848d810d1f025d/src/fastq_filter/__init__.py#L128)

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with process_reads in pipeline.py, identified in the profiling output, and reproduce the supplied cProfile command on a representative FASTQ input. Review the proposed StatsCounter, map, and filter pipeline alongside the linked fastq-filter implementation. Done means a measured reduction in Python pipeline overhead while preserving statistics, modifier application, filtering, progress updates, and output behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
performance
Issue type
Refactor
Difficulty
5/5
Estimated time
Over a week
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.