fix(write): bound SVAR2 genotype-writing memory with max_mem - #344
Merged
Conversation
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
force-pushed
the
worktree-issue-333-svar2-write-mem
branch
from
July 31, 2026 21:31
9ba3b85 to
4abd471
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #333.
Note
Unblocked. genoray 3.4.0 is published,
pixi.lockhas been regenerated against it, and all 8 CI checks pass.Three defects, only one named in the issue
find_rangeswas O(regions × total_variants). ASearchTreeplus av_endsvector 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 aVkColumnIndex, inverting the loop to column-outer, and parallelising with rayon._svar2_region_max_endsdecoded ALL samples for the whole contig, ignoring the caller's sample selection, purely to extendchromEnd. Deleted. genoray now returns per-region packed max-end keys from the same range sweep, so no decode pass happens at all._write_from_svar2received 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_chunkedstream: per-sample-slice chunks sized frommax_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.mdpreviously 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 elementwisenp.maximumand unpacked exactly once at the end. Reducing unpacked ends would let a lower-position deletion that reaches further win, silently corruptingchromEnd.Testing
ruff check/ruff format --checkclean;pyrefly check0 errors; Sphinx docs build succeeds (45 warnings, all pre-existingmyst.xref_missing, none on a touched line).test_write_svar2_chunked_matches_unchunkedforces genuine multi-chunk writing (max_mem=256→ 1 sample/chunk → 2 chunks) and asserts the output is byte-identical to a single-chunk write.test_write_svar2_max_ends_matches_svar1SVAR1 ground-truth oracle still passes through the new packed-key path.Before merging
Both former blockers are resolved:
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.RegenerateDone inpixi.lock.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 thepixi-lockandpyrefly-checkpre-push hooks enabled (they were skipped on the earlier push only becausepixi lockcannot resolve an unpublished version), and both passed.CI is green on all 8 checks:
pre-commit hooks,pre-commit.ci - pr,pyteston py310/py311/py312/py313,coverage (py312), andpolars join order audit (py312).Follow-ups filed
_write_from_svar(SVAR1) also ignoresmax_mem.svar2_slice::region_hitsrebuilt aVkColumnIndexper (region, column); pre-existing and read-path only. Filed during this work and since fixed upstream by perf(query): build each SVAR2 search tree once per channel, not once per region d-laub/genoray#149.🤖 Generated with Claude Code