Skip to content

perf: share a run's spectra, fit the mass calibration once, and shrink a decoded peak from 24 bytes to 8 - #116

Merged
RobbinBouwmeester merged 8 commits into
mainfrom
perf/stack
Sep 23, 2026
Merged

RobbinBouwmeester merged 8 commits into
mainfrom
perf/stack

Conversation

@RobbinBouwmeester

Copy link
Copy Markdown
Member

This is the integration branch for three changes already up separately, plus one that builds on them. If you merge #112, #113 and #114 individually, rebase this and only the peak layout remains; if you merge this, those three can be closed.

Contains:

The peak layout

A decoded peak carried 24 bytes for 8 bytes of data.

Peak Ms2Scan
before 24 104
without ion_mobility 16
with an f32 m/z and no id 8 80

ion_mobility is an Option<f32>, which is 8 bytes because an f32 has no niche, and it is written at three sites and read at none. mz was an f64 widened at load from the f32 the spectra artifact stores, so it provably never held more than the f32; Ms1Scan.mz is already Vec<f32> with that same justification in its own doc comment. The 22 read sites widen at the point of use.

Measured from the engine's own memory report rather than by arithmetic, on LFQ_Orbitrap_AIF_Ecoli_01 with 465,806 scans and 41,293,465 MS2 points: 0.968 GiB to 0.342, down 64.6%. On the smoke fixture, down 63.9%. Scaled to the HYE Astral shape the analysts measured, 3.84 GiB becomes 1.28.

That compounds with the first change in this branch. Before today a grouped run held one copy of the spectra per band in flight, so 48 bands at 3.84 GiB was 184 GB; it is now one copy at 1.28.

One correction to the survey that found this: Ms2Scan.id is not read at zero sites. It is read at two, a debug-only fingerprint and one test assertion, neither of which feeds a computation or an output column, so it goes, but the claim as written was wrong.

No wall-clock claim is made. The paired seed timings on the author's contended desktop spread 7.0-23.3 seconds against 9.6-20.0, which is machine noise rather than a cache effect.

Equality

Byte-identical, proved twice. Both binaries built and copied aside, then ci/smoke.sh run against each: 189 of 189 produced parquet, TSV and mzML files identical by sha256, covering the ungrouped run, the determinism re-run, the NaN retention-time run, both grouped per_group runs and the two-run experiment. On real data, search-seed over the converted AIF file against the 1.7M-candidate DIA-NN library gives 144,565 PSMs and 6,148 confident in both, with seed_psms.parquet and its masscal.json byte-identical; that sidecar carries the ppm deviations from the loop the narrowing touches, so the path is directly covered.

cargo fmt --check, clippy -D warnings, 408 tests, ci/smoke.sh → SMOKE_OK.

🤖 Generated with Claude Code

RobbinBouwmeester and others added 8 commits September 22, 2026 21:38
…ing arrays

Two items the extract rewrite could not reach, because they live in
`fragindex.rs`.

(1) `FragIndex::probe_peak_win_binned` takes the bin the probe would have computed,
and `bin_of` computes it; `probe_peak_win` is now that function with `bin_of`
applied, so the three cannot drift. The bin is a `ln()`, and extract's sub-range
tasks each re-walk every scan of their window, so each task recomputed it for every
peak: up to twice the thread count, about eight times per window in a grouped band
search. `accumulate_groups` now computes a window's bins once into a flat `u32`
buffer with one offset per scan, wherever the window is actually split into more
than one task, and into a task-local per-scan scratch otherwise.

The other half of the hoist, `peak.mz / mass_off.factor_at(peak.mz)`, is
deliberately still recomputed per task. The previous author measured it alone as a
loss (8 B/peak, +200 MB of peak RSS on the AIF fixture and 317 MB with the whole run
in one batch, no measurable time) and that reading holds: on the probe
microbenchmark added here, five rounds, buffering m/z AND bin was -25 to -27 / -9 to
-14 / -2 to -7% and the bin alone -20 to -29 / -9 to -17 / -4 to -6% (narrow /
medium / wide candidate window), which is one spread, for three times the buffer.
The division is 0.72 ns/peak; the `ln()` is 4.99.

So the buffer is 4 B/peak of the windows in flight: a sixteenth of the run under the
default `windows_in_flight`, a band's 1-3 windows under `groups.parallel`. Measured
against the shipped probe, min of 12 per round, five rounds: the split window (the
shared buffer) -28 to -31 / -8 to -17 / -5 to -7%, and the unsplit window (scratch
only, the same number of `ln()` calls) -15 to -27 / -4 to -11 / -3 to +1%. The
scratch pays because a separate pass takes the `ln()` off the dependency chain the
bin-cache load and then the posting loads hang from. It is also why the bins reach
the peak loop as a `&[u32]` in both cases rather than as an `Option` tested per
peak: that branch measured +4 to +7% on the wide arm even always taking one side,
more than the `ln()` it saved.

(2) `emit_range` slices the four posting arrays once and zips them, so the loop pays
four range checks for a whole range instead of four per posting, and returns early
on the empty range a narrow candidate window mostly produces. Paired A/B on one host
with two binaries differing only in this: -3 to -4.4% of the probe where the ranges
are long (8 and 65 emitted postings per peak), flat where they are not. The
`within_ppm` call is untouched, as the brief asks: precomputing m/z bounds from
`peak_mz` rounds differently from the per-pair `hi - lo <= tol * 1e-6 * lo`, and the
tolerance edge decides real matches.

Risk to output equality: none observed, and the shapes are argued rather than only
sampled. The bin is a pure function of `q_mz`, and `q_mz` is recomputed in the task
from the same peak and the same offset the buffer used, so the buffered bin is the
same value; extract debug-asserts that per peak. `emit_range` keeps the same
predicate, the same order and the same four columns per posting.

Measured, not assumed: `ci/smoke.sh` prints SMOKE_OK, and every one of the 29 data
artifacts it writes is byte-identical to the same fixture run through a binary built
from the parent commit (only `*.report.json` `elapsed_ms` and the manifest's paths /
timings / git sha differ). The fixture cannot resolve the timing difference: it is
IO-dominated, its extract stage is milliseconds, and its eight windows on two threads
never split, so the shared-buffer path does not even run there. That is what the
microbenchmark is for, and it is committed as an `#[ignore]`d test rather than left
in a comment.

Tests: `bin_of_is_the_bin_the_probe_uses` pins the equality the whole contract rests
on, over clamped and out-of-range m/z; the binned probe is compared callback-for-
callback against `probe_peak` inside the existing drop-in test;
`a_bin_past_the_top_is_clamped_not_a_panic` pins the defensive clamp;
`emit_range_pairs_every_posting_with_its_own_columns` pins that every within-
tolerance posting is emitted exactly once carrying its own candidate, intensity and
ordinal, over a run long enough for a mis-aligned slice to show, which a count-only
assertion on a three-posting fixture would not. The extract side is pinned by the
existing `candidate_range_split_reproduces_the_unsplit_accumulation`, which already
runs the same fixture at two threads (unsplit, now the scratch path) and at eight and
sixteen (split, now the shared buffer) and compares the accumulation hit for hit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…iations

A grouped search learned a fragment tolerance 35% too wide, and it cost
identifications. Six-file HYE Astral, same library and the same retention-time
model, 100 isolation-window bands against none:

                         unbanded    100 bands
  frag_ppm_offset         -1.8486     -1.8834
  frag_ppm_sigma           8.452      11.400
  n_dev                  181,196     200,257
  ppm_residual_mad         0.907       1.035
  candidates accepted  4,986,153   6,609,984
  peptides at 1%         113,860     110,006
  precursors at 1%       126,436     121,966

Causal rather than correlated: one band extracted twice, identical in every input
but the calibration file it was handed, accepted 12,414 candidates at 11.40 ppm and
8,791 at 8.45; an unbanded control on the same adapted library returned the unbanded
arm's 113,860 peptides exactly.

search-seed fits 1.5 * p95(|dev - median|) over the ppm deviations of the matched
fragments of its confident targets. Each band did that on its own ~2,000 deviations
and seed-pool combined the bands' SCALARS, which is not that estimator: a small
sample's p95 has a heavier tail than the union's, and each band selected calibrants
on its OWN q (106,088 band-confident against 97,584 pooled).

So fit once over the union, as the retention-time calibration already does with the
pooled anchors under groups.calibration = global:

- the estimator, the masscal.json body and the calibrant sidecar move into
  masscal.rs, so the two call sites share one function rather than two copies of the
  percentile logic;
- search-seed, when asked (SearchSeedParams::emit_calibrants, set only by the
  grouped path), writes <seed>.masscal.parquet: library-wide candidate_id,
  scan_index, frag_mz, ppm, one row per matched fragment, 16 B each;
- seed-pool keeps the deviations whose PSM the POOLED q accepts at fdr_seed, and
  whose scan the pool kept, so an overlap candidate contributes one PSM's fragments
  exactly as an ungrouped seed would, then fits MassCal::fit_from once.
  two_pass_mass_cal and the mass_cal_loess grid keep working on this path because
  both read the deviations rather than a scalar;
- a missing sidecar, which is a band directory from an older build, keeps today's
  scalar combination and says so in the log, with a second warning when
  mass_cal_loess cannot be honoured from scalars. masscal.json records which path
  ran in masscal_source.

The band q is not the pooled q in either direction, and where it is STRICTER the
pooled filter alone would starve the fit: on the CI fixture at three bands every
band's own q rejects every one of its targets, so a strictly q-selected sidecar is
empty in all three. The sidecar therefore also offers the band's best-scoring
targets down to masscal::CALIBRANT_OFFER_PSMS (2,000) and lets the pooled q choose.
The three bands then contribute 437 + 401 + 230 = 1,068 deviations and the pool fits
frag_tol_ppm 5.0 at offset 0.0, which is to the digit what the ungrouped search of
the same spectra produces. Before this the same run calibrated nothing and extracted
at the configured 20 ppm.

What stays identical: an ungrouped run. emit_calibrants is false at all three
ungrouped call sites, no sidecar is written and the calibrant selection is
unchanged. Verified by running ci/smoke.sh from this tree and from a816768 and
comparing all 223 output files: every artifact byte-identical, only elapsed_ms,
git_sha and embedded work-directory paths differ.

What deliberately moves: a grouped run's mass calibration, and every artifact
downstream of it (extract, features, compete, rescore, quant, report). That is the
point of the change.

Validated from rust/mumdia: cargo fmt --check, cargo clippy --workspace
--all-targets -D warnings (clean), cargo test --workspace (319 lib tests), plus
ci/smoke.sh (SMOKE_OK) and a three-band grouped run on the fixture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… buffer

An adversarial review said the -28 to -31% in 0f5310e could not be trusted and
that the change was probably a wall-clock LOSS on the default shape. It was
right on both counts. This commit fixes the harness, re-measures, reverts the
per-window buffer, and keeps the two parts that survive.

WHY THE OLD NUMBER WAS WRONG

Three separate defects, all in the direction that flattered the change.

1. The arms were not comparable. The shared bin buffer was built at
   `bench_fragindex.rs:251`, OUTSIDE every timer, while the arm it was compared
   against filled its scratch inside one. The buffer arm read a fill it never
   paid for.
2. The harness modelled the wrong peak stream: uniform random m/z in arbitrary
   order, where production peaks are m/z sorted (`Ms2Scan.peaks`, and the
   extract loop walks them in that order). Sorted input is exactly the cache
   behaviour the claimed mechanism depends on. Re-measured sorted, the medium
   arm's probe is 168.6 ns/peak, not 257.9.
3. Nothing measured wall clock. The buffer was filled SEQUENTIALLY on the
   calling thread before `rayon::scope`, and the per-task work it removed had
   been spread across the pool. A per-probe microbenchmark cannot see that
   trade at all.

THE CORRECTED MEASUREMENT

`tests/bench_fragindex.rs` now has two parts, peaks m/z sorted within a scan,
checksums asserted equal across every arm. `bench_probe` times one thread and
charges every arm its own setup inside its own timer (the buffer's fill is timed
separately and charged back, divided by the tasks that read one fill, which is
the only honest way to do it serially). `bench_wall` is new: it rebuilds
`accumulate_groups`' own task shape from its own arithmetic and runs it over
rayon, which is the unit that decides a serial-for-parallel trade.

`bench_wall`, 32 threads, min of 9, two independent passes, against the
pre-commit in-probe baseline:

  shape                        scratch          buffer, serial   buffer, on pool
  16 windows, 4 tasks each   -11.7 / -6.4%    +8.5 / +22.1%     -16.6 / -8.5%
  16 windows, 1-2 tasks      -15.1 / -17.9%   +38.9 / +30.0%    -10.3 / -15.3%
  one wide window, 49 tasks  -17.4 / -18.6%   -23.7 / -19.0%    -26.2 / -22.1%

The first row is the default configuration: `tasks_per_window` is
`(2 * threads).div_ceil(groups.len())`, which is 4 at 32 threads and 16 windows
in flight, so the fill costs one serial pass per peak to save three thirty-
seconds of one. Break-even needs `tasks_per_window >= threads`, i.e. a batch of
one or two windows, which only the all-ion row is.

End to end, `mumdia extract` on the real AIF run (152 windows, 1.69M candidates,
465,806 MS2 scans, 32 threads, three binaries interleaved over ~24 reps each,
timing the accumulation phase between the two log lines that bracket it so the
310 MB spectra read is not in the number). Against the pre-commit baseline's
3,274 ms min / 3,360 mean-of-3-fastest / 4,233 median:

  shared buffer, serial fill   +14.0 / +13.2 / +9.3%
  per-scan scratch              -0.3 /  -1.9 / -4.5%

The regression is the size the model predicts: one serial pass over the
in-flight peaks is 39.6M x 5.4 ns = 430 ms of a 3.3 s phase. The machine is
noisy (reps spread 2.5x, hybrid CPU), so the sign and the ordering are the
result; they are the same in every statistic of every pass.

REVERTED

- The per-window `peak_bins` buffer and `per_window`, with the `Option` branch,
  the per-scan offsets, and the two `debug_assert`s that were the only thing
  standing between a buffer/scan mismatch and silently dropped hits in release.
  Also gone with it: the second evaluation of `peak.mz / factor_at(peak.mz)`
  that the two sites had to keep textually identical forever.
- The memory claim. "4 bytes per peak of the windows in flight: a sixteenth of
  the run" was wrong twice: the in-flight fraction is
  `windows_in_flight / n_windows` (16 of 152 on AIF, about 17 MB there, but the
  whole run on an acquisition with 16 windows or fewer, and one copy per band
  under `groups.parallel`). Measured, peak RSS did not separate the arms at all
  (2,266-2,499 MB across all three binaries).
- "-4 to -6% of the probe" for `emit_range`. It came from a pair of binaries
  nothing could reproduce, and the harness it cited could not produce it because
  every arm calls the same private `emit_range`.

KEPT

- The per-scan setup scratch, which is where the whole gain was. `Vec<(f64, u32)>`
  holding ONE scan's `(q_mz, bin)`, a few KB, task-local, refilled per scan. It
  makes exactly as many `ln()` calls as the probe did; it pays because a separate
  pass takes the `ln()` off the dependency chain the bin-cache load and then the
  posting loads hang from. Nothing is shared, nothing is built before the pool
  starts, no thread waits, and no memory is added. It carries `q_mz` as well as
  the bin, so `factor_at` runs once per peak as it did before: that is free at
  this size and matters under `search_seed.mass_cal_loess`, where `factor_at` is
  a binary search over a ~74-point grid at 8.3 ns/peak against 0.725 scalar -- a
  bin-only scratch turned that arm from -1.7% into +5.6%.
- `bin_of` / `probe_peak_win_binned`, which the scratch needs. Their doc comments
  now say what they are for (compute the setup in a pass of its own) and what
  they are not for (caching bins across calls).
- `emit_range`'s slice-and-zip and its empty-range early return, measured
  SEPARATELY as the review asked. `bench_emit_range` (new, `#[ignore]`d, inside
  `mod tests` where it can reach the private fn) times it against a verbatim copy
  of the indexed loop it replaced, same binary, same index, checksum asserted
  equal: -9.3 / -5.7 / -6.3 / -6.7% at 1, 8, 64 and 512 postings in the range.
  That is a percentage of the verify loop, which is about 11% of the widest
  probe, so the ceiling end to end is under 1% and the AIF A/B cannot resolve it.
  Kept for being free and never slower, not for moving the stage.

Also: `bin_of_is_the_bin_the_probe_uses` no longer claims to be the test the
whole contract rests on (it is `f(x) == f(x)` bar a u32 cast); the equality that
matters is now structural, since the scratch computes `q_mz` and its bin in one
expression and the probe reads both out of one entry. The vacuous
`assert_eq!(a.len(), got.len())` in the emit_range test now dedups first.
docs/06 line references were half-stale before this branch and are all
refreshed.

OUTPUT EQUALITY

Byte-identical, verified rather than argued, on real data and on the fixture.
All 69 AIF extract runs across the three binaries wrote the same
`psms_extracted.parquet` and `chromatograms.parquet` (41,677 accepted in every
one) -- which, unlike `ci/smoke.sh`, actually exercises the split-task path.
`ci/smoke.sh` prints SMOKE_OK and 127 of its artifacts are byte-identical to a
binary built from the parent commit (the 5 that differ are logs and a
work-directory path inside `planted.json`).

Validation: `cargo fmt --check`, `cargo clippy --workspace --all-targets
-D warnings`, `cargo test --workspace` (314 passed, 1 ignored),
`cargo build --release --locked`, `ci/smoke.sh` SMOKE_OK,
`python ci/check_doc_refs.py` OK.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reference records by source line the environment reads whose name is not a
literal, and the probe scratch moved them in extract.rs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both branches added a field to SearchSeedParams: the lent MS2 buffer and the
calibrant-deviation switch. Both are kept at every call site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The note above the per-peak setup is merged rather than taken from one side: the
fragment-index branch's version records both attempted hoists and what they
measured, and the shared-spectra branch's version records that the memory-cheap
variant of the hoist, writing the corrected m/z back into the scan, is now
forbidden because the scans are one buffer lent to every band. Both belong.

The two seed call sites in tests/pipeline.rs are ungrouped in shape, so they
emit no calibrant sidecar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The decoded peak array is the engine's largest resident buffer, one entry per
MS2 point of the run, and since the shared-spectra change one copy of it is
resident for a whole grouped run. Two thirds of it were empty.

- `Peak.ion_mobility: Option<f32>` was 8 of the 24 bytes (an `f32` has no niche,
  so the `Option` costs a full word) and was written `None` at three sites and
  read at zero, workspace-wide. No artifact column carries it; `Peak` is not
  serialised. Deleted. A 4D run wants a per-scan `Vec<f32>` parallel to `peaks`,
  the shape `Ms1Scan` already uses, not a per-peak always-`None` `Option`.
- `Peak.mz: f64` was widened at load from the `f32` the spectra artifact stores,
  so the f64 held nothing the f32 did not. Narrowed to `f32`; the ~22 read sites
  widen with `as f64` at the comparison instead.
- `Ms2Scan.id: String` was decoded at load and read by nothing but a
  `debug_assert` fingerprint and one test assertion. Deleted; the `id` column
  stays in the artifact and scans are addressed by `scan_index`.

Measured sizes, not inherited. `size_of`, pinned by a new test:

    Option<f32>  8       Peak  24 -> 16 (no IM) -> 8 (f32 m/z)
    Ms2Scan    104 -> 80

Resident MS2, from the engine's own `mem: ms2 scans` report:

    fixture (480 scans, 30,391 peaks)         779,304 B -> 281,528 B  -63.9%
    AIF_01  (465,806 scans, 41,293,465 peaks)  0.968 GiB -> 0.342 GiB  -64.6%

Both memlog parts solve to the same peak count either side, which is the
accounting cross-check. Scaled to the analysts' 171.6 M-point HYE Astral run:
3.84 GiB -> 1.28 GiB. No wall-clock claim: the paired search-seed timings on
this contended desktop spread 7.0-23.3 s baseline against 9.6-20.0 s, which
swamps any cache effect.

Output equality: proved, not asserted. `f32 -> f64` is exact, so every
`peak.mz as f64` yields the bit pattern the f64 field held, no ppm predicate
changes and no float reduction is reordered. Checked two ways against a
`perf/stack` binary built and copied aside before the edit:

- `ci/smoke.sh` end to end under both binaries, SMOKE_OK from each, and all 189
  produced parquet/TSV/mzML files byte-identical by sha256 -- the ungrouped run,
  the determinism re-run, the NaN-RT run, both `window_groups: 3,
  calibration: per_group` grouped runs and the two-run experiment;
- real data: `search-seed` over `LFQ_Orbitrap_AIF_Ecoli_01` (465,806 scans, the
  1.7M-candidate DIA-NN library, 144,565 PSMs, 6,148 confident) gave a
  byte-identical `seed_psms.parquet` and a byte-identical `masscal.json`, which
  covers the ppm-deviation loop this change edits.

Tests: `peak_is_two_f32_and_nothing_else` and `f32_mz_widens_to_f64_exactly`
pin the layout and the exactness argument; `ms2_peak_mz_is_the_artifact_f32_...`
pins the loader's old behaviour by asserting the new expression against the old
one on f32-awkward values. `tests/pipeline.rs` shared-buffer byte-identity tests
unchanged and passing.

`docs/24_config_reference.md` is regenerated because three env-read line numbers
in `extract.rs` moved. Its "9 environment read(s)" -> "3" count line was already
stale on `perf/stack` (verified by running `--check` against the pristine base);
the generator prints `len()` of a list it then deduplicates.

Not done, and deliberately: the flat CSR peak buffer. At 8-byte peaks a 300-peak
scan block is 2.4 KB, the per-scan `Vec` headers are 37 MB of a 465,806-scan run,
and turning `Ms2Scan` into a non-owning view would touch every stage signature
for about 3% of what this commit already removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@RobbinBouwmeester
RobbinBouwmeester merged commit 8c7e3f2 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