Skip to content

perf(align): stitch into the transcript in place instead of cloning per node - #264

Open
BenjaminDEMAILLE wants to merge 5 commits into
scverse:mainfrom
BenjaminDEMAILLE:perf/stitch-in-place2
Open

perf(align): stitch into the transcript in place instead of cloning per node#264
BenjaminDEMAILLE wants to merge 5 commits into
scverse:mainfrom
BenjaminDEMAILLE:perf/stitch-in-place2

Conversation

@BenjaminDEMAILLE

Copy link
Copy Markdown
Contributor

Builds on #257 (contains its commits). Review only the last commit; merge #257 first and this rebases down to it.

What

stitchWindowAligns' include/exclude recursion cloned a WorkingTranscript at every node, because the include branch needed its own copy while the exclude branch still needed the original. With a 100k-node budget per window that is a clone, four vector allocations and a copy, per node.

This passes the transcript by &mut and undoes the attempt instead.

Why the undo is exact

The mutation surface is small enough to invert, and I checked it rather than assumed it. stitch_align_to_transcript and the base-case extensions only:

  • append to the four vectors,
  • rewrite the first and last exons in place,
  • update the scalars.

Never a middle exon, never a removal, never a reorder. So WtMark (four lengths + copies of the first and last exons + the scalars) is a complete inverse, and restore truncates and writes those back. Capacity survives the restore, so sibling branches reuse allocations instead of reallocating.

I got this wrong on the first pass: my initial WtMark saved only the last exon, and the base case turned out to mutate exons.first_mut() too. That is why the record carries both.

An owned clone is now taken only where one is genuinely needed: when a completed transcript is accepted into the result set, bounded by --alignTranscriptsPerWindowNmax rather than by the recursion.

Supersedes the headroom commit

clone_with_headroom (the last commit of #257) existed to make the per-node clone cheaper. There is no per-node clone left, so it is removed here. If you would rather not carry that commit only to delete it, I can rebase #257 without it.

Measured

User CPU at 1 thread, --outSAMtype None, 4 interleaved rounds:

dataset before after
nfcore PE 22.29s 21.72s (-2.6%)
yeast PE 15.24s 15.24s (neutral)

Ranges do not overlap on nfcore (21.71-21.77 vs 22.26-22.47). The gain tracks stitch depth: nfcore drives a deeper recursion so more clones disappear. Yeast is unchanged rather than slower, which is why both are reported.

Correctness

  • Aligned.out.sam and SJ.out.tab byte-identical on yeast 50k pairs and the nfcore pair.
  • 593 tests pass, 0 clippy warnings, cargo fmt --check clean.

Why it may be worth more than 2.6%

Per-branch state is now a mark and an undo rather than a fresh allocation, which is the shape a batched or GPU-side stitcher needs. On its own the CPU gain is modest and I am not overselling it; the structural change is the larger part of the value, and it stands or falls on your view of whether that direction is worth having.

🤖 Generated with Claude Code

BenjaminDEMAILLE and others added 5 commits August 28, 2026 19:05
…igns

`find_best_junction_position` is the single hottest function in the
aligner: on a 50k-pair yeast PE run it accounts for ~60% of alignment
time. The scan itself is not wasteful, but it is repeated.

`stitchWindowAligns`' include/exclude recursion reaches the same
(exon A end, seed B) pair through many different branch paths, and each
path re-runs the identical scan. The scan is a pure function of its
arguments, and within one window `read_seq`, the genome, `is_reverse`
and `n_genome` are all fixed, so six coordinates identify a scan
completely: the exon A read end and genome end, the read and genome
gaps, the previous exon length and the next seed length.

Add `JunctionScanCache`, a per-window `FxHashMap` on that key, and a
`find_best_junction_position_cached` wrapper that consults it. The
uncached function is untouched, so a hit returns exactly what a fresh
scan would have. The cache is created per window in `stitch_seeds_core`
and threaded down the recursion, which is what keeps the key complete:
it never outlives the read, genome and strand it was filled for. An
empty `HashMap` does not allocate, so windows that stitch nothing pay
nothing.

Measured on 50k yeast read pairs (Apple M4 Max, quiet machine,
best-of-6 interleaved rounds, `--outSAMtype None`):

    threads   before   after    change
    1         19.51s   16.76s   -14.1%
    8          2.55s    2.18s   -14.5%

Output is byte-identical: `Aligned.out.sam` (records and header alike,
modulo the `@PG` CL line naming the binary) and `SJ.out.tab` compare
equal against the pre-change binary on the same input. 592 tests pass,
0 clippy warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…on scan

`Genome::get_base` matches on the `GenomeSeq` discriminant, bounds-checks
and, for a memory-mapped genome's reverse-complement half, recomputes the
mirrored index and complements the byte. That is fine per call, but the
alignment inner loops call it once per base: the junction-position scan
reads two bases per candidate position across three loops, and
`extend_alignment` reads one per extended base. Together those two
functions are ~55% of alignment time after the scan memo.

Add `GenomeSeq::view()`, returning a `Copy` `SeqView` that resolves the
variant once. The view is a slice plus one integer, so the loops keep it
in registers and each base costs a bounds check and a load. Hoist it out
of the junction scan (including the sliding motif window) and out of both
`extend_alignment` loops.

Out-of-range reads return the `OUT_OF_RANGE` sentinel instead of `None`.
Every call site converted here already treated "not one of A/C/G/T" the
same way a `None` was treated, so the branch structure is preserved
exactly; `score.rs` already had this sentinel locally for the motif
window and it moves next to the view it belongs to. `SeqView::base`
duplicates the reverse-complement arithmetic in `GenomeSeq::base`, so a
unit test asserts the two agree on every index in `0..2n` plus the first
out-of-range one, for both storage variants.

Measured on 50k yeast read pairs (Apple M4 Max, quiet machine,
best-of-6 interleaved rounds, `--outSAMtype None`, 8 threads), on top of
the junction-scan memo: 2.18s to 2.12s, and 2.60s to 2.12s against the
pre-memo baseline (-18%).

Output is byte-identical: `Aligned.out.sam` and `SJ.out.tab` compare
equal against the pre-change binary on the same input. 593 tests pass,
0 clippy warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…base at a time

`score_region` walks a seed-length run scoring matches and mismatches. It
was ~11% of alignment time, and its loop could not vectorize: every base
re-derived a bounds-checked `Option` from `Genome::get_base` and tested
the read end, so the body carried branches and an early exit.

Bound the run once against the read length, then walk it in 256-base
chunks with the genome bases staged into a stack buffer through the new
`SeqView::bases_into`. The inner loop is then two plain byte slices of
equal length reduced into a match count and a mismatch count, which is
what lets it vectorize; `score` is still exactly `matches - mismatches`
and the genome-end `break` is preserved by stopping on a short fill.

`bases_into` is one `copy_from_slice` on the forward strand. The
reverse-complement half of a mapped genome has no contiguous slice to
hand out, so it is filled by walking the mirrored forward bytes, which
still leaves the comparison itself vectorizable.

The reduction uses bitwise `&` rather than `&&` and suppresses
`clippy::needless_bitwise_bool` at that loop. This is measured, not
stylistic: the lazy spelling reintroduces branches and gives back most
of the gain (median 2.125s vs 2.085s wall on the benchmark below).

Measured on 50k yeast read pairs (Apple M4 Max, 8 threads, best-of-6
interleaved rounds, `--outSAMtype None`): 2.10s to 2.04s best, 2.155s to
2.085s median. Small, but consistent across every paired round.

Output is byte-identical on two datasets: yeast 50k pairs and the
nfcore test pair (88k SAM lines). `Aligned.out.sam` and `SJ.out.tab`
compare equal against the pre-change binary in both. 593 tests pass,
0 clippy warnings, fmt clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hat follows

`stitch_align_to_transcript` clones the working transcript and then always
pushes onto it. `Vec::clone` allocates exactly `len`, so that push
reallocates every time: a malloc, a copy and a free for every stitched
seed, on a path the recursion walks up to its 100k-node budget per window.

Add `WorkingTranscript::clone_with_headroom`, which reserves the one slot
the caller is about to use, folding the reallocation back into the clone's
own allocation.

The junction vectors only get headroom when they already hold something.
Most transcripts carry no junction at all, and giving an empty vector
capacity would allocate for a push that never comes, which is worse than
what it replaces. The exon vector is never empty at these call sites, so
it always gets the slot.

Measured on 50k yeast read pairs (Apple M4 Max, 1 thread, 5 rounds,
`--outSAMtype None`), reported as user CPU time rather than wall: this
machine's wall clock was too noisy to resolve half a percent, and user
time is not. Median 14.86s to 14.79s, -0.5%, with every round at the same
rank improving. Small, and labelled as such.

Pre-sizing the per-window transcript accumulator was tried alongside this
and measured a small loss (median 14.97s, +0.7%): it over-allocates for
the many windows that finish with only a few transcripts. It is not
included here.

Output is byte-identical on two datasets: yeast 50k pairs and the nfcore
test pair. `Aligned.out.sam` and `SJ.out.tab` compare equal against the
pre-change binary in both. 593 tests pass, 0 clippy warnings, fmt clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…er node

`stitchWindowAligns`' include/exclude recursion cloned a `WorkingTranscript`
at every node it explored, because the include branch needed its own copy
while the exclude branch still needed the original. With a 100k-node budget
per window that is a clone, four vector allocations and a copy, per node.

Pass the transcript by `&mut` and undo the attempt instead. The mutation
surface is small enough to invert exactly: `stitch_align_to_transcript` and
the base-case extensions only append to the four vectors and rewrite the
*first* and *last* exons plus the scalars, never a middle exon and never a
removal or reorder. `WtMark` records the four lengths, copies of the first
and last exons, and the scalars; `restore` truncates and writes those back.
Vector capacity survives the restore, so sibling branches reuse the same
allocations rather than reallocating.

An owned clone is now taken only where one is actually needed: when a
completed transcript is accepted into the result set, which is bounded by
`--alignTranscriptsPerWindowNmax` rather than by the recursion.

This supersedes `clone_with_headroom`, which existed to make the per-node
clone cheaper and is removed: there is no per-node clone left to soften.

Measured, user CPU at 1 thread, `--outSAMtype None`:

| dataset | before | after |
|---|---|---|
| nfcore PE | 22.29s | 21.72s (-2.6%) |
| yeast PE | 15.24s | 15.24s (neutral) |

The gain tracks stitch depth: nfcore's reads drive a deeper recursion, so
more clones disappear. Yeast is unchanged rather than slower, which is the
point of checking both.

Beyond the timing, this is what a batched or GPU-side stitcher would need:
per-branch state is now a mark and an undo rather than a fresh allocation.

Output is byte-identical on both datasets: `Aligned.out.sam` and
`SJ.out.tab` compare equal against the pre-change binary. 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