Skip to content

fix(write): bound SVAR2 genotype-writing memory with max_mem - #344

Merged
d-laub merged 8 commits into
mainfrom
worktree-issue-333-svar2-write-mem
Jul 31, 2026
Merged

fix(write): bound SVAR2 genotype-writing memory with max_mem#344
d-laub merged 8 commits into
mainfrom
worktree-issue-333-svar2-write-mem

Conversation

@d-laub

@d-laub d-laub commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Closes #333.

Note

Unblocked. genoray 3.4.0 is published, pixi.lock has been regenerated against it, and all 8 CI checks pass.

Three defects, only one named in the issue

  1. genoray find_ranges was O(regions × total_variants). A SearchTree plus a v_ends vector were rebuilt for every (region, column) pair, so a batch swept the packed store once per region instead of once. At the reported scale (3,964 regions × 414,830 samples × ploidy 2) that is 6.6e9 tree builds — the caller sat at 0% for hours before being OOM-killed. Fixed upstream by hoisting per-column state into a VkColumnIndex, inverting the loop to column-outer, and parallelising with rayon.
  2. _svar2_region_max_ends decoded ALL samples for the whole contig, ignoring the caller's sample selection, purely to extend chromEnd. Deleted. genoray now returns per-region packed max-end keys from the same range sweep, so no decode pass happens at all.
  3. _write_from_svar2 received no memory budget and materialised a whole contig's ranges at once (~245 GiB at 414k samples). It now consumes genoray's chunked _find_ranges_chunked stream: per-sample-slice chunks sized from max_mem, written straight into the memmaps through a transposed view and flushed per chunk.

Also: a disk preflight that logs and warns on the ~98 GiB permanent range cache, fractional within-contig progress (a single-contig BED no longer sits at 0% for the whole run), and corrected docs — format.md previously called that cache "small".

Effect

At the #333 scale with max_mem="4g": bytes_per_sample = 253,696 → 8,465 samples/chunk → 49 chunks of ~2.15 GB. The 245 GiB peak is gone.

Max-end keys are reduced packed, not unpacked

SVAR1 parity picks the highest-position overlapping variant, ties broken by the larger end — not the largest end overall. So the reduction unit across chunks is the packed (pos << 21) | (1 + deletion_len) key, reduced with an elementwise np.maximum and unpacked exactly once at the end. Reducing unpacked ends would let a lower-position deletion that reaches further win, silently corrupting chromEnd.

Testing

  • Full tree: 1104 passed, 4 xfailed, 56 skipped, 0 failures. ruff check / ruff format --check clean; pyrefly check 0 errors; Sphinx docs build succeeds (45 warnings, all pre-existing myst.xref_missing, none on a touched line).
  • test_write_svar2_chunked_matches_unchunked forces genuine multi-chunk writing (max_mem=256 → 1 sample/chunk → 2 chunks) and asserts the output is byte-identical to a single-chunk write.
  • The pre-existing test_write_svar2_max_ends_matches_svar1 SVAR1 ground-truth oracle still passes through the new packed-key path.

Before merging

Both former blockers are resolved:

  1. Release genoray 3.4.0. Published; perf(query): make find_ranges O(total_variants) and add a chunked API d-laub/genoray#146 and Upgrade genoray dep #147 are merged upstream.
  2. Regenerate pixi.lock. Done in build(deps): require genoray>=3.4.0 for the chunked find_ranges API — genoray 3.3.1 → 3.4.0 across all 10 environments, with no other resolver churn. That commit was pushed with the pixi-lock and pyrefly-check pre-push hooks enabled (they were skipped on the earlier push only because pixi lock cannot resolve an unpublished version), and both passed.

CI is green on all 8 checks: pre-commit hooks, pre-commit.ci - pr, pytest on py310/py311/py312/py313, coverage (py312), and polars join order audit (py312).

Follow-ups filed

🤖 Generated with Claude Code

d-laub and others added 7 commits July 31, 2026 14:31
Design for bounding gvl.write's SVAR2 genotype-writing memory under max_mem.
Investigation found three defects, only one named in the issue:

- genoray find_ranges is O(regions x total_variants): SearchTree/v_ends are
  rebuilt per (region, column) instead of once per column (6.6e9 tree builds
  at the reported scale). This, not memory, is why the job sat at 0% for hours.
- gvl _svar2_region_max_ends decodes ALL samples for the whole contig,
  ignoring the caller's selection -- an independent, larger OOM.
- find_ranges triple-copies its payload and never releases the GIL.

Plan: invert the genoray loop to column-outer with a hoisted VkColumnIndex
(+ rayon), add a chunked _find_ranges_chunked stream API sized from max_mem,
fold max_ends into the same sweep, and have gvl consume it with per-chunk
memmap writes, a disk preflight, and fractional progress.

Relates to #333
Eight tasks across genoray and GenVarLoader, with the dependency graph and
parallel groups called out: the genoray chain (column-index hoist -> max-end
keys -> chunk bindings -> _find_ranges_chunked), GenVarLoader's preflight, and
the docs pass are mutually independent; the GenVarLoader integration waits on
genoray 3.4.

Two deliberate deviations from the spec, amended there too:

- max_ends travels as packed (pos << 21) | ext keys, not unpacked ends.
  Reducing ends across hap chunks would break the SVAR1 position-then-end
  ordering when a lower-position variant carries a longer deletion.
- find_ranges gains one transposed copy of its payload; the rayon-safe fill is
  hap-major and the bundle contract is region-major. Only the small-batch read
  path pays it.

Also adds PAR_COLUMN_THRESHOLD so small batches stay serial, which keeps the
thread-local search_tree_build_count observable for the complexity guard test.

Relates to #333
_write_from_svar2 now receives write()'s effective_max_mem (it previously
got no memory budget at all) and logs the projected on-disk size of the two
var-key range caches before allocating them, warning when it exceeds free
space. At 414k samples over ~4k regions that projection is ~98 GiB.

max_mem is accepted but not yet consumed; the chunked consumption lands
with the genoray 3.4 API.

Relates to #333
format.md described the per-(region, sample, ploidy) range arrays as
'small'. They are 2 * R * S * P * 2 * 8 bytes -- ~98 GiB for one chromosome
of a 414k-sample cohort. Give the formula, a worked population-scale
example, and the disk-vs-max_mem distinction, and note that max_mem now
governs the SVAR2 genotype write.

Relates to #333
…faq.md

SKILL.md:86 and faq.md:79 still called the per-(region, sample, ploid)
variant-key range cache 'small', contradicting the size formula and
~98 GiB example already added elsewhere in the same files. Rewrite both
to point at the correct figures instead of repeating the false claim.

Relates to #333
_write_from_svar2 called _find_ranges once per contig, materializing two
O(regions x samples x ploidy) int64 arrays plus Rust and numpy transients --
~245 GiB at 414k samples -- and then called _svar2_region_max_ends, which
decoded ALL samples for the whole contig regardless of the caller's
selection. Together those OOM-killed a real All of Us chr22 build.

Consume genoray 3.4's _find_ranges_chunked instead: per-sample-slice chunks
sized from max_mem, written straight into the memmaps via a transposed
view, flushed per chunk. max_ends now comes from the same sweep as packed
composite keys, reduced across chunks and unpacked once, so no decode pass
happens at all.

Progress is now fractional within a contig, so a single-contig BED no longer
sits at 0% for the entire run.

Closes #333
- format.md: drop the region_starts.npy row from the svar2_ranges
  table; _write_from_svar2 never writes that file, svar2_meta.json
  never lists it, and the read path never loads it. Every other row
  in the table checked out against the writer and its meta dict.
- write.md: reword the max_mem note to state the behavior directly
  instead of "as of this release", and drop the redundant bare
  `format.md` mentions now that the paragraph already links to it.
- test_write_svar2.py: update the stale "pins the reshape" comment —
  the write path chunks and transposes (`.transpose(2, 0, 1, 3)`),
  it doesn't reshape. Describe the assertion as the layout oracle it
  actually is: pinning the transposed chunked write against genoray's
  unchunked row-major _find_ranges bundle.

Relates to #333.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@d-laub
d-laub force-pushed the worktree-issue-333-svar2-write-mem branch from 9ba3b85 to 4abd471 Compare July 31, 2026 21:31
Regenerates pixi.lock now that genoray 3.4.0 is published. The pin in
pyproject.toml/pixi.toml was bumped alongside the SVAR2 write-path work,
but the lock could not be re-solved while 3.4.0 was unreleased, so it
still resolved 3.3.1 in every environment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@d-laub
d-laub marked this pull request as ready for review July 31, 2026 21:54
@d-laub
d-laub merged commit 849a491 into main Jul 31, 2026
8 checks passed
@d-laub
d-laub deleted the worktree-issue-333-svar2-write-mem branch July 31, 2026 21:54
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.

SVAR2: gvl.write ignores max_mem and OOMs on whole-contig range materialization at 414k samples

1 participant