Skip to content

docs(spec): #334 dataset concatenation design - #339

Merged
3 commits merged into
mainfrom
worktree-dataset-concat-spec
Aug 1, 2026
Merged

docs(spec): #334 dataset concatenation design#339
3 commits merged into
mainfrom
worktree-dataset-concat-spec

Conversation

@d-laub

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

Copy link
Copy Markdown
Collaborator

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() calls variants.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 with plink2 --keep is 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

  • One primitive, gvl.concat(path, datasets, axis="regions"|"samples"). Both axes reduce to a single run-coalesced gather over the (R, S[, P]) grid; axis only selects a provenance map.
  • Stores split into two kinds by offset semantics: cumulative-into-owned-payload (PGEN/VCF genotypes, intervals) vs absolute-ranges-into-external-store (.svar, .svar2). Both are bulk streaming moves — the .svar/.svar2 range arrays are not small (16–32 GB at R=1000, S=500k, P=2).
  • Single-threaded, buffered IO on both sides, 16 MiB chunks, destination-ordered. No np.memmap in the bulk data path.
  • All inputs must share one variant source; merging distinct variant tables is out of scope.

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:

pattern rep1 rep2 rep3
buffered read → buffered write 85.6 102.0 93.0 MB/s
memmap read → buffered write 16.9 19.7 14.4 MB/s
buffered read → memmap write 13.8 15.7 12.6 MB/s
memmap read → memmap write 13.6 14.1 14.4 MB/s

Local XFS (/dev/md0 on /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:

  • gvl never mmaps variants.arrow_haps.py:121 and _haps.py:185 both pass memory_map=False.
  • genoray rewrites the PGEN index (_pgen.py:1125) and VCF index (_vcf.py:1095) via atomic_write_path — temp + os.replace, which never touches the old inode.

So the fix is the missing integrity guard, not the aliasing: concat records a bounded blake2b fingerprint and verifies it at open, mirroring the existing svar_link/svar2_link precedent. The spec also documents why fallible-hardlink, catching SIGBUS, and NFS-detection were each rejected.

Related issues filed

Both are boy-scout findings from this design work, out of scope for the eventual implementation PR.

Relates to #334.

🤖 Generated with Claude Code

d-laub and others added 3 commits July 31, 2026 09:37
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>
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