Skip to content

perf(score): slide the splice-motif window across the junction scan (output-neutral) - #167

Open
BenjaminDEMAILLE wants to merge 3 commits into
scverse:mainfrom
BenjaminDEMAILLE:bd/perf-align
Open

perf(score): slide the splice-motif window across the junction scan (output-neutral)#167
BenjaminDEMAILLE wants to merge 3 commits into
scverse:mainfrom
BenjaminDEMAILLE:bd/perf-align

Conversation

@BenjaminDEMAILLE

@BenjaminDEMAILLE BenjaminDEMAILLE commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Slide the four-base splice-motif window across the junction scan instead of re-reading it at every position. Output-neutral, about 2.8% off the wall clock.

Where the time actually goes

Sampled a 2M-read run at 16 threads with symbols, ranked by self time:

function samples
detect_splice_motif 15 456
find_best_junction_position 13 932
cluster_seeds 7 936
extend_alignment 6 741
stitch_recurse 4 908
compare_seq_to_genome 3 870

The top two are together roughly 45% of the on-CPU samples. Worth noting for anyone planning work here: the seed search is not the bottleneck on this workload. search_direction_sparse is 629 samples.

What changed

find_best_junction_position slides the junction one base per iteration and calls detect_splice_motif at each position. That function reads four genome bases: the intron's first two and its last two.

Because the junction moves by exactly one base, consecutive positions share two of those four. Moving right, the old d2 and a2 are the new d1 and a1; moving left, the old d1 and a1 are the new d2 and a2. Carrying them in a MotifWindow and sliding turns four genome reads per position into two.

Two smaller things fell out of the same reading:

  • Whether the motif branch runs at all depends only on del, which does not change across the scan, so it is decided once before the loop instead of at every iteration.
  • An out-of-range position becomes a sentinel byte that no motif arm matches, which is exactly what get_base returning None already meant. That removes four Option unwraps from the inner comparison without changing which positions produce NonCanonical.

detect_splice_motif keeps its signature and behaviour; it is now a one-line call into the same window type, so genomeGenerate's junction insertion and alignment scoring still share one truth table.

Verification

Output-neutral. SAM byte-identical to the parent commit on 200 000 real reads (ERR12389696, yeast), whole file including header.

Timing. Interleaved A/B, order flipped on even rounds, 2M reads, --runThreadN 16, --outSAMtype None so the measurement is alignment work and not the writer. Machine at 87-95% CPU idle, and the script refuses to run below 88%.

round before after
1 23.24 s 22.23 s
2 22.54 s 22.34 s
3 22.64 s 22.57 s
4 23.24 s 21.88 s
median 22.94 s 22.29 s

Same direction in all four rounds, about 2.8%. I will not oversell it: the spread within the "before" column is 0.7 s, so a single pair would not have been worth reporting. Four out of four in the same direction is what makes it readable, and the mechanism is arithmetic rather than a scheduling effect.

Modest against 45% of the profile, because most of that self time is the branchy scoring logic around the fetches rather than the fetches alone. The remaining hotspots in the table above are the next targets, and they are separate changes.

Gate: 560 lib + 26 integration tests, cargo clippy --all-targets -- -D warnings, cargo fmt --check, all green. No new dependency.

Related

The same profiling run produced two findings that live elsewhere: #168 records that the align path does about 1 000 heap allocations per read, with the measurement and the conclusion that removing 7.6% of them changes nothing measurable, and #166 fixes --runThreadN 1 running on every core, which is what made the thread-scaling curve behind this work readable in the first place.

BenjaminDEMAILLE and others added 2 commits July 30, 2026 20:07
`detect_splice_motif` and `find_best_junction_position` were the two
largest self-time entries in a sampled 2M-read run, together about 45%
of the on-CPU samples.

The scan moves the junction one base per iteration, so the four bases
that decide the motif (the intron's first two and last two) overlap the
previous position in two of four places. Carrying them in a
`MotifWindow` and sliding turns four genome reads per position into two.
Whether the motif branch runs at all is decided once before the loop
rather than per iteration, since `del` does not change across the scan.

An out-of-range position becomes a sentinel that no motif arm matches,
which is what `get_base` returning `None` already meant.

Output-neutral: SAM byte-identical to the parent commit on 200k real
reads. Interleaved A/B at 16 threads on 2M reads, machine at 87-95% CPU
idle: 22.94s median before, 22.29s after, the same direction in all four
rounds. About 2.8%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The four-base motif decision compiled to a chain of comparisons whose
outcome is data-dependent and close to unpredictable, and the junction
scan pays it at every position. A 256-entry table indexed by the four
bases packed two bits each replaces it with one load.

`examples/jrbench.rs` measures the scan in isolation: 8.5 ns per
iteration before, 5.2 ns after, a 38% cut, with identical results.

That example exists because the earlier attempts on this path were guesses
about whether the loop was memory-bound, and two of them were wrong. It
answers the question by construction, holding the iteration count and the
instruction mix fixed and changing only how far the acceptor sits from the
donor:

    del          ns/iter
    64           8.42
    1024         8.47
    16384        8.52
    262144       8.41
    4194304      8.62
    67108864     8.56

Flat from 64 bases to 67 million, so the acceptor distance costs nothing
and the loop is not stalled on that stream. Which is why skipping the
acceptor read behind a donor-pair test measured *slower*: it traded a
prefetchable access for an unpredictable branch. The branch was the cost
all along, and this removes it.

`the_motif_table_agrees_with_the_original_match_on_every_input` checks
the table against the match it replaces over every combination of
`A`/`C`/`G`/`T`, `N`, the chromosome-boundary byte and the out-of-range
sentinel, so the inputs that no match arm covered are covered explicitly.

Output-neutral: SAM byte-identical on 200k real reads.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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