fix(dataset): stop holding three copies of the corpus at load (#211) - #212
Merged
Conversation
LeechDataset.__init__ held the whole corpus three times over: the numpy members load_chunks reads, one tensor per chunk, and the contiguous output torch.stack allocates while that list is still alive. A 41 GB npz peaked at 116 GB and OOM'd before the first epoch (#211). - Fill a preallocated tensor per field (_TensorFill) instead of stacking a list, removing the stack transient. - Read signals, residuals and features from the npz in row blocks (iter_npz_row_blocks) and defer those members out of load_chunks, so the numpy source is never resident alongside the tensors built from it. Only members the run actually consumes are decompressed. - Store seq_to_sig_maps CSR-style (values + offsets) rather than as a pickled object array, so the member is streamable and costs no per-chunk Python object. Legacy files with the object member still load. - Intern the repeated per-chunk strings in load_chunks. - Correct the load_chunks docstring: np.load never memory-maps a zip member.
…hangelog torch.stack(..., out=) writes a run of rows at C speed with no transient, where per-row assignment paid a few microseconds of dispatch each -- seconds over a multi-million-chunk corpus. Streamed rows are copied out of the block buffer so a staged batch cannot outlive the block it came from. Measured on a 300k-chunk corpus (1.8 GB npz): peak RSS 6.19 -> 2.36 GB and load time 21.5 -> 19.7 s against main, with bit-identical tensors.
test: cover the shape-mismatch fallback in _TensorFill
The per-chunk dicts measured 780 bytes each -- 5.2 GB for the 6.7M-chunk corpus in #211 -- to hold a handful of small integers and a few hundred distinct strings. ChunkTable keeps the npz's own arrays as columns and materialises a row view only when something asks for a chunk: 112 B/chunk, measured, with the same mapping interface every consumer already uses. Text is packed to fixed-width bytes (numpy stores <U as UTF-32) a block at a time, so the conversion has no transient either, and integers are narrowed to the smallest dtype holding their range. Fields the run never reads are not loaded: base_onehot skips sequence_with_kmer_context entirely. Peak on the 300k-chunk benchmark: 2.36 -> 2.20 GB, load 19.7 -> 19.0 s.
PR #212's CSR change removed the blocker -- every member of an uncompressed corpus is now fixed-shape and mappable -- so the question is whether to map, not whether we can. Measured on an rna node: sequential reads run at 724 MB/s against the 58 MB/s the loader consumes, but a cold mmap page fault costs 0.60 ms, giving 1,668 chunks/s per thread where the loader wants 10,900. A mapped corpus runs at RAM speed while the page cache holds it and falls off a cliff when it does not, which is the state mapping exists to serve. Records sequential shard streaming with a shuffle buffer as the direction -- including the sampler constraints it has to solve -- and fp16 signals as the cheaper win to try first.
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.
Fixes #211.
LeechDataset.__init__held the corpus three times over: the numpy membersload_chunksreads, one tensor per chunk built from them, and the contiguousoutput
torch.stackallocates while that list is still alive. The arraynulling at the end of
__init__is right but runs after all three exist, so itnever touched the peak. A 41 GB npz peaked at 116 GB and hit the 120 GB cap
before epoch 1.
What changed
Fill, don't stack. Each field fills a tensor allocated up front
(
_TensorFill), writing runs of 256 rows withtorch.stack(..., out=)— bulkspeed, no transient. Shape mismatches still degrade to the per-chunk list
__getitem__already handles (now covered by a test; it wasn't before).Stream the arrays.
iter_npz_row_blockswalkssignals_flat,signal_residuals_flatandfeatures_flatas sequential row blocks, andload_chunks(..., defer=...)never reads them into the chunk dicts, so thenumpy source is never resident alongside the tensors built from it. Blocks are
sized by a byte budget rather than a row count, so a wide signal doesn't
silently allocate a huge buffer.
Read only what the run consumes. Residuals are skipped for
--signal-mode signal, features for models without a feature branch, the base-to-signal mapsunless
--seq-encoding signal_kmerasks.dwells_flatis never read at all:_prepare_featuresonly neededlen(dwell), which the member header gives.seq_to_sig_mapsbecomes CSR —seq_to_sig_values+seq_to_sig_offsets,row
iisvalues[offsets[i]:offsets[i+1]]. The pickled object member cost onePython ndarray per chunk to unpickle and could not be read in row blocks.
data mergegathers those rows and rebuilds the offsets, normalizing legacyinputs so a mixed-vintage merge writes one format.
Metadata goes in columns. The per-chunk dicts measured 780 bytes each —
5.2 GB for that corpus — to hold a handful of small integers and a few hundred
distinct strings.
ChunkTablekeeps the npz's own arrays as columns (textpacked to bytes, since numpy stores
<Uas UTF-32; integers narrowed to thesmallest dtype holding their range) and materialises a row view only when
something asks for a chunk: 112 B/chunk measured, with the conversion done
a block at a time so it has no transient either. A row is a read-only
Mapping, sochunk["label_int"],chunk.get("source_group")and"feature_start" in chunkall work unchanged.Also:
load_chunksinterns the repeated per-chunk strings,calibratebuildsits dataset from a path so it streams too, and the
load_chunksdocstring nolonger claims the data is memory-mapped —
np.loadnever maps a zip member,compressed or not, which is what made this path look lazy when it wasn't.
Measured
300k chunks, 1.8 GB npz, 1.6 GB of output tensors, on a
rnacompute node.Tensors bit-identical across all three (checksummed):
main(v0.6.7)chunk_pathPer-chunk metadata, measured separately on the same corpus: 778 → 112 bytes.
62% off the peak, slightly faster. Scaled to the 450-context arm in the issue
that lands near 44 GB against the 116 GB that OOM'd — an extrapolation from a
synthetic corpus shaped like that one, since no real
train.npzwas availablehere to measure.
Tests
tests/test_dataset_streaming.py(40 tests) andtests/test_chunk_table.py(16). The gate is bit-identical paritybetween the streaming and eager paths — every tensor, every assembled sample,
and the chunk metadata — across signal modes, asymmetric crops, feature and
non-feature models, dwell offsets,
signal_kmervsbase_onehot, dwelltemplate channels, compressed and uncompressed files. Plus corpora with
label_int = -1rows interleaved between labelled ones: row/chunk misalignmentthere would pair signals with the wrong labels silently, so it gets its own
independent check by
read_id.Legacy corpora (object-array members, pickled
seq_to_sig_maps) are coveredtoo — they take the eager path and produce identical tensors.
The columnar store is held to the same standard: every metadata field of every
chunk must read exactly as the dict
load_chunkswould have built, includingwhich keys are absent (
"feature_start" in chunkdecides a feature-windowconvention in
training.py) and which values come back as None.Full suite: 1085 passed, 27 skipped. ruff, ty and the docs build clean.
Compat
Existing corpora read unchanged. The one edge is the other direction: a file
written by this version has no
seq_to_sig_maps, so leech <= 0.6.7 reading itfalls back to
base_onehotfor asignal_kmerrun — with the warning italready emits, but a fallback nonetheless. Called out in the changelog.
Item 4, the memmap idea: ADR 0006
The CSR change means every member of an uncompressed corpus is now fixed-shape
and mappable in place, so the blocker is gone and the question is whether to
map. Measured on an
rnanode (dev-notes/adr/0006-memory-mapped-chunk-corpora.mdhas the numbers and the caveats):
consumes — 12× headroom;
thread where the loader wants 10,900.
A mapped corpus runs at RAM speed while the page cache holds it and falls off a
cliff when it does not — which is the state mapping exists to serve. Local
staging does not rescue it here either: these nodes' local disks are rotational
and
/dev/shmis RAM with extra steps. So the ADR rejects mapping for theshuffled read path and records what to do instead — sequential shard streaming
with a shuffle buffer, built on the row-block reader this PR adds, plus the
sampler constraints (
--balance-groups,--oversample-minorityand--feature-noise-scaleall assume global access) that design has to solve. Italso flags fp16 signals as the cheaper win to try first: signals are 28.8 GB of
the ~36 GB that remains resident.
Nothing here has to be undone to run the mapping experiment later if the
storage picture changes.