Skip to content

Allocate the prediction output up front instead of concatenating batches - #125

Merged
RobbinBouwmeester merged 5 commits into
mainfrom
fix/predict-memory-guard
Sep 11, 2026
Merged

Allocate the prediction output up front instead of concatenating batches#125
RobbinBouwmeester merged 5 commits into
mainfrom
fix/predict-memory-guard

Conversation

@RobbinBouwmeester

Copy link
Copy Markdown
Member

Problem

predict() on a multitask model without task_idx returns one column per LC setup. At 6,543 setups that is 25.6 kB per peptide, so a 2.6 M PSM run needs a 63 GiB result.

_predict_epoch built it by collecting every batch in a list and calling torch.cat at the end. That needs the result twice over, once in the parts and once in the copy, so the peak is ~126 GiB. Worse, it only fails after every batch has been predicted.

A user hit this through MS2Rescore, which calls predict(..., return_matrix=True) for all PSMs at once. It failed after 100 minutes of prediction with a bare allocator error:

ERROR [enforce fail at alloc_cpu.cpp:115] data. DefaultCPUAllocator:
not enough memory: you tried to allocate 67731356304 bytes.
  File "deeplc/_model_ops.py", line 365, in _predict_epoch
    return torch.cat(predictions, dim=0)

Nothing in that tells the caller the output was 2,587,932 x 6,543, or that task_idx exists.

Change

Allocate the output once, on the first batch, and fill it in place:

  • Halves the peak. One copy instead of the parts plus the concatenation.
  • Fails before the compute, not after. The allocation is attempted on the first batch.
  • Says what went wrong. The error names the size and points at task_idx.
MemoryError: Predicting would need an output of 2,587,932 x 6,543 values (63.1 GiB).
A multitask model returns one column per LC setup, so ask for the setups you need
with predict_kwargs={'task_idx': [...]}, or predict in smaller groups of peptides.

The bucketed path already preallocated; it now shares the same helper so its failure carries the same message. Datasets that cannot report a length still fall back to collecting batches.

Not addressed here

This makes the failure fast and legible. It does not make the 63 GiB request succeed. FactorHead is rank-64, so that matrix is a rank-64 product plus a per-column affine and could be held in (n, 64) instead, 102x smaller. That is a follow-up PR.

Tests

  • preallocated output equals the batchwise concatenation it replaces
  • an allocation that cannot be satisfied raises MemoryError naming task_idx
  • the hint reports the size in GiB

230 tests pass. ruff check on the touched files reports the same 5 pre-existing findings as main, none added.

🤖 Generated with Claude Code

… batches

Predicting with a multitask model and no `task_idx` returns one column per LC
setup. At 6,543 setups that is 25.6 kB per peptide, so a 2.6 M PSM run needs a
63 GiB result. It was being built by collecting every batch in a list and
concatenating at the end, which needs the result twice over and only fails once
all the work is done. A user hit this through MS2Rescore after 100 minutes of
prediction, and the message was a bare DefaultCPUAllocator failure.

The output is now allocated once, on the first batch, and filled in place. That
halves the peak, fails before the compute rather than after it, and reports the
size that did not fit along with the `task_idx` argument that makes it smaller.

Datasets that cannot report a length still fall back to collecting batches. The
bucketed path already preallocated and now shares the same helper, so its
failure carries the same message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RobbinBouwmeester and others added 4 commits September 11, 2026 13:23
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FactorHead finishes with `proj(trunk) @ embedding.T * scale + shift`, so the
(n_peptides, n_tasks) matrix it returns is determined by (n_peptides, rank).
At rank 64 and 6,543 setups that is 102x smaller: a 2.6 M peptide run needs
0.6 GiB of factors instead of 63 GiB of predictions.

`predict_kwargs={"factored": True}` with `return_matrix=True` now returns a
FactoredPredictionMatrix holding those factors. It reports the same shape and
indexes the same way, evaluating only the block asked for, so a caller that
reads a few thousand rows to choose a head and then one column per run never
builds the rest. `np.asarray()` still gives the dense matrix.

Opt-in rather than the default. It was tried as the default and broke eight
tests that call ndarray methods on the result (`out.min()`, `np.isfinite(out)`),
which is exactly what it would do to callers' code: no lazy object can satisfy
the whole ndarray contract, so the choice belongs to the caller who knows
whether they slice the matrix or reduce over it.

Not available for a head fine-tuned onto one setup, which returns that column
alone and has nothing to factor, nor for single-task models.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FactorHead ends with `proj(trunk) @ embedding.T * scale + shift`, so the
(n_peptides, n_tasks) matrix it returns is determined by (n_peptides, rank). At
rank 64 and 6,543 setups that is 102x smaller: 0.6 GiB of factors instead of
63 GiB of predictions for 2.6 M peptides.

`predict(..., return_matrix=True)` now hands back a FactoredPredictionMatrix.
It reports the same shape and indexes the same way, selecting rows alone gives
another factored matrix, and `__getattr__` falls through to the dense array for
anything the factors cannot answer, so a caller that reduces over the result
rather than slicing it is unaffected. `predict_kwargs={"factored": False}`
forces the dense array.

MultiHeadCalibration.fit no longer expands a lazy source either. Head ranking
already walked the heads in blocks and every later step reads single columns,
so nothing in fit needed the matrix whole; the block size is now chosen from
the row count, which holds the peak near 64 MiB whether the reference has a
thousand rows or a million rather than growing with it.

Together these mean a caller that predicts the matrix, slices calibration rows
out of it, fits a calibration and transforms per run never materialises the
wide matrix. That is the path MS2Rescore takes, so it is fixed by upgrading
DeepLC, with no change on its side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DeepLC 4.5.0: keep the multitask prediction matrix as its low-rank factors
@RobbinBouwmeester
RobbinBouwmeester merged commit 557a6ae into main Sep 11, 2026
5 checks passed
@RobbinBouwmeester
RobbinBouwmeester deleted the fix/predict-memory-guard branch September 11, 2026 11:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant