perf: skip long periodic runs instead of scanning them (independent) - #10
perf: skip long periodic runs instead of scanning them (independent)#10BenjaminDEMAILLE wants to merge 1 commit into
Conversation
Extracted into its own change, as @rob-p asked in COMBINE-lab#7: it is independent of the packed-key work and should be reviewable and revertable on its own. The LCP-enhanced merge resolves most steps without touching the text, but when it does compare two suffixes it scans their shared prefix. That is fine until the text contains a long periodic run, where two suffixes inside it agree for as far as the run continues and one comparison scans megabytes. If `text[s..e)` has period `q` and two suffixes start at `a < b` inside it with `(b - a) % q == 0`, they agree until the later reaches `e`, so `lcp(a, b) >= e - b` is known in O(1) from the run's bounds with nothing scanned. Mismatched phase means they differ within `q` symbols, so the ordinary scan is already short. Scans are additionally bounded to stop at a run's start rather than traverse it. Detecting only single-symbol runs would have missed the case that occurs: in 60-column wrapped FASTA the longest run of one byte is 60, because each line of `N`s ends in a newline. The real structure is a period-61 repeat. The table is consulted only after an ordinary bounded scan has matched 256 symbols. Nearly every LCP call in real sequence mismatches within a few symbols and never reaches a run, and the lookup costs up to three binary searches, so paying it up front taxed every call: on a filtered `N`-containing chr21 that alone took a 1.97 s build to 2.40 s with identical output. Probing first brings the cost back to 2.02 s, within noise, while keeping the benefit where runs exist. Detection is two-stage so texts without runs pay almost nothing: a sampling pass collects the periods that occur, and the full scan runs only for those. On `N`-free DNA the table comes out empty and every query short-circuits on a slice-empty check. The detector covers periods up to 64 and not beyond. Measured on synthetic 1 MiB periodic inputs, periods 1, 2, 61 and 64 are detected with full coverage and sort 5.8-6.9x faster; 65 and 171 are not detected and run at parity, so alpha-satellite arrays are outside it. chr21 FASTA, external memory, 12 threads, output verified identical to the in-memory suffix array: 24.19 s -> 2.05 s. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks for separating this change and for incorporating the lazy lookup idea. I tested the implementation at PR head Decision: close without mergingThe inference is sound and the PR already implements the requested lazy policy: an ordinary bounded LCP scan must match 256 symbols before any run-table lookup occurs. The output was correct in the ruSTAR-shaped tests. However, on the real annotated-human workload the change is a clear regression, not a speedup. Production-shaped fixtureThis is the same fixture used for the issue review, built through ruSTAR's actual GENCODE parsing and junction construction:
One candidate warm-up was followed by an immediately paired baseline/candidate measurement.
The candidate warm-up was consistent at 404.648 s (phase 1 114.134 s; phase 4 270.800 s). Thus the result is far outside the sub-second run-to-run variation previously measured for this baseline and is not a cold-run artifact. Both full runs emitted exactly 6,176,694,310 positions with hash The focused chr21-backbone + full-annotation-flank fixture showed the same direction:
The candidate matched the stored baseline position-for-position for all 359,616,038 emitted suffixes. Why the synthetic win does not transfer to ruSTARThe periodic-run inference is genuinely valuable when the sorted suffix population contains many aligned starts inside one very long run, as in the raw-FASTA synthetic benchmark. That is not ruSTAR's construction:
Lazy table lookup removes the much larger eager-lookup penalty, but it cannot make this workload free: run detection still scans the 6.56-billion-symbol text, and every hot LCP call still goes through the run-aware wrapper/table-state branch. Here those costs are paid broadly while successful skips are too rare to recover them. This is visible in both phase 1 and phase 4 and in the +5.8% user-CPU increase. Correctness and checks
The default parallel test invocation can exceed this host's file-descriptor limit because multiple external-memory tests independently open pools at once; all tests pass with So I am closing #10 without merging. I still like the inference as a targeted technique for unfiltered periodic inputs. A future version could be reconsidered behind an explicit/adaptive opt-in or a filter/segment-aware activation criterion, but it should not be on by default in the ruSTAR path based on these data. |
`radix.rs` carried a phase-1 subarray seed alongside the in-memory doubling path, and that seed took its comparator as a `runs::Cmp`, the run-skipping wrapper closed as COMBINE-lab#10. It is also unreachable on 0.7.0, whose phase 1 fuses sorting and distribution and never called it. The seed is worth having, but as a re-derivation against the phase 1 that exists, which is COMBINE-lab#15. Removing it here leaves this module with the in-memory doubling path alone, which is what this PR is about, and drops the last dependency on the closed work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Independent of every other open PR. Branches from
main, touches no packed-key code, reviewable and revertable on its own.@rob-p asked in #7 to extract run skipping into its own PR and called it the strongest part of the stack. This is that extraction, with the two corrections from the same review applied.
What
The LCP-enhanced merge resolves most steps without touching the text, but when it does compare two suffixes it scans their shared prefix. That is fine until the text contains a long periodic run, where two suffixes inside it agree for as far as the run continues and one comparison scans megabytes.
If
text[s..e)has periodqand two suffixes start ata < binside it with(b - a) % q == 0, they agree until the later reachese, sois known in
O(1)from the run's bounds with nothing scanned. Mismatched phase means they differ withinqsymbols, so the ordinary scan is already short. Scans are additionally bounded to stop at a run's start rather than traverse it.A homopolymer detector would not have worked. In 60-column wrapped FASTA the longest run of a single byte is 60, because each line of
Ns ends in a newline. The real structure is a period-61 repeat.Corrections from your review, applied
The table is no longer consulted on every LCP call. You were right that this taxed the common path. Consulting it costs up to three binary searches before any comparison, and nearly every LCP call in real sequence mismatches within a few symbols and never reaches a run. It now probes with the ordinary bounded scan first and consults the table only after a match survives 256 symbols. On a filtered
N-containing chr21 matching your control fixture (80,177,238 retained positions), with byte-identical output throughout:The satellite claim is corrected. Your measurement is right: period 171 is not detected. The docs now state the measured coverage — periods 1, 2, 61 and 64 detected with full coverage and 5.8-6.9x faster; 65 and 171 not detected and at parity — and say explicitly that alpha-satellite arrays fall outside the detector, along with the note that the sampling stage can miss a sufficiently localised run.
Measured
12 threads, external memory, output verified byte-identical to the in-memory suffix array:
Neutral where there are no runs, which is the design goal. Interleaved against
mainon N-free DNA:Detection is two-stage so that costs nothing: a sampling pass collects the periods that occur and the full scan runs only for those, so an
N-free text gets an empty table and every query short-circuits on a slice-empty check.Verification
71 tests (62 on
mainplus 9 here) in debug and release; fmt, Clippy with warnings denied, rustdoc. The run-aware LCP is checked against a byte-at-a-time oracle over sampled position pairs on homopolymers, wrapped-FASTA blocks and multi-period texts, plus detection shape — sorted, disjoint, and the period claim actually holding — andmax_contextbehaviour inside a run.🤖 Generated with Claude Code