Repository navigation
Description
Tracking issue for Rust-vs-C performance. Output must stay byte-identical to C by default; validate every change with tests/differential/run.sh, and log results in .claude/PERFORMANCE_LOG.md. Items can be split off into their own issues as they're picked up.
Status (2026-10-15): a full Rust-vs-C sweep showed Rust faster than C, or at parity, on almost every config, with output identical everywhere: 5 metrics × 5 memory modes × 5 datasets, plus ends-free/extension and all five heuristics. Data in the comment below. The remaining gaps are mostly in the paths added or rewritten for C parity after the initial optimization pass: med/low, BiWFA, heuristics and corrected mode. Update 2026-10-17: a three-machine sweep at fc51088 (M2, EPYC, Xeon) gave a median Rust/C of 0.87 / 0.69 / 0.80, with output identical everywhere. Since then, #52 fixed the Xeon affine2p CIGAR gap and #58 the slab cost after long alignments.
Checklist
Done
- High-memory allocation over C's growing range, with a tight slab in CIGAR mode (Allocate wavefronts as C does: growing range, tight slab in CIGAR mode #36). Long pairs now match C in time; see the memory item below.
- med/low piggyback kernels vectorized and fused (Vectorize and fuse the med/low piggyback kernels #45). None vectorized before: LLVM turned selects between loaded values into gathers. med/low is now 0.52–0.83× its previous time and faster than C on Apple M2, which also fixes indel med/low (was 1.12–1.34× C). x86 (EPYC, partial sweep): med/low on 1k/10k pairs went from a median of 0.94× to 0.45× generic C (0.30–0.79). Before Vectorize and fuse the med/low piggyback kernels #45 it was 1.3–1.9× slower than C built with
-march=native. - x86 gather stalls removed (Remove the AVX2 gather extend kernels (x86 stalls); race-safe bench runs #48). The AVX2 extend kernels used
vpgatherdd, which stalled on Skylake-SP (Rust 1.50 G cycles vs C 0.70 G onl10k_5indel score, with 0.6× C's instructions). x86 now uses the scalar blockwise extend. On the EPYC,l10k_5indel score went from 1.69× to 0.80× C andm1k_20indel CIGAR from 1.25× to 0.87×. Confirmed on the Xeon:l10k_5indel score 2.16× → 0.91× C, linear CIGAR 1.88× → 0.86×. -
Compat::Correctedends-free overhead: +26–51% → +7–12% over C mode, via the extend kernel and exact pruning (Speed up Compat::Corrected ends-free (fast kernel, exact pruning); fix #41 #42). - BiWFA overlap scan (Vectorize the BiWFA overlap scan (affine2p BiWFA ~2x faster) #49). It was 73% of affine2p BiWFA time and scalar except the NEON M scan; C has AVX2/AVX-512 versions, which is why affine2p ultralow was ~2.2× C-native on the EPYC. The scan is now vectorized: on the M2 affine2p ultralow is 0.38–0.52× C (was ~1.0×), with output unchanged. EPYC: affine2p ultralow 1.08–1.15× → 0.50–0.59× C (C-native parity). Xeon ultralow run still pending.
- Xeon affine2p CIGAR gap (x86: claim affine2p output lines with PREFETCHW; drop input prefetches #52): store-buffer stalls on the kernel's five output stores (fresh lines in high memory).
PREFETCHWon the outputs, and no x86 input prefetches. Xeon affine2p CIGAR 1.01× → 0.89× C, banded 1.28× → 0.83×; EPYC 2–6% faster. - Slab reap after long alignments (Slab: reap only the wavefronts handed out since the last reap (port of C #140) #58, port of Wavefront slab clear Marco-Sola-Lab/WFA2-lib#140). High-memory
reap_cigarwalked every wavefront the slab ever created before each alignment, so short alignments after a long one paid for it (about 70% of a mixed run). Now only the wavefronts handed out are returned, recorded while the slab is large. C's #140 benchmark (10 × 10 kb, then 200k × 100 bp, high): 1231 → 365 ms (C: 421); a dada2-like interleaved workload: 735 → 209 ms. Output unchanged. - Per-step overhead on narrow banded ends-free alignments (Cut per-step overhead on narrow banded ends-free alignments #59): short
init_endsNULL fills as stores (they werememset_pattern16calls each step), and a banded-static cutoff fast path inside the band. 9–16% on dada2-like pairs, output unchanged. The begin-free ~3× (Optimize the WFA backend on long (PacBio) reads dada2-rs#51) is the step count of an exact WFA with seeded diagonals; early exact pruning doesn't help (see the comment of 2026-10-18).
Open: measured gaps
- High-memory CIGAR peak memory is up to 2× C on long pairs. 10 kb affine2p: 418 vs 214 MB; affine: 77 vs 45 MB; 2 kb affine with
match < 0: 66 vs 45 MB. Time is at parity. Find where the extra comes from (slab, CIGAR buffer, I1/D1 below). - BiWFA sequence handling. Each sub-problem re-copies the sequences with
init_ascii, and the reverse copy is a scalar byte loop. C copies them once per alignment and then only moves the bounds and end-of-sequence sentinels (wavefront_sequences_set_bounds). BiWFA is at parity with C (0.92–1.05×) on long or divergent pairs, rather than ahead. - wfa-adaptive on divergent pairs: 1.02–1.12× C's time (edit and affine2p, 20% divergence). Not yet examined.
- Corrected mode with distant first ends: when C stops at a wrong, Known issue (C parity): ends-free with whole-length free ends and match < 0 returns the no-overlap alignment #38-style first end (random flanks, whole-length free ends), corrected mode still costs +15–55% (short pairs up to +160%). Most of that is needed for optimality. A tighter early upper bound (e.g. from a cheap seed alignment) could make the pruning from Speed up Compat::Corrected ends-free (fast kernel, exact pruning); fix #41 #42 effective sooner.
- dada2-rs workload (Optimize the WFA backend on long (PacBio) reads dada2-rs#51): ends-free with a match reward, corrected, banded, bounded free ends, CIGAR, many short-to-5 kb pairs. Being benchmarked on the dada2-rs side as length × divergence, NW vs WFA, with affine
gap_opening = 0(current) vs gap-linear. Banded WFA's cost grows with divergence while banded NW's doesn't, so the crossover divergence is the key number. Library-side findings come back here.
Open: measurement
- Measure on x86-64. Full sweeps after Vectorize and fuse the med/low piggyback kernels #45/Remove the AVX2 gather extend kernels (x86 stalls); race-safe bench runs #48 are running on
hpcbioamd(EPYC 7713, AVX2) andhpcbio(Xeon Gold 6150, AVX-512). EPYC full run (8ccfb64): 277/301 cases are faster than generic C, and output is identical to C in all 301. Slower: affine2p ultralow (1.08–1.15×, ~2.2× C-native; Vectorize the BiWFA overlap scan (affine2p BiWFA ~2x faster) #49), s150 med/low (1.1–1.3×; runs of ~20–90 ms, so probably fixed per-run cost), and wfa-adaptive edit (1.03–1.07×). Xeon full run (8569e26, same Rust code): output identical to C in all 301. med/low median 0.51× C (1.01× C-native, was 2.10×). Slower than C: affine2p ultralow 1.2–1.4× (Vectorize the BiWFA overlap scan (affine2p BiWFA ~2x faster) #49), s150 med/low 1.1–1.6×, and xdrop/zdrop affine/affine2p 1.04–1.24×. See the JCC item below. Update: the three-machine sweep atfc51088is done (posted below). Remaining: the Xeon ultralow run for Vectorize the BiWFA overlap scan (affine2p BiWFA ~2x faster) #49. - Skylake layout sensitivity (JCC erratum). On the Xeon only, Remove the AVX2 gather extend kernels (x86 stalls); race-safe bench runs #48 made some cases 8–24% slower at an unchanged instruction count: end-to-end edit/indel/linear high-memory score/CIGAR on
m1k_5, all xdrop/zdrop, and banded edit. Their BiWFA versions got faster, and ends-free/extension didn't move. This fits the Skylake JCC-erratum microcode, where jumps crossing 32-byte boundaries leave the uop cache. A/B test (8569e26, Xeon): building with-C llvm-args=-x86-branches-within-32B-boundariesrecovers it, at 0.83–0.93× the plain build and 0.93–1.02× the pre-Remove the AVX2 gather extend kernels (x86 stalls); race-safe bench runs #48 times, with identical output. One control (affine score) is +4%. Uop-cache counters weren't captured (script bug, now fixed). Next: the same A/B on the EPYC, to see the flag's cost off Skylake; then decide between.cargo/config.tomlfor x86_64 and a README recommendation for downstream builds. Resolved by documenting: the README now recommends the flag for Skylake-family builds. The EPYC A/B was skipped; it's only needed if the flag ever becomes a default. - Reproducible Rust-vs-C sweep:
bench/sweep.py(bench: reproducible Rust-vs-C sweep (sweep.py) #44; per-commit output directories in bench/sweep.py: per-commit output dirs; WFA2-lib's new location #46). Builds C both generic and-march=native, uses fixed datasets, and supports offline nodes.
Checked, no action needed
- Per-component victim outputs for I1/D1 (and I2/D2), as C's
wavefront_compute_allocate_outputdoes: tried (patch intmp/bench/victim-outputs.patch), with identical output but no measurable change in time, instructions or peak memory on the M2. I/D components with all-null inputs are rare after the first steps. Not merged. (It was suspected for the Xeon CIGAR gap, which x86: claim affine2p output lines with PREFETCHW; drop input prefetches #52 fixed.) - Independent I/D
trim_ends(Banded alignments don't terminate as END_UNREACHABLE like C (hang in score-only mode) #4): per component, as in C (wavefront_compute_process_ends). Nothing to gain. - Per-step med/low checks (Port C's piggyback backtrace: --wfa-memory=med|low, buffer compaction #28): per step, not per cell; the plain kernels and extend loops have none. Under 1%.
- Ends-free begin-gap null steps (
endsfree_null_m): uses the slab free list, not a fresh allocation, and is rare. Same as C. - C-sized components (1000 × 1000) and the CIGAR buffer: allocated once, grown only when needed, with no per-alignment memset.
- BiWFA sub-aligners sized 10 × 10: created once and kept. Only a few early grow steps. (The per-sub-problem sequence copy above is the real cost.)
Original description (2026-10-13)
Symptom
Rust is faster than C on the 1 kb benchmark (PERFORMANCE_LOG.md), but in high-memory (CIGAR) mode it falls behind on longer pairs. Measurements on wfa.utest.seq (incl. its 10 kb pairs), M2 Pro, single run, Rust main vs C 3282f83:
| Configuration | Rust | C | Rust max RSS | C max RSS |
|---|---|---|---|---|
gap-affine2p, -1,4,6,2,24,1, ends-free 0.1,0,0.1,0 |
12.7 s | 4.3 s | 5.3 GB | 3.7 GB |
| gap-affine2p (default penalties) | 2.5 s | 1.7 s | 3.7 GB | 1.8 GB |
| gap-affine | 0.41 s | 0.35 s | 972 MB | 285 MB |
Under parallel load, the first case timed out in the differential harness (#30).
Main cause (known target)
In non-modular (CIGAR) mode, every wavefront is allocated over the full historic diagonal range, [-(plen + scope), tlen + scope]. C allocates each wavefront at its own lo..hi plus margins (wavefront_compute_allocate_output, slab tight mode). Memory therefore grows with sequence length × score rather than with the wavefront widths. It's already noted as the next memory target in PERFORMANCE_LOG.md (#8).
Other candidates from the port's C-parity work
Each was kept for output identity; look for an equivalent that is cheaper:
- Independent I/D
trim_ends(Banded alignments don't terminate as END_UNREACHABLE like C (hang in score-only mode) #4,ddae4d8): restored from C, and costs 3–10% on affine/affine2p. - Per-step checks added with med/low (Port C's piggyback backtrace: --wfa-memory=med|low, buffer compaction #28): piggyback branches and written-range tracking cost 1–2% (BiWFA up to 4%) in every mode.
- Piggyback kernels (
compute/piggyback.rs): split into per-component passes so they vectorize (the fused loop hit false store/load aliasing). A fused loop withnoaliasslice parameters could halve the memory passes for affine2p. - Ends-free begin-gap null steps (
endsfree_null_m): allocate a fresh wavefront per begin-free score, even in modular mode. - C-sized components (1000 × 1000 up front, BiWFA ignores heuristics; port C band inheritance (#125) and compute-limit clamp #6) and the
2·(plen+tlen)CIGAR buffer: small fixed costs; check they're amortized across alignments. - BiWFA sub-aligners: sized 10 × 10 (C differs) and re-initialized per sub-problem.
Process
- Validate each optimization with
tests/differential/run.sh(Add the Rust-vs-C differential test harness #30): no new differences. - Benchmark with
bench/run.shon an idle machine, and log the result inPERFORMANCE_LOG.md. - CI (Add GitHub Actions CI (build, test, clippy, fmt, C parity) #29) should run the parity checks on PRs. Benchmarks stay manual, since shared runners are too noisy.
Activity
cjfields commentedon Oct 14, 2026
PR #36 fixes the main target: wavefronts are now allocated over C's growing range, with a tight slab in CIGAR mode. Long pairs now match C on memory and time. For example, utest affine 927 → 275 MB (C 272) and affine2p 3.5 → 1.8 GB (C 1.75). The ends-free affine2p case with match < 0 goes from 18.8 s to 3.4 s, against C's 4.2 s.
Another candidate from that work: Rust always allocates the I1/D1 (and I2/D2) outputs, while C uses the victim wavefront when both of their inputs are null (
wavefront_compute_allocate_output).cjfields commentedon Oct 15, 2026
Rust-vs-C sweep, 2026-10-15 (
main0268377vs C3282f83, Apple Silicon M-series; C built bytests/differential/build_c.sh, i.e. without-march=native, Rust withtarget-cpu=native).Time.Alignment), each the best of 3 runs at load ~1.5–2.5, plus max RSS. Ratios are Rust/C; below 1 means Rust is better.generate_dataset):s150_2: 50k × 150 bp, 2%m1k_5: 20k × 1 kb, 5%m1k_20: 5k × 1 kb, 20%m1k_indel: 2k × 1 kb, 5% plus 5 indels of up to 50 bpl10k_5: 300 × 10 kb, 5%lng2k: 300 × 2 kb-1,4,6,2, and-1,4,6,2,24,1for affine2p). Heuristic parameters: wfa-adaptive10,50,1, xdrop/zdrop100,1, banded-50,50.—in the time column is a run over a minute, which the parser didn't read.Summary
match < 0: 0.58–0.93× time (med 0.63–0.93×).Core: metrics × memory modes × datasets (125 configs)
Ends-free / extension (72 configs)
Heuristics (104 configs)
🤖 Generated with Claude Code
cjfields commentedon Oct 16, 2026
Full EPYC 7713 sweep after #45 and #48 (
hpcbioamd/compute-5-6, commit8ccfb64,bench/sweep.py, 3 runs per case). Ratios are Rust / C, so below 1 means Rust is faster. "Before" is the quick run ataa091cf(1 run per case).l10k_5indel score went from 1.69× to 0.80× C, andm1k_20indel CIGAR from 1.25× to 0.87×.cjfields commentedon Oct 16, 2026
Three-machine sweep at
mainafter #45, #48 and #49. Generated byreport.py(a draft generator, not yet in the repo) from threebench/sweep.pyruns. The M2 run isc43fd1a, whose source is identical tofc51088; it's marked-dirtyonly because of an uncommittedbuild_c.shchange that gives C a cheap timer on macOS (see the note below). 3 runs per case.Rust time / C time: median (range) over the cases in each group. C is built without
-march=native.Overall
-march=nativeNotes
m1k_5didn't change. That's consistent with the effect depending on code layout (see README: note on the Skylake JCC erratum build flag #50).m1k_20, at about 1.3×.profiler_timer.creads the clock through three Mach calls, about 1.3 µs per alignment inside the timed interval. That made s150 C look about 6× slower andm1k_5fast modes about 20–25% slower. This M2 run usesclock_gettimeinstead, as on Linux.cjfields commentedon Oct 16, 2026
Xeon affine2p CIGAR gap: profiled (
perfon compute-5-1 atfc51088).The extra cost of CIGAR over score-only is about 3× larger in Rust than in C:
m1k_5affine2pm1k_20banded affine2pCounters, in millions:
resource_stalls.sb)compute_affine2p_stepallocates fresh slab wavefronts at every step for M, I1 and D1, and for I2/D2 unless both are inactive. All of them are written. C (wavefront_compute_allocate_output) gives each component whose inputs are null the shared victim wavefront, which stays hot in cache. So Rust writes many more cache lines of fresh memory per step. On Skylake each one is a read-for-ownership miss that holds a store-buffer entry. The EPYC absorbs this better.m1k_20affine2p CIGAR at 64 vs 40 MB, andl10k_5at 420 vs 222 MB.&mutoutputs, whenever its inputs are null, as C does. That cuts the stores, the memory and the store-buffer stalls. It should also help gap-affine and other machines, to a lesser degree.compute_affine2p_stepseems too large or badly laid out for Skylake's uop cache. That likely explains part of why Rust slows more than C on the Xeon (1.47× vs 1.29× its EPYC time). Possible fixes: keep the kernel loops out of line, or rely on the JCC flag (README: note on the Skylake JCC erratum build flag #50).cjfields commentedon Oct 16, 2026
Correction to the Xeon CIGAR diagnosis above: victim outputs are not the cause. I implemented per-component victim outputs, where an I/D component with all-null inputs writes to its own victim and its slot is left null, as in C. Output was identical: the differential harness was clean and all tests passed. But it made no measurable difference on the M2 to time, instructions or peak memory, in any mode. I/D components with all-null inputs are rare after the first few steps. I've kept the change as a patch rather than merging it.
What the data does show: the large CIGAR overhead (CIGAR time minus score-only time) is specific to the Xeon and to affine2p.
New hypothesis:
Test: an A/B on the Xeon comparing three builds: unchanged, no prefetches in the affine2p kernel, and no x86 software prefetches at all.
cjfields commentedon Oct 17, 2026
Xeon affine2p CIGAR: narrowed to the kernel's output stores.
Prefetch A/B, Xeon and EPYC:
Split stores: ruled out. Rust 12% of stores, C 10%.
What does differ,
m1k_5affine2p CIGAR on the Xeon:Where (
perf recordon those events, with source lines):affine2p.rs, the_mm256_storeu_si256lines).init_endsandwavefront_init.So Rust's kernel writes to lines that are cold in L2 about 1.8× as often as C's. The allocation width isn't the reason: both use
lo − (scope + 1) … hi + (scope + 1)merged with the historic range.Candidates, in a 4-way A/B on both x86 machines:
prefetchwon the 5 outputs, 4 lines ahead, to claim the lines before the stores arrive;cjfields commentedon Oct 17, 2026
Xeon affine2p CIGAR gap: fixed in #52. The affine2p kernel now issues
PREFETCHWon its 5 outputs, 32 diagonals ahead, and the x86 input prefetches are removed. On the Xeon, store-buffer stalls drop to C's level (4.63 G → 0.43 G cycles). Against C, affine2p CIGAR goes from 1.01× to 0.89×, banded from 1.28× to 0.83×, and 10 kb from 0.97× to 0.87×. The EPYC is 2–6% faster on most affine2p cases, and output is identical. Of the distances tried (32, 64 and 128 diagonals), 32 was best on the Xeon; 64 and 128 gave smaller or no gains. Cache coloring isn't worth changing. The full A/B is in the PR.cjfields commentedon Oct 18, 2026
Begin-free ends-free costs about 3× per alignment (dada2-rs, HPCBio/dada2-rs#51)
Measured on
bef359awithCompat::Corrected. Honoring begin-free gaps (#17) makes each ends-free alignment about 3× slower, on long and short reads alike. It is now the main reason WFA is slower than NW in dada2-rs; the rest of the port is not slower than before.Pair benchmark
7,980 pairs (top 20 × 400 most abundant uniques), match −5, mismatch 4, gap 8,
ComputeAlignment, Apple Silicon. dada2-rs settings:BandedStaticover the NW band ([−lband, +rband], widened by the length difference), free ends bounded toband + |len1 − len2|, and "begin-free on" sets the begin-free values to that same bound. Times are the median of 2 runs.o = 0)o = 0)The MiSeq reads are all 240 bp, so begin-free there is just 16. Even a begin-free span the size of the band triples the cost. Results are unchanged: dada2-rs's 10,000-pair parity sweep gives 0 disagreements with the NW optimum in both metrics.
Bisect (long reads, on/off at each first-parent merge since
4cfeda8): the cost appears exactly at #17 (3.4 → 11.7 s), and #36 recovered part of it. With begin-free off,5cdcea3was 2.76 s against4cfeda8's 3.13 s, so nothing else regressed. C3282f83shows the same multiplier (20 s → 62 s), so this is the algorithm's cost as C implements it, not a port quirk.End-to-end effect (dada2-rs
learn-errors, wall time)Before #17, WFA was about 6% faster than NW on MiSeq. Short reads now show the cost most, because NW is cheap there.
A lead, not verified
Seeding is already lazy (
endsfree_begin_k: diagonalkenters at scorek·|match|). My guess is that the cost comes afterwards. Once seeded, an off-diagonal begin-free cell stalls (it extends only until its first mismatch, which is short for non-repetitive sequence) but stays in the wavefront. Every later score step then computes the full[−band, +band]range, instead of the few diagonals a near-identical pair needs. Two checks would confirm or rule this out:Reproducing
Pair lists: the PacBio pairs come from the dada2-rs concordance fixture (
dev/concordance/data/pacbio, SRR8557463/4) after primer removal and filtering. The MiSeq pairs come from the MiSeq SOP forward reads afterfilterAndTrim(truncLen 240). Take the 400 most abundant uniques, one sequence per line. The benchmark loop:probe (Rust, depends on
wfa2lib-rsatbef359a)In dada2-rs, the same comparison end to end: branch
perf/wfa-remeasure-51,cargo build --release --features wfa, thenlearn-errors ... --align-backend wfa2 --wfa-max-edits 50against--align-backend nw, withDADA2RS_WFA_LINEAR=1for the linear arm.cjfields commentedon Oct 18, 2026
Begin-free cost: the lead is confirmed, but the cost is per score step, not per cell
Follow-up to the comment above (HPCBio/dada2-rs#51), on
bef359a, Apple M2. Synthetic dada2-like pairs: 20 bases × 100 mutants each, ~20% of differences indels. Penalties: linear-5,4,8and affine-5,4,0,8; corrected mode, banded-static ±band, free ends = band.1. Seeded diagonals do stay in the wavefront. Cells computed per alignment, counted with a temporary probe:
2. Pruning them early doesn't help. I gave corrected mode's exact pruning (#42) an upper bound from the start: the cheaper of two gapless alignments (diagonal 0, and the end diagonal after a begin gap), computed in O(n) and used only when it's inside the band. Output was identical in every case, but time was 2–18% worse. With a match reward, a cell's lower bound grows by only
|match|= 5 per diagonal of distance from the end diagonal, while each difference costs 18 (2·4 + 2·5). So the seeded diagonals can't be excluded until the search is nearly over. They're legitimate low-score cells that an exact search must visit, which is consistent with C showing the same 3×. Not merged.3. The time follows the number of steps. At 240 bp, 2% (linear), begin-free off is ~2.7 µs and 10 steps per alignment; on, it's ~8.3 µs and 43 steps. That's about 170 ns per extra step, or ~13 ns per extra cell, while the SIMD kernel's own work is under 1 ns per cell. Seeds make the wavefront non-null at many more scores, and each non-null step pays a fixed cost. Profile (
sample), begin-free on, 240 bp:compute_linear_stepitself (setup + kernel)memset_pattern16)align_endsfreeinlined: corrected extend, per-diagonal termination checksThat's also why banded NW wins at 240 bp despite ~13× more cells: it has no per-step setup.
Next (new checklist item): reduce per-step overhead for narrow wavefronts, keeping output identical. Starting points:
This helps every short-read alignment, with or without begin-free. I don't expect it to erase the 3×. With the ~10% divergence DADA2's screen allows, part of this is likely the inherent price of an exact WFA against banded NW.
cjfields commentedon Oct 18, 2026
Global vs ends-free on primer-trimmed amplicons (HPCBio/dada2-rs#51)
Context, from @cjfields: dada2-rs (like DADA2) denoises PCR-amplified data from a common region. The degenerate primers are trimmed before dereplication, screening, alignment and denoising. If trimming is done properly, the reads passing the screen are effectively anchored at both the 5' and 3' ends: every pair starts and ends at the same biological position. dada2-rs uses ends-free alignment to keep the full sequences of each pair, but the anchoring suggests a different alignment model.
What anchoring implies:
truncLen(MiSeq) aren't anchored at 3'. There an internal indel leaves an overhang, so end-free only (begin 0) is the right model.Speed (
b83c0ea, M2, ms per 2,000 synthetic dada2-like pairs, banded ±16 / ±32, median of 5; ends-free columns in corrected mode):o = 0o = 0o = 0o = 0o = 0.Suggested for dada2-rs (no wfa2lib-rs change needed; global and end-free-only are already the fast paths, and #59 applies to both):
align_end2end; NW withend_gap_p = gap_p), end-free only, or the current fully free ends. Keep fully free ends as the default for parity with R DADA2, whose NW frees end gaps at both ends.cjfields commentedon Oct 18, 2026
Correction to my earlier lead: the begin-free cost is a step, in both compat modes
My earlier comment guessed that seeded begin-free diagonals stay in the wavefront and widen every later step to the full band. A sweep of the begin-free limit rules that out:
Same 7,980 real pairs,
bef359a, gap-affine/linear, match −5,BandedStaticover the NW band, end-free limits unchanged (band + |Δlen|), begin-free set toton both sequences. Best of 2.tCompat::CCorrectedCompat::CCompat::Ct = 1doubles the cost, andt = 2reaches the full cost. Witht = 2only four begin-free cells are added (diagonals ±1, ±2), yet it costs as much as a 32+ bound, so the width of the seeded range isn't what costs.Compat::Cshows the same step, so the cost lies in the begin-free path both modes share (endsfree_begin_k/endsfree_initseeding withmatch < 0), not in corrected mode's end handling. My second guess (corrected-mode accounting, in Opt-in alignment end model for primer-anchored amplicons (global / end-free-only) dada2-rs#289) is wrong as well.A per-step count of wavefront width (or cells computed) for one near-identical pair at
t = 0,1and2should show where the extra work goes.cjfields commentedon Oct 19, 2026
Begin-free cost: cause found, mostly pruned exactly (
perf/begin-free-dominance)Follow-up to the step-cost comment above. The per-step count it suggested shows where the extra work goes.
Cause. Each begin-free seed k is placed at score k·|m| (5, 10, … with match −5). From there its cells advance only by mismatches and gaps (+18 / +21 here, the WFA costs of DADA2's 5/−4/−8). So each seed starts a lineage of wavefronts at scores the main lineage never reaches. With only two step costs, the main lineage is non-null at 33 of ~142 scores per pair (240 bp, 2%). The offsets of 5 and 10 fill most of the gaps: 94 non-null at a limit of 2, 120 at the full bound. That is why the cost is a step: once a couple of seeds exist, nearly every score already has a wavefront. It is the same in both compat modes because C does the same.
Most of that work is dead. On the shifted diagonals almost nothing matches. The seed lineages trail cells the main path already reached more cheaply. For example, diagonal +1 sits at offset 2 at score 23, after the main path reached offset 10 there at score 21.
Exact pruning. A cell is dominated if a cell of the same component reached at least as far on its diagonal at an earlier score. With non-negative WFA costs (match 0), any path on from a dominated cell can be redirected from the dominating one at no extra cost: its first gap run is one base shorter, or its first diagonal step is absorbed into it. The redirected path reaches the same end with the same skipped-base charge. So dropping dominated cells leaves the optimum unchanged. C and WFA in general keep them.
Implementation. Corrected mode, with no heuristic or
BandedStatic(dada2-rs's setting). After each compute step, dominated cells are trimmed from the ends of each M/I/D wavefront. The gate: always for gap-linear, and for affine/affine2p up to a begin-free limit of 4. Beyond 4, affine's three passes per score cost more than they save on short pairs. Fusing the check into the compute kernels would lift that limit.Correctness.
mainover 1,008 CLI configurations (linear/affine/affine2p, seven span shapes, high/med/low, with and without a band).05bc1a3(Opt-in alignment end model for primer-anchored amplicons (global / end-free-only) dada2-rs#289) found no differences:Speed (dada2-rs, real pairs, linear, s):
b83c0ea05bc1a3b83c0ea05bc1a3Linear ends-free at the full bound (R DADA2's model) drops from 3.0× to 1.5–1.6× the begin-free-0 cost. A small tolerance (1–3 bases) now costs 1.2–1.3×. Synthetic pairs in
.claude/PERFORMANCE_LOG.mdshow 0.47–0.95× for linear and up to 0.54× for affine at limits ≤ 4.