Skip to content

perf(pipeline): size alignment batches so the result vector stays off the arena path - #261

Open
BenjaminDEMAILLE wants to merge 1 commit into
scverse:mainfrom
BenjaminDEMAILLE:perf/batch-sizing
Open

perf(pipeline): size alignment batches so the result vector stays off the arena path#261
BenjaminDEMAILLE wants to merge 1 commit into
scverse:mainfrom
BenjaminDEMAILLE:perf/batch-sizing

Conversation

@BenjaminDEMAILLE

Copy link
Copy Markdown
Contributor

Independent of #256 / #257 — this touches only src/lib.rs and can merge in any order.

What

run_batch_pipeline allocates a fresh Vec<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 — 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. 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:

batch 10000 3000 2000 1500 1000 500 250
wall 3.46s 2.29s 2.15s 2.13s 2.17s 2.35s 2.58s

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.

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 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; and Aligned.out.sam + SJ.out.tab on both paired-end sets.

  • 593 tests pass
  • 0 clippy warnings (--all-targets --release)
  • cargo fmt --check clean

Note 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

… 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>
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