docs(spec): #334 dataset concatenation design - #339
Merged
3 commits merged intoAug 1, 2026
Merged
Conversation
Design for gvl.concat over the (region, sample) grid, plus the finding that #334's first ask needs no feature: gvl.write already supports `samples=` against the parent PGEN, so pre-splitting with plink2 is what forces the per-cohort index rebuild. Measured on NFSv3 rather than assumed: memmap reads are fastest (316 MB/s) but memmap writes are ~21x slower than buffered writes (15.2 vs 316 MB/s), so the merge streams memmap-read -> buffered-write in 16 MiB chunks, destination-ordered, single-threaded. Keeps the variants.arrow hardlink and adds a fingerprint instead of copying: gvl never mmaps that file (memory_map=False) and genoray rewrites its indexes via atomic_write_path, so the SIGBUS concern does not apply. Mirrors the existing svar_link/svar2_link precedent. Relates to #334. Files #337 (fingerprint guard for write()'s hardlink) and #338 (buffered writes for bulk output on NFS). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first benchmark did not evict the page cache and its sources had just been written, so its "memmap read -> buffered write = 316 MB/s" row was measuring RAM, not NFS. Re-measured with posix_fadvise(DONTNEED) on both NFSv3 and node-local XFS, 3 reps each. Corrected finding: on NFSv3 *any* memmap in the path costs ~5-6x (buffered/buffered 93 MB/s vs ~14-17 MB/s for every combination involving a memmap), because faults go out as 4 KiB RPCs instead of using the mount's 1 MiB rsize/wsize. On local XFS all four patterns are within noise of ~100 MB/s, so memmap is not harmful there and buffered IO is a safe unconditional choice. The design accordingly uses buffered seek()+read() for sources as well as buffered write() for destinations — no memmap in the bulk data path. Runs are already coalesced into large contiguous ranges, so this loses nothing relative to a memmap gather. Cost model updated from ~1h to ~3h per merged TB. Adds an explicit scope limit: these are sequential-streaming numbers and say nothing about the random fancy-indexed access in gvl's read path. Relates to #334. Updates #338. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eight tasks over four new modules (_concat_plan, _concat_io, _concat_validate, _concat). Tasks 2-4 are independent and dispatch in parallel; 5-8 are sequential. Carries the spec's measured constraints into every task: buffered IO on both sides in 16 MiB chunks with no np.memmap in the bulk path, single-threaded, destination-ordered, and neither pass materializing a full array. Test oracle is asymmetric by design — byte-identity on the region axis, read-equality only on the sample axis, since extend_to_length sizes each region's window to the cohort present at write time. Relates to #334. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Jul 31, 2026
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.
Design doc for
gvl.concat(issue #334). Spec only — no implementation. Draft pending review of the design before an implementation plan is written.Summary
#334 asks two things; they get different answers.
Q1 (reuse variant indexing across cohorts sharing a variant space) needs no feature.
gvl.write(path, bed, "parent.pgen", samples=cohort_A_samples)already works —write()callsvariants.set_samples(samples)(_write.py:307), genoray builds and caches the PGEN index once, and gvl hardlinks it into each dataset. Pre-splitting the PGEN withplink2 --keepis exactly what forces the per-cohort index rebuild the issue reports. This is a docs fix.Q2 (merging datasets) is the real feature, serving the issue's other two use cases: region-sharded parallel preprocessing, and incremental sample addition.
Design highlights
gvl.concat(path, datasets, axis="regions"|"samples"). Both axes reduce to a single run-coalesced gather over the(R, S[, P])grid;axisonly selects a provenance map..svar,.svar2). Both are bulk streaming moves — the.svar/.svar2range arrays are not small (16–32 GB at R=1000, S=500k, P=2).np.memmapin the bulk data path.Measured, not assumed
Benchmarked on both filesystems, 512 MiB × 3 reps, with
posix_fadvise(POSIX_FADV_DONTNEED)evicting sources before every read so reads are genuinely cold.carter-cn-03, Slurm job 13336789.NFSv3:
Local XFS (
/dev/md0on/tmp): all four patterns within noise of ~100 MB/s.So: on NFS any memmap in the path costs ~5–6× (4 KiB fault RPCs instead of the mount's 1 MiB
rsize/wsize); on local disk it makes no difference. Buffered IO is the safe unconditional choice.An earlier revision of this spec reported memmap reads at 316 MB/s and concluded they were fastest. That run never evicted the page cache and its sources had just been written, so it measured RAM. Corrected in the second commit — the finding is the opposite.
Scope limit stated in the spec: these are sequential-streaming numbers and say nothing about the random fancy-indexed access in gvl's read path.
variants.arrow stays a hardlink
The SIGBUS concern that motivated switching to a copy does not apply:
variants.arrow—_haps.py:121and_haps.py:185both passmemory_map=False._pgen.py:1125) and VCF index (_vcf.py:1095) viaatomic_write_path— temp +os.replace, which never touches the old inode.So the fix is the missing integrity guard, not the aliasing:
concatrecords a bounded blake2b fingerprint and verifies it at open, mirroring the existingsvar_link/svar2_linkprecedent. The spec also documents why fallible-hardlink, catching SIGBUS, and NFS-detection were each rejected.Related issues filed
gvl.writehardlinksvariants.arrowwith no integrity guardnp.memmap(~5–6× on NFSv3)Both are boy-scout findings from this design work, out of scope for the eventual implementation PR.
Relates to #334.
🤖 Generated with Claude Code