perf(convert): decode the mzML in parallel, and stop calling a whole file truncated - #115
Merged
Merged
Conversation
…file truncated
Four changes to `convert`, which costs 282 s per immuno file and 29 s per Astral
file, is strictly serial inside the per-run chain, and overlaps nothing.
1. CORRECTNESS, and the reason this is not a pure performance commit. A
non-finite retention time in the LAST spectrum made convert refuse a perfectly
whole mzML as "truncated or corrupt" and tell the user to re-transfer a
multi-gigabyte file. `read = count + 1` was the final statement of the loop
body and the non-finite-RT arm reached it through `continue`, so a dropped
scan never advanced `read`; anywhere but the tail the next good spectrum
re-assigned it and hid the bug. `read` is now assigned at the top of the body,
after the `--max-spectra` break so `capped` still evaluates identically. The
test pins the OLD behaviour: it fails on the previous code with exactly the
truncation bail, and a sibling test keeps the real guard firing on a file that
genuinely ends early.
2. The mzML parse now runs on several threads. `decode_one` computes everything
one spectrum contributes from that spectrum alone; `Fold::absorb` keeps every
order-dependent value (scan_index, the preceding MS1, the window-id map, the
two drop counters, the first offending scan id, the parquet row order) on one
thread in index order. `MZReader::open_path` already builds or reads the offset
index and then discarded it; the byte offsets now cut the file into ~1 MiB
chunks, worker `w` takes chunks `w, w + workers, ...` on its own reader with a
256 KiB buffer (mzdata's default is 10,000 bytes), and one bounded queue per
worker lets the fold consume in order with no reorder buffer and bounded
memory. Concurrent conversions under `experiment.parallel_runs` share the rayon
pool rather than each taking all of it; `MUMDIA_CONVERT_THREADS` overrides and
`0`/`1` forces the old path.
The parallel path is taken only for mzML with an initialised offset index
exactly as long as the header's `spectrumList count`, and only after a
two-spectrum probe confirms that seeking to the index's offset for the last and
middle spectra finds a spectrum that calls itself that index. That catches a
stale index, a short file, and a file whose `<spectrum>` elements omit the
required `index` attribute (mzdata reports 0 for all of them, and a seek-driven
decode would mislabel every scan). Anything else runs sequentially, which is
the path the completeness check is written against. A parse error part-way
through still ends the run exactly where it did: the fold stops at the first
index a worker could not read and discards everything after it.
3. Inflate moves from miniz_oxide to zlib-rs, by adding `mzdata-bindata` with its
`zlib-rs` feature as a direct dependency. NOT by adding `zlib-rs` to mzdata's
own feature list: that also enables `mzdata-spectrum/zlib` -> `flate2/zlib` ->
`any_c_zlib` -> libz-sys, and flate2 ranks the C backend above zlib-rs, so that
spelling quietly reintroduces a C build and selects the opposite codec.
`cargo tree` confirms this spelling adds zlib-rs 0.6.8 and no libz-sys and no
new `cc`.
4. msconvert is asked for `--mz64 --inten32` instead of `--64`. `--64` is
msconvert's own default and set both arrays to 64 bits, while convert stores
intensity as f32 whatever it reads, so every intensity was written at double
width, deflated, inflated and then halved on the first read. m/z keeps 64 bits.
EQUALITY. Byte-identical artifacts, in every case, and asserted rather than
argued. Every float in `decode_one` is confined to one spectrum, so no reduction
is reordered and the peak filter's counters are folded in the same order. DEFLATE
is exactly specified by RFC 1951. Both `--64` and `--inten32` round once, to
nearest, from the same source value to the same f32. Checked: three unit tests
diff all four parquet files and their content hashes between the two decode paths
(uncapped, under `--max-spectra`, and with a wrong `index` attribute); on
LFQ_Orbitrap_AIF_Ecoli_02.mzML and _03.mzML all four artifacts are identical
sequential against parallel; and `ci/smoke.sh` gives the same peptides.tsv
(f0b5dc38) and proteins.tsv (5a65d304) hashes either way. `--inten32` is zero on
every workload measured here, because AIF, HYE and immuno are all supplied mzML
or Thermo, and it could not be exercised (msconvert is not on this machine); it
earns its place as a two-line edit with an unchanged artifact, and must be
promoted on a vendor-file benchmark, not on these numbers.
MEASURED on LFQ_Orbitrap_AIF_Ecoli_02.mzML (1.544 GB, 236,042 spectra, 32 cores,
warm page cache, medians of 3-4 runs, the stage's own elapsed_ms):
sequential (the old path) 14,490 ms
1 worker 9,900
2 3,319
4 1,859
8 1,414
32 1,479
About 10x. LFQ_Orbitrap_AIF_Ecoli_03.mzML: 12,701 -> 1,544 ms. No IO was removed:
the same bytes are read from the same file, and what moved is CPU and read
syscalls. The 1-worker arm isolates the read buffer and the chunked reads from the
fan-out and is about a third of the total on its own. The curve is flat past 8
because the serial tail (index read, parquet encode and write, blake3 of the four
artifacts) is about 1.4 s on this file. zlib-rs is -20% at two decode threads
(4,143 -> 3,319 ms) and -11% at four (2,079 -> 1,859), and within noise at 32
because the stage is then at that floor. Peak process working set is unchanged at
9.4-9.8 MB, because chunks are sized in FILE bytes rather than in spectra.
Two corrections to the survey this came from. The zlib-rs gain is larger than the
1.34x microbenchmark predicted at low thread counts and invisible at high ones,
because the stage reaches a serial floor the survey sized at 1.0-1.3 s and which
measures 1.4 s here. And a non-trivial part of the parallel win is not
parallelism: replacing mzdata's 10,000-byte read buffer removes about a third of
the sequential time on its own.
Not done: the committed fixture is a few megabytes, which is three chunks, and at
that size a benchmark measures process setup and the parquet write rather than the
decode. Rather than commit a number that means nothing, the A/B is an `#[ignore]`d
test that takes a real mzML in `MUMDIA_BENCH_MZML`, times every worker count and
asserts the artifacts match.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
convertis strictly serial inside the per-run chain, so nothing overlaps it: 63.7 minutes of the seven-file immuno experiment, and 29 seconds per Astral file. It is now about ten times faster, and it no longer rejects a valid file.The correctness bug, taken first
A non-finite retention time in the last spectrum made convert refuse the whole mzML as truncated or corrupt. The read counter was the last statement of the loop body and the non-finite arm reached it through
continue, so the count came out one short of the header's declaration and the guard fired. The counter moves to the top of the body, after the--max-spectrabreak so the cap still evaluates identically. The test pins the old behaviour: it fails on the previous code with exactly that message. Siblings cover the mid-file case, a genuinely short file, and the cap.The parallel decode
decode_one(per spectrum) is split fromFold::absorb(all order-dependent state, one thread, index order). The offset indexMZReader::open_pathalready builds and then discarded now cuts the file into ~1 MiB chunks; workerwtakes chunksw, w+workers, …on its own reader, and one bounded channel per worker lets the fold consume strictly in order with no reorder buffer.Measured on
LFQ_Orbitrap_AIF_Ecoli_02.mzML, 1.544 GB, 236,042 spectra, 32 cores, medians of 3-4 runs:A second file goes 12,701 to 1,544 ms. Note the one-worker column: about a third of the sequential time was mzdata's 10,000-byte read buffer, not the serialism, which the survey had folded into its estimate.
It is gated on the file agreeing with its own index: mzML, an index as long as the header count, and a two-spectrum probe of the last and middle spectra. A nonconforming file that reports index 0 for every spectrum falls back to sequential rather than failing, and there is a test for it.
MUMDIA_CONVERT_THREADSoverrides;0or1forces the old path. Peak working set is unchanged at about 9.5 MB, because chunks are sized in file bytes rather than in spectra.Two smaller items
The zlib backend now really is zlib-rs. The obvious spelling selects the opposite codec:
mzdata/zlib-rsalso enablesflate2/zlib, and flate2 ranks the C backend above zlib-rs.cargo treeon the shipped spelling shows zlib-rs 0.6.8 with no libz-sys and no newcc. Separately measured at -20% at two threads, -11% at four, and invisible at 32, where the stage hits a serial floor of about 1.4 seconds in the index read, the parquet encode and the hashing.msconvertis asked for--mz64 --inten32instead of--64, so intensities are not inflated to 64 bits and halved again. No measurement: msconvert is not on this machine and every workload here is supplied mzML or Thermo.Equality
Byte-identical, asserted rather than argued. Every float in
decode_oneis per spectrum, so no reduction is reordered, and the counters fold in the same order. Three unit tests diff all four parquet artifacts and their content hashes between the two paths, including with the cap and with a wrong index attribute; both real 1.5 GB AIF files are identical sequential against parallel; andci/smoke.shgives the samepeptides.tsvandproteins.tsvhashes withMUMDIA_CONVERT_THREADS=1and with the default, on both its ungrouped and grouped arms.cargo fmt --check,clippy -D warnings, 401 tests,cargo build --release --locked,ci/smoke.sh→ SMOKE_OK, and the licence, SBOM, CLI-reference, doc-reference and workflow checks all pass.🤖 Generated with Claude Code