Skip to content

feat(bam): --outBAMsortingBinsN, spilling the coordinate sort to disk bins - #263

Closed
BenjaminDEMAILLE wants to merge 6 commits into
scverse:mainfrom
BenjaminDEMAILLE:perf/bam-incremental-spill
Closed

feat(bam): --outBAMsortingBinsN, spilling the coordinate sort to disk bins#263
BenjaminDEMAILLE wants to merge 6 commits into
scverse:mainfrom
BenjaminDEMAILLE:perf/bam-incremental-spill

Conversation

@BenjaminDEMAILLE

Copy link
Copy Markdown
Contributor

Six commits. The first five are the existing bd/bam-sort-bins work, which was finished and pushed but had no PR open. The sixth fixes a gap I found while validating it.

The feature

--limitBAMsortRAM was a threshold the sort died on rather than a bound it respected: every record stayed resident until finish(), and exceeding the limit aborted the run telling the user to raise it or give up on sorting. This partitions records by reference sequence into --outBAMsortingBinsN temporary BAMs, then reads them back one bin at a time. The bins are coordinate-disjoint and already ordered relative to each other, so no k-way merge is needed.

The gap, and the fix

Spilling only happened from finish(). write_batch accumulated every record with no bound check, so by the time the first bin was written the whole run was already resident and the peak had been reached. The bound was respected during the sort and nowhere else, which is not where the memory goes.

Measured, 560k records, yeast (17 references), --limitBAMsortRAM 100000000:

peak RSS
in-memory sort 1980 MB
spill at finish 1967 MB

The feature was buying nothing. The last commit opens the bins on the first crossing of the bound and pushes the buffer out as batches arrive:

records spill at finish incremental
560k 1967 MB 1744 MB
1.3M 2925 MB 2254 MB

The gap widens with record count, which is the property that was missing: the buffer no longer scales with the run. Peak is not flat, and the commit says so: the index, the alignment pipeline's own batches, and the allocator's retained pages sit underneath it and are untouched by this.

Caveat worth stating

Binning is by reference sequence, so the benefit scales with reference count. On a single-contig genome every mapped record lands in one bin and this buys nothing (that is how I first mis-measured it, on a 1-chromosome test index). Human, with its 24+ references, is the case it is for.

Correctness

  • Default path (no bins) byte-identical to current main.
  • Binned path: 561,312 decoded records identical to the in-memory sort.
  • Old binary aborts on --limitBAMsortRAM 100000000 for this input; this completes.
  • Tests pass, 0 clippy warnings, cargo fmt --check clean.

Independent of #256 / #257 / #261; touches only src/io/bam.rs and src/params/mod.rs.

🤖 Generated with Claude Code

BenjaminDEMAILLE and others added 6 commits August 26, 2026 23:51
`--limitBAMsortRAM` was a threshold the sort died on, not a bound it respected:
every record stayed resident until `finish()`, and exceeding the limit aborted
the run with a message telling the user to raise it or give up on sorting.

Now, when a bound is set and the buffer exceeds it, records are partitioned by
reference sequence into `--outBAMsortingBinsN` bins written to temporary BAMs,
then read back one bin at a time, sorted and appended. Because the bins are
coordinate-disjoint and already in order relative to one another, no k-way
merge is needed. Only one bin is resident during the sort, so peak usage is the
largest bin rather than the whole run. Unmapped records sort last and get their
own bin.

Spilling is off unless it buys something: it needs both a non-zero
`--outBAMsortingBinsN` and a `--limitBAMsortRAM` that the estimate actually
exceeds. Without a bound there is nothing to respect and temporary files would
be pure cost. `--outBAMsortingBinsN 0` disables it outright.

The bin files are written uncompressed: they are read back immediately, so
compressing them would cost time and save nothing.

Verified on 1161 records that the binned and in-memory paths produce
byte-identical decoded BAM:

    in-memory  b52a23436e3e939e98160dffed89c1448c5da4da
    binned     b52a23436e3e939e98160dffed89c1448c5da4da

An earlier version of this partitioned in memory, which bounded the sort's
working set but not residency — every bucket was live at once. The comment
claiming reduced peak usage would have been false, so it spills for real.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The test asserted the old contract: exceeding the bound was fatal. With binning
it is not, because the sort can now respect the bound instead of dying on it.

Both halves are covered: with bins available the sort succeeds, and with
--outBAMsortingBinsN 0 there is no way to honour the bound, so the run still
stops rather than quietly using more memory than it was allowed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The binned path may frame BGZF blocks differently; what was measured and
what holds is that the decoded records are identical.

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

main gained an accepted-but-inert copy of the flag in the CLI-parity work;
this branch implements it, so the inert declaration and its ACCEPTED_BUT_INERT
note both go.
`--outBAMsortingBinsN` spilled the coordinate sort to disk bins, but only
from `finish()`. `write_batch` accumulated every record with no bound
check, so by the time the first bin was written the whole run was already
resident and the peak had been reached. The bound was respected during
the sort and nowhere else, which is not where the memory goes.

Measured on 560k records (yeast, 17 references, `--limitBAMsortRAM 100000000`),
peak RSS: in-memory sort 1980 MB, spill-at-finish 1967 MB. The feature was
buying nothing.

Open the bins on the first crossing of the bound and push the buffer out
to them as batches arrive, so what stays resident is one buffer rather than
the run. `finish` then closes the bins it already has instead of building
them from a full buffer.

Peak RSS, same flags:

| records | spill at finish | incremental |
|---|---|---|
| 560k | 1967 MB | 1744 MB |
| 1.3M | 2925 MB | 2254 MB |

The gap widens with record count, which is the property that was missing:
the buffer no longer scales with the run. Peak is not flat, because the
index, the alignment pipeline's own batches and the allocator's retained
pages all sit underneath it, and none of those are affected by this.

Output is unchanged: 561,312 decoded records identical to the in-memory
sort, and the no-bins default path is untouched. Tests pass, 0 clippy
warnings, fmt clean.

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

Copy link
Copy Markdown
Contributor Author

Closing this as a duplicate of #214, which I should have found before opening it.

#214 has been open since 2026-08-06 and solves the same problem better:

#214 (external merge) this PR (per-reference bins)
peak memory flat in output size still grows: 1744 MB at 560k records, 2254 MB at 1.3M
single-contig genome works no benefit — every record lands in one bin
bin/run count balanced merge passes, guards against EMFILE bounded by reference count
tie order stable merge on run index stable within bin

The measurement I took here is the one useful thing to carry over: the existing bd/bam-sort-bins work enforced the bound only in finish(), so write_batch accumulated the whole run first and the peak had already been reached. In-memory sort 1980 MB vs spill-at-finish 1967 MB, i.e. it bought nothing. Whatever lands, the bound has to be enforced while records arrive, not after. #214 does that by construction.

Also worth noting for #214: --limitBAMsortRAM 0 changing from unlimited to 512 MiB is a behaviour change against STAR, where 0 means unlimited. Worth being deliberate about, since ruSTAR is a faithful port.

Sorry for the noise.

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