Repository navigation
Conversation
load_packed gains obs_idx/var_idx (boolean mask, integer positions, or a slice), returning exactly load_packed(path)[obs_idx, var_idx] without building the full matrix. The packed stream is still read whole, but only the selected major slices are decoded: a parallel varint count splits the stream, each span walks forward to the selected slices starting in it, and those are decoded in parallel. A minor-axis selection renumbers indices through a lookup table in the same pass, dropping groups it empties. Unsorted/repeated selections decode the sorted unique set and are reordered in memory with the existing indexing. Varint counting is now branch-free (one add per byte), which also speeds up the existing parallel full decode, and the locate walk skips whole blocks with it. VCSCAnnData's metadata subsetting moves into _subset_fields, shared by __getitem__ and load_packed. Claude-Session: https://claude.ai/code/session_01QBeTZy6BKKGKGJ7fwvkWgM
Resolve README/usage conflicts by keeping both new sections. Reuse _io._read_shape from #70 in the subset reader, and document and test that a subset load recomputes its stats rather than inheriting the file's sidecar, which describes the whole matrix. Claude-Session: https://claude.ai/code/session_01QBeTZy6BKKGKGJ7fwvkWgM
…undary Two primitives a windowed reader needs; no behaviour change. `_compression.read_range(ds, start, stop)` decodes only the zstd chunks that cover a byte range, through the same `chunk_table`/`pread`/`numcodecs` path #68's full read already uses -- it never seeks into a compressed chunk, so this is as compatible with compression as reading all of it is. `read_array` is now that over `[0, len)`. `chunk_table` is public within the module so a caller reading many windows builds it once: it walks every chunk through a Python callback, and HEK293T's `packed_indices` has 190,770 of them. `_ivcsc.trim_to_boundary` is load-bearing rather than cosmetic. Every helper below it assumes the buffer's last varint is complete -- free when the buffer is the whole stream, false for a window out of the middle of one. In particular `_count_varints_range` walks its inner continuation-bit loop past `end`, which on a truncated final varint reads out of bounds, and `njit` without `boundscheck` will not stop it. `_varint_chunks`'s docstring now says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… file The remaining half of #67 -- "decompress the stream in slabs of zstd chunks with the parallel reader, rather than holding all of it. Carry the row/rank state across slab boundaries" -- which this PR's predecessor left out as a separate change. Adds `vsparse.iter_packed(path, chunk_size=)` and the `iter_blocks`/`open_packed` layer under it. Why a block iterator and not another selective read: `obs_idx=` cannot express a reduction over every cell. Per-perturbation means over 4.5M cells are one pass here; as selective reads they are one pass *per group*, each running the whole stream again, because nothing in the file indexes a slice's bytes. The same goes for anything that has to see all of `X` once. It is a sequential read, not a random one. Slices own contiguous byte runs and every value group restarts its delta at -1, so a window cut at a slice boundary carries only `(next slice, next byte)` -- two integers, no decoder state. A contiguous block needs no selective machinery at all: its pointer arrays are slices of the parent's, so only the indices are decoded, by the existing `unpack_indices`. Finding where the next slice starts reuses the window's own span table and walks at most one span, rather than paying a second counting pass. Sizing the window is the whole performance story, and getting it wrong is expensive: a short read costs a re-read *and* a re-count. A first cut that assumed single-byte varints was 3.1x slower than a full decode on a fixture whose deltas average 1.66 bytes. Sizing from the file's own bytes-per-nonzero (free from `len(packed) / value_ptr[-1]`) and rescaling a retry by what the window measured fixes it; the benchmark's `slab_time_ratio_over_full` gate is set where that regression would be caught. Measured on the two files the plan was written for, whole-file pass, 48 threads: | | wall | GB/s | peak RSS | |---|---|---|---| | Parse-10M, full decode | 11.7 s | 1.88 | 63.14 GB | | Parse-10M, slabs | 22.1 s | 0.99 | 2.75 GB | | HEK293T, slabs | 40.2 s | 0.82 | 4.66 GB | | HEK293T, full decode | — | — | 116 GB of indices alone; does not fit | So: about 2x the wall time for about 23x less memory, and it reads a file a full decode cannot. A run-through really does exceed 1 GB/s, and a windowed one is just under it -- the gap is per-window overhead (1123 and 1737 windows respectively), which `_WINDOW_ELEMS` trades against residency. `major_ptr`, `value_ptr` and `values` are still read whole: ~12 bytes per value group against 4 per nonzero, 4.1 GB rather than 116 GB on HEK293T. That floor scales with value groups, not `nnz`. `VCSCAnnData._subset_fields` now delegates to a module-level `subset_fields` taking a field mapping, with no behaviour change, because `iter_packed` windows fields it read straight from a file rather than off an existing object. Blocks deliberately carry no statistics sidecar: `row_sums`/`row_nnz` would window correctly but `col_sums`/`col_nnz`/`col_depth_sums` are whole-matrix quantities. Normalizing block by block is exact for a per-cell recipe (`cp10k_log1p`) and not for `parafac2`/`pearson`, whose median-depth target and gene scale are whole-population; the docstring says which and where to get them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fishidaho
approved these changes
Oct 21, 2026
fishidaho
left a comment
Contributor
There was a problem hiding this comment.
So the only way to access this streaming is via the load_packed() call?
Contributor
There was a problem hiding this comment.
Is there a particular advantage in avoiding the byte-offset and side statistics calculation?
This branch has not been deployed
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.
Stacked on #71 — based on
subset-unpack, so the diff here is just the twocommits on top. Implements the one item #71 lists under "Not in this PR":
and answers the ask in #39 ("I think I'd want to see benchmarking to understand
the performance implications") with numbers rather than an argument. Those
numbers are mixed, and the summary up front is: ~2x the wall time for ~23x
less memory, plus files a full decode cannot read at all. Whether that trade
is worth carrying is a judgement call, and the benchmark is here so it can be
made on evidence.
What it adds
_compression.read_range(ds, start, stop, *, table=)— decodes only the zstdchunks covering a byte range, through the same
chunk_table/pread/numcodecspath Replace Blosc2 with zstd-1 and decode chunks in parallel on read #68's full read already uses.read_arrayis now that over[0, len).chunk_tableis module-public so a caller reading many windowsbuilds it once; it walks every chunk through a Python callback and HEK293T's
packed_indiceshas 190,770 of them._ivcsc.trim_to_boundary— a window out of the middle of the stream usuallyends mid-varint, and every helper below assumes its last varint is complete.
_count_varints_rangein particular walks its inner continuation-bit looppast
end, which on a truncated final varint reads out of bounds, andnjitwithout
boundscheckwill not stop it._varint_chunks's docstring now saysthe buffer must end on a boundary.
vsparse.iter_packed(path, chunk_size=), overiter_blocks/open_packed.On "streaming is not easily compatible with compression": that is true of
random-access streaming and not of this.
read_rangefetches whole zstdchunks and never seeks into a compressed one, so a windowed read is exactly as
compatible with compression as reading all of it. The stream is still walked
front to back —
value_ptrcounts elements, not bytes, so nothing in the filesays where slice r begins. The only compression-specific cost is re-decoding
at most one boundary chunk per window.
Why a block iterator rather than another selective read
obs_idx=cannot express a reduction over every cell. Per-perturbation meansacross 4.5M cells are one pass here; as selective reads they are one pass per
group, each running the whole stream again, because nothing indexes a slice's
bytes. Same for anything that has to see all of
Xonce — which is whatbackfill_stats-style work and the downstream bi-cross-validation reductionboth need.
A contiguous block needs none of the selective machinery: its pointer arrays
are slices of the parent's, so only the indices are decoded, by the existing
unpack_indices. Locating where the next slice starts reuses the window's ownspan table from
_varint_chunksand walks at most one span, rather than payinga second counting pass over the window.
Three of the four
_ivcschelpers an earlier draft of this added turned out tobe redundant against #71 and were dropped:
_varint_chunksis the same function,and
_locate_ranks/_slice_startssubsume anelement_offsetshelper with abetter block-skipping walk. The diff is ~1/5 of what it was as a result.
The benchmark caught a bug in this PR's own code
The first run reported the slab reader 3.1x slower than a full decode. The
cause was here, not in the method: windows were sized assuming ~1 byte per
varint. That holds on HEK293T (1.138 bytes/nonzero) and not on the benchmark
fixture (1.664), so every read came up ~40% short and retried — re-reading and
re-counting, which is the expensive half. Sizing from the file's own
len(packed_indices) / value_ptr[-1](free) and rescaling a retry by what thewindow measured fixes it.
slab_time_ratio_over_fullis gated where thatregression gets caught.
Measurements
New
@slowcaseslab_stream_vs_full_decode, beside #71'ssubset_load_vs_full_load. It writes its own temp.h5ad(400,000 x 3,000,2% dense). Memory is measured with
tracemalloc, not RSS:ru_maxrssis ahigh-water mark that never falls, so on a fixture small enough to build
in-process it only ever reports the fixture. Timing and memory are separate
passes, since tracing distorts allocation-heavy timing.
Gated:
slab_time_ratio_over_full(margin 1.5) andslab_peak_ratio_over_full(1.1). The other metrics are recorded for context.
--recordalso fills inentries for the two pre-existing
@slowcases, which had none; I removed thoseagain, since starting to gate them is not this change's business.
Whole-file pass on the two real corpora, 48 threads, warm cache:
So a run-through really does exceed 1 GB/s and a windowed one is just under it.
The gap is per-window overhead, which
_WINDOW_ELEMS(currently 2^24 elements,~18 MB of bytes and ~67 MB decoded) trades against residency; the benchmark is
the place to retune it.
What the ~23x actually refers to
It is not 23x less of the same thing. A full decode holds the packed stream and
the fully decoded index array at the same time; the slab pass holds neither
in full. On Parse-10M:
packed_indices(the byte stream)indices(18.83 B x 2 B,uint16)value_ptrvaluesmajor_ptrru_maxrssSo the honest statement is "2.2 GB of pointer arrays plus a working set of a
few tens of MB, against 62 GB". About 80% of the slab pass's 2.75 GB is floor
that both paths pay; what is removed is the 21.88 GB stream and the 37.66 GB
index array, neither of which is ever materialized whole.
(Parse-10M stores
uint16indices — 40,352 genes fits — so its decoded indexarray is 37.66 GB rather than the 75 GB an
int32file of the samennzwouldneed. HEK293T is
int32, hence its 116 GB.)Neither ratio is a constant, and quoting them without the file is misleading:
nnz; itsdenominator is a fixed floor plus one window. On this PR's own benchmark
fixture the ratio is only 1.5x, because that fixture is ~1.4 windows wide —
the window is the file.
the ~1.4-window fixture the slab pass is faster (0.80x).
_WINDOW_ELEMStrades the 2x back against residency and has not been tuned.
run: 116.5 GB of indices on a 137 GB machine. There the trade is not slower
versus faster.
major_ptr,value_ptrandvaluesare still read whole — ~12 bytes per valuegroup against 4 per nonzero, 4.1 GB rather than 116 GB on HEK293T. That is this
module's floor and it scales with value groups, not
nnz.Correctness on real data
A streamed pass over Parse-10M accumulating per-gene column sums reproduces that
file's
X/stats/col_sumsbitwise (np.array_equaltrue, max abs diff 0.0),in 28.1 s at 2.80 GB. That sidecar was written by the ordinary eager
compute_statspath duringwrite_h5ad, so it is an independent referencerather than a restatement of this code.
Not in this PR
load_packed(obs_idx=)itself is still not slab-bounded. This adds thecursor and proves it out on whole-file passes; threading it through
unpack_selectedneeds that function to fill a preallocated output at anoffset, so a selection spanning windows doesn't concatenate. Natural follow-up,
same cursor.
_WINDOW_ELEMSagainst the new benchmark. 2^24 was chosen to keep adecoded block near 67 MB, not because it was measured to be optimal.
pass, at the cost of a thread and an error-propagation path.
Notes
VCSCAnnData._subset_fieldsnow delegates to a module-levelsubset_fieldstaking a field mapping, with no behaviour change, because
iter_packedwindowsfields read straight from a file rather than off an existing object.
Blocks deliberately carry no statistics sidecar:
row_sums/row_nnzwouldwindow correctly, but
col_sums/col_nnz/col_depth_sumsare whole-matrixquantities and a block's are not the file's. Relatedly, normalizing block by
block is exact for a recipe whose scales are per-cell (
cp10k_log1pislog1p(x * 1e4 / row_sums[i])) and not forparafac2/pearson, whosemedian(row_sums)target andcol_depth_sumsgene scale are whole-population;the docstring says which, and where to get them instead.
iter_packedreadsobsonce, whole — ~3 GB of Python strings on HEK293T,whose
_index/sample/guide_targetarenullable-string-arrayover 4.5Mrows. Paid once per call, not per block.
iter_blocksskips it entirely, whichis why the figures above use it.
Checks
pytest: 1547 passed, 72 skipped.tests/test_iter_packed.pyadds 20,covering every chunk size against the same in-memory slice, empty rows at both
ends, an all-zero matrix, a zero-row file, IVCSC raising, and — the one that
matters — that the cursor's byte total lands exactly on
len(packed_indices), which is what catches an off-by-one in locating thenext slice.
tests/test_parallel_read.pyadds 10 forread_rangeacrosschunk boundaries, partial first/last chunks, empty ranges, a cached table and
every non-zstd fallback.
ruff,codespell, formatting: clean.radon cc -n C: nothing ranked C orworse.
vulture: nothing new.xenon: only the two pre-existing module-rank-Bfiles.
jscpd: no new clones.--set fast: no regressions.--set slow: no regressions past recordedceilings.
🤖 Generated with Claude Code