Repository navigation
Conversation
Outputs keep the source's chunks and shards, clamped to the output, and are written a whole shard or chunk at a time through storage.RowWriter (F031, F033, F036, F060). Reads take whole source chunks, in column tiles where a chunk height across the matrix is over the read budget (F061, F081). Sparse subset and split read about --chunk rows' entries a block, a major larger than that in parts (F032). Shard indexes are kept for the life of a store opened for reading (F062). split holds at most 128 HDF5 outputs open a pass, with no chunk cache (F079). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
Test Results (py3.13)3 569 tests - 103 3 557 ✅ - 101 10m 43s ⏱️ - 4m 24s Results for commit 72c66c5. ± Comparison against base commit 417aa4a. This pull request removes 197 and adds 94 tests. Note that renamed tests count towards both.This pull request removes 4 skipped tests and adds 2 skipped tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
Test Results (py3.12)3 569 tests - 103 3 557 ✅ - 101 8m 0s ⏱️ - 1m 17s Results for commit 72c66c5. ± Comparison against base commit 417aa4a. This pull request removes 197 and adds 94 tests. Note that renamed tests count towards both.This pull request removes 4 skipped tests and adds 2 skipped tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
…ywhere RowWriter fills one preallocated unit in place and writes whole units from the block itself; the unit budget is 8 MiB per output, data and indices sharing it, and 128 MiB across split's outputs. A guard checks peak allocation against the unit. The shard-index cache is one 64 MiB budget for the process, its entries dropped with their store. concat, recast (and its cast check) and export read whole source chunks too (row_bands). recast --layout dense writes through RowWriter, and a Zarr output with no source chunks takes zarr's own, cut to the budget. Tile mode no longer cuts chunks when one band covers every row. split writes 128 outputs a pass in any format. Growable outputs clamp their shard to the entries they can hold. The RSS runner raises the open-file limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The F029 cast-check guard bounds the largest read by --chunk rows or one chunk height of the source, whichever is taller; the F013 overflow test gives its source chunks 16 rows tall, so it still shows the refusal after the first block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The unit job ran past its 20-minute timeout: coverage's trace function roughly doubled the suite on CI, and the cost guards are a fifth of it. The coverage job now measures through sys.monitoring (COVERAGE_CORE= sysmon: the same line coverage, 95.6%) and deselects `perf`; a new parallel job runs the guards, marked `perf` as a module, on both Pythons. publish.yml calls tests.yml whole, so a release needs both. B6b's guards share their sharded stores (built once per module), and the unit-memory and split RSS guards use half the data; each still fails on main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closes #97. Part of #87. For 0.17.6.
Reads and writes now line up with the source's chunks and Zarr v3 shards. Outputs keep the source's chunks and shards (clamped to the output) and are written a whole shard or chunk at a time through a new
storage.RowWriter. A writer holds at most 8 MiB of rows per output, and 128 MiB across the outputssplitwrites at once. Reads take whole source chunks; subset, split and convert read column tiles when one chunk height across the matrix is over the read budget. Sparse subset/split blocks follow--chunk. Shard indexes are read once, under one 64 MiB budget for the process.Findings
F031 (dense subset/split of anndata 0.13's sharded Zarr v3 rewrote a shard per block): outputs keep the shard and are written a whole shard at a time.
RowWriterfills one preallocated unit in place and writes whole units straight from the block. A unit is cut along its rows to 8 MiB, or to the output's share of 128 MiB insplit.F032 (sparse blocks were 4,096 majors whatever
--chunksaid; a CSC block grew with n_obs): a block is about as many entries as--chunkrows hold on average (sparse_block_entries), in either layout. A major holding more, such as a gene in every cell, is read in parts (_sparse_blocks).F033 (outputs dropped the shard and kept the ~1 KB inner chunk; HDF5 outputs took it as their chunk):
fit_chunkskeeps shards, clamped with the chunk._growable_likekeeps the source's shard, clamped to the most entries the output can hold. An HDF5 target of a sharded source lets HDF5 choose its chunks. A v2 target takes the shard as its chunk.F036 (unverified; reproduced: recast
--dtype15.4×,--indices-dtype9.7×, a transpose's output 7–13×, concat 15×): every streamed writer of an output that keeps a shard goes throughRowWriter:CsrAppender,copy_datasetand_copy_records.A sparse output's
dataandindicesshare one budget. A Zarr output whose source has no chunks takes zarr's own chunks, made explicit (backend_chunks) and cut like any other.F060 (split's outputs got
--chunk/G rows a block into chunks many times taller): each output buffers rows to whole chunk heights, within its share of 256 MiB.F061 (row blocks unaligned to the source's chunk height):
read_blocksrounds--chunkup to whole chunk heights, within max(32 MiB,--chunkrows). subset and split read column tiles of whole chunks past that, each output column band with its own writer. concat, recast (and its cast check) andexport array/sparseread throughrow_bands, the same rounding with--chunkrows past the budget.F062 (shard index re-read on every block): Zarr stores opened for reading are wrapped in a
WrapperStorethat keeps suffix reads (shard indexes). They share one LRU budget of 64 MiB for the process, and a store's entries are dropped when it is collected.F079 (split RSS grew with group count): measured first. B6a's 1 MiB cache did not change it, and neither does any chunk-cache or metadata-cache setting: an open HDF5 file costs ~0.55 MB of HDF5's own per-file structures. split now writes at most 128 outputs per pass, in any format, so each output's share of the write budget stays at 1 MiB or more. HDF5 outputs open with no chunk cache, since
RowWriterwrites whole chunks. The cost: past 128 groups the matrices are read once per 128.F081 (
copy_datasetread a whole chunk height across the full width, i.e. the whole matrix for column-chunked arrays): column tiles of whole chunks withinTARGET_READ_BYTES. A shard is made to fit the tile, and whole rows are written throughRowWriter.Measurements (B6a's harness: bytes, never a clock)
"Encoded ×" is bytes of whole chunks or shards encoded per byte written to X; "decoded ×" is the same for reads. The
zarr31fixtures are anndata 0.13's layout as zarr 3.1 wrote it (inner (16, 8) or 245 elements, shards of the whole array;perf_fixtures.zarr31_layout). zarr 3.4.1 gives ~1 MB inner chunks, so a store written today hides these costs.--chunk 256--dtype float64, dense zarr31--indices-dtype int64, CSR zarr31peak_rsssubset dense zarr3 16384×2048 to Zarr,-C 64peak_rsssubset CSR zarr31 8M entries to Zarrpeak_rssrecast--indices-dtype int64, same CSRX/dataX/datachunks--chunk 64, 4,096 → 32,768 cells: largest readGRID_GAPSnow empty--dtype/ recast--layout csr/ export array, gzip chunks (8192, 8)Every new guard fails on main and passes here. I checked this by running the same test file with main's
srconPYTHONPATH: 23 of 23 guards intest_performance.pyfail on main, each by its assertion. The reviewer also reverted each fix in turn on this branch, and each guard failed (below).Tests
tests/test_performance.py, new section "blocks and shards":--dtype/--indices-dtype/--layout csc(X's arrays, not the transpose's scratch), convert;--dtype, recast--layout csr, export array;--chunk: CSR, CSC, a gene in every cell;slow,peak_rss; the runner now raises the open-file limit, so macOS's 256 does not cap the outputs first).The four F032 grid xfails are removed.
perf_countersgainswritten_by_name/encoded_by_name;perf_fixturesgainszarr31_layoutandmatrix_store(groups=, compression=).tests/test_block_layout.py(new), with anndata as the oracle:--chunk 2(CSC majors read in parts) on h5ad, zarr2, zarr3 and zarr31, in every layout;Explicit layout checks (F033): chunks and shards equal the source's (clamped) after subset, split, concat and recast; a zarr31 → h5ad convert lets HDF5 choose its chunks and stores X in what anndata's own h5ad takes; v3 → v2 takes the shard as its chunk. Unit tests cover
RowWriter(every backend, sharded, grow, cols, budget),read_blocks, a property test of_sparse_blocks(every kept major's entries exactly once, each block within budget), and the shard-index cache (one index fetch per array; none kept at a 0-byte budget; one budget across stores, dropped with them).Updated:
test_storage.py: the old test asserted that a clamped chunk drops its shard, which is F033.test_subset_fan_out.py:chunk_major→chunk_rows, at 1 and 5.--chunkrows or one source chunk height, whichever is taller.test_recast_streaming.py's F013 overflow test: its source is chunked 16 rows tall, so the refusal after the first block is still shown.CI cost: the new tests take about 45 s on this machine:
test_block_layout.py27 s, the new guards 18 s (the RSS guard 5 s of that).Local results
At head
8ddfb29:COLUMNS=80 uv run pytest -m "not integration" --cov=adata --cov-fail-under=90: 3,648 passed, 9 skipped, coverage 96.03%uv run pytest -m perf: 4 passedADATA_REQUIRE_VERSION_FIXTURES=1 uv run pytest tests/test_anndata_versions.py -m integration: 268 passed, 12 skippedtest_block_layout.pyand the new guards pass (89 passed, 2 skipped).Docs
docs/reference/commands.md: under "Writing outputs", what outputs keep (shards, inner chunks) and when a unit is cut (8 MiB, about four times that while zarr encodes it; 128 MiB shared across split's outputs). Also how reads are sized (whole chunks up to 32 MiB, then column tiles in subset, split and convert), how sparse blocks follow--chunk, and that shard indexes are cached. In split, one pass per 128 outputs. subset's and split's--chunkhelp now reads "Rows per block, of a dense or a sparse matrix".docs/guides/pipelines.md(memory),docs/troubleshooting.md(memory, and "It is slow on a Zarr store anndata 0.13 wrote"),docs/reference/testing.md(harness fields, the zarr31 fixture, the new guards).AGENTS.mdanddocs/developers.md: shards are kept byfit_chunks; write streamed outputs throughRowWriterand flush it.Files outside the batch
src/adata/core/recast.py: cast_dense, cast_sparse, transpose pass 3, sparsify and densify write throughRowWriter(F036's repro is recast); the cast check, cast_dense and sparsify read throughrow_bands(F061).src/adata/core/concat.py: the dense, sparse, CSC, varm and varp writers write throughRowWriter, the dense readers throughrow_bands;_layout_likepasses itemsize; the growable writers are flushed.src/adata/core/csr_build.py:CsrAppender.finishflushes its growable writers (2 lines).src/adata/formats/array.py,src/adata/formats/mtx.py: export's dense reads go throughrow_bands(F061; a few lines each).src/adata/cli.py: the help text of subset's and split's--chunk.tests/test_recast_streaming.py: the F013 overflow test's source chunks (above).These are mechanical. Each writer's slice assignment becomes
writer.write(block), followed by aflush(). Each reader'srange(0, n, chunk_rows)becomesrow_bands(array, chunk_rows)._growable_likenow returns aRowWriter, so every caller of it flushes.Follow-ups (not in this PR)
csr_build.scratch_group), sorecast --layout cscto Zarr still encodes ~135× its scratch writes. The output arrays themselves are written once now. An HDF5 scratch beside the output, as concat does, would fix it.copy_datasetwrites HDF5 variable-length strings uncompressed: obs_index/valuesfrom a zarr → h5ad convert is 320 KB against anndata's 37 KB.--chunkrows, not column tiles, where one chunk height across the matrix is over the read budget (chunks of (n_obs, 8) at scale).Changelog entry
Fixed
--chunkblock at a time, and zarr rewrites a whole shard for a write of any part of it: subset and concat wrote 15 times the bytes they meant to, split 121 times, and a dense subset held 1.7 GB. Outputs are written a whole shard (or chunk) at a time now. A shard is cut along its rows to at most 8 MiB, 128 MiB shared across the outputssplitwrites at once, and about four times that is held while zarr encodes it.dataandindicesdropped the shard and kept the inner chunk, 245 elements a file under anndata 0.13 with zarr 3.1: thousands of files per array. An h5ad output took the inner chunk as its HDF5 chunk, making a file twice anndata's own. Zarr v3 outputs keep the shard. HDF5 outputs let HDF5 choose their chunks, and--zarr-format 2takes the shard as the chunk.subsetandsplitfollow--chunk(#97). Blocks were 4,096 rows or columns whatever--chunksaid, so a CSC block (4,096 genes across every cell) grew with the number of cells. A block holds about as many entries as--chunkrows do now, and a gene expressed in every cell is read in parts.subset,split,concat,recast,convertandexportread a dense matrix in whole chunks of the source, so a chunk taller than--chunkis no longer decoded once for every block: 7 to 60 times the matrix for zarr's own chunks or chunks of (n_obs, 8). Past 32 MiB,subset,splitandconvertread a band of columns, soconvertno longer reads a column-chunked matrix whole. A Zarr v3 shard's index is read once per command, not once per block.splitmemory no longer grows with the number of groups (#97). An open HDF5 file costs about 0.6 MB whatever is in it, and split held up to 1,024 open at once: 581 MB of RSS at 512 groups, against 143 MB now. It writes at most 128 outputs per pass over the matrices, so past 128 groups the matrices are read once per 128. Each output is written a whole chunk at a time, so a chunk 512 rows tall is no longer decoded and encoded once for every block.Independent review
A fresh subagent with none of my context reviewed the diff against the audit entries and AGENTS.md. It reverted each fix in turn, compared outputs with anndata, and measured with the harness.
Verdict at
52d063d: not ready.--chunk1 / 7 / 1024. The same held with the unit, buffer and read budgets set to a few KB.--inplace, and--zarr-format 2from sharded v3.What it found, and what changed (head now
8ddfb29):WRITE_UNIT_BYTES; sparse subset to Zarr regressed (335 MB vs main's 50 MB at 64 MiB units). Fixed.RowWriterfills one preallocated unit buffer in place and writes whole units from the block itself.dataandindicesshare it everywhere.test_writing_whole_units_holds_a_few_units_not_the_shard. The sparse case is now 58 MB vs main's 50 MB.test_the_shard_index_cache_is_one_budget_across_stores.row_bandsin concat, recast (cast check, cast_dense, sparsify) and export array / sparse. The F061 guard now covers concat, both recasts and export array; all fail on main at 7.7–8.0×. Column tiles stay subset/split/convert only, which is documented and listed as a follow-up.recast --layout densewrote--chunkrows straight in (13× on Zarr). Fixed: it writes throughRowWriter, with zarr's own chunks made explicit and cut._growable_like(size=)clamps it to the most entries the output can hold, known fromindptr.peak_rss's runner raises the soft limit. The guard now fails on main underulimit -n 256(536 vs 138 MB).copy_datasetand the dense fan-out too.Not acted on: it noted that STATUS.md does not list the follow-ups. The coordinating session owns STATUS.md, per the plan's rules.
🤖 Generated with Claude Code