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();