Skip to content

perf(compare): make compare bams faster than the sort it validates - #696

Merged
nh13 merged 7 commits into
mainfrom
686/nh/perf-compare-bams
Aug 6, 2026
Merged

nh13 merged 7 commits into
mainfrom
686/nh/perf-compare-bams

Conversation

@nh13

@nh13 nh13 commented Aug 1, 2026 •

Copy link
Copy Markdown
Member

Closes #686.

fgumi compare bams --command sort ran ~3x slower than the fgumi sort it validates, and --threads did nothing. Profiling found why, and four changes fix it; a fifth removes a redundant traversal in content mode.

Result

20.1M records/file, ~2 GB BAMs, M2 Max (12 core), page cache warmed identically before every run, minimum of 3 reps:

before after
--command sort -t 8 23.44s 4.14s
--command sort scaling flat 8.92s (-t 1) → 5.58s (-t 4) → 4.14s (-t 8)
--command group -t 8 24.36s ~10s
--command simplex (content) 47.1 CPU-s 27.4 CPU-s

Both of #686's acceptance criteria hold: comparison is now 2.0x faster than the sort it validates (4.14s vs 8.20s), having started 2.7x slower, and throughput scales measurably with --threads.

Content-mode results are quoted in CPU-seconds deliberately — see Measurement.

What was wrong

tricorder --trace showed sort and group running the entire comparison in exactly one OS thread — n_threads never left 1, so --threads created nothing at all. Sampling put ~61% of that single thread in BGZF decode (~55% libdeflate inflate, ~6% the per-block CRC32) while eleven cores idled. content reached only 2.46 cores and additionally traversed each input twice, because verify_records_in_order ran over both paths before positional_compare reopened and streamed them again — four traversals for a job needing two.

The changes

Suggested reading order is commit order; each commit is independently revertable.

  1. f7fbf95 Read both inputs concurrently. OpenedInput::open_pair gives each input a share of the --threads BGZF budget and its own read-ahead thread, so the two decodes overlap each other and the comparison. The budget is split rather than handed to each input in full: they are symmetric and both must be fully decoded, so giving each the whole count would request double the parallelism asked for. (zipper splits asymmetrically because its two inputs differ in cost; a comparison's do not.)

  2. 352cfa1 Cancel byte-identical records without building content keys. RunCanceller built a canonical content key per record — a Vec per aux tag, a sort, a concatenation, a hash and a map insert. Comparing the raw bytes first skips all of it for pairs that agree.

  3. 13d8907 Verify sort order during the content pass. Folds the order check into the comparison's own record pass via OrderCheck, removing the two dedicated traversals.

  4. 388ff00 Skip key and content checks for byte-identical pairs (content mode). record_keys_match ran on every pair before content_diffs, whose first act is the same byte comparison. Also splits content mode's decompression budget, which was still starting 8 BGZF workers per reader at -t 8.

  5. f0126b3 Read the MI tag in one aux scan. get_mi_tag_raw probed find_int_tag then find_string_tag, each walking the aux block from the start; fgumi group writes MI:Z:, so the integer probe always missed and its scan was wasted.

Why the byte-equality fast paths are sound

Both rest on the same argument, and neither replaces the general path. A RecordKey is a pure function of a record's bytes, and content_key_exact excludes bin, width-normalizes integer tags and sorts the tag multiset — so byte equality strictly implies key and content equality. The converse fails, which is exactly why these are fast paths: records that are content-equal but differ in tag order, integer width or bin fall through and still match. For the run comparison there is a second step: removing one record from each side with the same key leaves the multiset symmetric difference unchanged whatever else is pending, so the fast path needs no precondition on canceller state.

Correctness

The reader change carried a real trap. RawReadAheadReader is Iterator<Item = RawRecord> — no Result — and signals a read failure by ending iteration and parking the error for a later take_error(). Adopted directly, a truncated or CRC-corrupt BAM would have read as a shorter file: a record-count DIFFER, or a false IDENTICAL when both inputs are damaged alike. Rather than requiring every engine loop to remember a trailing check, CheckedRecords restores the Result contract at the boundary, so the engines' existing rec? propagation is correct by construction and forgetting the check is not an available mistake.

Every change was gated against a 121-comparison differential corpus — matched, missing, extra, edited, tag-reordered, intra-run reordered, mis-sorted, truncated, header-conflicting and byte-corrupt inputs, in both directions and at three thread counts — requiring byte-identical verdicts, exit codes and RESULT lines against a binary built from main. The corpus is generated (fgumi simulate), not committed. Two cases carry particular weight: tag-reorder and swap-adjacent are content-equal but not byte-equal, so they prove the fast paths did not swallow the general checks.

Error text changed only for damaged inputs, where the messages now name the failing file and diagnose it more precisely (block data checksum mismatch rather than a generic open failure).

New tests: sort-order violations are pinned by count, by which file is named, and by acceptance of correctly-ordered input at more than one thread count; the damaged-input regression test is parameterized over thread count as well as damage mode, covering the worker-pool error route that open_pair actually uses. An existing test asserting only that an error contains("order") was strengthened to assert the actual contract.

Measurement

Content-mode numbers are CPU-seconds, not wall. This host's wall time proved unreliable under interactive load — identical -t 8 runs measured 3.56s and 16.56s — while total CPU stayed within 24–27s across the same sweep. Where wall is quoted, arms were interleaved with the page cache warmed identically before every run and the minimum of 3 reps taken; the outliers are all slower, i.e. contention.

All numbers are from this workstation. #686's criteria were written against a 780M-record c7g.4xlarge, and that run has not been done — the effects here (5.7x) are far outside the noise, but the issue's own scale has not been reproduced.

Notes for reviewers

  • fgumi-sort gains one export and one signature generalization. RawReadAheadReader is re-exported, and verify_sort_order now takes any Iterator<Item = io::Result<RawRecord>> rather than a concrete reader. The Result item type is deliberate: it keeps a read failure distinguishable from end-of-stream.
  • positional_compare gained a required parameter, so this is a breaking change for out-of-tree callers of that feature-gated API. A defaulting overload was considered and rejected: it would let a caller opt out of order verification by omission.
  • verify_records_in_order is removed — OrderCheck subsumes it and it had no callers left. fgumi sort --verify continues to use fgumi_sort::verify_sort_order.
  • Grouping mode keeps its ~10s beyond the reader win; its engine (molecule_join) was otherwise untouched, and its remaining hot spots are find_tag_position and get_mi_tag_raw.

Not done, deliberately

Migrating the readers to the sort engine's pooled architecture was investigated and rejected for now. PooledInputStream::next_block blocks on std::thread::park() and pool workers unpark() the thread captured at construction — correct for the sort pipeline's single stream and single consumer. Two pools feeding one comparison thread would let stream A's workers wake the consumer while it waits on stream B, producing a cross-stream unpark storm: a CPU-burning busy-wait, the opposite of the intent. Fixing it properly needs per-stream condvars or a per-stream consumer thread, and the latter reintroduces the channel it would be removing.

Raising the read-ahead batch from 256 to 4096 records was also measured and reverted: CPU was unchanged (26.5 vs 27.0 CPU-s) while peak RSS tripled. That null result is informative — the time in crossbeam_channel::recv is the comparison thread waiting on decode, not per-handoff overhead, so the remaining lever is decode throughput rather than handoff granularity.

CLAUDE.md records the profiling ladder this work established (tricorder first, then in-tree phase timers, then perf on Linux) along with three macOS sampling traps that produced confidently wrong profiles here — including "38% of CPU in clap_builder::error::Error::print" on a successful run, from symbolicating another image's addresses against the fgumi binary.

Risk: comparison verdicts, exit codes, RESULT lines, --max-diffs, and diagnostics remain pinned; unsafe changes are none and the CLAUDE.md allowlist is unchanged; thread allocation and read-ahead behavior change, with no reported memory-bound or queue-capacity change.

  • fgumi compare bams reads both BAM inputs concurrently.
  • --threads applies to sort and grouping comparisons through split decompression budgets.
  • Sort-order checks run during comparison instead of a separate traversal.
  • Byte-identical records use fast paths.
  • MI tags use one auxiliary-tag scan.
  • CheckedRecords preserves deferred read errors and identifies the affected input.
  • Tests cover ordering diagnostics, damaged BAMs, thread counts, and differential comparison behavior.
  • Benchmarking guidance and performance data were added to CLAUDE.md.

@nh13
nh13 temporarily deployed to github-actions August 1, 2026 17:16 — with GitHub Actions Inactive
@coderabbitai

coderabbitai Bot commented Aug 1, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: fe4a928d-ae0b-494c-a0aa-e212e03cb068

📥 Commits

Reviewing files that changed from the base of the PR and between 018068c and 2b68b78.

📒 Files selected for processing (12)
  • .gitignore
  • CLAUDE.md
  • crates/fgumi-sort/src/lib.rs
  • crates/fgumi-sort/src/read_ahead.rs
  • crates/fgumi-sort/src/verify.rs
  • src/lib/commands/compare/bams.rs
  • src/lib/commands/compare/engines/mod.rs
  • src/lib/commands/compare/engines/positional.rs
  • src/lib/commands/compare/engines/sort_verify.rs
  • src/lib/commands/compare/molecule.rs
  • tests/integration/test_compare_bams.rs
  • tests/integration/test_compare_mutation.rs

Walkthrough

compare bams now opens both BAM inputs with split decompression threads, propagates deferred read errors, and performs sort-order validation during streaming comparison. MI parsing uses one auxiliary scan, and identical records use a direct comparison path.

Changes

BAM comparison pipeline

Layer / File(s) Summary
Raw record stream contracts
crates/fgumi-sort/..., src/lib/commands/compare/molecule.rs
Comparison consumers accept fallible raw-record iterators. Deferred read failures remain distinct from clean end-of-stream.
Threaded paired input wiring
src/lib/commands/compare/bams.rs, src/lib/commands/compare/engines/mod.rs
OpenedInput::open_pair creates one checked reader per BAM and splits decompression threads between them.
Integrated comparison and order checks
src/lib/commands/compare/engines/positional.rs, src/lib/commands/compare/engines/sort_verify.rs
Order validation runs during record consumption. Errors identify the input path. Identical records bypass canonical key construction.
Comparison validation and benchmarking support
tests/integration/..., CLAUDE.md, .gitignore
Tests cover sort violations, damaged BAMs, MI handling, updated call sites, profiling guidance, and ephemeral artifact exclusions.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CompareBams
  participant OpenedInput
  participant CheckedRecords
  participant PositionalCompare
  participant OrderCheck
  CompareBams->>OpenedInput: open_pair(bam1, bam2, threads)
  OpenedInput->>CheckedRecords: create checked record streams
  CheckedRecords-->>PositionalCompare: yield records or labeled read errors
  PositionalCompare->>OrderCheck: observe records
  PositionalCompare-->>CompareBams: comparison result and order diagnostics
Loading

Possibly related issues

  • #699: The PR changes the streaming sort_verify engine and its pending-run processing, which is related to the issue objective.

Possibly related PRs

Suggested labels: fgumi sort

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title uses valid Conventional Commit format and accurately describes the performance change to compare bams.
Linked Issues check ✅ Passed The changes address [#686] by enabling threaded concurrent reads, measurable scaling, faster sort comparison, preserved behavior, and error coverage.
Out of Scope Changes check ✅ Passed The code, tests, documentation, and ephemeral-artifact rule support the performance, threading, and reliability objectives in [#686].

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

@codecov

codecov Bot commented Aug 1, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.75641% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.06%. Comparing base (c09669a) to head (2b68b78).
⚠️ Report is 10 commits behind head on main.

Files with missing lines Patch % Lines
src/lib/commands/compare/engines/positional.rs 87.23% 6 Missing ⚠️
src/lib/commands/compare/engines/sort_verify.rs 98.88% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #696      +/-   ##
==========================================
+ Coverage   93.93%   94.06%   +0.13%     
==========================================
  Files         178      178              
  Lines      108128   108601     +473     
==========================================
+ Hits       101565   102152     +587     
+ Misses       6563     6449     -114     

☔ View full report in Codecov by Harness.
📢 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 Aug 1, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai pause

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
✅ Action performed

Reviews paused.

@nh13

nh13 commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Ran this branch at production scale on c7g.4xlarge, which the PR notes as not done. The workstation result transfers: 5.84x throughput, against 5.7x measured on the 12-core host.

Numbers

Both arms --command sort --threads 8 on c7g.4xlarge (16 vCPU / 30 GiB), inputs on gp3 provisioned to 500 MB/s, comparing two independently-sorted BAMs:

fgumi records wall throughput
before 3df1022c (v0.5.0, pre-#696) 779,820,469 31m 43s 409,673/s
after 7585aba3 (this branch) 1,327,219,598 9m 14s 2,392,619/s

Three comparisons ran on the after side, all at 1.33B records:

  • coordinate, fgumi vs samtools — 9m 14s (2,392,619/s)
  • coordinate, mako vs fgumi — 9m 15s (2,390,736/s)
  • template-coordinate, mako vs fgumi — 10m 1s (2,205,211/s)

#686's acceptance criteria at this scale

comparison completes in less wall time than fgumi sort --threads 8 over the same data on the same host

Met, and not narrowly. On the coordinate cell the fgumi sort that produced one of these inputs took 2529.2s; comparing its output took 554s — the comparison is 4.6x faster than the sort it validates. Against the same sort without its consolidation pass (1535.5s, the mako arm) it is still 2.8x faster.

Caveats, since these are not an interleaved A/B

The two rows are different datasets and different record counts — the before is a 1000 Genomes HG00096 pair from an earlier run, the after is GIAB HG002 60x. I have normalized to items/s, and the instance type, thread count and storage configuration are identical, but this is not the same-inputs comparison your workstation numbers are. Treat 5.84x as "the effect is the same order at production scale on the target hardware", not as a precise replication.

I also did not sweep --threads at this scale, so the second criterion — measurable scaling from 1 to 8 — remains verified only on the workstation.

Also

The reader change held up on inputs where per-record cost is high: the template-coordinate cells run over a BAM with 77.3M both-ends-unmapped records, and the only thing that stopped one of those four comparisons was the pending-window abort that #700 fixes — not throughput or memory.

@nh13
nh13 force-pushed the 686/nh/perf-compare-bams branch from 7585aba to 1399b02 Compare August 2, 2026 17:47
@nh13
nh13 temporarily deployed to github-actions August 2, 2026 17:47 — with GitHub Actions Inactive
@nh13

nh13 commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 5, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

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: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/commands/compare/engines/mod.rs`:
- Around line 67-80: Update the read-ahead flow used by CheckedRecords and
RawReadAheadReader::next_record so a producer-channel disconnect before the EOF
sentinel is represented as an error rather than None. Track explicit producer
completion or propagate the disconnect failure, and surface it from
CheckedRecords with the existing reader context, matching positional_compare’s
“reader disconnected before EOF” behavior while preserving normal sentinel-based
EOF.

In `@src/lib/commands/compare/engines/sort_verify.rs`:
- Around line 1508-1512: Add a paired rejection test case for
SortOrder::TemplateCoordinate in the sort verification table, using the existing
mapped helper with records whose positions descend so core_cmp is Less and one
violation is expected. Keep the existing template_coordinate_accepts_equal_keys
case and follow the naming and structure of the other order-specific reject
cases.

In `@tests/integration/test_compare_bams.rs`:
- Around line 3189-3192: Update the damaged_bam1_while_pairing and
damaged_bam2_while_pairing cases in the test case definitions to use an intact
record count above the truncated BAM’s 20,000-record failure point, such as
20_000; leave the while_draining cases at 1.
🪄 Autofix

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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3f856848-8ee9-4327-93bf-40bde9375ab3

📥 Commits

Reviewing files that changed from the base of the PR and between c09669a and 1399b02.

📒 Files selected for processing (12)
  • .gitignore
  • CLAUDE.md
  • crates/fgumi-sort/src/lib.rs
  • crates/fgumi-sort/src/read_ahead.rs
  • crates/fgumi-sort/src/verify.rs
  • src/lib/commands/compare/bams.rs
  • src/lib/commands/compare/engines/mod.rs
  • src/lib/commands/compare/engines/positional.rs
  • src/lib/commands/compare/engines/sort_verify.rs
  • src/lib/commands/compare/molecule.rs
  • tests/integration/test_compare_bams.rs
  • tests/integration/test_compare_mutation.rs

Comment thread src/lib/commands/compare/engines/mod.rs
Comment thread src/lib/commands/compare/engines/sort_verify.rs
Comment thread tests/integration/test_compare_bams.rs Outdated
@nh13
nh13 force-pushed the 686/nh/perf-compare-bams branch from 1399b02 to f6a7f76 Compare August 5, 2026 16:12
@nh13
nh13 temporarily deployed to github-actions August 5, 2026 16:12 — with GitHub Actions Inactive
@nh13

nh13 commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 6, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

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 commented Aug 6, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@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: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/commands/compare/molecule.rs`:
- Around line 152-161: Update the read-error arm in the iterator implementation
to clear or discard pending before returning Some(Err(e.into())). Ensure
subsequent polling cannot emit the buffered partial molecule run, preserving the
invariant documented by the relevant test while leaving successful end-of-stream
handling unchanged.
🪄 Autofix

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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2599d700-adf0-45bd-90f3-99caff897ec0

📥 Commits

Reviewing files that changed from the base of the PR and between 73779f3 and f6a7f76.

📒 Files selected for processing (12)
  • .gitignore
  • CLAUDE.md
  • crates/fgumi-sort/src/lib.rs
  • crates/fgumi-sort/src/read_ahead.rs
  • crates/fgumi-sort/src/verify.rs
  • src/lib/commands/compare/bams.rs
  • src/lib/commands/compare/engines/mod.rs
  • src/lib/commands/compare/engines/positional.rs
  • src/lib/commands/compare/engines/sort_verify.rs
  • src/lib/commands/compare/molecule.rs
  • tests/integration/test_compare_bams.rs
  • tests/integration/test_compare_mutation.rs

Comment thread src/lib/commands/compare/molecule.rs Outdated
nh13 added 7 commits August 5, 2026 20:03
`compare bams --command sort` and `--command group` decoded both inputs inline
on the single main thread: `OpenedInput::open` took no thread count, so
`--threads` created no threads at all and the whole comparison ran on one core.
Profiling a 20.1M-record pair put ~61% of that one thread in BGZF decode (~55%
libdeflate inflate, ~6% the per-block CRC32) while eleven cores sat idle;
tricorder confirmed it independently with mean_load=98% and a peak OS thread
count of 1.

Open both inputs through the multithreaded BGZF reader that content mode has
always used, wrapped in a per-input read-ahead thread, so each input's decode
overlaps the other's and the comparison itself. On a 12-core host that takes
sort from 23.44s to 8.93s and grouping from 24.36s to 9.92s (2.6x and 2.5x,
interleaved arms with the page cache warmed identically before every run),
raising mean_load to 370%.

Split the `--threads` budget between the two inputs rather than giving each the
full count: they are symmetric and both must be fully decoded, so handing each
the whole budget would request twice the requested parallelism and oversubscribe
the host. This differs from zipper, which gives its unmapped input 1 thread and
its mapped input the full count because its two inputs differ in cost.

The read-ahead reader signals a read failure by ending iteration and parking the
error for a later `take_error()`, which is a hazard here: a truncated or
CRC-corrupt BAM would read as a *shorter* file, reported as a record-count
DIFFER, or as a false IDENTICAL when both inputs are damaged alike. Rather than
requiring every engine loop to remember a trailing check, `CheckedRecords`
restores the `Result` contract at the boundary so the engines' existing `rec?`
propagation stays correct by construction.

Generalize `verify_sort_order` and `molecule_runs` from a concrete reader type
to any `Iterator<Item = io::Result<RawRecord>>` so both reader stacks satisfy
them; the `Result` item type is deliberate, keeping a read failure
distinguishable from end-of-stream.

Verified against a 121-comparison differential corpus spanning matched, missing,
extra, edited, reordered, truncated, header-conflicting and corrupt inputs: all
verdicts, exit codes and RESULT lines are byte-identical to the pre-change
binary. Error text changed only on damaged inputs, where the new messages name
the failing file and diagnose it more precisely.

This does not yet close #686. The gain is from overlap, not from `--threads`:
scaling stays flat (-t 1 8.64s, -t 8 8.89s) because the comparison thread is now
the critical path, balanced against ~8.0s of per-file decode. `fgumi sort` over
the same data is 8.82s, so comparison went from 2.7x slower than the sort it
validates to roughly par, but not strictly faster. Cutting comparison-thread
work is the next step, after which the BGZF parallelism wired up here should
begin to matter.

Refs #686
… keys

Profiling `--command sort` after the reader work put ~32% of the critical-path
thread in the canonical content key that `RunCanceller::observe` builds for every
record: `hash_one` 10.2%, `RunCanceller::observe` 11.1%, allocator traffic 6.2%,
`collect_tag_entries` 3.2%, and the key's own tag sort 1.2%. Each record costs a
`Vec` per aux tag, a sort, a concatenation, and an `AHashMap` hash plus insert.

In the overwhelmingly common case — two files that agree, record for record —
none of that is needed. Compare the raw bytes of the two records first and, when
they are identical, count the pair and move on.

This is sound rather than merely convenient. The run comparison decides on the
symmetric difference of the two sides' record multisets, and removing one element
from each side *with the same key* leaves that symmetric difference unchanged,
regardless of what else is pending — so the fast path needs no precondition on
the canceller's state. Byte equality implies content-key equality, and strictly
so: `content_key_exact` excludes `bin`, width-normalizes integer tags and sorts
the tag multiset, so byte-identical records necessarily share a key. The converse
does not hold, which is exactly why this is a fast path and not a replacement:
records that are content-equal but differ in tag order, integer tag width or
`bin` fall through to the key path and still cancel there.

On a 20.1M-record pair, `--command sort` goes from 8.93s to 4.14s at
`--threads 8` (warm cache, 3 reps, minimum), 5.7x against the pre-campaign
23.44s. With the comparison thread no longer the longest leg, the BGZF
parallelism wired up previously now pays: 8.92s at `-t 1`, 5.58s at `-t 4`,
4.14s at `-t 8`.

Both of #686's acceptance criteria are now met: comparison is 2.0x faster than
the `fgumi sort` it validates over the same data (4.14s vs 8.20s), having started
2.7x slower, and throughput scales measurably from `--threads 1` to `--threads 8`.

Verdicts are unchanged: all 121 comparisons in the differential corpus — matched,
missing, extra, edited, tag-reordered, intra-run reordered, mis-sorted,
truncated, header-conflicting and corrupt inputs, in both directions — report
identical verdicts, exit codes and RESULT lines. The `tag-reorder` cases are the
ones that matter here: they are content-equal but not byte-equal, so they prove
the key path still runs and still matches.

Grouping mode is untouched by this change; it pairs molecules through
`molecule_join` rather than this engine, and remains at ~10s.

Closes #686
`content` mode read each input twice. `CompareBams::execute` called
`verify_records_in_order` on both paths — a complete traversal of each file — and
only then did `positional_compare` open both and stream them again: four full BAM
traversals for a comparison that needs two. `sort_verify` had already solved this
for `--command sort` with its `OrderChecked` adapter; `content` never adopted it.

Fold the check into the comparison's own pass. `OrderCheck` extracts each record's
sort key and tracks monotonicity as records flow by, and `positional_compare` feeds
every record it receives through its file's checker — including the ones drained
after pairing stops, which is what keeps the totals equal to a dedicated pass.

The check accumulates rather than failing on the first bad record because the
diagnostic reports *how many* records violated the order, which is only known once
the file has been fully seen. Evaluating bam1's result before bam2's preserves
which file a mis-sorted pair names. The resulting message is byte-identical to the
one the standalone pass produced, count and first-violation position included.

Read errors from the comparison readers now name the offending input rather than
its positional slot ("BAM1"), matching how the sort and grouping engines already
report them; a two-input tool that says only "BAM1" leaves the reader to map that
back to a path.

Total CPU for a 20.1M-record content comparison drops from 47.1s to 34.5s (-27%),
which is the decode work the two removed traversals were doing.

`verify_records_in_order` had no callers left and is removed; `OrderCheck`
subsumes it, and `fgumi sort --verify` continues to use
`fgumi_sort::verify_sort_order` directly.

All 121 comparisons in the differential corpus report identical verdicts, exit
codes and RESULT lines, including the mis-sorted and corrupt inputs that exercise
this path.
…pairs

Content mode paired every record through `record_keys_match` — extracting flags,
testing secondary/supplementary, and walking both read names — before handing the
pair to `content_diffs`, whose very first act is to compare the two records byte
for byte and return early when they match. For the common case of two files that
agree, all of that key work was performed only to confirm what a `memcmp` was
about to establish anyway.

Compare the bytes once, up front, and skip both checks when they are equal. A
`RecordKey` is a pure function of a record's bytes, so identical bytes yield
identical keys; and byte equality implies content equality under every
`ContentPredicate`, which is exactly why `content_diffs` opens with that test.
Records that differ take the original path unchanged, so a genuine desync is still
reported as a key mismatch rather than a content diff.

Also split the decompression budget across content mode's two readers, matching
the convention the sort and grouping engines already follow. `--threads 8` was
starting 8 BGZF workers per reader — 16 in total, plus two reader threads and the
main thread — which oversubscribes a smaller host without decoding any faster.

Total CPU for a 20.1M-record content comparison falls from 34.5s to 27.4s, and to
27.4s from 47.1s before the traversal work — a 42% reduction overall.

Also corrects a comment that still described content mode as making two passes
over each input; the order-verification pass it referred to is now folded into the
comparison pass.

All 121 comparisons in the differential corpus report identical verdicts, exit
codes and RESULT lines. The `tag-reorder` and `swap-adjacent` cases matter most
here: both are non-byte-equal, so they exercise the slow path and confirm the fast
path has not swallowed the checks.
`get_mi_tag_raw` probed `find_int_tag` and then fell back to `find_string_tag`,
and each of those walks the record's aux data from the start. `fgumi group` writes
MI as `MI:Z:<id>[/A|/B]`, so the integer probe always missed and its full scan was
wasted, and `MI` is appended late in the tag block, so both walks covered nearly
all of it. This runs once per record on the grouping engine's critical path, where
profiling attributed 7.1% of CPU to `find_tag_position` and a further 1.9% to
`get_mi_tag_raw` itself.

Use the existing `RawTagsView::get`, which resolves a tag to a typed `TagValue` in
a single `find_tag_position` call, and branch on the type. No new API: the
zero-copy typed accessor was already there.

Behaviour is unchanged, including the cases the two-probe form handled implicitly.
An integer MI still yields `MiKey::Int`, a `Z` payload is still parsed for the
`<id>` and `<id>/A|B` forms, and any other aux type is still treated as "no MI" —
previously by both probes failing, now by an explicit match arm. Negative integer
ids remain accepted, which is why this does not reuse `fgumi_raw_bam::find_mi_tag`:
that helper collapses `MI:i:` and `MI:Z:.../A` into one representation and rejects
negatives, both of which `MiKey` deliberately keeps distinct.

All 121 comparisons in the differential corpus are unchanged, including the
`mi-renumber` case that exercises MI parsing directly.
Captures what the `compare bams` optimization work established about measuring
this codebase, so the next campaign does not rediscover it.

The profiling ladder, in the order worth reaching for: `tricorder` for core
utilization and I/O (its `mean_load` and the `--trace` `n_threads` column answer
"is this actually parallel" with no symbolication to get wrong), then in-tree
phase timers on the `SortPhaseTimer` pattern, then `perf` on Linux when
per-function attribution is genuinely needed.

macOS sampling is documented as a last resort because it produced three
confidently wrong profiles here: the release profile strips debuginfo; blocked
threads are counted as CPU unless samples are weighted by `threadCPUDelta`, which
made an 18s run look like 285s across 21 threads; and `atos -o <binary>`
mis-resolves addresses belonging to other images, which is where "38% of CPU in
`clap_builder::error::Error::print`" on a *successful* run came from.

Also records the before/after numbers for all three comparison modes, and two
results that outlive this change: content-mode conclusions rest on CPU-seconds
because this host's wall time is unreliable under load (identical runs measured
3.56s and 16.56s), and parallel BGZF decode costs ~40% more total CPU than
single-threaded decode for the same work — which raising the read-ahead batch size
did not recover, so that time is the consumer waiting on decode rather than
per-handoff overhead.
…d reader

Self-review of this branch found the new order-verification path under-tested and
two docs left describing the pre-change flow.

`content_mode_rejects_records_not_in_declared_coordinate_order` asserted only that
the error message contained "order", which a degraded diagnostic would still
satisfy. It now asserts the message names the declared order that was violated and
the offending input.

Three properties of the folded check had no test at all, and each is one a later
refactor could quietly drop:

- the reported violation count covers the whole file, which is why the check
  accumulates rather than failing on the first bad record;
- bam1 is reported before bam2 when both inputs are mis-sorted, which is what keeps
  the diagnostic stable now that both files are checked in a single pass rather than
  one after the other;
- a correctly ordered pair still compares normally at more than one thread count,
  covering the split decompression budget alongside the check.

`damaged_input_yields_err_not_clean_eof` only exercised the single-threaded reader,
where decode runs inline on the read-ahead thread. Above one thread it runs on a
worker pool, so the failure travels a different route — worker to error slot to
`CheckedRecords` — which is the route `open_pair` actually uses. The test is now
parameterized over thread count as well as damage mode.

Also updates two doc comments the byte-identical fast path invalidated: `compare_run`
still described every record as being cancelled through `RunCanceller`, and
`RunCanceller` still described every arriving record as being reduced to a content
key. Neither holds for the pairs the fast path handles.

Records why `positional_compare` takes `verify_order` as a required parameter: a
convenience overload defaulting it to `None` would let a caller opt out of order
verification by omission, which is the failure the parameter exists to prevent.
@nh13
nh13 force-pushed the 686/nh/perf-compare-bams branch from f6a7f76 to 2b68b78 Compare August 6, 2026 03:04
@nh13
nh13 temporarily deployed to github-actions August 6, 2026 03:04 — with GitHub Actions Inactive
@nh13

nh13 commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 6, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

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 commented Aug 6, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

This branch was previously deployed

1 inactive deployment
github-actions — 2b68b785 Deployed Aug 6, 2026 by nh13 via coverage #3304
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.

perf(compare): compare bams --command sort ignores --threads and runs ~3x slower than sort

1 participant