Skip to content

fix(duplex-metrics): drop dense (max_ab × max_ba) grid that OOMs on cfDNA hot spots - #359

Merged
nh13 merged 4 commits into
mainfrom
nh/duplex-metrics-hoist-hashmaps
May 21, 2026
Merged

nh13 merged 4 commits into
mainfrom
nh/duplex-metrics-hoist-hashmaps

Conversation

@nh13

@nh13 nh13 commented May 20, 2026 •

Copy link
Copy Markdown
Member

What this fixes

fgumi duplex-metrics OOMs on real cfDNA-class inputs (vendor-profile samples — idt-cfdna, kapa-umi, agilent-hs2, schmitt-abl1, twist-umi) on a 30 GiB c7g.4xlarge. The same machine processes the simplex-metrics counterpart of the same files without issue.

The OOM happens after the streaming pass: every affected job logs Processed N templates and then gets OOM-killed during the per-collector yield-metric pass, at the exact same template count run after run.

Real cause

DuplexMetricsCollector::duplex_family_size_metrics() computed the 2D cumulative fraction_gt_or_eq_size by materializing a dense (max_ab + 1) × (max_ba + 1) × usize grid and running 2D suffix sums over it. The underlying duplex_family_sizes: HashMap<(usize, usize), usize> is sparse — typically well under a thousand distinct (ab, ba) pairs even on cfDNA-scale inputs — but the grid is sized by the maxima of the two dimensions. On a cfDNA sample whose hottest duplex family lands in the tens of thousands of reads per strand, this allocates billions of cells (max_ab = max_ba = 50_000 → ~20 GiB scratch) before any metric is computed. With 20 collectors and generate_yield_metric calling duplex_family_size_metrics() per collector, you OOM before producing any output.

Changes

  • crates/fgumi-metrics/src/duplex.rs::duplex_family_size_metrics — replace the dense-grid 2D suffix-sum with a sparse O(N²) iteration over the actual (key, count) HashMap entries, where N is the number of distinct (ab, ba) pairs (typically <1000, dwarfed by upstream I/O cost). No more max_ab × max_ba allocation.
  • src/lib/commands/duplex_metrics.rs::generate_yield_metric + new calculate_ideal_duplex_fraction_per_size — stop expanding family_size_metrics into a flat Vec<usize> of m.family_size repeated m.ds_count times. Iterate per-size buckets and weight each size's binomial probability by its ds_count. Mathematically equivalent; eliminates the flat-Vec allocation and the O(ds_count) redundant Binomial::cdf calls per distinct family size.
  • src/lib/commands/duplex_metrics.rs::process_coordinate_group — hoist ss_groups and ds_groups scratch HashMaps out of the 20-fraction loop and reuse via .clear(); skip entry.2.push(...) on non-full fractions; switch update_umi_metrics to &[(&str, &str)] to drop the owned-String clone at the call site. These are the original commits in this PR — they're real micro-perf improvements (mirroring the existing pattern in simplex_metrics::process_coordinate_group) but did NOT move the needle on the OOM, because the OOM is downstream of the streaming pass. They stay because they reduce allocator churn on the hot path.

Behaviour

No behavioural change in any output column. Verified by:

  • 20 existing duplex_metrics::tests pass on the post-fix tree.
  • 107 existing fgumi-metrics::tests pass (was 106; the new regression test below adds one).
  • 7 existing simplex_metrics::tests pass (unchanged file, sanity check on the shared metrics path).
  • cargo ci-fmt, cargo ci-lint clean.

Regression test

test_duplex_family_size_metrics_sparse_hot_spot in crates/fgumi-metrics/src/duplex.rs constructs a collector with one duplex family at (50_000, 50_000) alongside two small entries. Under the previous dense-grid implementation this would have allocated ~20 GiB before any assertion ran; under the sparse implementation it completes in microseconds. Pins the invariant against any future refactor that might silently reintroduce an O(max_ab × max_ba) allocation.

Note on simplex-metrics

Reviewer asked whether simplex-metrics has the analogous bug. No — simplex-metrics has no equivalent 2D (ab, ba) cumulative structure (its family_size_metrics is 1D over 1..=max_size, which is bounded by max_size × ~80B per call). The 1D densification pattern is shared between duplex and simplex family_size_metrics, but at realistic max_size it does not OOM. Left untouched in this PR.

Test plan

  • cargo test --release --lib duplex_metrics — 20 passed
  • cargo test --release --lib simplex_metrics — 7 passed
  • cargo test --release -p fgumi-metrics — 107 passed (incl. new sparse-hot-spot regression)
  • cargo ci-fmt, cargo ci-lint clean
  • Real vendor benchmark on cfDNA-class inputs against the prior broken build reproduced the OOM at the same template count; re-running with this PR's bundled into 2fdcc7b reproduced the OOM at the same point (confirming the original hoist-only fix was insufficient) — that's the empirical evidence behind this follow-up fix
  • Re-run vendor profile against this PR head after a fresh submodule bump to confirm duplex_metrics_fgumi no longer OOMs on any of the 5 duplex-capable vendor samples

Commit sequence

Commit What
4649c11 perf(duplex-metrics): hoist scratch maps and drop unnecessary string clones — micro-perf; not the OOM fix
2fdcc7b docs(duplex-metrics): clarify what HashMap::clear actually reuses — comment-accuracy nit from initial review
f6a1174 fix(duplex-metrics): drop dense (max_ab × max_ba) grid that OOMs on cfDNA hot spots — the actual OOM fix
7460059 test(duplex-metrics): pin the sparse-hot-spot regression — guards against re-introducing dense allocation

nh13 added 2 commits May 20, 2026 15:47
…clones

`process_coordinate_group` allocated two fresh `HashMap`s on every
iteration of its 20-fraction inner loop — `ss_groups` and `ds_groups`,
the latter containing a per-template `Vec<(&str, &str)>` — and at the
100% fraction additionally cloned every `(mi, rx)` pair into an owned
`Vec<(String, String)>` before invoking `update_umi_metrics`. On real
cfDNA-class inputs with millions of coordinate groups this dominates
the allocator pressure inside `fgumi duplex-metrics` and is sufficient
to OOM a 30 GiB instance partway through the metrics pass on samples
whose simplex-metrics counterpart finishes comfortably.

Changes, all confined to `src/lib/commands/duplex_metrics.rs`:

* Hoist `downsampled`, `ss_groups`, and `ds_groups` out of the
  per-fraction loop and reuse them with `.clear()` between iterations,
  mirroring the pattern already used by
  `simplex_metrics::process_coordinate_group` (which is why simplex
  metrics has not exhibited the same OOM behaviour).

* Move the `is_full_fraction` flag above the `ds_groups` population so
  we can skip `entry.2.push(...)` on the 19 downsampled fractions; the
  per-template `(mi, rx)` pair vec is consumed exclusively by
  `update_umi_metrics` at the 100% fraction.

* Change `update_umi_metrics` to take `&[(&str, &str)]` and drop the
  ad-hoc `Vec<(String, String)>` clone at the call site — the
  underlying `String` storage lives in `group`, whose lifetime
  outlasts the call.

No behavioural change is intended: the existing 20 duplex_metrics and
7 simplex_metrics tests all pass unchanged, and the metrics outputs
should be byte-identical to the previous implementation.
The previous comment overstated the reuse — `HashMap::clear()` preserves
the outer bucket array but still drops each entry's inner
`Vec<(&str, &str)>`. Combined with the `is_full_fraction` gate, that
inner Vec now allocates exactly once per coordinate group rather than
once per (fraction × `or_default()` slot), but the bucket array is the
dominant per-group win — not the inner Vec capacity. No code change.
@nh13
nh13 temporarily deployed to github-actions May 20, 2026 22:53 — with GitHub Actions Inactive
@coderabbitai

coderabbitai Bot commented May 20, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 927a4244-bd78-4b35-af1e-6a4791063a8e

📥 Commits

Reviewing files that changed from the base of the PR and between 2fdcc7b and 7460059.

📒 Files selected for processing (2)
  • crates/fgumi-metrics/src/duplex.rs
  • src/lib/commands/duplex_metrics.rs

📝 Walkthrough

Walkthrough

Reuses downsampling scratch buffers and gates DS MI/RX pair collection to the 100% fraction; changes update_umi_metrics to accept borrowed &[(&str,&str)] and uses borrowed comparisons for duplex-U error counting. Replaces flattened-family ideal-duplex calculation with a per-size weighted helper. Replaces dense 2D suffix-sum in duplex-family-size metrics with a sparse snapshot-and-filter approach and adds a regression test for sparse large entries.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed Title accurately describes the main OOM fix: replacing dense grid allocation with sparse iteration in duplex metrics.
Description check ✅ Passed Description comprehensively explains the OOM root cause, the sparse-grid fix, related micro-optimizations, regression test, and verification steps.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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/duplex-metrics-hoist-hashmaps

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

@codecov

codecov Bot commented May 20, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.97%. Comparing base (c781d53) to head (7460059).

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #359   +/-   ##
=======================================
  Coverage   90.97%   90.97%           
=======================================
  Files          78       78           
  Lines       51017    51023    +6     
=======================================
+ Hits        46412    46418    +6     
  Misses       4605     4605           

☔ 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 added 2 commits May 20, 2026 16:51
…fDNA hot spots

`DuplexMetricsCollector::duplex_family_size_metrics()` computed the
2D cumulative `fraction_gt_or_eq_size` by materializing a dense
`(max_ab + 1) × (max_ba + 1)` `Vec<usize>` and running 2D suffix sums
across it. The underlying `duplex_family_sizes: HashMap<(usize, usize),
usize>` is sparse — well under a thousand distinct `(ab, ba)` pairs
even on cfDNA-scale inputs — but the grid is sized by the *maxima* of
the two dimensions, which on real cfDNA where the hottest duplex
family has tens of thousands of reads per strand explodes to billions
of cells. A single sample with `max_ab = max_ba ≈ 50_000` allocates
~20 GiB before any metric is computed, OOM-killing a 30 GiB
`c7g.4xlarge` `fgumi duplex-metrics` worker partway through the
per-collector yield-metric pass.

This is the real cause of the OOM behaviour observed on the
`vendor` benchmark profile across all five duplex-capable samples
(`idt-cfdna`, `kapa-umi`, `agilent-hs2`, `schmitt-abl1`, `twist-umi`).
The previous commit in this branch (the per-coordinate-group HashMap
hoist) did not move the needle on this OOM because the kill happens
*after* the streaming pass — observably, the same template count
(1,252,712 for idt-cfdna) is processed and the kill lands at the same
point in the call graph both before and after that change. The hoist
is a real micro-perf improvement and stays, but it is not the OOM
fix.

Changes:

* `crates/fgumi-metrics/src/duplex.rs::duplex_family_size_metrics` —
  replace the dense-grid 2D suffix-sum with a sparse `O(N²)` iteration
  over the actual `(key, count)` pairs, where `N` is the number of
  distinct `(ab, ba)` entries in the HashMap (typically <1000, dwarfed
  by upstream I/O cost). No more `max_ab × max_ba` allocation.

* `src/lib/commands/duplex_metrics.rs::generate_yield_metric` and
  `calculate_ideal_duplex_fraction_per_size` (new) — stop expanding
  `family_size_metrics` into a flat `Vec<usize>` of `m.family_size`
  repeated `m.ds_count` times before binomial evaluation. The new
  helper iterates per-size buckets and weights each size's probability
  by its `ds_count`, which (a) cuts the flat-Vec allocation, and (b)
  computes each `Binomial::cdf` exactly once per distinct family size
  rather than `ds_count` times. Mathematically equivalent.

No behavioural change in any output column. The existing 20
`duplex_metrics` tests and 106 `fgumi-metrics` tests all pass on the
post-fix tree; in particular `test_generate_accurate_duplex_family_size_counts`
and `test_duplex_umi_expected_fraction_calculation` exercise both the
2D cumulative and the ideal-fraction paths end-to-end.
Add `test_duplex_family_size_metrics_sparse_hot_spot` which builds a
collector with one duplex family at `(50_000, 50_000)` alongside two
small entries. Under the previous dense-grid implementation this test
would have allocated `~20 GiB` of scratch memory before any assertion
ran; under the sparse implementation it completes in microseconds.
The behavioural assertions on `fraction_gt_or_eq_size` for each of the
three sparse entries pin the cumulative-fraction logic against any
future refactor that might silently reintroduce an `O(max_ab × max_ba)`
allocation. Catches the class of bug fixed by the previous commit
without itself being implementation-bound.
@nh13
nh13 temporarily deployed to github-actions May 20, 2026 23:57 — with GitHub Actions Inactive
@nh13 nh13 changed the title perf(duplex-metrics): hoist scratch maps and drop unnecessary string clones fix(duplex-metrics): drop dense (max_ab × max_ba) grid that OOMs on cfDNA hot spots May 20, 2026
@nh13

nh13 commented May 21, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 21, 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.

@nh13
nh13 merged commit a9353d3 into main May 21, 2026
10 checks passed
@nh13
nh13 deleted the nh/duplex-metrics-hoist-hashmaps branch May 21, 2026 21:45
@nh13 nh13 mentioned this pull request May 21, 2026

This branch was previously deployed

1 inactive deployment
github-actions — 74600593 Deployed May 20, 2026 by nh13 via coverage #1466
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.

1 participant