Skip to content

perf(rescore): fit the folds one at a time, and the logistic regression in two parallel phases - #119

Merged
RobbinBouwmeester merged 10 commits into
mainfrom
perf/r2-final
Sep 24, 2026
Merged

RobbinBouwmeester merged 10 commits into
mainfrom
perf/r2-final

Conversation

@RobbinBouwmeester

Copy link
Copy Markdown
Member

PR #117 was merged at 09:28 UTC, before the rescoring commits and their fix round were pushed to the same branch. These are the six that were left behind.

What is in it

  • perf(rescore): fit the native logistic regression in two parallel phases, and fit the cross-validation folds one at a time rather than all at once.
  • perf(fdr): put entrapment_q on the layout its sibling already uses.
  • fix(rescore): the correction round after an adversarial review of the above.
  • docs: the (1 + folds)x memory statements the change invalidates.

The benchmark was wrong, and the corrected answer is better than the original claim

The original commit measured against a Vec<Vec<f32>> straw man rather than the production code it replaced, which inflated exactly the configuration where the change is weakest. Rebuilt as percolator_lite_parent, a line-for-line transcription of the parent commit's production percolator_lite, with MUMDIA_BENCH_ITERS defaulting to the shipped rescore.num_iter of 10 and three interleaved repeats reported best-of:

folds num_iter 2 num_iter 10 (shipped)
3 1.91x 2.03x
5 1.31x 1.30x

At 30,000 PSMs, num_iter 10: 6.16x / 3.82x / 2.17x at 2 / 3 / 5 folds.

Three numbers are withdrawn in the code: the committed "1.51x at 3 folds, 0.91x at 5", and the "0.24x at 5 folds" on the 30,000-PSM fixture together with the cache-thrashing story invented to explain it — repeated and interleaved, that configuration reads 1.89x.

MUMDIA_BENCH_BASELINE=per_row makes the bad baseline measurable: it costs 1.03x the parent's wall at 3 folds but 1.56x at 5.

Memory

A training copy is (folds - 1) / folds of the matrix, so folds of them is folds - 1 matrices and the OLD peak was folds x the matrix, not the (1 + folds) x five places said. Sequentially it is 1 + (folds - 1) / folds: 1.67x at the default 3 folds, 1.80x at 5, approaching 2x and never above it, and no longer growing with folds. Verified at 300,000 x 387: folds 3 1,385 -> 767 MB, folds 5 2,308 -> 829 MB.

The statement that actually misled an operator is the rescore.max_feature_matrix_gib bail text, which read 1.8x too high at the default and 2.8x at 5 folds. Corrected there, in the config doc it is generated from, in docs/11, in the regenerated docs/24 and in CLAUDE.md.

Also restored

Bounds checks that the perf commit had dropped: assert_eq! on fold_key, is_decoy and init_score against n in percolator_lite (the silent-partial-rescore path), and assert_eq!(rows.len(), y.len()) in logreg_fit replacing a min truncation. Two new should_panic tests.

GRAD_BAND = 16 was re-derived rather than changed: at 300,000x387x5 epochs, best of 3, 0.144 / 0.101 / 0.123 / 0.160 s at B = 8 / 16 / 32 / 64.

Validation

cargo fmt --check, cargo clippy --workspace --all-targets -- -D warnings, rustdoc with -D warnings, cargo test --workspace (361 + 5 + 2 + 6 + 49 + 32 pass), and SMOKE_OK with peptides.tsv f0b5dc38... and proteins.tsv 5a65d304... unchanged.

🤖 Generated with Claude Code

RobbinBouwmeester and others added 10 commits September 22, 2026 23:34
`logreg_fit` was one serial loop over training rows per epoch, and serial all
the way down: `z` is a left-fold into a single f64, which rustc can neither
reassociate nor vectorise, so each of the d terms costs a full scalar-add
latency. The only source of threads in the whole native rescorer was
`percolator_lite`'s parallel map over `folds`, which defaults to 3, so the
shipped default classifier ran ~2,000 full passes per fold (num_iter 10 x
epochs 200) on three cores of whatever machine it was given.

The epoch is now two phases. Phase 1 computes the per-row residual in parallel
over rows; each row keeps its own strict ascending-j fold for `z`, so `z`, `p`
and `err` are bit-for-bit unchanged. Phase 2 folds the residuals in: `grad[0]`
stays one serial left-fold over the rows in ascending order, and `grad[j+1]` is
an independent left-fold per column, so it parallelises over COLUMN BANDS of 16
(64 B = one cache line of f32, so the second pass moves its payload rather than
16x it). Every column sees the same terms in the same order. No sum is
reordered anywhere. The band accumulators are a local array copied back once:
accumulating straight into the `grad` slice shares cache lines between
neighbouring bands, and that false sharing alone made the first version 3.4x
SLOWER than the serial fit.

Also parallelised, all per-row maps with no reduction in them: the standardised
training matrix fill (`std_row_into` now writes a caller slice so the whole
matrix can be filled by `par_chunks_mut`), the per-iteration rescoring of the
training rows, and the held-out test-fold scoring.

Numbers, `logreg_fit_epoch_cost` (new, #[ignore]d; both arms share the prebuilt
row pointers and each allocates its own per-epoch working set inside the timer).
i9-13900KS, 32 rayon threads, release, 387 features: 5.3x at 4,000 rows, 4.4x at
16,000, 2.7-4.3x at 60,000, 3.2x at 300,000 (serial 104 ms/epoch, two-phase 32).
The gain falls as the slice grows because both passes become DRAM streams:
2 x 464 MB in 32 ms is ~29 GB/s, about all this desktop's two channels have. The
survey projected ~12x from a 64-core server model; that is NOT resolvable on
this machine and I am not claiming it. What the fixture does corroborate is the
size of the problem: 104 ms/epoch at 300,000 x 387 scales to ~415 ms at a real
~1.2M-row fold, against the survey's 466 ms estimate, i.e. ~14 min per fold.

Equality: byte-identical. No float sum changes order in either phase.
`percolator_lite_reference` in the test module now calls a transcribed
`logreg_fit_reference` (the old one-pass body) rather than the shared kernel, so
the existing end-to-end test pins the OLD behaviour instead of comparing the new
code with itself. New `two_phase_gradient_fits_bit_identical_weights` compares
fitted weights bit for bit across widths that straddle the band (1, 5, 15, 16,
17, 32, 33, 387), row counts 1..301, an empty row set, a `y` shorter than `rows`
(the old `zip` truncation) and zero epochs.

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

`percolator_lite` mapped over folds with `into_par_iter()`, which was the only
source of threads in the native rescorer, and the price was that every fold's
standardised training copy was live simultaneously. On a six-run Astral pool
(3,133,636 PSMs x 387 features) that is the 4.85 GB matrix plus three 3.23 GB
copies = 14.55 GB; at 11.6M PSMs, 17.95 + 3 x 11.97 = 53.8 GB. Sequentially it
is the matrix plus one copy: 8.08 GB and 29.9 GB, saving 6.47 and 23.9 GB.
`train_idx`/`test_idx`, `mean`/`std`, `train_scores` and the `rows` pointer
vector become single-copy at the same time (~280 MB at the Astral pool, ~1.0 GB
at 11.6M), and `fold_of` and the two index vectors are now `u32` rather than
`usize` (~60 MB / ~220 MB more). This is only affordable because the previous
commit made one fold saturate the machine; before it, this would have been 3x
the wall. What it still costs is the serial remainder that no longer overlaps
across folds -- the tied-block walk in `target_decoy_q`, the positive-set build,
the bias fold -- which is seconds per fold against a fit measured in minutes.

Second change, the same call path: `fdr::target_decoy_q_split(&[f64], &[bool])`
is the same kernel from two parallel columns instead of a slice of pairs.
`percolator_lite` held a `Vec<(f64, bool)>` allocated OUTSIDE the `num_iter`
loop and merely refilled inside it, so 16 bytes per training row stayed resident
for the whole fit purely to pair two columns the fold already had: 3 x 2.089M x
16 = 100 MB concurrent at the Astral pool, 371 MB at 11.6M, now a 2.1 MB decoy
mask gathered once. `entrapment_q` already took its columns separately, so the
two kernels are now consistent. `target_decoy_q` keeps its signature and is a
one-line wrapper over the shared body, so no caller outside this subsystem
changes.

Equality: byte-identical. Folds are genuinely independent -- each fits its own
scaler on its own train rows and writes only its own disjoint test indices -- so
the merge is a scatter, not a reduction, and the order the folds are consumed in
cannot move a value; scattering inside the loop is the same scatter. The split q
form reaches the same kernel with the same `(score, is_decoy)` per row.
`total_d` is now counted off the sorted records instead of the input, which is
the same integer over the same multiset.

Validation: SMOKE_OK, 144 assertions. `cargo test --workspace` 314+ pass.
`flat_training_matrix_scores_bit_identically_to_the_per_row_layout` now compares
against a reference that is the complete OLD implementation -- parallel folds,
`usize` bookkeeping, `Vec<Vec<f32>>` training rows, the paired-staging q call and
the one-pass fitter -- via a transcribed `fit_standardizer_reference`, so it pins
the behaviour being replaced rather than the shape replacing it. New
`the_split_and_paired_q_forms_agree_bit_for_bit` and a should_panic test for the
mismatched-column guard.

Not done, blocked: the error text at stages/rescore.rs:250-256 still tells the
user the native peak is "roughly (1 + folds) times" the matrix. After this it is
2x regardless of `folds`. That file is outside this task's file list.

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

`target_decoy_q` was rewritten to rank one 16-byte sortable record per row and
to monotonize inside the same backward pass that computes each block's FDR.
`entrapment_q` was left behind on exactly the form that rewrite documents as
having been removed: a `key` column, a `usize` index permutation sorted with a
comparator that dereferences it, a forward tied-block walk into a third
n-vector, and a fourth for `q`.

Same treatment, line for line. Four n-vectors (32 bytes per row) become two
(`ranked` 16 + `q` 8 = 24): 75 against 100 MB at 3.13M rows, 278 against 371 MB
at 11.6M. The serial `sort_by` over `usize` with two random loads per comparison
becomes `par_sort_unstable_by` over a cache-resident record, and the tied-block
walk is a sequential scan instead of two indirections per row. This kernel runs
five or more times per entrapment rescore -- pooled PSM q, once per source for
`run_psm_q`, three times through `grouped_q` -- over the whole scored table.

Size, stated honestly: the memory term is ~25 MB / ~93 MB, which on its own is
immaterial. The sort is the real term and I cannot put a wall-clock number on it
here, because no entrapment-arm timing exists for this tree and the smoke
fixture does not reach the kernel at all (`QMode::Entrapment` only). What earns
the change is that FDR validity is a first-class objective, the rewrite is a
transcription of one that already exists and is already proven in-repo, and two
divergent copies of one kernel is how the next reader picks the wrong one.

Equality: byte-identical. The stable sort on key-descending over an ascending
index vector is the same permutation as an unstable sort on (key desc, row asc),
and no two records compare equal. In the fused pass, `ne` and `nr` are integer
counts at or above the block -- totals minus what is strictly below -- which is
exactly what the forward walk had accumulated by the end of that block, so
`(ratio * ne + 1) / max(1, nr)` is the same float from the same integers and no
float reordering occurs at all. `qmin` accumulates worst-to-best in both, and
one min per block rather than one per rank is the same value because every rank
in a block held the same `f`. Entrapment still wins over real where a row claims
both, which is the precedence the forward walk's `else if` had.

Tests: `entrapment_q_reference` transcribes the old body and
`entrapment_q_is_bit_identical_to_the_previous_kernel` compares bit patterns over
heavy ties, signed zeros, NaN and both infinities, rows that are neither class,
rows that are both, all-entrapment and all-real populations where `max(1, nr)`
bites, the empty input, and three library-size ratios.

Validation across all three commits: cargo fmt --check, clippy -D warnings,
cargo test --workspace (315 pass), and SMOKE_OK with all 104 parquet and TSV
artifacts hashing identically to the ones the base commit a816768 produced.

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

The previous two commits only make sense as a pair, and the pair is not a
free win: the old arrangement got `folds`-way parallelism for nothing, so
running folds sequentially is faster only where the per-fold speedup beats
`folds`. `percolator_lite_fold_cost` (#[ignore]d) measures exactly that, both
arms handed the same `FeatureMatrix` and each allocating its own standardised
copies, index vectors and per-epoch working set inside the timer; it also
asserts the two arms agree bit for bit, so it is a second equality check.

Measured, i9-13900KS, 32 rayon threads, release, 387 features, 200,000 PSMs
(training slices 206-310 MB, nothing cache-resident): 1.51x at the default
3 folds, 0.91x at 5. So at the shipped default this is a wall-clock gain as
well as a 1.8x smaller peak; under the opt-in `folds: 5` sensitivity recipe it
is wall parity at best on a desktop and the memory is the whole reason.

The same fixture at 30,000 PSMs reads 5.00x / 0.97x / 0.24x at 2 / 3 / 5 folds.
That 0.24x is NOT a fold-count effect: the training slices there are 23 / 31 /
37 MB against this chip's 36 MB L3, so the largest one thrashes and the new
fitter, which is bandwidth-bound, pays for it while the old serial one hides the
misses behind its dependency chain. It is recorded because it is the sort of
number that gets quoted out of context, and because it is the reason the fixture
worth believing is the large one.

Both comments in `percolator_lite` and `logreg_fit` now carry these numbers
rather than a projection. No production code changes in this commit.

Equality: unchanged, byte-identical; this commit adds a test and comments only.

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

An adversarial review extracted `fdr.rs` and `rescoring.rs` into a standalone
crate, transcribed the PARENT commit's production `percolator_lite`, and measured
against that. Its verdict: the equality claims all hold and are strong (bit
identical at 50,000x387, 200,003x97 and 1,000,001x8, across rayon pools of 1/2/3/5/13
threads; `target_decoy_q_split` and `entrapment_q` likewise), the memory claim
holds (300,000x387, folds=3 1,385 -> 767 MB, folds=5 2,308 -> 829 MB), and the
measurement claims do not. It was right about the measurement and about a
behavioural regression.

WHAT WAS WRONG WITH THE MEASUREMENT

`percolator_lite_fold_cost` timed `percolator_lite_reference`, which is the layout
from TWO commits back: it rebuilds `Vec<Vec<f32>>` per training row, which the
parent had already replaced with a flat `xtr`. That arm is now measurable
(`MUMDIA_BENCH_BASELINE=per_row`) and costs 1.03x the parent's wall at 3 folds but
1.56x at 5 (211.6 s against 135.8 s at 200,000x387x10 iters), so it inflated
exactly the configuration where this change is weakest. It also perturbs the arm it
is compared against: at 5 folds it keeps ~800,000 live heap blocks, and the
interleaved sequential arm then varied 124-212 s where it is 104-105 s against the
parent. Second, `MUMDIA_BENCH_ITERS` defaulted to 2 against a shipped
`rescore.num_iter` of 10. Third, every number was a single shot on a hybrid desktop
CLAUDE.md warns about.

CORRECTED NUMBERS

The benchmark now times `percolator_lite_parent`, a line-for-line transcription of
a816768's production `percolator_lite` (parallel fold map, `usize` bookkeeping, flat
`xtr` filled serially, the `Vec<(f64, bool)>` staging buffer outside the `num_iter`
loop, the serial one-pass fitter, `score_std_row` for the test fold).
`MUMDIA_BENCH_ITERS` defaults to 10. `MUMDIA_BENCH_REPS` (default 3) repeats with
the arms interleaved and the order alternating; the summary is best-of with the
spread printed. i9-13900KS, 32 rayon threads, release, 387 features, 200,000 PSMs:

    folds   num_iter 2   num_iter 10 (shipped)
    3       1.91x        2.03x
    5       1.31x        1.30x

and at 30,000 PSMs, num_iter 10: 6.16x / 3.82x / 2.17x at 2 / 3 / 5 folds (at
num_iter 2: 5.82x / 3.46x / 1.89x). Repeat spread 1.00-1.16x on the parent arm.
The implied per-fold speedup is consistent (107.4 s of parent against 3 x 17.6 s,
135.8 against 5 x 20.9: about 6.1x and 6.5x) and beats both fold counts.

So the honest answer is a win everywhere measured, not the "1.51x at 3 folds, 0.91x
at 5" the commit claimed, and not parity. WITHDRAWN: that pair, and the "0.24x at 5
folds" on the 30,000-PSM fixture along with the L3-thrashing story invented to
explain it -- repeated and interleaved, that exact configuration reads 1.89x, so
the story was explaining a noise artifact. The review's mechanism for `num_iter` is
real but small here: the per-fold serial remainder costs relatively more when the
fit is short, worth 6% at 3 folds and nothing at 5, not a change of sign.

`logreg_fit_epoch_cost` also gained repeats, and its old numbers were also single-shot
low: 6.1x at 4,000 rows, 8.5x at 16,000, 5.1x at 60,000, 5.1x at 300,000 (serial
103 ms/epoch, two-phase 20) and 4.9x at 1,000,000, against 5.3x / 4.4x / 2.7-4.3x /
3.2x before. The serial arm reproduces to 1% (104 against 103 ms/epoch), so it was
the two-phase arm that was mistimed. The bandwidth ceiling is 53-55 GB/s on this
desktop, measured twice, not the 29 GB/s recorded before.

THE BEHAVIOURAL FIX

`percolator_lite` drove the fold loop off `fold_of.iter().enumerate()` where the
parent used `(0..n).filter(|&i| fold_of[i] != test_fold)`. With a `fold_key` shorter
than the matrix the parent panicked; the new code returned, leaving every trailing
PSM at its unrescored `init_score`, from where it enters the q population
indistinguishable from a real score. `assert_eq!` on `fold_key`, `is_decoy` and
`init_score` against `n`, with a test for two of them. `logreg_fit`'s
`rows.len().min(y.len())` truncation is likewise now an `assert_eq!`: the one-pass
form fitted on the shorter prefix while dividing the gradient by `rows.len()`, and
callers push the two in lockstep.

OTHER CORRECTIONS IN THESE TWO FILES

- the old peak was `folds` x the matrix, not "(1 + folds) x": a training copy is
  (folds-1)/folds of the matrix, so `folds` of them is folds-1 matrices. The new
  peak is 1 + (folds-1)/folds, so 1.67x at the default and 1.80x at folds 5,
  approaching 2x and never above it, and no longer growing with `folds`;
- the GRAD_BAND rationale was wrong at d = 387. 16 f32 = 64 B BOUNDS the over-fetch
  at 2x, it does not remove it: the row stride is 1548 B, 1548 mod 64 = 12, so one
  row in 16 starts line-aligned and the rest straddle two lines, ~1.94x payload. The
  real trade is over-fetch (~1 + 16/B) against task count (ceil(d/B), already 25
  against 32 threads at B = 16). Swept at 300,000x387x5 epochs, best of 3, with the
  serial control at 0.500-0.517 s in all four builds: 0.144 s at B = 8, 0.101 at 16,
  0.123 at 32, 0.160 at 64. 16 is the measured optimum, so the constant stands on
  the measurement rather than on the reasoning;
- the per-epoch cost is 2.4x the serial form's DRAM traffic, not 2x: phase 2b
  re-reads the `rows` pointer vector and `errs` once per band, 25 times per epoch at
  d = 387, which is 1.25 GB on top of two 3.23 GB matrix passes at a 2.09M-row fold;
- `percolator_lite_fold_cost` now states what it does NOT measure: it is a kernel on
  a synthetic population, not `mumdia rescore` on a real competed table, so no
  share-of-stage-runtime claim can rest on it.

REJECTED

- the review's finding 9 calls the two q entry points a divergence hazard. They are
  not: `target_decoy_q` and `target_decoy_q_split` are both one-line wrappers over
  `target_decoy_q_core`, so there is no second estimator to drift. The five
  remaining pair-form call sites are a saving left on the table (186 MB at 11.6M
  rows) and are now named in `fdr.rs` with that distinction stated; they are outside
  this file set;
- GRAD_BAND is not changed. The reasoning was wrong, the constant is right.

NOT REACHABLE FROM THIS FILE SET (five stale "(1 + folds)" statements, all wrong by
the arithmetic above; two of them also still describe the matrix as `Vec<Vec<f64>>`,
which it has not been since it became a flat f32 `FeatureMatrix`):
stages/rescore.rs:252, mumdia-core/src/config.rs:1572,
docs/11_compete_rescore_fdr.md:301, docs/24_config_reference.md:319, CLAUDE.md:405.

Equality: unchanged, byte-identical. The equality test now pins against BOTH older
layouts (parent and per-row), and the benchmark asserts bit equality against the
parent on every repeat, which it did at 200,000x387 and 30,000x387 over six
configurations. SMOKE_OK, 144 assertions, `peptides.tsv` and `proteins.tsv` hashes
unchanged across the bounds-check restoration. `cargo fmt --check`, `cargo clippy
--workspace --all-targets -D warnings`, `cargo test --workspace` 318+ pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`rescoring.rs` now fits the folds one at a time, so only one standardised
training copy is live. Five places still described the parallel-fold peak as
`(1 + folds)x` the matrix, and four of them additionally described the matrix as
`Vec<Vec<f64>>`, which it has not been since it became a flat f32
`FeatureMatrix`.

Both figures were wrong in the same direction. A training copy is
`(folds - 1) / folds` of the matrix, so `folds` of them is `folds - 1` matrices
and the OLD peak was `folds x` the matrix, not `(1 + folds) x`. The new peak is
`1 + (folds - 1) / folds`: 1.67x at the default 3 folds, 1.80x at 5, approaching
2x and never above it. Measured at 300,000 x 387: folds 3 1,385 -> 767 MB, folds
5 2,308 -> 829 MB.

The one that misled an operator is the `rescore.max_feature_matrix_gib` bail
text, which read 1.8x too high at the default and 2.8x at 5 folds. Corrected
there, in the config doc it is generated from, in docs/11, in the regenerated
docs/24 and in CLAUDE.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The chunked `write_scored_table` from #118 moved every environment read below it
in `rescore.rs` by ten lines, and `docs/24_config_reference.md` records each read
by source line. No setting, default or variable changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@RobbinBouwmeester
RobbinBouwmeester merged commit c0d5974 into main Sep 24, 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