From 4b64cfbfbd79c645c6e157cfa8e57e6afe052fa5 Mon Sep 17 00:00:00 2001 From: Benjamin Demaille Date: Sat, 29 Aug 2026 03:28:24 +0200 Subject: [PATCH] perf(pipeline): size alignment batches so the result vector stays off the arena path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `run_batch_pipeline` allocates a fresh `Vec>` 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::()`, 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) --- src/lib.rs | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 7b60e9b..eb7e20e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1057,6 +1057,35 @@ struct AlignmentBatchResults { signal_n_tr: usize, } +/// Reads per alignment batch, chosen so a batch's result vector stays an +/// ordinary-sized allocation. +/// +/// [`run_batch_pipeline`] allocates a fresh `Vec>` for every +/// batch and drops it once consumed. mimalloc serves anything past its +/// large-object threshold from the arena path rather than a thread-local page, +/// so once that vector crosses roughly 512 KB each batch pays a fresh mapping +/// and — with `arena_eager_commit` off, which `main` sets deliberately to keep +/// RSS down — faults every page back in. At the previous fixed 10,000 reads the +/// vector was 1.7 MB and that path accounted for ~38% of the samples in a solo +/// run. +/// +/// The ceiling is the measured one. Sweeping batch size on a 2M-read solo run +/// (8 threads) put the optimum on a plateau from roughly 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 starts to dominate, which is what the +/// floor guards. Some of the per-batch cost tracks the read count rather than +/// the result vector — sizing purely by bytes picked 4096 for the solo product +/// and left time on the table — so the count is capped as well. +/// +/// The byte rule still earns its place as the other half: it keeps the +/// allocation under the threshold if the result struct grows later, rather than +/// leaving a tuned constant to rot. +fn batch_size_for() -> usize { + const TARGET_BYTES: usize = 384 * 1024; + const MAX_READS: usize = 2048; + (TARGET_BYTES / std::mem::size_of::>().max(1)).clamp(512, MAX_READS) +} + /// One `Result` per read/pair in an aligned batch. type BatchOut = Vec>; @@ -1462,7 +1491,7 @@ fn align_reads_single_end( params.read_map_number as u64 }; - let batch_size = 10000; + let batch_size = batch_size_for::(); let max_multimaps = params.out_filter_multimap_nmax as usize; // `--outSAMtype None` (e.g. quant-only) skips building SAM records. let emit_sam = params.emits_alignments(); @@ -2094,7 +2123,7 @@ fn align_reads_solo( } else { params.read_map_number as u64 }; - let batch_size = 10000; + let batch_size = batch_size_for::(); let clip5p = params.clip5p(0); let clip3p = params.clip3p(0); let cr4_clip = params.clip_adapter_type == "CellRanger4"; @@ -2409,7 +2438,7 @@ fn align_reads_solo_pe( } else { params.read_map_number as u64 }; - let batch_size = 10000; + let batch_size = batch_size_for::(); // Per-mate clip: mate 1 (--clip5pNbases[0], e.g. 39 to strip the 5' barcode // region) and mate 2 ([1], e.g. 0). CellRanger4 adapter clipping is not used // by the cellgeni 5' path (it uses clip5pNbases instead), so it is not applied. @@ -2802,7 +2831,7 @@ fn align_reads_paired_end( params.read_map_number as u64 }; - let batch_size = 10000; + let batch_size = batch_size_for::(); let max_multimaps = params.out_filter_multimap_nmax as usize; // `--outSAMtype None` (e.g. quant-only) skips building SAM records. let emit_sam = params.emits_alignments();