Skip to content

Vectorize the BiWFA overlap scan (affine2p BiWFA ~2x faster) - #49

Merged
cjfields merged 1 commit into
mainfrom
perf/bialign-overlap-simd
Oct 16, 2026
Merged

cjfields merged 1 commit into
mainfrom
perf/bialign-overlap-simd

Conversation

@cjfields

Copy link
Copy Markdown
Member

Part of #31. After #45 and #48, the full EPYC 7713 sweep (compute-5-6, 8ccfb64) left affine2p BiWFA (--wfa-memory ultralow) as the main case where Rust is slower than C: 1.08–1.15× generic C, ~2.2× C built with -march=native, and 1.2–1.4× C's instructions.

Cause

At every BiWFA step, bialign_overlap checks the new wavefronts against the last max_score_scope scores of the other direction. For affine2p that is one M scan plus four I1/D1/I2/D2 scans, with a scope of about 25 under the default penalties.

  • Only the M scan had SIMD, and only NEON.
  • The indel scans everywhere, and every scan on x86, were scalar.
  • A sample profile on the M2 put 73% of affine2p BiWFA time in bialign_overlap, with 11% each for compute and extend.
  • C has AVX2 and AVX-512 versions of both scans (wavefront_bialign_breakpoint_*_avx2), used when built with -march=native. That's the C-native gap on x86.

Change

  • All five scans now share one scan_overlap (src/bialign.rs).
  • It tests chunks of 8 diagonals at once:
    • NEON: 2 × 4 lanes.
    • AVX2: one reversed load. It's inlined with target-cpu=native and runtime-detected otherwise.
  • Only a chunk with a candidate is rechecked one diagonal at a time. That includes the indel bounds check, and a rejected hit continues the scan.
  • The visiting order is unchanged, so the chosen breakpoint is the same as the scalar scan's and C's.

Results (M2, ultralow with CIGAR output, C 3282f83)

Case main this PR C this PR / C
m1k_5 affine2p 1612 ms 732 ms 1.58 s 0.46
m1k_5 affine2p, match −1 7118 ms 2799 ms 7.45 s 0.38
l10k_5 affine2p 1325 ms 702 ms 1.36 s 0.52
l10k_5 affine2p, match −1 3576 ms 1474 ms 3.63 s 0.41
m1k_5 affine 132 ms 122 ms 149 ms 0.82
edit / indel / linear unchanged 0.81–0.85

Testing

  • Output: identical to C and to main for every case above.
  • x86 under Rosetta: both an AVX2 build (target-cpu=haswell) and a generic build match C on 1k and 10k subsets, across 7 penalty configurations.
  • Differential harness: 1242 of 1262 cases identical. The 20 differences are the known Known issue (C bug): Z-drop in med/low backtraces from uninitialized cells (high vs med differ) #22 cases.
  • New unit tests:
    • The scan is compared with a naive scan over 400 random overlaps: tails shorter than 8, null offsets, and the first 0–2 hits rejected.
    • Each SIMD chunk test is compared with scalar.
    • Both tests fail on a deliberately broken lane pairing and on a scan that skips the rest of a chunk after a rejected hit.
  • cargo test: 271 passed. cargo clippy --all-targets -D warnings: clean.

Still to check on x86: a bench/sweep.py run on hpcbioamd and hpcbio. I expect affine2p ultralow to end up near or below C-native.

🤖 Generated with Claude Code

At every BiWFA step the new wavefronts are checked against the last
max_score_scope scores of the other direction: one M scan plus four
I1/D1/I2/D2 scans for affine2p. Only the M scan had SIMD (NEON); the indel
scans, and every scan on x86, were scalar. Profiling affine2p ultralow put
73% of the time there. C has AVX2/AVX-512 versions of both scans, which is
why C built with -march=native was ~2x faster than Rust on x86.

All five scans now share scan_overlap: chunks of 8 diagonals are tested at
once (NEON, or AVX2 inlined with target-cpu=native and runtime-detected
otherwise), and only a chunk with a candidate is rechecked diagonal by
diagonal. The visiting order is unchanged, so the breakpoint (and output)
is the same as the scalar scan and C.

M2: affine2p ultralow 0.38-0.52x C (was ~1.0x); affine 0.82x; others
unchanged. Differential harness clean (20 known #22 diffs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cjfields

Copy link
Copy Markdown
Member Author

Measured on x86 (EPYC 7713, hpcbioamd). This was a bench/sweep.py --filter ultralow run at c43fd1a, 3 runs per case and 51 cases, compared with the full run at 8ccfb64. Ratios are Rust / C, so below 1 means Rust is faster.

Case time / C, before → after time / C-native, before → after Rust after / before
m1k_5 affine2p 1.09 → 0.51 2.05 → 0.96 0.46
m1k_20 affine2p 1.08 → 0.50 2.25 → 1.05 0.46
m1k_indel affine2p 1.15 → 0.53 2.35 → 1.07 0.46
l10k_5 affine2p 1.08 → 0.59 2.21 → 1.22 0.55
affine2p banded (4 cases) 0.99–1.07 → 0.52–0.56 1.69–1.85 → 0.90–0.94 0.50–0.56
affine (core, 4 cases) 0.79–0.84 → 0.71–0.80 0.86–0.94
xdrop, zdrop, s150 unchanged 0.99–1.02
  • Output is identical to C in all 51 cases.
  • affine2p BiWFA went from about 2.2× C-native to parity, except l10k_5 at 1.22×. Only one case is still slower than generic C: s150_2 affine2p ultralow, at 1.13×. It was 1.14× before, and its sequences are short.

@cjfields
cjfields merged commit fc51088 into main Oct 16, 2026
17 checks passed
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