Skip to content

perf(concat): block-copy concatenation instead of SeqRecord round-trip (89x faster, -2.6 GB) - #90

Open
sanjaynagi-eit wants to merge 5 commits into
gbouras13:mainfrom
sanjaynagi-eit:perf/concat-and-fasta-io
Open

perf(concat): block-copy concatenation instead of SeqRecord round-trip (89x faster, -2.6 GB)#90
sanjaynagi-eit wants to merge 5 commits into
gbouras13:mainfrom
sanjaynagi-eit:perf/concat-and-fasta-io

Conversation

@sanjaynagi-eit

@sanjaynagi-eit sanjaynagi-eit commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Hey @gbouras13

PR here which should hopefully make a worthwhile difference in terms of speed and memory. You should be able to edit the PR, but let me know if you want me to make any changes!


Claude-ish below:

Problem

concatenate_single_fastq parses both input FASTQs into a python list of BioPython SeqRecords and then writes them out again:

records = []
records.extend(SeqIO.parse(handle, "fastq"))   # x2
SeqIO.write(records, handle, "fastq")

SeqRecord overhead is roughly 5-10x the file size, so concatenating the short reads of a hybrid run can transiently need tens of GB. concatenate_single_fasta does the same for contigs, and Plass.get_depth_long / Assembly.combine_input_fastas each carry their own copy of the list(SeqIO.parse(...)) habit.

Concatenating records requires no parsing at all.

Change

Copy the inputs in 1 MiB blocks, decompressing gzipped sources on the way through. Two details worth calling out:

  • Newline safety: a source whose final line is unterminated would otherwise run straight into the next file's first header. _append_file appends a newline when the copied data does not end with one.
  • Format validation: parsing every record used to catch a wrong-format input, and test_concat_single_fastq_bad depends on that. The first byte is now checked against the format's record marker (@ / >) — same guarantee, O(1) instead of O(file).

Plass.get_depth_long now calls concatenate_single_fasta instead of building a list of records. Assembly.combine_input_fastas genuinely has to parse (it renames contigs) but streams them rather than materialising the assembly as a list.

Measurements

573 MiB of ONT FASTQ, each implementation in a fresh process:

before after
time 28.44 s 0.32 s 89x
peak RSS 2672 MB 88 MB -2.6 GB

Output is byte-identical, and the parsed (id, sequence, qualities) records match exactly.


One thing deliberately left alone

Assembly.combine_input_fastas contains:

for record in records:
    ...
    record.id = str(i)
    records[0].description = ""   # <- always index 0, inside the loop

That clears only the first plasmid's description while every later one keeps its own — almost certainly a copy-paste slip. It matters, because get_contig_circularity() decides circularity by looking for "circular" in the description. Fixing it would change results, so the behaviour is preserved exactly and flagged with a comment for a separate PR.

sanjaynagi-eit and others added 5 commits August 13, 2026 21:46
…ords

concatenate_single_fastq parsed both inputs into a list of BioPython
SeqRecords and rewrote them. SeqRecord overhead is roughly 5-10x the file
size, so concatenating short reads in a hybrid run could transiently need
tens of GB. concatenate_single_fasta did the same for contigs, and
Plass.get_depth_long / Assembly.combine_input_fastas had their own copies of
the list(SeqIO.parse(...)) habit.

Concatenating records requires no parsing. Copy in 1 MiB blocks instead,
decompressing gzipped inputs on the way through, and guarantee a newline
between files so a source with no trailing newline cannot run its last line
into the next file's header.

Measured on 573 MiB of ONT fastq:
  seconds       28.44 -> 0.32     (89x)
  peak RSS    2672 MB -> 88 MB    (-2.6 GB)
with byte-identical output and identical (id, sequence, qualities) records.

Parsing every record used to catch a wrong-format input, and a test relies on
that, so _append_file checks the first byte instead - O(1) rather than
O(file). Empty inputs are still fine; they are normal when there are no
unmapped reads.

Assembly.combine_input_fastas genuinely has to parse, because it renames
contigs, but it now streams records rather than materialising the whole
assembly as a list first. Its odd 'records[0].description = ""' inside the
per-record loop - which only ever cleared the first plasmid's description,
while later ones kept theirs - is preserved with a comment, because
get_contig_circularity() reads that description and changing it would change
results.
The block copy opens its output before it has looked at the second input,
so a wrong-format input, a truncated gzip or a full disk left a
half-written FASTQ where the run expects a complete one. The SeqIO
version got this for free: it parsed everything before opening the
output, so a failure left no output at all.

Copy to a sibling .tmp and os.replace it into place once every source has
been read. A failed re-run now also leaves any previous output intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
combine_input_fastas used list(SeqIO.parse(...))[0], which raised
IndexError when the chromosome FASTA held no records. Streaming the
records has no equivalent, so an empty or non-FASTA chromosome would
have sailed through, leaving chromosome_name unset and every depth and
copy number computed against a combined.fasta with no chromosome in it.

Check explicitly and report it through logger.error, matching how
qc.py's _fail_chopper handles a fatal condition. The sentinel is None
rather than "" so a record with an empty header still counts as found.

Also pins the renaming in a test, including the long-standing quirk that
only the first plasmid loses its description - get_contig_circularity()
reads circularity out of that description, so fixing it has to be a
deliberate change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Credits gbouras13#90 for the block-copy concatenation and records the two
behaviour changes that come with it: a truncated uncompressed FASTQ is
no longer rejected now that validation is a first-byte check rather than
a full parse, and an empty chromosome.fasta is fatal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.83333% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 47.32%. Comparing base (779d922) to head (236a5f5).
⚠️ Report is 48 commits behind head on main.

Files with missing lines Patch % Lines
src/plassembler/utils/plass_class.py 87.50% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##             main      #90       +/-   ##
===========================================
- Coverage   78.37%   47.32%   -31.05%     
===========================================
  Files          20       20               
  Lines        2044     2132       +88     
  Branches      257      271       +14     
===========================================
- Hits         1602     1009      -593     
- Misses        340     1064      +724     
+ Partials      102       59       -43     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants