Skip to content

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

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

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

Conversation

@sanjaynagi-eit

Copy link
Copy Markdown
Owner

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.

Tests

New tests/test_concat.py: record preservation for FASTQ and FASTA, gzipped input, empty input, the missing-trailing-newline case, wrong-format rejection for both directions, and that FASTA descriptions survive (circularity depends on them). All existing tests pass (76 non-slow).

…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.
@sanjaynagi-eit
sanjaynagi-eit force-pushed the perf/concat-and-fasta-io branch from 4ec43d1 to 1147f80 Compare August 13, 2026 21:46
sanjaynagi-eit and others added 9 commits August 13, 2026 21:53
The chopper QC chain is gunzip -c | chopper --threads N | gzip. Only the
compressor is parallel-unaware, and it dominated: on a 300 MB ONT fastq the
whole chain took 47.7s, of which gunzip+chopper was 5.4s and gzip was 42s.

Use bgzip -@ threads instead. bgzip ships with htslib/samtools, already a hard
plassembler dependency, so this adds nothing to the environment; BGZF is a
valid gzip stream, so flye, minimap2 and chopper read the result unchanged.
It is also slightly smaller here (277 vs 293 MiB). Falls back to gzip if bgzip
is somehow missing.

  gunzip | chopper (no compression)   5.4s
  ... | gzip     (before)            47.7s   293.4 MiB
  ... | bgzip -@ 8 (after)            5.9s   276.7 MiB

Verified content-identical: with chopper --threads 1 (chopper is not
order-deterministic above 1 thread) the decompressed output of the two chains
is byte-for-byte equal.

Also fixes the process handling. Only gzip_proc was waited on, so a failing
gunzip or chopper was silently swallowed - the bare 'except: logger.error'
never saw it - and left zombies behind. Every stage is now waited on and a
non-zero exit is reported with the stage name and log path. The parent also
closes its copy of each upstream pipe read end, and plain (uncompressed) input
is handed to chopper directly instead of through a pointless 'cat' process.

gzip_file, used by --skip_qc, went through python's gzip module, which is
slower still; it now uses the same compressor with a python fallback.
tests/test_data/end_to_end/input_half.fastq.gz is 778 lines - 194.5 FASTQ
records. It ends mid-record, with a header and a sequence line but no + or
quality line, so chopper rejects it (IncompleteRecord) and exits 101 after
emitting 193 complete reads.

That has been true since the fixture was committed;
test_plassembler_case_depth_filter_some passed only because the chopper
failure was swallowed. With this PR's process handling it is detected, and
the test fails.

Drop the two orphan lines. chopper then exits 0 and emits the same 193
records, so nothing the test exercises changes - and the test asserts only
that the command exits 0, so no expectation depends on the fixture's exact
contents.

The full suite (including slow) is 169 passed / 1 failed before this commit
and 170 passed after.
chopper v0.11.0 introduced --trim-approach and made --headcrop/--tailcrop
apply only under `fixed-crop`. They were silently ignored otherwise, with no
warning until v0.13.0 added one, so plassembler's intended 75bp head and tail
crop has been a no-op for anyone on chopper >=0.11.0 since September 2025.

Pass --trim-approach fixed-crop and raise the minimum chopper version to
v0.11.0 in pyproject.toml, build/environment.yaml, README.md and docs/install.md.
Older chopper rejects the flag outright, and plassembler does not check
chopper's exit code, so allowing <0.11.0 would yield an empty read file.

Note chopper >=0.11.0 also re-checks --minlength against the post-crop
segment, which older versions did not, so reads within 150bp of --min_length
are now dropped rather than kept and cropped.

Verified end to end: plasmid sequences are byte-identical, with small shifts
in long read depths and one copy number moving by 0.01.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tests/test_data/validation/concat.fastq was truncated to 0 bytes in the bgzip
commit, unrelated to everything else in that change.

It is not a fixture clobbered by a suite run. test_concat_single_fastq_bad
(tests/test_plassembler.py:218) does name it as an output path, but
concatenate_single_fastq consumes both inputs via
records.extend(SeqIO.parse(handle, "fastq")) before it opens the output, so
parsing test.fasta as fastq raises ValueError first and the file is never
opened for writing. Restoring it and running the full non-slow suite leaves it
at 104636 bytes.

Nothing reads it as an input, so the deletion broke nothing, but it drops 100 KB
of real ONT reads for no reason. Restored from main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The OSError handler kills each already-started stage but never waits on it,
leaving exactly the zombies the rest of this change exists to avoid.

Also records why the `return` below it is load-bearing rather than dead code:
logger.error terminates the process under the ERROR sink that begin_plassembler
installs, but plassembler.utils.qc is importable without that sink, and falling
through would wait on the processes just killed above and report them a second
time as failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Only the last process in the gunzip -> chopper -> compressor chain was
checked, so a chopper that exited non-zero went unnoticed: the compressor
still wrote a valid but empty chopper_long_reads.fastq.gz, "Finished
running chopper" was still logged, and Flye/Raven ran on zero reads. The
real diagnosis reached only <logdir>/chopper.err, which users rarely open.

Every stage is now checked, and any failure - a dead stage, a chopper that
could not be spawned at all, or output with no reads in it - reports
chopper's own stderr alongside the path to the logfile and exits, matching
how external_tools.py handles a CalledProcessError.

check_dependencies() also warns when the installed chopper predates v0.11.0.
The pin was raised to >=0.11.0 for --trim-approach, but a pin only binds when
an environment is first created, so an existing environment can still hold a
chopper that rejects the flag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 1.8.4 entry covered the cropping fix and the fatal-failure handling but
said nothing about the change they were built on top of.

Adds the bgzip compression speedup, including the caveat that bgzip only
reaches us transitively via samtools -> htslib and does not resolve on
osx-arm64, so Apple Silicon users get the gzip fallback and no speedup.

Also credits the per-stage process handling on the existing fatal-failure
bullet, and notes that it is what exposed the long-standing mid-record
truncation in the input_half.fastq.gz fixture.

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

Copy link
Copy Markdown
Owner Author

hey @gbouras13, hope you are well.

During my (LLM) exploration of the plassembler source, this was another finding I wondered if it would be worth scoping further and incorporating?

p.s You may notice there are other PRs in this fork, but I dont think they are necessarily worthwhile.

@gbouras13

Copy link
Copy Markdown

Sounds good and thanks for this - can you make a PR? I'll add some changes on top of it

gbouras13 and others added 4 commits September 7, 2026 22:57
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>
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