Skip to content

perf(zipper): eliminate RecordBuf re-encode bottleneck with raw-byte merge - #228

Merged
nh13 merged 1 commit into
mainfrom
nh/zipper-perf-v1
Apr 4, 2026
Merged

nh13 merged 1 commit into
mainfrom
nh/zipper-perf-v1

Conversation

@nh13

@nh13 nh13 commented Apr 4, 2026

Copy link
Copy Markdown
Member

Summary

  • Eliminates the noodles RecordBuf serialization bottleneck in zipper by performing all 7 merge operations directly on raw Vec<u8> BAM bytes, then writing via RawBamWriter
  • Adds raw-byte primitives to fgumi-raw-bam: field setters (set_mapq, set_mate_ref_id, etc.), cigar_to_string_from_raw, signed int normalization, i32 array tags, and bulk tag copy
  • Implements fix_mate_info_raw on Template with three helper methods for the mapped/unmapped/mixed pairing cases
  • Consolidates four process methods into a single process_raw method, removing ~330 lines of dead/duplicate code

Test plan

  • All 2220 existing tests pass (including zipper integration tests)
  • New unit tests for AS/XS normalization (small and large values)
  • New unit tests for negative strand tag copying (R1-only, both reads)
  • New unit tests for fix_mate_info values (RNEXT, PNEXT, TLEN, MQ, MC)
  • New unit tests for fix_mate_info supplementary alignments
  • New unit tests for fix_mate_info one-unmapped case
  • Roundtrip tests for all 6 new field setters
  • Tests for cigar_to_string_from_raw, append_i32_array_tag, normalize_int_tag_to_smallest_signed, copy_aux_tags
  • cargo ci-fmt and cargo ci-lint clean
  • Benchmark against previous version on real data

@nh13
nh13 temporarily deployed to github-actions April 4, 2026 07:07 — with GitHub Actions Inactive
@coderabbitai

coderabbitai Bot commented Apr 4, 2026 •

Copy link
Copy Markdown

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

Adds raw-byte BAM manipulation across the crate. New public APIs and helpers: cigar_to_string_from_raw, six fixed-header setters (set_mapq, set_ref_id, set_pos, set_mate_ref_id, set_mate_pos, set_template_length), aux-tag utilities (append_i32_array_tag, append_signed_int_tag, normalize_int_tag_to_smallest_signed, copy_aux_tags), and raw aux serializers/transforms (append_buf_value_raw, reverse_tag_in_place_raw, revcomp_tag_in_place_raw). Replaces the zipper merge/output with a raw-byte pipeline (convert_template_to_raw, merge_raw, create_raw_bam_writer/write_raw_record) and adds Template::fix_mate_info_raw, set_mate_flags_raw, and compute_insert_size_raw. Tests expanded throughout.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title directly summarizes the main change: eliminating the RecordBuf serialization bottleneck by switching to raw-byte merge operations.
Description check ✅ Passed The description clearly outlines the changeset, covering the performance optimization, new raw-byte primitives, mate-info fixing, and test coverage.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch nh/zipper-perf-v1

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov

codecov Bot commented Apr 4, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.22705% with 99 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.88%. Comparing base (ea6e3fc) to head (657f1f8).
⚠️ Report is 11 commits behind head on main.

Files with missing lines Patch % Lines
src/commands/zipper.rs 89.17% 85 Missing ⚠️
src/lib/template.rs 93.85% 14 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #228      +/-   ##
==========================================
+ Coverage   88.18%   88.88%   +0.69%     
==========================================
  Files         113      113              
  Lines       53388    54452    +1064     
==========================================
+ Hits        47080    48398    +1318     
+ Misses       6308     6054     -254     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@nh13

nh13 commented Apr 4, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Apr 4, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/commands/zipper.rs (1)

737-750: Avoid leaving Template in mixed raw/RecordBuf mode.

After this conversion, raw_records becomes the source of truth, but records still holds the pre-merge RecordBufs. Any later caller that touches records, r1(), or all_reads() on this template will see stale data instead of the bytes being written.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/zipper.rs` around lines 737 - 750, convert_template_to_raw
creates template.raw_records but leaves template.records populated, causing
callers of records/r1()/all_reads() to see stale RecordBufs; after successfully
building raw_records in convert_template_to_raw, replace or clear
template.records so the Template is no longer in a mixed mode (e.g., set
template.records = Vec::new() or rebuild RecordBufs from the new raw_records),
while preserving any read_count compatibility semantics; update
convert_template_to_raw to mutate the Template.fields (records and raw_records)
consistently so subsequent callers use raw_records as the source of truth.
🤖 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/commands/zipper.rs`:
- Around line 724-729: The loop over mapped.raw_records currently
unconditionally calls bam_fields::append_i32_array_tag(record, pa_tag,
&pa_values) which can create duplicate PA tags; change this to overwrite the PA
tag instead of appending: for each record in mapped.raw_records (rr/record)
detect if the PA tag (pa_tag) already exists and either remove it first or use
the appropriate "set" operation so the existing PA value is replaced with
pa_values (mirroring the RecordBuf path that replaced PA_TAG) rather than
appending a second AUX field.

In `@src/lib/template.rs`:
- Around line 1064-1070: The code currently converts mate AS values with
i32::try_from(...).unwrap_or(0) and writes ms=0 on overflow, which silently
corrupts the tag; change the logic so you only call bam_fields::update_int_tag
when the try_from succeeds (e.g., if let Ok(v) = i32::try_from(as_value) {
bam_fields::update_int_tag(&mut rr[r1_i], b"ms", v); }) and skip updating the
tag when conversion fails, mirroring the RecordBuf path; apply the same fix to
the other occurrences that use r1_as/r2_as and bam_fields::update_int_tag (the
analogous blocks that currently use unwrap_or(0)).

---

Nitpick comments:
In `@src/commands/zipper.rs`:
- Around line 737-750: convert_template_to_raw creates template.raw_records but
leaves template.records populated, causing callers of records/r1()/all_reads()
to see stale RecordBufs; after successfully building raw_records in
convert_template_to_raw, replace or clear template.records so the Template is no
longer in a mixed mode (e.g., set template.records = Vec::new() or rebuild
RecordBufs from the new raw_records), while preserving any read_count
compatibility semantics; update convert_template_to_raw to mutate the
Template.fields (records and raw_records) consistently so subsequent callers use
raw_records as the source of truth.
🪄 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: 3ea9a6d4-8d3b-4733-aea7-1aef675cf33d

📥 Commits

Reviewing files that changed from the base of the PR and between 1ab1bd5 and 070eaf5.

📒 Files selected for processing (5)
  • crates/fgumi-raw-bam/src/cigar.rs
  • crates/fgumi-raw-bam/src/fields.rs
  • crates/fgumi-raw-bam/src/tags.rs
  • src/commands/zipper.rs
  • src/lib/template.rs

Comment thread src/commands/zipper.rs
Comment thread src/lib/template.rs Outdated
@nh13
nh13 force-pushed the nh/zipper-perf-v1 branch from 070eaf5 to e7cf302 Compare April 4, 2026 17:57
@nh13
nh13 temporarily deployed to github-actions April 4, 2026 17:57 — with GitHub Actions Inactive
@nh13

nh13 commented Apr 4, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Apr 4, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
src/commands/zipper.rs (2)

789-796: Doc comment skips step 4.

Numbering goes 1, 2, 3, 5, 6, 7. Likely should be 1-7 sequential.

Minor doc fix
 /// 1. Fix mate info (raw)
 /// 2. Remove tags from mapped records
 /// 3. Copy tags from unmapped to mapped (with reverse/revcomp for neg strand)
-/// 5. Transfer QC flags
-/// 6. Normalize AS/XS tags
-/// 7. Add PA tags
+/// 4. Transfer QC flags
+/// 5. Normalize AS/XS tags
+/// 6. Add PA tags
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/zipper.rs` around lines 789 - 796, Update the doc comment above
the function (the comment that starts "Performs the same 7 operations as
`merge()`") to fix the step numbering so it reads 1 through 7 sequentially
(change the "5." entry to "4." and renumber remaining items if needed) and
ensure the list accurately matches the seven operations performed by merge();
refer to the comment mentioning `merge()` and the surrounding doc block in
zipper.rs when making the edit.

597-607: Silent truncation on array overflow.

unwrap_or(0) writes count=0 but proceeds to write all elements if vals.len() > u32::MAX. Practically impossible, but inconsistent with append_i32_array_tag which panics.

Consider using expect() for consistency with other array tag functions, or document the behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/zipper.rs` around lines 597 - 607, The Array::UInt8 branch
silently uses u32::try_from(vals.len()).unwrap_or(0) which sets count=0 on
overflow but still writes all elements; change this to mirror
append_i32_array_tag by using expect() (or otherwise explicitly panic) on failed
conversion so an overflow cannot silently truncate the length, i.e. locate the
Array::UInt8 arm in the zipper serialization code and replace the unwrap_or(0)
with a conversion that fails loudly (e.g., u32::try_from(...).expect("array
length overflow") ) so behavior is consistent with other array tag functions.
src/lib/template.rs (1)

1076-1110: Repeated unwrap pattern after immutable borrows.

The self.raw_records.as_ref().unwrap() at line 1078 and as_mut().unwrap() at line 1090 are safe (initial check guarantees Some), but rebinding rr multiple times is verbose.

Consider passing the slice directly to avoid repeated unwraps:

let rr = self.raw_records.as_mut().unwrap();
// work directly with rr throughout
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/template.rs` around lines 1076 - 1110, The code currently does two
borrows of self.raw_records (as_ref() then as_mut()); replace them with a single
mutable borrow to reduce verbosity and avoid repeated unwraps: after confirming
self.r2 (and obtaining r2_i) do let rr = self.raw_records.as_mut().unwrap();
then read r2 fields from rr[r2_i] (r2_ref_id, r2_pos, r2_flags, r2_is_reverse,
r2_is_unmapped, r2_tlen, r2_mapq, r2_cigar_str, r2_as) and then use the same rr
slice to iterate the supplementals (self.r1_supplementals) and update records
via set_mate_flags_raw and bam_fields::set_mate_ref_id/ set_mate_pos/
set_template_length/ update_int_tag/ update_string_tag/ update_int_tag for ms;
this removes the prior as_ref().unwrap() and the second as_mut().unwrap() while
keeping the same logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/commands/zipper.rs`:
- Around line 789-796: Update the doc comment above the function (the comment
that starts "Performs the same 7 operations as `merge()`") to fix the step
numbering so it reads 1 through 7 sequentially (change the "5." entry to "4."
and renumber remaining items if needed) and ensure the list accurately matches
the seven operations performed by merge(); refer to the comment mentioning
`merge()` and the surrounding doc block in zipper.rs when making the edit.
- Around line 597-607: The Array::UInt8 branch silently uses
u32::try_from(vals.len()).unwrap_or(0) which sets count=0 on overflow but still
writes all elements; change this to mirror append_i32_array_tag by using
expect() (or otherwise explicitly panic) on failed conversion so an overflow
cannot silently truncate the length, i.e. locate the Array::UInt8 arm in the
zipper serialization code and replace the unwrap_or(0) with a conversion that
fails loudly (e.g., u32::try_from(...).expect("array length overflow") ) so
behavior is consistent with other array tag functions.

In `@src/lib/template.rs`:
- Around line 1076-1110: The code currently does two borrows of self.raw_records
(as_ref() then as_mut()); replace them with a single mutable borrow to reduce
verbosity and avoid repeated unwraps: after confirming self.r2 (and obtaining
r2_i) do let rr = self.raw_records.as_mut().unwrap(); then read r2 fields from
rr[r2_i] (r2_ref_id, r2_pos, r2_flags, r2_is_reverse, r2_is_unmapped, r2_tlen,
r2_mapq, r2_cigar_str, r2_as) and then use the same rr slice to iterate the
supplementals (self.r1_supplementals) and update records via set_mate_flags_raw
and bam_fields::set_mate_ref_id/ set_mate_pos/ set_template_length/
update_int_tag/ update_string_tag/ update_int_tag for ms; this removes the prior
as_ref().unwrap() and the second as_mut().unwrap() while keeping the same logic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: aef2d937-e36b-4b30-8c6c-e7558630856c

📥 Commits

Reviewing files that changed from the base of the PR and between 070eaf5 and e7cf302.

📒 Files selected for processing (5)
  • crates/fgumi-raw-bam/src/cigar.rs
  • crates/fgumi-raw-bam/src/fields.rs
  • crates/fgumi-raw-bam/src/tags.rs
  • src/commands/zipper.rs
  • src/lib/template.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/fgumi-raw-bam/src/cigar.rs
  • crates/fgumi-raw-bam/src/fields.rs

@nh13
nh13 force-pushed the nh/zipper-perf-v1 branch from e7cf302 to d56a97f Compare April 4, 2026 20:34
@nh13
nh13 temporarily deployed to github-actions April 4, 2026 20:34 — with GitHub Actions Inactive
@nh13
nh13 force-pushed the nh/zipper-perf-v1 branch from d56a97f to 3deabae Compare April 4, 2026 20:44
@nh13
nh13 temporarily deployed to github-actions April 4, 2026 20:44 — with GitHub Actions Inactive

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (1)
src/commands/zipper.rs (1)

857-864: Add an end-to-end PG preservation test for the raw path.

merge_raw now carries its own "keep mapped PG, ignore unmapped PG" logic, but the current tests only cover that behavior through copy_tags. A small run_zipper case with PG on both inputs would pin this branch down.

Also applies to: 893-898

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@crates/fgumi-raw-bam/src/tags.rs`:
- Around line 711-715: Update the docstring for
normalize_int_tag_to_smallest_signed to accurately describe its behavior: state
that when the tag exists and is an integer type it is re-encoded using
append_signed_int_tag semantics (i.e., it promotes only through signed types i8
-> i16 -> i32), and remove any mention of unsigned-preferring behavior or
u8/u16; reference normalize_int_tag_to_smallest_signed and append_signed_int_tag
in the description so readers know which function controls the re-encoding path.

In `@src/commands/zipper.rs`:
- Around line 1071-1093: The code currently calls writer.finish() in the error
branches that handle leftover mapped reads (the branches that inspect
mapped_peek and mapped_iter.next()), which finalizes the BAM and can leave a
valid-looking but incomplete output on fatal input-order errors; remove the
writer.finish() calls from those error paths in zipper.rs (the branches handling
mapped_peek and the mapped_iter.next() result) so that writer.finish() is only
invoked on successful completion, and simply bail with anyhow::bail! including
the String::from_utf8_lossy(&remaining.name) context instead of finalizing the
writer.
- Around line 590-649: The match arm for BufValue::Array currently uses
u32::try_from(vals.len()).unwrap_or(0), which silently writes a zero length on
overflow; change each place that calls u32::try_from(vals.len()).unwrap_or(0)
(inside Array::Int8/UInt8/Int16/UInt16/Int32/UInt32/Float branches) to fail fast
on overflow instead of inserting 0: perform the try_from and propagate or return
an explicit error (or panic) when conversion fails (e.g., map_err/ok_or and
return Err) before writing the count or payload so the function that serializes
arrays (the BufValue::Array handling) aborts on oversized arrays rather than
producing a corrupt header.

In `@src/lib/template.rs`:
- Around line 1100-1102: The code only calls bam_fields::update_string_tag(rec,
b"MC", ...) when r2_cigar_str is present, so existing MC tags remain when the
mate becomes unmapped or has "*" / empty CIGAR; update the logic in the MC
branches (e.g., the block using r2_cigar_str and r2_is_unmapped and the
analogous blocks at lines referenced for r1/r2) to explicitly remove the MC tag
when the mate CIGAR should disappear: detect the "mate absent" condition
(r2_is_unmapped || r2_cigar_str.is_empty() || r2_cigar_str == "*") and call the
tag-removal path (the same mechanism used elsewhere for deleting tags) instead
of skipping update_string_tag, while keeping update_string_tag for the non-empty
case.
- Around line 1064-1072: Replace calls to bam_fields::update_int_tag that write
the "ms" tag with the signed-int writer so positive values >127 keep the signed
aux type; specifically, where code currently does
bam_fields::update_int_tag(&mut rr[r1_i], b"ms", v) or update_int_tag(&mut
rr[r2_i], b"ms", v) use bam_fields::append_signed_int_tag(&mut rr[r1_i], b"ms",
v) (or append_signed_int_tag(&mut rr[r2_i], b"ms", v)) so the signed encoder is
used, or alternatively call bam_fields::normalize_int_tag_to_smallest_signed
after writing to normalize existing tags; apply the same replacement at the
other occurrences mentioned (around the blocks with r1_as/r2_as and the lines
referenced in the comment).
🪄 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: d25112ed-b2ba-4c5a-9ee1-f8e49d64d24f

📥 Commits

Reviewing files that changed from the base of the PR and between e7cf302 and d56a97f.

📒 Files selected for processing (5)
  • crates/fgumi-raw-bam/src/cigar.rs
  • crates/fgumi-raw-bam/src/fields.rs
  • crates/fgumi-raw-bam/src/tags.rs
  • src/commands/zipper.rs
  • src/lib/template.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/fgumi-raw-bam/src/cigar.rs
  • crates/fgumi-raw-bam/src/fields.rs

Comment thread crates/fgumi-raw-bam/src/tags.rs Outdated
Comment thread src/commands/zipper.rs
Comment thread src/commands/zipper.rs
Comment thread src/lib/template.rs
Comment thread src/lib/template.rs
…merge

Replace the noodles RecordBuf encode path in the zipper with direct
raw Vec<u8> BAM record manipulation, eliminating the serialization
bottleneck visible in profiling.

The mapped template is converted to raw bytes once (via the vendored
BAM encoder), then all 7 merge operations (fix mate info, tag removal,
tag copying with reverse/revcomp, QC flag transfer, AS/XS normalization,
PA tags) are performed directly on the raw bytes using fgumi_raw_bam
primitives. Output is written via RawBamWriter::write_raw_record,
bypassing the noodles re-encode entirely.

New raw BAM primitives: field setters (set_mapq, set_mate_ref_id,
set_mate_pos, set_template_length, set_ref_id, set_pos),
cigar_to_string_from_raw, append_i32_array_tag, append_signed_int_tag,
normalize_int_tag_to_smallest_signed, copy_aux_tags.

New Template method: fix_mate_info_raw operating on raw_records.

Adds tests for AS/XS normalization, negative strand tag copying,
and fix_mate_info value verification.
@nh13
nh13 force-pushed the nh/zipper-perf-v1 branch from 3deabae to 657f1f8 Compare April 4, 2026 20:59
@nh13
nh13 temporarily deployed to github-actions April 4, 2026 20:59 — with GitHub Actions Inactive
@nh13
nh13 merged commit 7c1cc80 into main Apr 4, 2026
7 of 8 checks passed
@nh13
nh13 deleted the nh/zipper-perf-v1 branch April 4, 2026 21:02
@nh13 nh13 mentioned this pull request Apr 4, 2026

This branch was previously deployed

1 inactive deployment
github-actions — 657f1f83 Deployed Apr 4, 2026 by nh13 via coverage #893
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant