Skip to content

Release 0.6.1: sparse outputs at their real size, and split in one pass - #19

Merged
Claptar merged 3 commits into
mainfrom
fix/subset-split-dtypes-single-pass
Sep 24, 2026
Merged

Claptar merged 3 commits into
mainfrom
fix/subset-split-dtypes-single-pass

Conversation

@Claptar

@Claptar Claptar commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

Fixes two defects that predate 0.6.0, both visible in the 0.6.0 benchmark, and bumps the version to 0.6.1.

Sparse outputs were about twice the size they should be

Every sparse output of subset, split and concat had two problems:

  • indices and indptr were widened to int64, whatever the source used.
  • The source's compression was dropped, because the growable datasets they were written into forwarded no storage settings. With an lzf input, data and indices were written uncompressed.

convert already did this correctly. The fix moves its helpers (_sparse_dataset, _growable_like) into subset.py, and all three commands now use them.

  • subset and split keep the source's dtypes. That is always safe, because a subset has no more nonzeros and no larger coordinates than its source.
  • concat uses the widest dtype among its inputs. It widens to int64 only when the combined nnz or the minor dimension needs it (_concat_index_dtypes).

split read the whole matrix once per group

split ran one subset for each group. When groups are interleaved through the file, as samples usually are, each subset read nearly all of X.

  • One read for all groups. The matrix writers now take a list of outputs and share each source block among them. split_store makes a single split_h5ad call. Open outputs are capped at a quarter of the file-descriptor limit, with one pass per batch beyond that.
  • Vectorised row gather. Rows are now gathered in one step, which replaces a Python loop over rows. subset gets this too, and it now skips blocks that hold none of the selected rows.
  • No more h5py fancy indexing for dataframe columns. It cost 0.38 s per column per group on 50k cells, against 5 ms for the whole column. Columns are now read as contiguous blocks and the rows selected in memory.

Results

ci tier (50k × 20k, lzf), 3 repeats. All "now" figures are from one laptop; the 0.6.0 figures are from the CI runner.

Case 0.6.0 Now: adata-cli Now: anndata
split-by-sample 56.7 s, 630.9 MB 1.6 s, 302.6 MB 2.3 s, 301.7 MB
subset-query 309.7 MB 1.0 s, 147.1 MB 1.4 s, 146.6 MB
concat-inner 1232 MB 3.0 s, 583 MB 2.2 s, 832 MB (concat_on_disk)

The tag will rerun this on the runner and publish it to docs/BENCHMARKS.md.

Behaviour change

Row selections passed to the subset writers must now be sorted and unique, and anything else raises ValueError. Every command already produces sorted selections, and h5py required the same for fancy indexing.

Tests

  • New file tests/test_subset_fan_out.py:
    • dtype and compression preservation for CSR, CSC, h5ad and zarr
    • the gather checked against scipy with random selections, straddling block boundaries
    • split output equal to per-group subset output, and to anndata slicing, for CSR, CSC and dense, h5ad and zarr, obs and var
    • batched output equal to a single pass
    • concat dtypes, and the blocked row read
  • New guard in test_performance.py: split's reads of X must not grow with the number of groups. On the old per-group split it fails with 32× growth.
  • 1034 unit tests pass locally (-m "not integration").

After merge

Tagging 0.6.1 on main fires publish.yml (PyPI), quay-on-tag.yml (image) and benchmark.yml.

🤖 Generated with Claude Code

Claptar and others added 2 commits September 24, 2026 15:33
Every sparse output of subset, split and concat widened `indices` and
`indptr` to int64 and dropped the source's compression, because the
growable datasets they wrote into forwarded no storage settings. Outputs
came out about twice the size anndata writes: 309.7 MB against 146.6 MB
for subset in the 0.6.0 benchmark. They now keep the source's dtypes and
layout, through the helpers convert already used, moved into subset.py.
concat widens to int64 only when the combined matrix needs it.

split ran one subset per group. With interleaved groups each subset read
the span of its own rows, which was nearly all of X, so X was read k
times. The matrix writers now take a list of outputs and share each
source block among them; split_store makes one split_h5ad call, batched
by the file-descriptor limit. Rows are gathered in one vectorised step
rather than a Python loop.

Dataframe columns were read through an h5py fancy index, which cost
0.38 s per column per group on 50,000 cells. They are now read as
contiguous blocks and selected in memory.

ci tier, locally: split 56.7 s -> 1.6 s (anndata 2.3 s), outputs within
0.3% of anndata's size for split, subset and concat.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A patch: two fixes to existing commands and no new surface. The one
behavioural change is that a row selection passed to the subset writers
must be sorted and unique; every command already produced one, and
h5py already required it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 24, 2026 14:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-24T14:38:08.171710Z d0b59e7 PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@Claptar Claptar self-assigned this Sep 24, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d0b59e740c

ℹ️ 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".

Comment thread src/adata/core/concat.py Outdated
Comment on lines +403 to +410
limit = np.iinfo(np.int32).max
indices = np.result_type(*[s["indices"].dtype for s in sources])
indptr = np.result_type(*[s["indptr"].dtype for s in sources])
nnz = sum(int(s["data"].shape[0]) for s in sources)
if n_minor > limit:
indices = np.result_type(indices, np.int64)
if nnz > limit:
indptr = np.result_type(indptr, np.int64)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Widen from the actual source dtype limits

When valid sparse inputs use an index dtype narrower than int32, the overflow checks still compare only against int32's limit. For example, concatenating two matrices whose indptr arrays are int16 and contain 20,000 nonzeros each leaves pointer_dtype as int16; the final 40,000 offset is then silently wrapped by _write_indptr. Remapped coordinates can overflow similarly when the union exceeds the source indices dtype. Since write_sparse preserves arbitrary supplied integer dtypes, compare the combined bounds with np.iinfo(indices/indptr).max and widen as needed.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9124367. Both dtypes are now checked against their own iinfo(...).max and widened to the narrowest of int32/int64 that holds the bound, so the int16 case you describe gives an int32 indptr. It's covered by a unit test and by an end-to-end concat of two int16 inputs, and that test fails on the previous commit.

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (py3.13)

1 036 tests  +40   1 036 ✅ +40   4m 27s ⏱️ + 1m 27s
    1 suites ± 0       0 💤 ± 0 
    1 files   ± 0       0 ❌ ± 0 

Results for commit 9124367. ± Comparison against base commit 8868fde.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (py3.12)

1 036 tests  +40   1 036 ✅ +40   3m 24s ⏱️ -49s
    1 suites ± 0       0 💤 ± 0 
    1 files   ± 0       0 ❌ ± 0 

Results for commit 9124367. ± Comparison against base commit 8868fde.

♻️ This comment has been updated with latest results.

`_concat_index_dtypes` only compared the combined nnz and the minor
dimension with int32's maximum, so inputs with a narrower index dtype
kept it past its range: two int16 `indptr`s of 20,000 nonzeros each gave
an int16 output whose 40,000 offset wrapped silently. Each is now checked
against the chosen dtype's own limit and widened to the narrowest of
int32/int64 that holds it.

Found by Codex review on #19.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Claptar
Claptar merged commit ccb580a into main Sep 24, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants