Skip to content

Commit d13d367

Browse files
authored
Merge branch 'main' into claude/optimistic-diffie-2ef839
2 parents 9b14db0 + ef6cfe2 commit d13d367

37 files changed

Lines changed: 2594 additions & 667 deletions

‎.github/workflows/zarr-indexing.yml‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,10 @@ jobs:
4343
uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4
4444
- name: Set up Python ${{ matrix.python-version }}
4545
run: uv python install ${{ matrix.python-version }}
46-
# The transform tests exercise chunk resolution against zarr's ChunkGrid,
47-
# so they run against the repo-root environment (which provides `zarr`)
48-
# with this package as an editable overlay rather than in package
49-
# isolation. The recipes carry that invocation; this step only fixes the
50-
# interpreter the matrix asked for.
46+
# The suite imports nothing from `zarr`; it runs against the repo-root
47+
# environment, with this package as an editable overlay, to share the
48+
# parent project's pinned test toolchain. The recipes carry that
49+
# invocation; this step only fixes the interpreter the matrix asked for.
5150
- name: Sync test dependency group
5251
run: uv sync --project ../.. --group test --python ${{ matrix.python-version }}
5352
- name: Run pytest

‎changes/4284.bugfix.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed a `ValueError` when setting an orthogonal selection on a sharded array where more than one dimension is indexed by an array. The sharding codec re-derives an indexer from the chunk selection it is handed, which turns such a selection into a coordinate selection addressing the value buffer flat, so the write failed on a shape mismatch. Both partial-encode paths are fixed, so the write works under either codec pipeline.

‎changes/4316.bugfix.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed a `ValueError` when setting an orthogonal selection on a sharded array that mixes an integer index with two or more array indices, such as `a.oindex[[3, 1, 2], 1, [0, 2]] = value`. The fix for the array-only case in #4284 reshaped the value only when its shape matched the coordinate selection exactly; the sharding codec now also ravels a value that is the selection's shape minus the integer-indexed axes. Values of any other rank are left alone, so a write that is invalid on an unsharded array fails the same way on a sharded one. Both partial-encode paths share one helper for this.

‎packages/zarr-indexing/README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ Key types:
2525
- `ChunkPlan` and `ChunkProjection` — lazily partition a selection over a
2626
caller-selected grid and pair each chunk-local transform with its placement in
2727
the request, without binding a storage backend or scheduler
28+
- `GridPartition` — the plan's factored, columnar form: one table per axis
29+
(`StridedSet`, `IndexedSet`) plus `joint_sets` for connected index-array components,
30+
from which projections are derived on demand
2831
- `ConstantMap`, `DimensionMap`, `ArrayMap` — the three ways a single output
2932
dimension can depend on the input
3033
- `compose` — chain two transforms into one
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Chunk planning benchmarks
2+
3+
Run from the repository root, with `zarr-indexing` installed in the selected
4+
Hatch environment:
5+
6+
```sh
7+
hatch run test.py3.12-minimal:python packages/zarr-indexing/benchmarks/chunk_planning.py --repeats 9
8+
```
9+
10+
`chunk_planning.py` measures construction, projection traversal, local-column
11+
access, and chunk-coordinate enumeration. See the script for workloads and output fields.
12+
13+
Use the same interpreter, dependencies, inputs, and script revision when
14+
comparing checkouts. Put the intended checkout's package source on `PYTHONPATH`;
15+
installed package metadata is also required for version lookup. Record both git
16+
revisions and the interpreter/dependency versions alongside saved output.
17+
18+
Compare complete operations, not only constructor times: plans can defer work
19+
until iteration. Measure allocation separately from elapsed time. Distinguish
20+
streaming consumption from retaining all projections or coordinates, and report
21+
whether input arrays and transform construction are included. Repeated local
22+
column access measures cache reuse, which trades allocation against retained
23+
memory. Bounded coordinate batches avoid constructing the full coordinate array.
24+
25+
These scripts measure planning rather than codec or storage throughput. Repeat
26+
measurements with alternating operation order before interpreting small timing
27+
differences. Preserve raw benchmark output as an experiment artifact rather than
28+
accumulating successive result tables in this README.
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""Reproducible planning measurements; no storage I/O or timing assertions.
2+
3+
Run with the package on PYTHONPATH in the repository's Hatch test environment.
4+
Use the same interpreter and this script with the old/new package paths to compare.
5+
The connected-components probe is deliberately not a production planner: it
6+
measures independent table construction, without defining a new public row API.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import argparse
12+
import json
13+
import statistics
14+
import time
15+
import tracemalloc
16+
from typing import TYPE_CHECKING, Any
17+
18+
import numpy as np
19+
20+
from zarr_indexing import ArrayMap, IndexDomain, IndexTransform, plan_chunks
21+
from zarr_indexing.grid import dimension_grids_from_chunks
22+
23+
if TYPE_CHECKING:
24+
from collections.abc import Callable
25+
26+
27+
def measure(operation: Callable[[], Any], repeats: int) -> dict[str, float]:
28+
samples = []
29+
for _ in range(repeats):
30+
start = time.perf_counter()
31+
operation()
32+
samples.append(time.perf_counter() - start)
33+
tracemalloc.start()
34+
result = operation()
35+
_, peak = tracemalloc.get_traced_memory()
36+
tracemalloc.stop()
37+
del result
38+
return {"median_ms": statistics.median(samples) * 1000, "peak_mib": peak / 2**20}
39+
40+
41+
def consume(values: Any) -> int:
42+
return sum(1 for _ in values)
43+
44+
45+
def main() -> None:
46+
parser = argparse.ArgumentParser(description=__doc__)
47+
parser.add_argument("--repeats", type=int, default=5)
48+
args = parser.parse_args()
49+
results: dict[str, Any] = {}
50+
indices = np.arange(1000)
51+
cases = [
52+
("box", IndexTransform.from_shape((1000, 1000)), (10, 10), (1000, 1000)),
53+
(
54+
"sorted",
55+
IndexTransform.from_shape((1_000_000,)).oindex[np.arange(1_000_000)],
56+
(100_000,),
57+
(1_000_000,),
58+
),
59+
(
60+
"correlated",
61+
IndexTransform.from_shape((10_000, 10_000)).vindex[indices * 9, indices * 9],
62+
(10, 10),
63+
(10_000, 10_000),
64+
),
65+
(
66+
"correlated_slab",
67+
IndexTransform.from_shape((1000, 1000, 1000)).vindex[indices, indices, :],
68+
(1000, 1000, 1000),
69+
(1000, 1000, 1000),
70+
),
71+
]
72+
for name, transform, chunks, shape in cases:
73+
grids = dimension_grids_from_chunks(chunks, shape=shape)
74+
results[name] = measure(
75+
lambda transform=transform, grids=grids: consume(plan_chunks(transform, grids)),
76+
args.repeats,
77+
)
78+
79+
transform = IndexTransform.from_shape((10_000,)).oindex[np.arange(10_000)]
80+
grids = dimension_grids_from_chunks((10,), shape=(10_000,))
81+
(table,) = plan_chunks(transform, grids).partition().sets
82+
83+
def read_local_rows() -> None:
84+
for row in range(len(table)):
85+
table.local[table.run(row)]
86+
87+
results["local_rows"] = measure(read_local_rows, args.repeats)
88+
part = plan_chunks(
89+
IndexTransform.from_shape((100, 100, 100)),
90+
dimension_grids_from_chunks((1, 1, 1), shape=(100, 100, 100)),
91+
).partition()
92+
results["all_coordinates"] = measure(part.chunk_coords, args.repeats)
93+
94+
# TensorStore factors connected components in the input/grid dependency
95+
# graph. Here outputs 0 and 1 depend on u, while output 2 depends on v.
96+
# A single joint block unnecessarily expands these independent components.
97+
i = np.arange(1000)
98+
transform = IndexTransform(
99+
IndexDomain.from_shape((1000, 1000)),
100+
(ArrayMap(i[:, None]), ArrayMap(i[:, None]), ArrayMap(i[None, :])),
101+
)
102+
grids = dimension_grids_from_chunks((1000, 1000, 1000), shape=(1000, 1000, 1000))
103+
results["component_partition"] = measure(
104+
lambda: plan_chunks(transform, grids).partition(), args.repeats
105+
)
106+
results["component_walk"] = measure(
107+
lambda: consume(plan_chunks(transform, grids)), args.repeats
108+
)
109+
base = IndexTransform.from_shape((1000, 1000, 1000))
110+
results["component_vindex_compile"] = measure(
111+
lambda: base.vindex[i[:, None], i[:, None], i[None, :]], args.repeats
112+
)
113+
left = IndexTransform(IndexDomain.from_shape((1000,)), (ArrayMap(i), ArrayMap(i)))
114+
right = IndexTransform(IndexDomain.from_shape((1000,)), (ArrayMap(i),))
115+
results["independent_components_probe"] = measure(
116+
lambda: (
117+
plan_chunks(left, grids[:2]).partition(),
118+
plan_chunks(right, grids[2:]).partition(),
119+
),
120+
args.repeats,
121+
)
122+
print(json.dumps(results, indent=2))
123+
124+
125+
if __name__ == "__main__":
126+
main()
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
Chunk plans now have a factored, columnar form. `ChunkPlan.partition()`
2+
returns a `GridPartition`: one `StridedSet` or `IndexedSet` table per output
3+
dimension the transform reads independently, plus `joint_sets`, one `JointSet`
4+
per connected component of index arrays. Arrays that share request axes are
5+
sorted into chunks together; independent ones stay in separate tables, so
6+
`(u, v) -> (a[u], b[u], c[v])` stores 3,000 index values rather than
7+
3,000,000. A `ChunkProjection` is one row of each table and is derived on
8+
demand, so planning costs the sum of the touched chunks per axis rather than
9+
their product, and a vectorized consumer can read the tables (`chunk_coords()`,
10+
the CSR columns, the memoized read-only `local` coordinates) without
11+
materializing a projection per chunk.
12+
13+
The projections a plan yields map the same cells to the same storage as
14+
before. Correlated projections now keep residual slices symbolic and index
15+
arrays compact along axes they do not vary over, and `vindex` compilation
16+
preserves singleton axes for the same reason.
17+
18+
A hand-built transform in which two output maps read one input axis (a
19+
diagonal, which no selection produces) is rejected with `ValueError`; the
20+
whole-transform walk that previously served it produced wrong projections.

‎packages/zarr-indexing/docs/api/index.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@ links:
1111
[Lazy views compose](../guide/index.md#lazy-views-compose),
1212
then open [`zarr_indexing.lazy_array`](lazy_array.md) for `LazyArray`.
1313
- **Integrate a chunked source:** finish
14-
[One cell domain, two projections](../guide/index.md#one-cell-domain-two-projections),
14+
[A plan is a product of per-axis tables](../guide/index.md#a-plan-is-a-product-of-per-axis-tables),
1515
then open [`zarr_indexing.chunk_resolution`](chunk_resolution.md) for
16-
`plan_chunks`. Start with
16+
`plan_chunks` and `GridPartition`. Start with
1717
[Coordinates are addresses](../guide/index.md#coordinates-are-addresses) if
1818
literal coordinates are unfamiliar.
1919

@@ -36,7 +36,10 @@ and the wire format built on top of it.
3636

3737
- [`zarr_indexing.chunk_resolution`](chunk_resolution.md) —
3838
`plan_chunks`, which lazily projects a request through a caller-selected grid,
39-
plus the reusable `ChunkPlan` and paired-transform `ChunkProjection` values
39+
the reusable `ChunkPlan` and paired-transform `ChunkProjection` values, and
40+
the plan's factored form: `GridPartition` (from `ChunkPlan.partition`),
41+
holding one `StridedSet` or `IndexedSet` table per axis and `joint_sets`
42+
for connected index-array components
4043
- [`zarr_indexing.grid`](grid.md) — `DimensionGridLike`, the Protocol
4144
describing the narrow chunk-grid surface chunk resolution consumes, so that
4245
nothing here imports `zarr`, plus `EdgeDimensionGrid` and

‎packages/zarr-indexing/docs/design-notes.md‎

Lines changed: 39 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -35,27 +35,21 @@ about the deliberately matching semantics:
3535
discriminator, and `tests/test_ndsel_tensorstore.py` loads our bodies into
3636
`tensorstore.IndexTransform(json=...)` and round-trips them back through our
3737
engine layer.
38-
39-
The representations differ in one place: index arrays. Both models want an index
40-
array at the transform's full input rank, with singleton axes for the dimensions
41-
a map does not vary over. TensorStore enforces it — its JSON parser rejects a
42-
rank-1 array over a rank-2 domain outright, with `Index array for output
43-
dimension 0 has rank 1 but must have rank 2` (checked against tensorstore
44-
0.1.84) — while our loader is the more permissive of the two and also accepts a
45-
lower-rank array that broadcasts against the input domain. That is a
46-
compatibility affordance, not a difference in the model: ndsel leaves index-array
47-
rank to [the engine layer](ndsel.md#lowering-to-a-transform), and everything the
48-
algebra builds itself is at full rank.
49-
50-
The reason full rank matters here is that we *derive* meaning from those
51-
singletons rather than merely tolerating them: an array full-sized on one axis
52-
and singleton elsewhere is orthogonal, and one varying over several shared axes
53-
is vectorized, so the distinction is readable off the shape — and the shape is
54-
the *only* place it lives. An earlier `ArrayMap.input_dimension` field pinned
55-
the orthogonal axis redundantly and was retired: the one shape it disambiguated
56-
(a single-coordinate array, all axes singleton) is now normalized away at
57-
construction, collapsed to the `ConstantMap` it equals, exactly as
58-
[the serializer](api/json.md) has always collapsed it on the wire.
38+
- **Chunk partitioning.** Both factor a transform over a grid before visiting
39+
any cell, rather than intersecting the whole transform with each chunk.
40+
TensorStore's `IndexTransformGridPartition` holds strided sets and index
41+
array sets and derives a per-cell transform on iteration;
42+
[`GridPartition`](api/chunk_resolution.md) also partitions index-array
43+
dependency components. Its `StridedSet` is per storage axis, where TensorStore's
44+
is per input dimension and spans every grid axis reading it (which is how
45+
TensorStore factors diagonals). `joint_sets` holds one `JointSet` per
46+
connected index-array component, including independent single-array
47+
components within a mixed request. Both derive the per-chunk transforms
48+
from the partition ([the guide](guide/index.md#a-plan-is-a-product-of-per-axis-tables)
49+
shows the tables). TensorStore keeps strided sets implicit, while this
50+
library materializes their per-axis rows for vectorized consumers. Diagonals are rejected here; supporting them needs a strided set per
51+
*input* dimension spanning every storage axis that reads it, TensorStore's
52+
representation.
5953

6054
Four deliberate differences:
6155

@@ -85,11 +79,14 @@ safe, `partial` proves it is not, and `unknown` conservatively covers fancy
8579
selections whose duplicates would require additional work to classify.
8680

8781
The comparison also runs the other way. TensorStore is a mature, heavily
88-
optimized C++ system whose performance this library cannot approach: resolution
89-
here is Python-level bookkeeping over NumPy, and the per-part overhead is
90-
significant. This library is small and depends on nothing beyond NumPy, so the
91-
algebra can be adopted by a Python project that wants the model without the C++
92-
runtime.
82+
optimized C++ system whose performance this library cannot approach. Independent strided planning
83+
here is per axis, but each materialized `ChunkProjection` is
84+
Python-level bookkeeping over NumPy — two domains, two transforms and the
85+
projection itself — so the per-part overhead of the object view is
86+
significant; a consumer that reads the partition's tables directly pays no
87+
per-chunk object construction. This library is small and depends on nothing beyond
88+
NumPy, so the algebra can be adopted by a Python project that wants the model
89+
without the C++ runtime.
9390

9491
## Bounding-box selections vs query selections
9592

@@ -277,15 +274,20 @@ rewritten in place. Resolution classifies the result by structure
277274
(`index_array_structure`): pure per-axis outer products keep the orthogonal
278275
resolvers, and everything else — correlated maps, mixtures, index arrays
279276
sharing an input axis (a diagonal gather, reachable only by hand-building a
280-
transform) — takes the pointwise path that collapses the joint block.
277+
transform) — takes the general reader/intersection path. Chunk planning
278+
factors index arrays into connected dependency components before flattening,
279+
so independent groups do not expand one another. Vectorized selection preserves
280+
broadcast singletons to retain those dependencies.
281281

282282
Three limits remain, all intentional and all expected to be lifted:
283283

284-
- **Affine diagonals.** A hand-built transform in which an *index array* and a
285-
*slice map* bind the same input dimension, or two slice maps share one, is
286-
rejected at resolution with `NotImplementedError`. No selection dialect
287-
produces one; supporting them means lowering the slice maps into the joint
288-
block too. *Planned.*
284+
- **Affine diagonals.** A hand-built transform in which two output maps read
285+
one input dimension — two slice maps, or a slice map and an orthogonal index
286+
array — is rejected at planning with `ValueError`; a correlated index array
287+
varying over a dimension a slice map also reads is rejected with
288+
`NotImplementedError`. No selection dialect produces either. Supporting them
289+
needs a strided set per *input* dimension spanning all dependent storage
290+
axes, TensorStore's connected-component representation. *Planned.*
289291
- **Finite explicit bounds only.** `IndexDomain` has no implicit or unbounded
290292
dimensions; the message layer will normalize a body with `"-inf"`/`"+inf"`
291293
bounds, but the engine layer refuses to lower one into a transform.
@@ -294,3 +296,8 @@ Three limits remain, all intentional and all expected to be lifted:
294296
dimension labels and the wire format round-trips them, but indexing
295297
operations build new domains without them, so a label does not survive a
296298
slice. *Planned.*
299+
300+
## Selection to chunk operations
301+
302+
[The selection-flow guide](guide/selection-flow.md) traces normalization,
303+
partitioning, and paired chunk-local/request projections with executable examples.

0 commit comments

Comments
 (0)