What I measured
Wrapping the global allocator in a counter and running 200 000 real reads (yeast, ERR12389696) at --runThreadN 8:
ALLOCSTATS count=200093070 bytes=401267330431
1 000 heap allocations per read, and 2 MB allocated per read.
For context, at 16 threads on 2M reads a sampled profile attributes roughly 8% of on-CPU time to mimalloc itself (mi_bchunk_try_find_and_clearNX, mi_free, mi_malloc_aligned, mi_bitmap_clearN) and another ~3.5% to _platform_memmove. So this is the second-largest cost in the aligner after find_best_junction_position, and unlike that one it is not intrinsic work — it is bookkeeping.
Where it comes from
stitch_recurse clones a WorkingTranscript at each include/exclude branch (src/align/stitch.rs:1195, :1320, :2403), and MAX_RECURSION is 100 000 per cluster. Each clone copies four Vecs:
pub(crate) struct WorkingTranscript {
pub(crate) exons: Vec<ExonBlock>,
pub(crate) junction_motifs: Vec<SpliceMotif>,
pub(crate) junction_annotated: Vec<bool>,
pub(crate) junction_shifts: Vec<(u32, u32)>,
...
}
The three junction vectors are empty on an unspliced read, and an empty Vec does not allocate, so the steady-state cost is exons plus whatever the spliced reads add. cluster_seeds contributes its own per-read set: anchor_set, anchor_indices, windows, the win_bin map, a Vec per window, and diag_ranges with a Vec per diagonal.
What I am not doing without asking
The obvious fix is inline storage for those four vectors, which in practice means smallvec with inline capacities around 8/4/4/4. CONTRIBUTING.md says a new dependency has to be raised before the PR, so I am raising it rather than opening one.
Three ways I can see, and I have no strong preference between the first two:
smallvec. Widely used, no build-script or C, MSRV-friendly. One more dependency on a project that publishes to crates.io and builds on five platforms.
- A small in-tree inline vector. No dependency, but it is
unsafe-adjacent code to review and maintain for a type this project would only use in one place.
- Per-read scratch reuse instead, hoisting the
cluster_seeds containers into thread-local buffers cleared per read. This attacks a different share of the 1 000 and needs no new type, but it is a larger and more invasive change to a function the code already calls the top align hotspot.
What I would want to agree before writing any of it
The ceiling is roughly 11% of wall clock, not more, since that is what the allocator and the copies add up to in the profile. That is worth having, but it is not the 2× that "1 000 allocations per read" might suggest at first reading, because mimalloc is genuinely fast and is already tuned in src/main.rs.
I would rather hear which of the three you want than pick one and have the shape of it be the thing that gets sent back. Happy to do the measurement for whichever you prefer first.
Reproducing the number takes about ten lines: wrap mimalloc::MiMalloc in a GlobalAlloc that increments two atomics, and print them from a Drop guard in main. I can push that as a feature-gated helper if it would be useful to keep around.
What I measured
Wrapping the global allocator in a counter and running 200 000 real reads (yeast,
ERR12389696) at--runThreadN 8:1 000 heap allocations per read, and 2 MB allocated per read.
For context, at 16 threads on 2M reads a sampled profile attributes roughly 8% of on-CPU time to mimalloc itself (
mi_bchunk_try_find_and_clearNX,mi_free,mi_malloc_aligned,mi_bitmap_clearN) and another ~3.5% to_platform_memmove. So this is the second-largest cost in the aligner afterfind_best_junction_position, and unlike that one it is not intrinsic work — it is bookkeeping.Where it comes from
stitch_recurseclones aWorkingTranscriptat each include/exclude branch (src/align/stitch.rs:1195,:1320,:2403), andMAX_RECURSIONis 100 000 per cluster. Each clone copies fourVecs:The three junction vectors are empty on an unspliced read, and an empty
Vecdoes not allocate, so the steady-state cost isexonsplus whatever the spliced reads add.cluster_seedscontributes its own per-read set:anchor_set,anchor_indices,windows, thewin_binmap, aVecper window, anddiag_rangeswith aVecper diagonal.What I am not doing without asking
The obvious fix is inline storage for those four vectors, which in practice means
smallvecwith inline capacities around 8/4/4/4.CONTRIBUTING.mdsays a new dependency has to be raised before the PR, so I am raising it rather than opening one.Three ways I can see, and I have no strong preference between the first two:
smallvec. Widely used, no build-script or C, MSRV-friendly. One more dependency on a project that publishes to crates.io and builds on five platforms.unsafe-adjacent code to review and maintain for a type this project would only use in one place.cluster_seedscontainers into thread-local buffers cleared per read. This attacks a different share of the 1 000 and needs no new type, but it is a larger and more invasive change to a function the code already calls the top align hotspot.What I would want to agree before writing any of it
The ceiling is roughly 11% of wall clock, not more, since that is what the allocator and the copies add up to in the profile. That is worth having, but it is not the 2× that "1 000 allocations per read" might suggest at first reading, because mimalloc is genuinely fast and is already tuned in
src/main.rs.I would rather hear which of the three you want than pick one and have the shape of it be the thing that gets sent back. Happy to do the measurement for whichever you prefer first.
Reproducing the number takes about ten lines: wrap
mimalloc::MiMallocin aGlobalAllocthat increments two atomics, and print them from aDropguard inmain. I can push that as a feature-gated helper if it would be useful to keep around.