perf(pipeline): size alignment batches so the result vector stays off the arena path - #261
Open
BenjaminDEMAILLE wants to merge 1 commit into
Open
perf(pipeline): size alignment batches so the result vector stays off the arena path#261BenjaminDEMAILLE wants to merge 1 commit into
BenjaminDEMAILLE wants to merge 1 commit into
Conversation
… the arena path
`run_batch_pipeline` allocates a fresh `Vec<Result<T, Error>>` for every
batch and drops it once consumed. At the fixed 10,000 reads that vector
was 1.7 MB, which is past mimalloc's large-object threshold, so every
batch was served from the arena path instead of a thread-local page: a
fresh mapping, and — with `arena_eager_commit` off, which `main` sets
deliberately to keep RSS down on genome-scale runs — every page faulted
back in. Profiling a 2M-read solo run put ~38% of samples under
`mi_huge_page_alloc` / `_mi_arenas_page_alloc`, against 4,370 for the
alignment itself.
Replace the constant with `batch_size_for::<T>()`, which derives the
count from the element size and caps it at a measured ceiling.
The ceiling is measured, not guessed. Sweeping batch size on the solo run
at 8 threads put the optimum on a plateau from about 1000 to 2000 reads:
10,000 took 3.46s, 3000 2.29s, 2000 2.15s, 1500 2.13s, 1000 2.17s, 250
2.58s. Below the plateau per-batch dispatch begins to dominate, which is
what the floor guards. Some per-batch cost tracks the read count rather
than the result vector — sizing purely by bytes chose 4096 for the solo
product and left time on the table (2.47s vs 2.14s) — so the count is
capped too. 4096 also cost about 1.5% on paired-end, which 2048 does not.
The byte rule earns its place as the other half: it keeps the allocation
under the threshold if a result struct grows later, rather than leaving a
tuned constant to rot.
Measured (Apple M4 Max), solo = 2M reads, 5000 barcodes, 4546 genes:
workload before after change
solo, wall, 8 threads 3.49s 2.15s -38.4%
solo, user CPU, 1 thread 15.03s 10.63s -29.3%
yeast PE, user CPU, 1 thread 18.72s 18.68s neutral
nfcore PE, user CPU, 1 thread 25.44s 25.48s neutral
Paired-end is unchanged because its per-read work is large enough to
amortize the batch allocation; the win is on workloads whose per-read
cost is small, which is what a clean solo run looks like.
Output is byte-identical: solo `Aligned.out.sam`, `matrix.mtx`,
`barcodes.tsv` and `features.tsv`, plus `Aligned.out.sam` and
`SJ.out.tab` on the yeast and nfcore paired-end sets. 593 tests pass,
0 clippy warnings, fmt clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Independent of #256 / #257 — this touches only
src/lib.rsand can merge in any order.What
run_batch_pipelineallocates a freshVec<Result<T, Error>>per batch and drops it once consumed. At the fixed 10,000 reads that vector is 1.7 MB, past mimalloc's large-object threshold, so every batch was served from the arena path rather than a thread-local page: a fresh mapping, and — witharena_eager_commitoff, whichmainsets deliberately to keep RSS down on genome-scale runs — every page faulted back in.Profiling a 2M-read solo run put ~38% of samples under
mi_huge_page_alloc/_mi_arenas_page_alloc, against 4,370 for the alignment itself. The allocator was costing nearly as much as the work.batch_size_for::<T>()derives the count from the element size and caps it at a measured ceiling.The ceiling is measured
Sweeping batch size on the solo run, 8 threads:
Plateau from ~1000 to 2000; below it per-batch dispatch starts to dominate, which is what the floor guards.
Sizing purely by bytes was not enough on its own: some per-batch cost tracks read count rather than the result vector, so the byte rule alone picked 4096 for the solo product and left time on the table (2.47s vs 2.14s). 4096 also cost ~1.5% on paired-end, which 2048 does not. Hence both a byte target and a count cap. The byte rule still earns its place — it keeps the allocation under the threshold if a result struct grows later, rather than leaving a tuned constant to rot.
Measured
Apple M4 Max. Solo workload is 2M reads, 5000 barcodes, 4546 genes,
CB_UMI_Simple.Paired-end is unchanged because its per-read work already amortizes the batch allocation. The win lands on workloads whose per-read cost is small, which is what a clean solo run looks like — and, I would expect, a production 10x run.
Correctness
Byte-identical: solo
Aligned.out.sam,matrix.mtx,barcodes.tsv,features.tsv; andAligned.out.sam+SJ.out.tabon both paired-end sets.--all-targets --release)cargo fmt --checkcleanNote on the benchmark
There is no solo dataset in the repo big enough to measure (the checked-in one is 400 reads), so I generated one: a synthetic gene model over the yeast genome (4546 genes, 1-3 exons each), a 5000-barcode whitelist, and 2M reads with 2% single-base barcode errors so CB correction is actually exercised. 95.2% uniquely mapped. Happy to contribute the generator if a solo benchmark fixture would be useful to have.
🤖 Generated with Claude Code