Repository navigation
Description
Inspired by zarrs, where you can do a sort of jump-around-slicing, I broadly wish to be able to do the following disjoint slice indexing (although I am suggesting, motivated by this request, a more general API).
The current "public" solution would be to either asyncio.gather a bunch of disparate slice requests or to use numpy indexing if you are not using the async API.
I get better performance by batching together all of the slice requests into one indexer use the pipeline-level parallelism as opposed to top-level parallelism.
import zarr
import numpy as np
import asyncio
import platform
import subprocess
def clear_cache():
if platform.system() == "Darwin":
subprocess.call(['sync', '&&', 'sudo', 'purge'])
elif platform.system() == "Linux":
subprocess.call(['sudo', 'sh', '-c', "sync; echo 3 > /proc/sys/vm/drop_caches"])
else:
raise Exception("Unsupported platform")
class MultiBasicIndexer(zarr.core.indexing.Indexer):
"""An indexer that batches together multiple disparate slicing 1D slicing indexers together into a single indexer"""
def __init__(self, indexers: list[zarr.core.indexing.Indexer]):
self.shape = tuple(
sum(i.shape[k] for i in indexers) for k in range(len(indexers[0].shape))
)
self.drop_axes = indexers[0].drop_axes # maybe?
self.indexers = indexers
def __iter__(self):
total = 0
for i in self.indexers:
for c in i:
gap = c[2][0].stop - c[2][0].start
yield type(c)(c[0], c[1], (slice(total, total + gap)), c[3])
total += gap
z = zarr.create_array("foo.zarr", data=np.random.randn(10_000 * 10_000))
chunk_size = z.chunks[0]
chunks = list(range(0, 15, 2)) # disparate chunks i.e., skipping around by a factor of 2
slices = [zarr.core.indexing.BasicIndexer(slice(i * chunk_size, (i+1)*chunk_size), shape=z.metadata.shape, chunk_grid=z.metadata.chunk_grid) for i in range(0, 15, 2)]
np_indexer = np.concatenate([np.arange(i * chunk_size, (i+1)*chunk_size) for i in range(0, 15, 2)])
async def run():
return np.concatenate(await asyncio.gather(*(z._async_array._get_selection(s, prototype=zarr.core.buffer.default_buffer_prototype()) for s in slices))).reshape(chunk_size * len(chunks))
assert (z[np_indexer] == zarr.core.sync.sync(run())).all()
clear_cache()
%timeit z[np_indexer]
clear_cache()
%timeit zarr.core.sync.sync(run())
clear_cache()
%timeit zarr.core.sync.sync(z._async_array._get_selection(MultiBasicIndexer(slices), prototype=zarr.core.buffer.default_buffer_prototype()))I'm uploading a juv runnable notebook as well with cache clearing built in:
indexing_benchmark.ipynb.zip
So I see about a 40% speedup for the batched indexing vs. asyncio.gather separated indexing (both of which are light years ahead of integer indexing). This is an upper bound - usually, the difference was ~20-30%, but zarrs gives another 20-30% which means we're now close to a 2X speedup.
Thus I see a potential route forward here as making the following public
AsyncArray._get_selection(or something similar, just saying this because it exists)Indexerabc class +ChunkProjectionfor iteration- An
into_indexerfunction that returnsIndexer-based classes for a simple selection (like tuple of slices or so). I think theIndexerreturn type would probably be enough to do useful things since the iterator for allIndexersubclasses returnsChunkProjection, which can be modified as needed (see my motivating example).
Open to other thoughts!
Activity
ilan-gold commentedon Jul 11, 2025
I've been pondering this a bit more. While this solution is definitely faster, that's more a testament to just how slow integer-based indexing can be. For example, in the above example, the slices are perfectly aligned to chunks. But if you were to request, say, multiple slices from within a given chunk, then with the current
MultiBasicIndexersolution, you end up fetching the same chunk multiple times becauseChunkProjectiongoes in one at a time to the codec pipeline tied to both its chunk and the selection, which must be expressable via normal python indexing (i.e., not disparate, maybe uneven, slices).When I account for this locally, making
chunk_sizesmaller (i.e., the size of the slices fetched), the performance gap closes but the indexing via slices is still faster (albeit by less) despite the extra requests for the same on-disk chunk.I added a class (cc @d-v-b for examples maybe! I should re-write using
WrapperStoreyou mentioned on the call) that tracks requests to a store to check thisand can see that the sliced-based indexing makes multiple requests for the same chunk (I didn't have any reason to believe this optimization existed in
zarr-pythonbut wanted to check).This also brings up an issue that @d-v-b has mentioned before, of tying stores to arrays. At the moment, I don't think we can cache uncompressed chunks before indexing. We could cache the chunks themselves, compressed, via a cache-store though (however, this would still require repeated decompression).
That being said, this sort of thing does exist in
zarrsviapartial_decoderor with user-handled chunk caching, see: https://docs.rs/zarrs/latest/zarrs/array/struct.Array.html?search=cache#optimising-readsI would venture a guess that this is a non-trivial portion of the speed improvement we see with
zarrs(although likely not all).So I think something concrete would be for zarr-python to somehow cache decompressed chunks within a single
read_batchcall from the codec pipeline.At the moment, I am not sure how this sort of thing would actually change performance without a more general API like the one proposed above, but I think this is a nice diagnostic.
d-v-b commentedon Jul 11, 2025
i don't see why the codec pipeline couldn't be responsible caching this stuff -- I'd say caching falls under the remit of "find the most efficient way to get me this chunk N times", which is arguably the job of the codec pipeline
ilan-gold commentedon Jul 28, 2025
So I've been doing some further thinking about this issue. I think that whatever the solution ends up being, we would want some sort of pipeline-level cache similar to https://docs.rs/zarrs/latest/zarrs/array/struct.Array.html?search=cache#optimising-reads.
Creating a top-level multi-slice iterator-like API
One option discussed in the meeting on Friday was to create a top-level API that takes in slice-like selections (although, really, could be any sort of index selection) and the outputs one-array-per-selection. Pretty simple! This would be instead of making public the above requested APIs (i.e.,
Indexeretc.).Downsides
readcall. This paradigm works for fine for the exemplar 1D case where the output buffer can be contiguous and written to with expected results, but breaks down in more dimensions (i.e., how do you write(2,2)selection followed by a(3,3)selection?). But implementing a cache that lives as long as a givenBatchedCodecPipeline.readmeans that the cache is tied to having a single output buffer, which means we can't cache between different selections unless we extend theBatchedCodecPipeline.readandBatchedCodecPipeline.read_batchAPIs to accept a cache argument (and then implement some sort of known API around the cache, which we wouldn't have to do if it were internal).Upsides
asyncio.gatherunless we implement internal caching (see above)Using
Awkward.ArrayA more flexible option could be something like:
Indexer,_get_selectionetc.zarr-python)So my thought goes something like this:
Downsides
shapecurrently is required by bothNDBufferandIndexer, but of course does not really apply as a concept toAwkward.Arrayat least given the current typing (i.e., the current typing does not allow for ragged arrays).zarr-pythonwithout accepting a new dependencyUpsides
Awkward.Arraywould likely allow allow us to save on memory compared to the current pure-iteration approach because the output buffer would be anAwkward.Arraythat is built up instead of allocating a new buffer for every selection that is identical to the output of the selection itself inreadi.e., the decompressed selection would fill the entire output buffer, which means the output buffer is redundant. This in theory could be solved by Codec pipeline performance #2904 (comment)awkward.Array