Repository navigation
Conversation
The perf suite counts bytes (returned, decoded in whole chunks, re-encoded in whole chunks or shards), measures resident memory in a subprocess, builds every layout in h5ad, Zarr v2 and anndata's sharded Zarr v3, and grows n_var as well as n_obs (F091, F146). Recast within it: - check_cast reads --chunk rows of a dense matrix (F029) - CSC densify and dense-to-CSC go through a CSR scratch (F030) - the transpose steps by entries and streams huge majors (F077) - sparsify's 1-D chunks are elements, not a row height (F078) - the index range check always runs, and on dense sources (F013) - float16 sparse output is refused (F058) - a lossy cast drops the zeros it makes (F121) - nested matrix paths are converted (F043) h5py 3.15 leaks on every dataset call; the floor is 3.16.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Its growth ratio missed main's n_obs x --chunk block on Zarr, where the store's fixed cost hid it, and failed once in a full run under coverage when a one-time growth landed in one measurement. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.
An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: db33a98781
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if in_memory or not src.sparse: | ||
| return 0.0 |
There was a problem hiding this comment.
Include intermediate CSR files in the space estimate
For every dense-to-CSC conversion, and for CSC-to-dense with --in-memory, the core converter still writes an intermediate CSR into scratch_group, but this early return estimates no scratch at all. With an HDF5 output on a disk sized near the preflight estimate, that uncounted file can exhaust the filesystem during an HDF5 operation—the exact failure the preflight is intended to prevent—so count the intermediate CSR independently of whether the subsequent transpose uses buckets or memory.
AGENTS.md reference: AGENTS.md:L178-L183
Useful? React with 👍 / 👎.
- open_store keeps HDF5's chunk cache at 1 MiB a dataset: HDF5 2.0 (h5py 3.16) made it 8 MiB, and split grew with its outputs - dense to CSC writes a plain CSR scratch (scratch has no chunk cache, so a gzip one was re-encoded per block) and gives the output the source's codecs; it checks free space again once nnz is known, and refuses an nnz overflow as soon as the count passes the range - cheap refusals before the cast check; coordinates checked against the output's minor axis; in-memory CSC densify's scratch estimated - chunk model: chunks go into HDF5's cache whole unless too big for it - grid: bigger chunk and density, each size measured twice; concat no longer marked; lower bounds on the largest-read guards - docs: F121 is about dense sources Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ru_maxrss on Linux starts at the parent's peak (the kernel folds the replaced memory map into it at exec), so on CI every command added nothing: peak_rss reads VmHWM there. The new tests cost about 350 s of the unit job. Grid stores are built once and shared, each size measured once against a share of a half (bounded paths reach 0.21), with --chunk 256 rows along n_obs and 32 along n_var. The per-finding guards run on h5ad and sharded Zarr v3, at smaller sizes, and the recast correctness tests use real chunks where the blocks do not matter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # docs/reference/testing.md
Conflicts: sparsify keeps this branch's _sparsify_rows (it already has B3b's numpy per-row counts); open_store takes both B5's content check and the 1 MiB HDF5 chunk cache. The F071 xfail on bool readback is dropped: B5 fixed it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The CSC per-row guard's Zarr v3 cases took 38 s of CI and measure the algorithm, not the store; the skewed CSR case reads only the transpose's own scratch. The sharded reads stay covered by the dense-cast guard and the skewed CSC case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closes #96. Part of #87. For 0.17.6: merge after 0.17.5 is tagged.
The perf suite now sees memory that
--chunkdoes not bound: it counts bytes (returned, decoded in whole chunks, re-encoded in whole chunks or shards), measures resident memory in a subprocess, builds stores in every layout and format (including anndata 0.13's default sharded Zarr v3), and grows n_var as well as n_obs. Recast was then brought within it. Each guard was run against main'ssrc/first and failed there.Findings
test_peak_memory_is_a_small_share_of_the_input: recast (every dtype and layout change), subset and concat over CSR, CSC and dense, growing n_obs and then n_var, asserting peak memory added per byte of input added (assert_memory_is_a_fraction_of_the_input, at most half: bounded paths reach 0.21, holding the matrix 1 or more).--chunkis 256 rows growing n_obs (a block of columns over every row shows plainly) and 32 growing n_var (a row block widens with the input). Each store is built once and shared by the commands. Sparse subset is a strict xfail for F032 (B6b); concat passes on main and on B3b's branch.--dtype(both axes), CSC densify and dense to CSC.count_iocountsread_bytes,largest_read(overall and per dataset),decoded_bytes,written_bytes,encoded_bytes(whole shards for sharded Zarr) and store bytes; hooksoindex, Zarr writes andcreate_array(data=). The chunk model follows HDF5's per-dataset cache (whole chunks into the cache; in place only for an unfiltered chunk too big for it) and Zarr's lack of one.peak_rss(argv)runs a command in a fresh interpreter and reads its own high-water mark:VmHWMon Linux, whereru_maxrssstarts at the parent's peak (the kernel folds the replaced memory map into it at exec), andru_maxrsson macOS.tests/perf_fixtures.pybuilds h5ad, Zarr v2 and sharded Zarr v3 stores with anndata, any layout and shape.test_the_byte_counters_see_whole_chunks_decoded_and_rewritten(numbers worked by hand),test_peak_rss_sees_a_command_that_holds_the_matrix(--in-memorymust show).check_castreads--chunkrows of a dense matrix (rows=), notchunkrows, which was the whole matrix.--chunkany more, and neither reads or writes narrow column slabs (each of which decoded every chunk of a row-chunked source, or rewrote every chunk of the dense output). Same route the loom exporter already takes.--chunkcolumns per row, on h5ad (fail on main); a gzip source's scratch is not rewritten per block and the output keeps gzip; every layout to every other in blocks of 3 rows / 7 entries against scipy and anndata, onnew_store's three backends.chunkentries at a time (_majors_of), splitting a major across blocks. Pass 3 gives any output major larger than a bucket a bucket of its own and streams it in chunks without sorting (pass 2 adds entries in input order, so it is already in minor order).sparsifybuildsdata/indiceswith_growable_from: the dense template's codecs, never its 2-D chunks, so a 4-row chunk height no longer becomes a 4-element chunk. Covers the 10x-h5 export, which callssparsify.--forceno longer waives it. Coordinates are checked against the output's minor axis. A dense source has its coordinates checked before writing and its nonzero count while sparsifying, refused as soon as it passes the range, CSC scratch included (check_index_range). The checks that need no read run before the cast check.--indices-dtypeis parsed againstINDEX_DTYPES.--force, refused, nothing written; dense coordinates refused up front; dense nnz overflow refused while writing, before the whole matrix is read, and staging leaves nothing; a CSR with more rows than int16 holds keeps int16 column indices; offsets that fit are written narrow. On main,--forcewrote int16 indptr ending at -5,536 and dense-to-CSR wrapped coordinates to -32,768.--allover sparse layers), with "use --dtype float32, or --layout dense".sparsifycasts each block before finding its nonzeros, so a lossy cast from a dense matrix drops the entries it zeroes, as scipy does. A sparse source keeps its stored entries, as scipy'sastypedoes._writedescends to any depth (_write_members), souns/neighbors/connectivitiesanduns/a/b/care converted, not copied.obsm/X_pca, siblings untouched, read back by anndata (fails on main for the nested ones).Also, because the harness showed it: h5py 3.15.x leaks 32 to 160 bytes on every dataset read, write and
.shape(48 MB of resident memory after 300,000 writes; 3.13, 3.14 and 3.16.0 do not). Every streaming loop grew with its block count, the transpose (whose bucket writes are quadratic in buckets) fastest. The floor is nowh5py>=3.16.0(pyproject and uv.lock), guarded bytest_h5py_calls_keep_no_memory_once_they_return. h5py 3.16 brings HDF5 2.0, whose chunk cache is 8 MiB a dataset rather than 1 MiB, soopen_storenow opens HDF5 withrdcc_nbytesof 1 MiB (HDF5_CHUNK_CACHE):splitinto 8 groups of a 200,000 x 2,000 CSR took 530 MB of RSS without it, 431 MB with it, and 429 MB on main with h5py 3.15.1. Its Linux wheels are manylinux_2_28 (glibc 2.28 or later). The free-space estimate for recast also counts the transpose's scratch (_scratch).Not reproducing: none. All ten reproduced on main.
Files outside the batch
pyproject.toml,uv.lock: the h5py floor. B5 (B5: Element fidelity, legacy stores and HDF5 safety #117) also changes both (zarr); on merge, take main's lock and re-runuv lock --upgrade-package h5py.docs/reference/testing.md,docs/reference/commands.md: the harness and recast's refusals.src/adata/storage/__init__.py:HDF5_CHUNK_CACHEandrdcc_nbyteson theh5py.File(path, mode)line ofopen_store, for HDF5 2.0's cache (above). B5 (B5: Element fidelity, legacy stores and HDF5 safety #117) adds lines next to it.core/subset.py,core/csr_build.pyorconversion/.Follow-ups (not in this batch)
BucketSorter, writes (nnz/chunk)^2 x 3 bucket appends, since every input block touches every bucket: 126k appends and 36 s at 838k nnz and a 4,096-entry chunk. A two-level scatter would make it about (nnz/chunk)^1.5.recast --dtypeof 8192 x 64 at --chunk 64). That is F036's class, B6b's; the newencoded_bytescounter measures it.BucketSorterkeeps three growable datasets a bucket (about 57 KB of memory and one 65,536-entry chunk of disk each, B3b measured). Through the CLI recast's sparse chunk is 1M entries, about 0.06 bytes an entry; at the API's 4,096-entry floor about 14. The transpose knows its row sizes, so fixed regions with cursors, as concat's_ColumnBucketsdoes, would remove it._csc_bounds/_csc_runs(B3b) and recast's_transpose_bounds/_bucket_runsisolate and stream oversized majors the same way; they could share one helper incsr_build.--chunkrewrite their 65,536-element chunk each time (zarr2 dense 20,000 x 64 to CSR at--chunk 16: 108 MB written for a 9.8 MB store). Buffering appends to whole chunks would fix it; F036's class.Merge notes
sparsifykeeps this branch's_sparsify_rows, which already has B3b's numpy per-row counts (B3b's sparsify guard passes: 10 bytes a row against 30);open_storetakes B5's content check and this branch's 1 MiB chunk cache; the lock keeps B5's zarr 3.4.1 with h5py 3.16.0. The F071 bool-readback xfail is gone, since B5 fixed it.GRID_GAPSwhen F032 is fixed.Tests
COLUMNS=80 uv run pytest -m "not integration" --cov=adata --cov-fail-under=90on the merge with wave 2: 3,545 passed, 7 skipped, 4 xfailed (the strict F032 subset cases); coverage 96.1%.uv run pytest -m perf: 4 passed.test_peak_rss_sees_a_command_that_holds_the_matrix. On Linuxru_maxrssstarts at the parent pytest's peak, so every command measured 0 MB and the RSS guards were vacuous. The fix readsVmHWM, and CI at a3a3f7d passed all 11 checks with it.Independent review
A fresh subagent with the diff, the audit entries and AGENTS.md (no other context) reverted each fix and ran the guards, checked recast against scipy over 153 conversions and 384 transposes of non-canonical input on all three backends, and read the new paths for memory that
--chunkdoes not bound. Every reverted fix failed its guards (F029: 9 guards; F030 densify: 4; F030 sparsify: 3; F077 pass 2: 3; F077 pass 3: 3; F078: 6; F013 12, F058 21, F121 6, F043 6 tests). It found no blocker in recast. What it raised, and what was done:splitmemory grew with outputsopen_storekeeps 1 MiB (530 MB -> 431 MB, main 429 MB), guarded--chunk--in-memoryCSC densify's CSR scratch neither_scratchcounts the in-memory case--chunkrewrite their 65,536-element chunk.shapeleaks 32 B, not 100+cast_denseto bool fails on h5adEach new test was run against the previous head and failed there.
Changelog entry
Fixed
recast --dtypechecks a dense matrix a block at a time (#96). The round-trip check read the whole matrix at once, and two more copies of it, whatever--chunksaid. It reads--chunkrows now, as the conversion does.--chunkrows (#96). Each block was--chunkcolumns of every row, so memory followed the number of cells: 8 GB a block at 2M cells. Both now go through a CSR in scratch and the streaming transpose.recast --layout csr/cscand the 10x-h5 export gavedataandindicesthe dense source's chunk height as their chunk, down to 16 elements: hundreds of thousands of chunks and a file three times the size.--forceno longer skips the index range check (#96). Given to accept a lossy cast, it also letindptroverflow--indices-dtypeand wrap negative, a corrupt matrix with exit 0. A dense matrix made sparse is range-checked too.recastwrote could not be read by anndata or scanpy. Use--dtype float32, or--layout dense.recastofuns/neighbors/connectivitiesreported converting it and copied it unchanged.🤖 Generated with Claude Code