Skip to content

perf(convert): decode the mzML in parallel, and stop calling a whole file truncated - #115

Merged
RobbinBouwmeester merged 2 commits into
mainfrom
perf/convert-final
Sep 23, 2026
Merged

RobbinBouwmeester merged 2 commits into
mainfrom
perf/convert-final

Conversation

@RobbinBouwmeester

Copy link
Copy Markdown
Member

convert is 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-spectra break 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 from Fold::absorb (all order-dependent state, one thread, index order). The offset index MZReader::open_path already builds and then discarded now cuts the file into ~1 MiB chunks; worker w takes chunks w, 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:

decode threads stage elapsed
sequential 14,490 ms
1 worker 9,900 ms
2 3,319 ms
4 1,859 ms
8 1,414 ms
32 1,479 ms

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_THREADS overrides; 0 or 1 forces 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-rs also enables flate2/zlib, and flate2 ranks the C backend above zlib-rs. cargo tree on the shipped spelling shows zlib-rs 0.6.8 with no libz-sys and no new cc. 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.

msconvert is asked for --mz64 --inten32 instead 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_one is 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; and ci/smoke.sh gives the same peptides.tsv and proteins.tsv hashes with MUMDIA_CONVERT_THREADS=1 and 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

RobbinBouwmeester and others added 2 commits September 23, 2026 08:37
…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>
@RobbinBouwmeester
RobbinBouwmeester merged commit e8aee45 into main Sep 23, 2026
12 checks passed
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.

1 participant