Add sparse-read primitives: shards_initialized and read_regions#4028
Add sparse-read primitives: shards_initialized and read_regions#4028espg wants to merge 6 commits into
shards_initialized and read_regions#4028Conversation
| @@ -0,0 +1,157 @@ | |||
| """Benchmark for sparse-array reads via the chunk-access primitives. | |||
There was a problem hiding this comment.
not sure we want this checked in -- we have a benchmarks directory already, could you see if these code paths are already exercised there? Those benchmarks get run in CI, which is nice.
| return [ | ||
| tuple( | ||
| slice(c * s, min((c + 1) * s, dim)) | ||
| for c, s, dim in zip(coords, shard_shape, array.shape, strict=True) |
There was a problem hiding this comment.
maybe we can re-use the per-shard and per-chunk iteration routines we already have defined?
There was a problem hiding this comment.
for example, I would express "give me all the regions that are initialized" as a filter over iter_shard_regions, where the predicate function is whether the corresponding shard was initialized or not.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #4028 +/- ##
==========================================
+ Coverage 93.55% 93.57% +0.02%
==========================================
Files 88 88
Lines 11896 11934 +38
==========================================
+ Hits 11129 11167 +38
Misses 767 767
🚀 New features to boost your workflow:
|
| if concurrency is None: | ||
| concurrency = zarr_config.get("async.concurrency") | ||
|
|
||
| region_list = await _initialized_regions(array) if regions is None else list(regions) |
There was a problem hiding this comment.
I'm not sure this function should speculatively check which regions are initialized ahead of time. That seems like something the caller should do when coming up with the collection of regions.
| @@ -0,0 +1,194 @@ | |||
| """Tests for the shard-discovery and region-read primitives. | |||
|
|
|||
| These cover :func:`zarr.shards_initialized` (discover which shards/chunks of an | |||
| @@ -0,0 +1,194 @@ | |||
| """Tests for the shard-discovery and region-read primitives. | |||
There was a problem hiding this comment.
I feel like these tests should just be in the same place as all the other array tests? not sure we need a new test file here.
|
I'm not sure this approach would be useful, but we could also frame the question "how should we store our knowledge that a chunk is missing" as a caching problem, and express this in the storage layer by caching missing keys. I'm not sure if our experimental storage cache does this already. |
Related to / closes #3929 (first of two PRs)
Summary
Adds two composable, public functions for efficiently reading sparse arrays — arrays where most chunks are empty and resolve to the fill value:
zarr.shards_initialized(array, *, strategy="auto")— discover which shards (or chunks, when unsharded) actually exist in the store.zarr.read_regions(array, regions=None, *, concurrency=None)— concurrently read and decode array regions — by default only the populated ones — yielding each(region, data)pair spatially resolved to its location in the array.Both are available synchronously (
zarr.*,zarr.api.synchronous) and asynchronously (zarr.api.asynchronous); the asyncread_regionsis a generator that streams each region as soon as its data is available. Nothing about the existingarr[:]path changes — these are additive.Motivation
On a sparse array,
arr[:]pays a store round-trip + codec call for every chunk, including empty ones. In the issue's 49,152-chunk HEALPix example (~3% populated), ~150 s of the 173 s wall time is spent iterating empty chunks with zero useful I/O.These primitives let callers touch only the populated chunks, so cost scales with the populated count rather than the total count.
Design
This follows the direction from the discussion in #3929: rather than mutable state on the array that changes how
__getitem__behaves, expose plain, composable functions -- decomposes into two pieces:Discover the chunks that exist (
shards_initialized). Reported at the granularity of stored objects — shard keys for sharded arrays, chunk keys otherwise — because that is what physically exists in the store and is what a singlelist_prefixreturns. Two strategies, selected bystrategy=:"list"— onestore.list_prefix, filtered to this array's shard grid (ignoreszarr.jsonand any other objects sharing the prefix)."probe"— concurrent per-keyexists()checks; avoids listing a prefix that may hold many unrelated objects, and is faster when there are few possible keys."auto"(default) — probe for small grids, list otherwise.Read + decode those chunks, spatially resolved (
read_regions). Keyed on array regions (a tuple of slices) rather than key strings, on the assumption that regions are the more reusable handle. Reads concurrently and yields(region, data)in completion order. For sharded arrays it yields whole shard regions; empty inner chunks within a populated shard are still skipped efficiently by the existingShardingCodecpartial-decode path.The "pack N decoded chunks into one contiguous array" step that
arr[:]performs is deliberately not forced here — pipelines that operate per chunk skip it for a further performance win. Apack/read_sparseconvenience will follow in a second PR underzarr.experimental.Implementation notes
_initialized_shards) returns(coords, key)pairs;shards_initializedprojects it to keys andread_regionsprojects it to regions, so neither has to reverse-parse the other's output. This mirrors the existing_nchunks_initialized→nchunks_initializedand_iter_*core/wrapper pattern inarray.py._shards_initialized(used bynchunks_initialized/nshards_initialized/info) now delegates to that same core, removing duplicatedlist_prefix-and-intersect logic and incidentally fixing an O(grid×objects) membership check (list → set).API
Benchmarks
bench/empty_chunks.pysweeps chunk count at ~3% sparsity, comparing stockarr[:]againstread_regions+ pack and a per-region stream:LocalStore plateaus around ~10–13×; remote object stores see much more (~64× in the issue's S3 report) because each skipped empty chunk avoids a network round-trip.
Testing
tests/test_chunk_access.py(memory + local stores; unsharded, sharded, 2-D; all-empty / all-populated / sparse layouts):"list"strategy ignores non-chunk objects sharing the prefix;read_regionsoutput reproducesarr[:]byte-for-byte;shards_initialized;concurrency=1paths;Existing
test_array/test_api(incl. the sync/async docstring-match test) andtest_zarrpass unchanged.TODO:
docs/user-guide/*.mdchanges/