Skip to content

perf: prefetch the next candidates' text in the merge loop (independent) - #12

Merged
rob-p merged 1 commit into
COMBINE-lab:mainfrom
BenjaminDEMAILLE:perf/merge-prefetch
Aug 12, 2026
Merged

perf: prefetch the next candidates' text in the merge loop (independent)#12
rob-p merged 1 commit into
COMBINE-lab:mainfrom
BenjaminDEMAILLE:perf/merge-prefetch

Conversation

@BenjaminDEMAILLE

Copy link
Copy Markdown
Contributor

Independent of every other open PR. Branches from main, one commit, ~30 lines.

Extracted from #5 so it is separately bisectable, as @rob-p asked in #7.

Why

The merge is latency-bound. Its tied branch dereferences the text at two random addresses, and the address for step i + 1 is not known until step i retires, so there is no memory-level parallelism to exploit and the hardware prefetcher cannot see the pattern either.

But the candidate positions themselves live in the two index arrays, which are sequential and already in cache. The addresses several steps ahead are known, even though the dependent loads are not. Issuing them as prefetches, offset by the current boundary LCP m — which estimates where the next scans start — costs two instructions per step.

This is not the prefetch recorded as a negative result in lcp.rs. That one sat inside the strided scan loop, which the hardware prefetcher already covers. This one targets the random access, which it cannot.

Measured

chr21 forward ++ revcomp (80 MB), external memory, 12 threads, interleaved against main, three pairs, prefetch winning every pair:

  main       4.198  4.419  4.708 s   mean 4.442
  prefetch   3.284  3.580  3.620 s   mean 3.495   -21%

Output byte-identical to main.

Portability

_mm_prefetch on x86_64. On aarch64 core::arch::aarch64::_prefetch is still unstable, so the instruction is emitted directly with asm!; prfm never faults. A no-op elsewhere. The address is clamped and only ever used as a prefetch operand, never dereferenced.

🤖 Generated with Claude Code

Phase 4 had become the largest ext-mem cost, and its merge CPU (19.2 s
on 80 MB of DNA) exceeded the entire CPU of the in-memory path. The
merge is latency-bound: the tied branch dereferences the text at two
random addresses, and the address for step i+1 is not known until step i
retires, so there is no memory-level parallelism and the hardware
prefetcher cannot see the pattern.

The candidate positions themselves live in the two index arrays, which
are sequential and already in cache, so the addresses several steps
ahead are known even though the dependent loads are not. Issue them as
prefetches, offset by the current boundary LCP `m`, which estimates
where the next scans start.

This is not the prefetch recorded as a negative result in `lcp.rs`. That
one sat inside the strided scan loop, which the hardware prefetcher
already covers. This one targets the random access, which it cannot.

Apple M4 Max, 12 threads, ext-mem, output verified identical to the
in-memory suffix array on both inputs:

  chr21.0123, 80 MB    phase4 merge CPU 19.20 s -> 12.42 s
                       phase4 wall       2.17 s ->  1.46 s
                       total             3.55 s ->  2.90 s   CPU 33.8 -> 27.0 s
  chr21.fa, 47.5 MB    phase4 merge CPU  7.99 s ->  6.76 s
                       total             2.56 s ->  1.99 s   CPU 23.3 -> 18.0 s

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

rob-p commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Accepted and merged into main as 2b527c9b6289beedf1d439237e6c50b5f99d60c2.

I tested PR head 80ee097e462195e2da5b4f0bc64f9858be42c4ea integrated onto the current post-#9/#11 main (cf71aaa75aff9021e9a228f14d52d9f256315733). The cache-prefetch reasoning is feasible, the outputs are exact, and the benefit on the real annotated-human ruSTAR workload is large and repeatable.

Full production-shaped benchmark

Same fixture and protocol used for the other open-PR decisions:

  • GENCODE Human v50 GRCh38 primary-assembly FASTA
  • comprehensive primary-assembly GTF parsed through ruSTAR's actual annotation path
  • sjdbOverhang = 100; all 698,597 deduplicated splice junctions
  • ruSTAR forward/reverse-complement layout
  • 1,397,582 spacer-delimited segments and STAR boundary ordering
  • only A/C/G/T-starting suffixes retained
  • text length: 6,557,611,930 symbols
  • output: 6,176,694,310 suffixes
  • external-memory u64, 8,192 partitions, 32 pinned physical cores

One candidate warm-up was followed by an immediately adjacent baseline/candidate pair:

Variant Build (s) Phase 1 (s) Phase 4 (s) Phase-4 merge CPU (s) User CPU (s) System CPU (s) Peak RSS (KiB) FS output blocks
current main 373.521 105.331 263.060 7,111.106 10,425.47 53.49 9,940,252 193,032,832
#12 measured 293.552 106.482 181.534 4,679.501 8,031.94 54.53 10,600,132 193,028,936
delta -79.969 (-21.41%) +1.151 (+1.09%) -81.526 (-30.99%) -2,431.605 (-34.19%) -2,393.53 (-22.96%) +1.04 +659,880 -3,896

The candidate warm-up was almost identical: 292.905 s total, 106.547 s phase 1, 181.146 s phase 4, and 4,683.203 merge CPU-seconds. The two candidate totals differ by only 0.647 s. Nearby baseline totals on the same machine were 373.521, 373.800, and 374.029 s. The improvement is therefore decisively above noise.

The measured peak-RSS difference is not accompanied by any allocation change in this PR and was not reproduced by the warm-up (candidate warm-up RSS was 9,869,584 KiB, below the paired baseline). I treat that counter as run-to-run residency variation rather than a memory cost of the prefetch instructions. Temporary-I/O volume is unchanged in practical terms.

Every full run emitted 6,176,694,310 positions with stream hash e81c8f9881e322148741a23c92ae2000.

Why it helps here

Unlike run skipping, this optimization does not require suffixes to begin inside a special periodic region. The LCP-enhanced merge eventually needs text at suffix positions that are effectively random across the 6.56-billion-symbol backing text. The upcoming positions are already available sequentially in the two index streams, so issuing cache hints eight candidates ahead creates memory-level parallelism that the hardware prefetcher cannot infer from the eventual random text accesses.

The data localize the effect exactly where expected: phase 1 pays about one additional second, while phase 4 saves 81.5 seconds and its aggregate merge CPU falls 34.2%. This AMD EPYC x86_64 result also independently confirms that the benefit is not specific to the PR author's Apple M4/Arm64 measurements.

Correctness and validation

  • focused chr21-backbone + full-annotation-flank output matched the stored baseline position-for-position for all 359,616,038 suffixes
  • full annotated-human output count and stream hash matched exactly
  • randomized custom STAR boundary-order probe: pass
  • negative-i8 generic-symbol probe: pass
  • finite-context output: unchanged from current main
  • 66 debug tests: pass
  • 66 release tests: pass
  • formatting: pass
  • Clippy, all targets, warnings denied: pass
  • rustdoc, warnings denied: pass

This is a clear accept on the ruSTAR workload. Thanks for extracting it into an independent change; that made the attribution straightforward.

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.

2 participants