Release v0.7.0
Fixed
-
Optional text fields wrote
Noneas the literal string"None".
chunk.get("label", "")returns the default only when the key is missing,
so a key present with the valueNone— which is whatdata prepare
produces when--labelis not passed, and whatload_chunksproduces for an
absent source group — reachednp.array(..., dtype=str)and was stringified.
Two consequences: an unlabelled prepare stored the label"None"for every
chunk, and a save/load round trip was not idempotent ("" -> None -> "None"), so merging a corpus renamed its empty source groups to a group
called"None"— which--balance-groupsthen weighted like a real one,
and which pairwise relabelling (matching on the stored label) matched
against nothing, leavinglabel_intat -1 so the dataset dropped those
chunks.Noneis now written as"", which is the convention every reader
already honours. Corpora written before this fix keep their"None"
strings — the readers are not changed to reinterpret them, because a group
legitimately named "None" would then be silently destroyed. Check with
np.load(f)["source_groups"]and re-prepare or rewrite if affected. -
Calling
run_inferencetwice in one process could hang forever. The
sequential path shut itsThreadPoolExecutors down withwait=False,
leaving worker threads alive past the return; the parallel path then forks an
mp.Pool, and a fork inherits the memory of a process with running threads —
including any lock those threads hold — but not the threads themselves, so
nothing releases it.num_workers=0followed bynum_workers>0deadlocked
with no error and no timeout. The CLI never hit this (one predict per
process); anyone scripting the Python API did. The pools are drained by that
point anyway, so they now shut down withwait=True. -
data merge --k-foldcrashed on multiclass inputs.
merge_and_kfold_split_multiclasscarried its own inline copy of the merge,
and that copy never learned about the CSR base-to-signal members added in
0.6.8 — it maskedseq_to_sig_values(one row per map entry) with the
per-chunk mask and raisedIndexError: boolean index did not match indexed array. Every k-fold multiclass merge of a corpus written by the current
save_chunksfailed. It now calls the shared_merge_arrays_by_split, like
the binary k-fold path always did. No test covered any merge entry point,
which is why the drift shipped;tests/test_splitter_merge.pynow covers all
four. -
Merging corpora with different member sets wrote a misaligned column.
An input missingfocus_signal_pos(or the residual channel) contributed no
rows for that member but full rows for every other one, and nothing checked
the row counts agreed. Depending on input order the result either raised
IndexErroron load or — silently — gave every affected chunk another read's
focus position, and so the wrong asymmetric signal crop. Mismatched inputs are
now rejected up front, naming the file and the missing member, and every
output member is asserted to have one row per chunk before it is written. -
The per-base mean absorbed any signal past the end of the map.
np.add.reduceatsegments on starts alone and runs its final segment to the
end of the array, solevel_meanfor the last mapped base summed the whole
tail: a map of[0, 3, 6]over ten samples reported 135.33 instead of 2.0.
Median, std and range come from the explicit loop over both boundaries and
were always right, which is why nothing caught it. Python fallback only
(HAS_RUSTFalse) — what an install without therustextra runs, and what
any caller of the exportedcompute_signal_levelswith a partial map gets.
Values on a map that covers its signal are bit-identical to before. -
LeechDatasetno longer holds three copies of the corpus while it loads
(#211).load_chunksread every npz member, the tensorize loop built one
tensor per chunk from them, andtorch.stackallocated the whole contiguous
output while that list was still alive — a 41 GB npz peaked at 116 GB and
hit the 120 GB cgroup limit before epoch 1. The fields now fill a
preallocated tensor in bounded batches (torch.stack(..., out=)), and the
arrays are read from the npz in row blocks rather than materialised, so the
numpy source is never resident alongside the tensors built from it. Measured
on a 300k-chunk corpus (1.8 GB npz, 1.6 GB of output tensors): peak RSS
6.19 GB -> 2.36 GB, load time 21.5 s -> 19.7 s, tensors bit-identical. -
Only the members a run consumes are decompressed.
signal_residuals_flat
is skipped for--signal-mode signal,features_flatfor models without a
feature branch, and the base-to-signal maps unless--seq-encoding signal_kmerasks for them — up to 20 GB of decompression that used to
happen on every load regardless. -
Chunk metadata is stored as columns, not a dict per chunk (#211). The
dicts measured 780 bytes each — 5.2 GB for a 6.7M-chunk corpus — holding a
handful of small integers and a few hundred distinct strings.ChunkTable
keeps the npz's own arrays (text packed to bytes, integers narrowed) and
hands out a row view on demand: 112 B/chunk measured, with no conversion
transient, anddataset.chunksstill reads as a sequence of mappings. -
load_chunks's docstring no longer claims the data is memory-mapped.
np.loadnever maps a zip member, compressed or not; it is always a full
read, which is what made this path look lazy when it was not.
Changed
-
Merging chunk files with different member sets is now an error. It used
to produce a corpus that was silently wrong (above). Corpora prepared by
different leech versions must be re-prepared, or merged within their vintage. -
seq_to_sig_mapsis stored asseq_to_sig_values+seq_to_sig_offsets
(CSR: rowiisvalues[offsets[i]:offsets[i+1]]) instead of a pickled
object array. The old member cost one Python ndarray per chunk to unpickle
and could not be read in row blocks.load_chunks,data mergeand the
dataset still read the legacy member, so existing corpora stay valid — but a
file written by this version and read by leech <= 0.6.7 has no
seq_to_sig_maps, so asignal_kmerrun on that older version falls back to
base_onehot(with the warning it already emits).
Performance
preparewrites the corpus as it is extracted instead of accumulating it.
Both backends used to extend one list until every batch was done and only
then callsave_chunks, so peak held the per-chunk dicts, their arrays, and
the stacked copy at once. Batches now spool to disk throughChunkSpooland
the.npzis assembled at the end. Measured on 100k chunks / 231 MB of
arrays: peak 2.46x -> 0.27x of the payload without a split, 2.17x ->
0.25x with one, at unchanged wall time. The corpus is written twice (spill,
then.npz), so the output directory needs room for it twice over; both
paths log this at the start of a run.save_chunksno longer duplicates every field it stacks.
np.stack(...).astype(np.float32)copied the array it had just built —
astypecopies by default, and the chunks were already float32 — and every
stacked member was held untilnp.savezreturned. Members are now stacked
and written one at a time withcopy=False: peak 1.99x -> 0.97x of the
payload (100k chunks), andnp.stack(200k x 540).astype(...)alone drops
824 MB -> 443 MB.- The merge holds one output at a time, not the whole corpus.
_merge_arrays_by_splitaccumulated every sliced array for every split
across every input, then concatenated with all of it still alive. It now
counts kept rows in a header-only first pass, preallocates one array per
output member, and fills fromiter_npz_row_blocks. 240k chunks / 661 MB,
4 inputs to 3 splits: peak 1.80x -> 1.10x of the payload, wall 9.04 s ->
6.71 s. The k-fold multiclass path no longer caches every input file in RAM
either: 120k chunks,k_fold=3, peak 3.95x -> 2.07x. LeechDatasetbuilds its tensors a row block at a time. The tensorize
loop ran per chunk — onetorch.tensorper label, onenp.stackper
signal/residual pair, one row view per chunk — over arrays that arrive in
blocks of ~1,500 rows. Metadata now comes off theChunkTablecolumns in one
slice per block and only signal and features are handled per block. Measured
on 200k chunks with production shapes (540-sample signal + residual, 12x21
features): construction 57.7 -> 26.3 us/chunk withsignal_kmer,
66.9 -> 23.7 us/chunk withbase_onehot, peak RSS unchanged (1.99 ->
1.95 GB). 2,216 output tensors across 28 option combinations are
bit-identical.- The loader fetches a batch at a time.
LeechDataset.__getitems__returns
an already-collated batch andcollate_fnpasses it through, replacing 256
per-sample__getitem__calls and atorch.stack. Batch 256,
num_workers=0: 100,959 -> 925,425 chunks/s. Per-sample randomness in
augmentation is preserved; cross-layer shift/time-mask and the list-fallback
path still go per sample.signal_kmergets the construction win but not the
loader win — its per-sampleencode_signal_kmerstill dominates. - Splitting reads are mapped to splits in one pass. The masks were built
with astr()comprehension over the whole read-id column plus one
membership comprehension per split. 500k rows over 3 splits: 756 -> 176 ms. - Inference stages its host-to-device copies through pinned memory.
np.stack(...)then a synchronous.to(device)from pageable memory blocked
the GPU thread for the whole copy. Batch 512 on an A30: the copy itself is
1.31x faster forbase_onehotand 1.85x forsignal_kmer(42.5 MB
per batch). End-to-endpredictmoves 1.0-1.03x — it is extraction-bound —
so this shows up only when the GPU thread is the bottleneck. ReadInforebuilds the reference sequence on demand.
get_reference_sequence()ran in the constructor for every read whether or
not the run was reference-anchored. Construction drops 6.45 -> 4.80 us on
139 nt reads and 43.8 -> 10.5 us (4.2x) on 6.8 kb reads. The value is
still materialised before pickling, so the multiprocessing prepare path is
unaffected.
Internal
- One batch accumulator instead of three.
single.py's two extraction
paths andbundle.pyeach carried their own four parallel buffers, size
check, flush and mega-batch write.BatchAccumulatorand
prepare_signal_channelsare now shared;single.pydrops 1400 -> 1259
lines. Output BAMs are byte-identical across {multiclass, binary, bundle} x
{rust, python} x {0, 2 workers}. - The four merge functions share their common shape.
_collect_read_index,
_assign_splits,_assign_kfold_splitsand friends replace four copies of
scan-ids / assign / merge / build-result. Public signatures and returned
dicts are unchanged; outputs verified member-for-member identical across 17
scenarios. - Feature channel order is resolved once per read, not rebuilt per chunk
from a dict merge insideget_chunk. The order is unchanged and now pinned
by name, by row, and by value against the Rust pipeline's own order. - The tally passes over chunk metadata read columns.
max(label_int),
the source-group and label counts, the sampler weights and_crop_starts
each built a row view per chunk. 200k chunks:max(label_int)97.9 -> 0.03 ms,
the focus-position loop 124.2 -> 0.08 ms.
What's Changed
Other Changes
- fix(dataset): stop holding three copies of the corpus at load (#211) by @jayhesselberth in #212
- perf,fix: the audit sweep behind #211 — write path, merge, loader, and three live bugs by @jayhesselberth in #213
- fix: "None" strings in chunk metadata, and the fork-after-threads deadlock in predict by @jayhesselberth in #214
Full Changelog: v0.6.7...v0.7.0