Repository navigation
Conversation
concat now opens its inputs as it needs them, at most a quarter of the file descriptor limit at once, closing the most recently used to make room, so any number of inputs works and memory no longer grows with them (F076). Every element is looked up again on each pass rather than held. CSC inputs are read once each, in blocks of entries sized by --chunk, and their columns put together through buckets in an HDF5 scratch file beside the output, instead of 2 x n_var x n_inputs reads (F011). A column bigger than a block is streamed. The free-space check counts the scratch, for a Zarr output too, and moves into concat_on_disk to do so. Row pointers are one int64 array, not a Python list, in concat and in recast's sparsify (F132). Dense X, layers and obsm arrays keep the first input's chunking and compression (F075). --fill-value widens an integer matrix only when an outer join fills cells it cannot hold (F116). varp follows --merge as varm does, sparse entries compared by value (F118). An outer join's var and merged categories are ordered as anndata.concat orders them (D2; F113, F114), and ordered categoricals whose orders conflict are written unordered with a warning. A matrix whose rows are not its input's obs is refused before writing. Closes #93. Part of #87. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
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.
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. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
From the independent review of #116: - CSC buckets get fixed regions of three contiguous datasets, since every output column's size is known up front. BucketSorter's three growable datasets per bucket held about 57 KB of memory and a 1.3 MB chunk of disk each, and buckets multiply as --chunk shrinks, so the scratch came to 16 times the free-space estimate at --chunk 1 and memory grew with n_obs / --chunk. The scratch is now exactly the estimate. - At most 64 inputs open at once (fewer under a low descriptor limit): each costs about a megabyte, and clusters set the limit in thousands. - natural_key compares text in NFD form, as natsort does ("Müller" after "Muscle"); integer categories meeting float ones lose the first input's order, as in anndata. - Dense obsm out of step with obs is refused, as sparse obsm was. - A float32 matrix becomes float64 for a fill beyond its range. - Docs: a sparse varp entry never equals a dense one; the pool's cap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closes #93. Part of #87. For 0.17.5: merge after 0.17.4 is tagged.
What changed, by finding
--chunk, and the output's columns put together through column-range buckets in an HDF5 scratch file beside the output, instead of one read per column per input. Every output column's size is known up front, so each bucket is a fixed region of three contiguous datasets (_ColumnBuckets): three datasets open, and the scratch exactly 16 bytes plus the value per entry. A column holding more than a block is in a bucket of its own and streamed; if an input stored it out of order, the bucket is sorted whole. The output stays canonical (sorted rows). The free-space check now counts the scratch (about 16 bytes plus the value per stored entry of the largest CSC matrix), for a Zarr output too, since the scratch is HDF5; that check moved fromcommands/concat.pyintoconcat_on_disk, which knows the encodings. Tested: againstanndata.concaton three CSC inputs (reordered and missing genes, empty columns, several buckets) on h5ad, zarr2 and zarr3, inner and outer; index dtypes and compression kept; reads of X don't grow withn_varat fixed nnz on h5ad and Zarr; open HDF5 datasets don't grow with the bucket count; the scratch on disk equals the estimate at--chunk1 and 1024.dataset_create_kwargs+fit_chunks, as varm already was). Tested: gzip and(3, 2)chunks survive on all three backends, inner and outer; values equal anndata's.MAX_OPEN_INPUTS: 64, or a quarter ofRLIMIT_NOFILEif that is fewer (as split'sMAX_OPEN_OUTPUTS), since each open HDF5 input costs about a megabyte. Nothing holds an object across inputs any more: every element is looked up again on each pass. When the pool is full it closes the most recently used input, since every pass scans first to last (LRU would reopen every input on every pass: 344 opens against 56 with one input over the limit). Identical var lists are held once; merge comparisons stream, holding at most two values. AnEMFILEnamesulimit -n. Tested: a rich concat with the pool at 1 equals the one at 64 and anndata's (h5py invalidates closed handles, so any held object would fail); 100 inputs under a 64-descriptor limit in a subprocess; peak open HDF5 files independent of input count; reopen count with one input over the limit.util/names.natural_keynow compares text in NFD form, as natsort does ("Müller" after "Muscle"). Ordered categoricals stay ordered only when every input has the same categories in the same order; otherwise they are written unordered with a warning (anndata gives plain values). Tested: against anndata on text, integer and float categories, outer joins where the first input lacks the column, and five ordered cases.--fill-valuewidens a dense matrix only when some input actually leaves cells unfilled, and only if the dtype cannot hold the value (exactly, for integers and booleans; within range, for floats); then float64. Tested: inner join keeps int32 as anndata does (fill -1 and NaN); outer join over nine dtype/fill pairs, values equal anndata's.--mergeas varm does. Dense entries are compared by digest on the target genes (dtype included, as anndata); sparse ones by an order-free digest of their nonzero triples, so CSR equals CSC and float32 equals float64, as in anndata. A kept sparse entry already on the target genes is copied; one in another gene order is rebuilt through buckets. Without--merge, varp is dropped with a note; obsp is always dropped. Tested: against anndata for all four strategies × dense/CSR/CSC with a permuted gene order, a rebuild from shuffled genes on h5ad and Zarr, the value comparisons, an outer join, and the notes.sparsifykeeps per-major counts in a numpy array. Tested: allocation guards: 12.7 B/row for concat (main: 56.6) and 14.3 B/row for sparsify (main: 46.8).Also: a matrix (X, a layer, an obsm array) whose rows differ from its input's obs is now refused before the output exists, naming the input; dense obsm used to be stacked out of line with the cells.
None of the findings failed to reproduce: each new test failed on main before the fix.
Outside
core/concat.pysrc/adata/core/recast.py: the F132 sparsify fix (counts in an int64 array; an unusedListimport dropped).src/adata/util/names.py:natural_keyNFD-normalises (two lines). This moves accented labels to anndata's order wherevernatural_sortedis used (conversion, table and pickle imports too).src/adata/commands/concat.py: passesspace_checkthrough; the check itself moved intoconcat_on_disk.src/adata/cli.py: concat's help text (varp,--fill-value).docs/reference/commands.md: the concat section.Tests run locally
COLUMNS=80 uv run pytest -m "not integration" --cov=adata --cov-fail-under=90: 2818 passed, 1 skipped; total coverage 95.8 % (run before merging main; after the merge, which brought only the 0.17.4 release files, the docs and concat test files pass again: 647).ADATA_REQUIRE_VERSION_FIXTURES=1 uv run pytest tests/test_concat_versions.py -m integration: 24 passed (a new read-back of a CSC concat with varp and an ordered categorical, by anndata 0.8 to 0.13, h5ad and Zarr).ADATA_REQUIRE_VERSION_FIXTURES=1 uv run pytest tests/test_anndata_versions.py -m integration -k concat: 12 passed.Changelog entry
Changed
concat --join outerkeeps the inputs' var order when they all share itand sorts the union otherwise, as
anndata.concatdoes; it used to be theorder of first appearance. Merged categories keep the first input's order
when it already holds every category, and are natsorted otherwise
(accented letters as natsort orders them, "Müller" after "Muscle"). An
ordered categorical whose inputs disagree on the order is written
unordered, with a warning, where it used to keep the first input's order.
Fixed
concatreads CSC inputs once, not once per column (#93).Each column of each input was a separate read that decompressed a whole
chunk: 37 times the inputs' size was read on h5ad and 572 times on Zarr,
so atlas-sized CSC inputs never finished. Inputs are now read in blocks
and the columns put together in a scratch file beside the output, which
the free-space check counts.
concattakes any number of inputs (#93).Every input was held open, so concat failed with "Too many open files" at
about 253 h5ad inputs on macOS (1,020 on Linux), and memory grew with the
number of inputs. Inputs are now opened as they are needed, at most 64 at
once.
concat(#93).Dense X, layers and obsm arrays were written uncompressed and unchunked,
whatever the inputs were: 34 times larger for a matrix of zeros.
concat --mergekeeps varp (#93).varp was always dropped, with a reason that holds only for obsp. It now
follows
--mergeas varm does, sparse entries compared by value asanndata compares them.
--fill-valueno longer makes integer matrices float64 for nothing (#93).An integer X became float64 even on an inner join, where nothing is
filled. It now widens only when an outer join fills cells with a value
the dtype cannot hold.
concatandrecast --layout csr(#93).Row pointers and per-row counts were Python lists, about 36 bytes a row
against 8: 400 MB more at 10M cells.
Independent review
A fresh subagent with only the diff, the audit entries and AGENTS.md reviewed the PR. It ran a 300-case CSC fuzz (h5ad, zarr2 and zarr3 mixed; zero-row and zero-var inputs; int64 pointers; unsorted columns;
--chunk1 to 1024; pool sizes 1 to 64), a 120-case varp/varm merge fuzz against anndata, and sharded Zarr inputs; all correct. Its findings and what was done:--chunk, 16× at--chunk 1; each bucket's growable datasets took a whole chunk). Fixed: fixed bucket regions in contiguous datasets; a test checks the size on disk against the estimate.--chunk: about 57 KB per open bucket. Fixed by the same change; a guard checks that open HDF5 datasets don't grow with the bucket count (old code: 16 to 124).Also: a float32 matrix with a fill beyond its range now becomes float64 rather than holding infinity. Outer-join var columns/varm/varp still count an input lacking genes as lacking the entry (B3a's rule; documented).
_merge_valuesis kept only as the value-returning wrappertest_commands_coverage.pytests.🤖 Generated with Claude Code