Skip to content

feat: serve sorted integer-array selections through zarrs - #189

Draft
selmanozleyen wants to merge 4 commits into
zarrs:mainfrom
selmanozleyen:feat/integer-array-indexing
Draft

feat: serve sorted integer-array selections through zarrs#189
selmanozleyen wants to merge 4 commits into
zarrs:mainfrom
selmanozleyen:feat/integer-array-indexing

Conversation

@selmanozleyen

@selmanozleyen selmanozleyen commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Hi, @ilan-gold @LDeakin,

This PR is currently blocked by #190 and #191

I'd like to expose zarrs-python for the case when the integer indices are sorted instead of falling back. This is possible thanks to zarr-developers/zarr-python#4172. This is actually a realistic and a possibly very useful case for anndata and annbatch! It can't be used right away but it doesn't require much changes to utilize it, I linked the branches under the details.

The setup: 14 sharded stores (Tahoe-100M, CSR X, 100.6M rows,
146e9 nnz, ~89.4M-element shards, 64-row inner chunks).

The loader: Per batch: 1024 random rows
drawn across all 14 stores, (chunk_size=1, batch_size=1024, preload_nchunks=8192
in annbatch), 100 batches = 102,400 rows.

Results

arm rows/s total
upstream main 749 136.7 s
integer_array_indexing=True 4,053 25.3 s

Current status: if many runs hit same chunk it regresses due to deduplication of reads. But it might also regress in any local system atm. I only ran these numbers on lustre

Details

After this I will give a shot at #182, and in my AI draft at least it shows some stuff can be improved.

arm rows/s total first batch
integer_array_indexing only (branch A) 4,353 23.5 s 5.92 s
+ shard_index_cache_size=512 4,713 21.7 s 5.45 s
+ plan_reads 5,274 19.4 s 5.15 s
+ plan_reads_fetch_threads=32 5,331 19.2 s 5.15 s

Refs measured:

repo baseline branch (zarrs crate 0.23.6)
zarrs-python zarrs/zarrs-python@3345fd8 selmanozleyen/zarrs-python@07f9326 feat/integer-array-indexing and @1d284db feat/planned-reads-only
anndata scverse/anndata@2b43cc4 selmanozleyen/anndata@a4720ff feat/async-csr-row-read
annbatch scverse/annbatch@19d8b6e selmanozleyen/annbatch@7da8023 feat/anndata-async-csr-fetch

A dependency bump and the API migration it forces, and nothing else. Kept on its own
because a dependency migration reviewed together with a behaviour change means neither can
be reverted alone.

All of it is one upstream idea -- the #411 refactor -- which is that a codec chain is BOUND
to a data type and fill value once, where it is built, instead of being handed both at every
call. `CodecChain` is now unbound; `decode`, `encode`, `decode_into`, `partial_decoder` and
`recommended_concurrency` live on `CodecChainBound`, reached by `with_context`. So the chain
is bound in the constructor and five call sites stop passing a pair they no longer need.

`zarrs::array::StoragePartialDecoder` is also gone, and nothing named replaced it: the
(storage, key) TUPLE is the store-backed BytesPartialDecoderTraits implementation now.

No behaviour change, with one exception worth naming rather than hiding: a codec chain that
cannot BIND to its data type now fails in the constructor as a TypeError, where before it
surfaced at the first read as a RuntimeError. Binding has to happen somewhere, and the
constructor is the only place it can. The codec metadata is still PARSED where it was, so
an array with both bad codecs and a bad fill value still reports the codecs.

The dependency is a git rev rather than a version, because 0.24 is not released yet, and it
carries a [patch.crates-io] block for zarrs_storage. That patch is load-bearing, not
cosmetic: zarrs_opendal and zarrs_object_store track RELEASED zarrs, so they pull
zarrs_storage from crates.io while zarrs comes from git -- two copies of one crate, two
distinct AsyncReadableStorageTraits, and trait bounds that cannot be satisfied. Patching
that single crate collapses the graph; every other zarrs crate already resolves through the
git checkout, verified from a clean lockfile. Both the pin and the patch are commented with
exactly what to do on release day, and nothing in src/ changes then.
@selmanozleyen
selmanozleyen force-pushed the feat/integer-array-indexing branch from bf4e570 to a52c2aa Compare August 28, 2026 13:25
zarr-python enforces read-only in the store itself -- `Store._check_writable`, reached from
the concrete store's `_set`. This pipeline never gets there: it is handed a `StoreConfig` and
builds its own Rust store, writable whatever mode the array was opened in, so a write to a
`mode="r"` array succeeds here and raises through the default pipeline. Silent divergence,
and silent data loss for anyone relying on the mode.

The flag has to be carried from Python because nothing on the Rust side can infer it: the
mode is zarr-python's, not the store's.

One guard, at the only entry point Python can write through. `store_chunk_bytes` and
`store_chunk_subset_bytes` are reached only from it.

The tests open STRICT. zarr's own refusal message is byte-identical to the guard's, so with a
fallback available they would pass whether the guard fired or zarr-python served the write.
…t all

A sorted integer-array selection is mostly runs of consecutive indices, and a run is a
slice. Split into runs, each one is a contiguous box the existing read path already knows
how to serve.

Without this a fancy index that is not contiguous is declined outright and the whole read
falls back to zarr-python's pipeline -- so the shape a row-sampling data loader produces
never reached zarrs, however well zarrs could have served it.

Reads and writes stop sharing one description function, because they now want different
ones. A read is split into runs; a WRITE must not be. Splitting a write can put two items
on one chunk key, and the write path is read-modify-write, so two items on one key race.

Indices are normalised to int64 first, which two dtypes needed for different reasons. A
uint8 selection like [255, 0] differenced to 1, read as consecutive, and returned an empty
slice -- wrong data, with no error. And a BOOLEAN MASK is not an index array at all: cast
blindly it becomes [1, 1, ...], which is non-decreasing and exactly as long as the output
slice, so it passed every test here and read element 1 once per True. A mask's POSITIONS
are what it means, so that is what it is turned into -- which also lets a mask take this
path rather than falling back.
@selmanozleyen
selmanozleyen force-pushed the feat/integer-array-indexing branch from a52c2aa to ee7d47e Compare August 28, 2026 15:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant