Repository navigation
perf(zipper): speed up --restore-unconverted-bases hot path - #271
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughThe PR adds 🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #271 +/- ##
==========================================
+ Coverage 89.84% 89.88% +0.03%
==========================================
Files 120 120
Lines 61359 61585 +226
==========================================
+ Hits 55127 55354 +227
+ Misses 6232 6231 -1 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
`fgumi zipper --restore-unconverted-bases` (added in #170) was the only zipper code path that dropped the streaming raw-byte merge in favour of a `RecordBuf` round-trip — full BAM parse, per-field allocation, per-record mutation, then re-encode every byte. That round-trip is the dominant cost on the methylation path. This brings the methylation path onto the same raw-byte fast path TAPs already uses. The dispatch in `process_raw` is now uniform — convert_template_to_raw(...); merge_raw(...); if let Some(ref_reader) = reference { restore_unconverted_bases_in_raw_template(..., ref_reader, ...); } — with the methylation case adding one extra step that mutates SEQ in place via `bam_fields::set_base`. SEQ stays the same length (4-bit nibble rewrite), so no record resizing is needed; only `NM` / `MD` are removed when SEQ is actually modified. The new `restore_unconverted_bases_in_raw_record` reuses the `fgumi-raw-bam` accessors catalogued in #272 (`flags`, `pos`, `ref_id`, `get_cigar_ops`, `cigar_op_kind`, `find_string_tag_in_record` for `YD`, `seq_offset`, `get_base`, `set_base`, and `remove_tag`). It also keeps the perf wins from #271 — borrowed-slice `fetch_slice`, precomputed lowercase target, `memchr2`-based early-exit — so reads aligned to spans without candidate ref bases skip the CIGAR walk entirely. The `RecordBuf` reference implementation is kept under `cfg(test)` as an oracle: every existing `test_restore_unconverted_bases_*` test still passes against it, and six new `test_restore_unconverted_bases_in_raw_record_*` tests cover the same scenarios on the raw-byte path (top strand, bottom strand, reverse top strand, no-`YD` skip, unmapped skip, `NM` / `MD` stale-tag clearing). Stacks on #271 for `ReferenceReader::fetch_slice`. Rebase onto `main` once #271 lands. Full workspace `cargo ci-test`: passes. `cargo ci-fmt` and `cargo ci-lint` (clippy pedantic + `-D warnings`) clean.
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/lib/reference.rs (1)
291-313:⚠️ Potential issue | 🟡 MinorInvalid interval guard allows
start = end + 1to pass.
start_idx > end_idxmisses the adjacent inverted case and returns an empty slice instead of erroring.Suggested fix
- if end_idx > sequence.len() || start_idx > end_idx { + if end_idx > sequence.len() || start_idx >= end_idx { return Err(FgumiError::InvalidParameter { parameter: "region".to_string(), reason: format!(🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lib/reference.rs` around lines 291 - 313, In fetch_slice, the interval check uses "if end_idx > sequence.len() || start_idx > end_idx" which lets the adjacent inverted case (start = end + 1) slip through; change the second check to "start_idx >= end_idx" so any non-positive-length or inverted interval (start_idx >= end_idx) returns the InvalidParameter error; update the conditional referencing start_idx and end_idx in the fetch_slice method accordingly.
🧹 Nitpick comments (1)
src/lib/reference.rs (1)
414-439: Add a regression assertion for inverted intervals (start > end).This test covers missing-contig and out-of-bounds, but not the
start > endcase that exercises interval validation directly.Suggested test addition
assert!( reader .fetch_slice("chr1", Position::try_from(1)?, Position::try_from(10_000)?) .is_err() ); + assert!( + reader + .fetch_slice("chr1", Position::try_from(5)?, Position::try_from(4)?) + .is_err() + ); assert!( reader.fetch_slice("nope", Position::try_from(1)?, Position::try_from(2)?).is_err() );🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lib/reference.rs` around lines 414 - 439, The test test_fetch_slice_returns_borrowed_bytes is missing a regression assertion for inverted intervals; add an assertion that calling reader.fetch_slice with start > end (e.g., Position::try_from(4) as start and Position::try_from(1) as end) returns an Err, mirroring how fetch should behave on invalid intervals—place this assertion alongside the existing bounds and missing-contig checks to ensure fetch_slice validates interval ordering the same way as fetch.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/lib/commands/zipper.rs`:
- Around line 4049-4084: The test
test_restore_unconverted_bases_no_target_in_span currently doesn't verify NM/MD
preservation because the Record lacks those tags; update the RecordBuilder setup
to include explicit NM and MD tags (e.g., set NM and MD via the builder or add
them to the Record data) before calling restore_unconverted_bases_in_record,
then after the call assert that the NM and MD tags (Tag::new(b'N', b'M') and
Tag::new(b'M', b'D')) still exist and equal the original values to prove they
were preserved on a no-op.
---
Outside diff comments:
In `@src/lib/reference.rs`:
- Around line 291-313: In fetch_slice, the interval check uses "if end_idx >
sequence.len() || start_idx > end_idx" which lets the adjacent inverted case
(start = end + 1) slip through; change the second check to "start_idx >=
end_idx" so any non-positive-length or inverted interval (start_idx >= end_idx)
returns the InvalidParameter error; update the conditional referencing start_idx
and end_idx in the fetch_slice method accordingly.
---
Nitpick comments:
In `@src/lib/reference.rs`:
- Around line 414-439: The test test_fetch_slice_returns_borrowed_bytes is
missing a regression assertion for inverted intervals; add an assertion that
calling reader.fetch_slice with start > end (e.g., Position::try_from(4) as
start and Position::try_from(1) as end) returns an Err, mirroring how fetch
should behave on invalid intervals—place this assertion alongside the existing
bounds and missing-contig checks to ensure fetch_slice validates interval
ordering the same way as fetch.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: c76703a5-d05c-4578-a0fc-603cf0011a4e
📒 Files selected for processing (2)
src/lib/commands/zipper.rssrc/lib/reference.rs
`restore_unconverted_bases_in_record` is called once per mapped record and dominates throughput of `fgumi zipper --restore-unconverted-bases`. Profiling a 26.8M-record EM-Seq run showed ~46K records/s, about 10x slower than upstream bwa+bwameth can produce, causing bwameth.py to back up and OOM-kill its `c2t` worker under default `bwa -K` chunks. Five changes that touch the same hot loop: 1. ReferenceReader::fetch_slice — new borrowed-slice API. `fetch` already kept the entire reference resident in memory but returned a fresh `Vec<u8>` per call. For a 13M-template run that's ~2 GB of allocator churn. `fetch_slice` returns `&[u8]` into the in-memory buffer; `fetch` is now a thin wrapper. 2. Borrow the contig name from the BAM header instead of allocating a `String` per record. One fewer alloc per record on the hot path. 3. Early-exit when the aligned reference span contains no candidate base (C for top-strand, G for bottom-strand). Uses `memchr::memchr2` for SIMD-accelerated detection of upper- and lower-case targets in one pass; skips the SEQ allocation and CIGAR walk entirely. 4. Hoist case-folding out of the inner loop. Previously the per-base compare did `to_ascii_uppercase()` on both ref and read bytes; precompute the lowercase variants once per record and just check equality against both. 5. Defer the SEQ `Vec<u8>` allocation until the first base actually needs to change. Reads that align to ref-C positions but already carry C in the read (highly methylated CpGs in EM-Seq, common case) now leave the function without allocating a SEQ buffer at all. Adds `test_restore_unconverted_bases_no_target_in_span` to lock in the early-exit, plus `test_fetch_slice_returns_borrowed_bytes` for the new API. The existing `test_restore_unconverted_bases_preserves_already_unconverted` already exercises the deferred-allocation path. All other tests pass unchanged (90 directly-affected reference + restore tests; full workspace 2495 pass, 19 skipped).
679bd0a to
f2781e7
Compare
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
`fgumi zipper --restore-unconverted-bases` (added in #170) was the only zipper code path that dropped the streaming raw-byte merge in favour of a `RecordBuf` round-trip — full BAM parse, per-field allocation, per-record mutation, then re-encode every byte. That round-trip is the dominant cost on the methylation path. This brings the methylation path onto the same raw-byte fast path TAPs already uses. The dispatch in `process_raw` is now uniform — convert_template_to_raw(...); merge_raw(...); if let Some(ref_reader) = reference { restore_unconverted_bases_in_raw_template(..., ref_reader, ...); } — with the methylation case adding one extra step that mutates SEQ in place via `bam_fields::set_base`. SEQ stays the same length (4-bit nibble rewrite), so no record resizing is needed; only `NM` / `MD` are removed when SEQ is actually modified. The new `restore_unconverted_bases_in_raw_record` reuses the `fgumi-raw-bam` accessors catalogued in #272 (`flags`, `pos`, `ref_id`, `get_cigar_ops`, `cigar_op_kind`, `find_string_tag_in_record` for `YD`, `seq_offset`, `get_base`, `set_base`, and `remove_tag`). It also keeps the perf wins from #271 — borrowed-slice `fetch_slice`, precomputed lowercase target, `memchr2`-based early-exit — so reads aligned to spans without candidate ref bases skip the CIGAR walk entirely. The `RecordBuf` reference implementation is kept under `cfg(test)` as an oracle: every existing `test_restore_unconverted_bases_*` test still passes against it, and six new `test_restore_unconverted_bases_in_raw_record_*` tests cover the same scenarios on the raw-byte path (top strand, bottom strand, reverse top strand, no-`YD` skip, unmapped skip, `NM` / `MD` stale-tag clearing). Stacks on #271 for `ReferenceReader::fetch_slice`. Rebase onto `main` once #271 lands. Full workspace `cargo ci-test`: passes. `cargo ci-fmt` and `cargo ci-lint` (clippy pedantic + `-D warnings`) clean.
`fgumi zipper --restore-unconverted-bases` (added in #170) was the only zipper code path that dropped the streaming raw-byte merge in favour of a `RecordBuf` round-trip — full BAM parse, per-field allocation, per-record mutation, then re-encode every byte. That round-trip is the dominant cost on the methylation path. This brings the methylation path onto the same raw-byte fast path TAPs already uses. The dispatch in `process_raw` is now uniform — convert_template_to_raw(...); merge_raw(...); if let Some(ref_reader) = reference { restore_unconverted_bases_in_raw_template(..., ref_reader, ...); } — with the methylation case adding one extra step that mutates SEQ in place via `bam_fields::set_base`. SEQ stays the same length (4-bit nibble rewrite), so no record resizing is needed; only `NM` / `MD` are removed when SEQ is actually modified. The new `restore_unconverted_bases_in_raw_record` reuses the `fgumi-raw-bam` accessors catalogued in #272 (`flags`, `pos`, `ref_id`, `get_cigar_ops`, `cigar_op_kind`, `find_string_tag_in_record` for `YD`, `seq_offset`, `get_base`, `set_base`, and `remove_tag`). It also keeps the perf wins from #271 — borrowed-slice `fetch_slice`, precomputed lowercase target, `memchr2`-based early-exit — so reads aligned to spans without candidate ref bases skip the CIGAR walk entirely. The `RecordBuf` reference implementation is kept under `cfg(test)` as an oracle: every existing `test_restore_unconverted_bases_*` test still passes against it, and six new `test_restore_unconverted_bases_in_raw_record_*` tests cover the same scenarios on the raw-byte path (top strand, bottom strand, reverse top strand, no-`YD` skip, unmapped skip, `NM` / `MD` stale-tag clearing). Stacks on #271 for `ReferenceReader::fetch_slice`. Rebase onto `main` once #271 lands. Full workspace `cargo ci-test`: passes. `cargo ci-fmt` and `cargo ci-lint` (clippy pedantic + `-D warnings`) clean.
Summary
ReferenceReader::fetch_slicereturns&[u8]instead ofVec<u8>; eliminates ~2 GB ofVecallocation churn over a 13M-template run.fetchbecomes a thin wrapper for callers that need owned bytes.String-allocating per record.restore_unconverted_bases_in_recordviamemchr::memchr2when the aligned reference span has no candidate base — skips both the SEQ allocation and the CIGAR walk.to_ascii_uppercase()per byte.Vec<u8>allocation until the first base actually needs to change. Reads aligned to ref-C positions but already carrying C in the read (common for highly-methylated CpGs in EM-Seq) now return without allocating SEQ at all.Why
fgumi zipper --restore-unconverted-bases(added in #170) drops the streaming raw-byte merge path used by TAPs/non-methyl in favour of aRecordBufround-trip so it can fetch reference bases per record. Profiling a real 26.8M-record EM-Seq run showed ~46K records/s — about 10× slower thanbwa mem | bwameth.pycan produce. With defaultbwa -K 150000000, bwameth.py accumulates a chunk's worth ofBamobjects in Python while waiting on zipper, eventually getting OOM-killed mid-pipe.The five changes here all sit on the per-record hot path and stack cleanly. None of them change observable output for any record that has bases to restore — the existing 9 restore tests still pass byte-for-byte. The new
no_target_in_spantest locks in the early-exit, andtest_restore_unconverted_bases_preserves_already_unconvertedalready exercises the deferred-SEQ-allocation path (it asserts the no-change case, which now also incurs no allocation).End-to-end speedup will be measured in the next fgumi-benchmarks methylation run; the alloc/copy reductions are mechanical and the early-exit + deferred-allocation are unconditional skips for any read that doesn't need work.
Test plan
test_fetch_slice_returns_borrowed_bytescovers&[u8]return, byte-equality withfetch, and bounds errors.test_restore_unconverted_bases_no_target_in_spancovers thememchr2early-exit fast path.test_restore_unconverted_bases_*tests pass byte-for-byte (no behavioral regressions).cargo ci-test: 2495 passed, 19 skipped.cargo ci-fmtandcargo ci-lint(clippy pedantic +-D warnings) clean.