Skip to content

perf(zarr-metadata): read a document in one refine walk, with lazy model keys - #407

Merged
d-v-b merged 14 commits into
feat/zarr-metadata-document-context-stackfrom
claude/zarr-metadata-read-perf-52cd33
Oct 22, 2026
Merged

d-v-b merged 14 commits into
feat/zarr-metadata-document-context-stackfrom
claude/zarr-metadata-read-perf-52cd33

Conversation

@d-v-b

@d-v-b d-v-b commented Oct 18, 2026

Copy link
Copy Markdown
Owner

🤖 AI text below 🤖

Reading a metadata document is 2.5 to 4 times faster: a document is refined to JSON once, the model holds what was refined, and a model's key -- what == and hash compare -- is computed the first time the model is compared rather than when it is built. A definition's canonical is therefore first asked when a model is first compared. Every problem is reported as before, with the same loc, kind, message and input.

Timings

Per read, best of ten timeit repeats, Python 3.12, PYTHONHASHSEED=0, Apple M5, on documents as zarr==3.4.1 writes them (zarr.create_array(MemoryStore(), shape=(100, 100), chunks=(10, 10), dtype="f8") as v3 and v2, and a v3 group of 200 int32 arrays consolidated with zarr.consolidate_metadata), each round-tripped through json.dumps/json.loads:

case before (c414f94) after speedup
ZarrV3ArrayMetadata.from_json 194.1 µs 72.5 µs 2.7x
ZarrV2ArrayMetadata.from_json 75.0 µs 18.2 µs 4.1x
ZarrV3GroupMetadata.from_json, 200 consolidated arrays 37.4 ms 14.7 ms 2.5x

For scale, zarr-python 3.4.1's own ArrayV3Metadata.from_dict reads the v3 array document in about 23 µs on the same machine. The benchmark is committed as tests/benchmarks/read_documents.py; run it from the package directory with PYTHONHASHSEED=0 uv run --group test python -m tests.benchmarks.read_documents (--profile prints a cProfile of the v3 array read). A smoke test reads each case once so the script keeps working.

Where the time went, and what changed

Profiling 1000 v3 array reads on the base branch put 55% of the time in resolve for the five extension points, 23% in _refine (58 calls per document: each field refined inside resolve, then the whole document refined again to build the model's document), 13% in the model's _adopt (key and claims computed at construction), 6% in json.dumps for the eager JSON-text keys, and 5% in Scope.format. The compiled type checker was about 4% and is untouched.

  • One refine walk. read_array_v3, read_array_v2 and the group reader refine each member where they read it, hand the refined value to the new internal resolve_refined (what resolve is after it has refined), and assemble the document the model holds from what was refined, in the document's own order. ArrayMembersV3, ArrayMembersV2 and GroupMembersV3 carry that document; consolidated children's documents are taken from their members, so a group of 200 arrays is no longer refined a second time as a whole. refined_object is no longer called on the read paths.
  • Lazy keys and claims. Keyed._model_key computes _key_of() the first time ==, hash, or a holding model asks, and keeps it; claims likewise. A model that is only read pays nothing for them. (This surfaced a latent sharing bug in ZarrV2GroupMetadata.__init__, which kept the caller's nested attribute containers; it now holds the refined document's.)
  • Caches of what never changes. Context._format is computed once per scope in __post_init__; as_kind remembers its answer per way a kind is named; kind_of walks the MRO once per definition class; well_named is an lru_cache over names; json_text reuses one JSONEncoder; the get_args(...) calls in storage_of, the float fill-value rules and ValidationProblem.__post_init__ are module constants.
  • Cheaper walks. _refine and frozen tell the exact types json.loads builds apart by type() before falling back to the ABC isinstance checks (subclasses, mapping proxies and other sequences take the same path as before); frozen keeps scalar leaves without a call per leaf; _put_back walks nothing when a configuration holds no nested field; one_of checks a frozenset of (type, value); within and _located return at once for no problems.

Verification

  • just test (2543 passed, 1 skipped), just lint, just typecheck (pyright 1.1.x on 3.11, 0 errors) and just docs-check all pass.
  • A differential run of the base branch's sources against this branch over 25,780 documents -- the three benchmark documents, every JSON fixture under tests/, and systematic mutations of each member and nested member (deleted, replaced by 90 kinds of junk, extra keys, must_understand: false, bad names, NaN, big ints, non-string keys, model entries in consolidated metadata, ...) -- produced byte-identical records of every validator's problems (loc, kind, message, input, ctx), every reading's fields, each model's to_json/to_key_value, equality and hash round trips, claims and canonical spellings.
  • New tests: resolve_refined agrees with resolve on eight kinds of field, and its two error paths (a field that is not JSON; a scope of the other format); the model key is computed on first comparison (a counting canonical); mapping proxies, UserDict and dict/list subclasses refine as dict/list do; as_kind refuses an unhashable value as a non-kind; _document_of refuses members with no document; each benchmark case reads.
Stack bookkeeping

Base is feat/zarr-metadata-document-context-stack (upstream zarr-developers#4490, head c414f94). This PR is one commit on top of it plus the changelog fragment; rebase onto whatever zarr-developers#4490 becomes before it lands, and renumber the fragment if it is folded into an upstream PR.

🤖 Generated with Claude Code

maxrjones and others added 2 commits October 17, 2026 10:57
…file system (zarr-developers#4520)

The three docstrings, inherited from Zarr 2, opened with "to the local
file system". The functions take a StoreLike and a storage_options
argument, and work with every store the library supports. The wording
was being read as a design limitation of the functions (see the
discussion in zarr-developers#3976), which it is not.
…del keys

Reading a v3 array document took about 8x as long as zarr-python's own
parser, and a consolidated group with 200 arrays 200 times that. The
cost was repeated work, not the compiled type checker: each extension
point was refined to JSON inside `resolve` and the whole document refined
again to build the model's document; every field was frozen into views
and its JSON-text key computed at construction whether or not the model
was ever compared; `Scope.format` recomputed its answer on every call,
and `as_kind` and `kind_of` walked `get_origin` and the MRO on every
field.

Now a document is refined once: `read_array_v3`, `read_array_v2` and the
group reader refine each member where they read it, hand the refined
value to `resolve_refined`, and assemble the document the model holds
from what was refined, in the document's order, each consolidated child
document among them. A model's key and its claims are computed the first
time `==`, `hash` or `claims` asks, as `Keyed._model_key`; a scope's
format is computed once per scope; a kind is looked up once per way it is
named and once per definition class; `well_named` remembers each name;
`json_text` reuses one encoder; `frozen` and `_refine` tell the types
`json.loads` builds apart by type before falling back to the ABC checks;
`_put_back` walks nothing when a configuration holds no nested field;
`one_of` checks a set; and the `get_args` calls in `storage_of`, the
float fill-value rules and `ValidationProblem` are hoisted to constants.

Every problem is reported as before, with the same `loc`, `kind`,
message and `input`: a corpus of 25,780 documents (the three benchmark
documents, the test fixtures, and systematic mutations of each member)
read identically on both sides. The one change in behavior is that a
definition's `canonical` is first asked when a model is first compared,
not when it is built.

Per read, best of ten `timeit` repeats, Python 3.12, PYTHONHASHSEED=0,
documents as zarr 3.4.1 writes them:

    ZarrV3ArrayMetadata.from_json        194.1 us ->  72.5 us  (2.7x)
    ZarrV2ArrayMetadata.from_json         75.0 us ->  18.2 us  (4.1x)
    ZarrV3GroupMetadata, 200 children   37.4 ms  -> 14.7 ms   (2.5x)

The benchmark is `tests/benchmarks/read_documents.py`, run as
`python -m tests.benchmarks.read_documents`; a smoke test reads each
case once so it keeps working.

Assisted-by: ClaudeCode:claude-fable-5-1
d-v-b and others added 12 commits October 18, 2026 18:46
Assisted-by: ClaudeCode:claude-fable-5-1
---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.14.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…rs#4535)

Bumps [certifi](https://github.com/certifi/python-certifi) from 2026.4.22 to 2026.10.19.
- [Commits](certifi/python-certifi@2026.04.22...2026.10.19)

---
updated-dependencies:
- dependency-name: certifi
  dependency-version: 2026.10.19
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…r-developers#4543)

* ci(docs): fix the Read the Docs sub-site builds and skip filters

The zarr-indexing, zarr-metadata and zarr-http-server docs sites install their docs group with pip on Read the Docs, without a lockfile, so pymdown-extensions floated to 12.3, whose Highlight extension API the pinned mkdocstrings 1.0.6 cannot drive (Highlight.__init__() missing argument 'md'). No zarr-indexing build has succeeded since. Pin pymdown-extensions to 11.0.1, the version the repo-root uv.lock resolves to, and refresh the package lockfiles.

The post_checkout skip filters compared the PR head against the tip of main with a two-dot diff, so any PR that was behind main appeared to touch every package and built every site. Compare against the merge base instead, after fetching full history into the shallow checkout. Also exclude zarr-http-server from the root site's filter, as the other two packages already were.

Assisted-by: ClaudeCode:claude-fable-5.1

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs: add changelog entry for zarr-developers#4543

Assisted-by: ClaudeCode:claude-fable-5.1

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…rusting Debian (zarr-developers#4547)

* ci(big-endian): resolve dependencies from pyproject.toml instead of trusting Debian

The s390x job installed zarr with --no-deps into an image whose numpy and numcodecs come from Debian sid, so Debian's package versions defined the test environment and zarr's own constraints were never checked. Debian currently ships numcodecs 0.17.0, which zarr excludes (GHSA-jqc8-qjfv-5m6r), and the job failed through the pickle opt-in tests instead of saying so.

Install zarr with its dependencies resolved and run pip check, so an image that does not satisfy pyproject.toml fails at install time. Debian stays a source of prebuilt s390x binaries only. Build a satisfying numcodecs from source in the image, where the compile is cached as a layer, using Debian's meson, Cython and numpy so nothing else compiles under emulation.

Assisted-by: ClaudeCode:claude-fable-5.1

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs: add changelog entry for zarr-developers#4547

Assisted-by: ClaudeCode:claude-fable-5.1

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Bumps [pillow](https://github.com/python-pillow/Pillow) from 12.3.0 to 13.0.0.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](python-pillow/Pillow@12.3.0...13.0.0)

---
updated-dependencies:
- dependency-name: pillow
  dependency-version: 13.0.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [uv](https://github.com/astral-sh/uv) from 0.12.21 to 0.13.4.
- [Release notes](https://github.com/astral-sh/uv/releases)
- [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md)
- [Commits](astral-sh/uv@0.12.21...0.13.4)

---
updated-dependencies:
- dependency-name: uv
  dependency-version: 0.13.4
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Davis Bennett <davis.v.bennett@gmail.com>
---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.14.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…zarr-developers#4552)

Bumps the actions group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) | `10.2.0` | `10.3.0` |
| [CodSpeedHQ/action](https://github.com/codspeedhq/action) | `5.2.1` | `5.5.0` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `7.0.1` | `7.0.2` |
| [actions/download-artifact](https://github.com/actions/download-artifact) | `8.0.1` | `8.0.2` |
| [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) | `0.6.4` | `0.7.1` |



Updates `astral-sh/setup-uv` from 10.2.0 to 10.3.0
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](astral-sh/setup-uv@c18668a...1c37ad0)

Updates `CodSpeedHQ/action` from 5.2.1 to 5.5.0
- [Release notes](https://github.com/codspeedhq/action/releases)
- [Changelog](https://github.com/CodSpeedHQ/action/blob/main/CHANGELOG.md)
- [Commits](CodSpeedHQ/action@373d686...821a40c)

Updates `actions/upload-artifact` from 7.0.1 to 7.0.2
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](actions/upload-artifact@043fb46...cf430e0)

Updates `actions/download-artifact` from 8.0.1 to 8.0.2
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](actions/download-artifact@3e5f45b...9000827)

Updates `zizmorcore/zizmor-action` from 0.6.4 to 0.7.1
- [Release notes](https://github.com/zizmorcore/zizmor-action/releases)
- [Commits](zizmorcore/zizmor-action@cc914d7...85b2b9f)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: 10.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: CodSpeedHQ/action
  dependency-version: 5.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/download-artifact
  dependency-version: 8.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: zizmorcore/zizmor-action
  dependency-version: 0.7.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…s, which it lands in

Assisted-by: ClaudeCode:claude-fable-5-1
…a-read-perf-52cd33

The docs pins take upstream's resolution of the pymdown-extensions
breakage -- mkdocstrings 1.0.6 with pymdown-extensions pinned to 11.0.1,
as the root and the sibling packages pin them -- in place of this
branch's bump to mkdocstrings 1.0.7, so the stack changes no docs pin.
The package lock is regenerated against that.

Assisted-by: ClaudeCode:claude-fable-5-1
A v2 `order` or `dimension_separator` that only compares equal to "C" or
"." without being a string was written back as null once the document
was built of what the read refined; both are now judged as refined, so
such a value is refused as any other outside the set is. A document
writing the `UNSET` sentinel at `consolidated_metadata` was taken for
one without the member and written as null; the member is read whenever
the key is present. A `ZarrV3ConsolidatedMetadata` given as the member
is read in its document's order, so the group writes it as the member
wrote it. `_located` takes the problems a kind's `envelope_problems`
yields lazily, as it did before. `one_of` is as it was, since its
frozenset drifted at the edges for a 1% gain. The kind caches are bounded.
The group reader copies listed models only on the problem path, and the
depth rule `_refine` applies inline is stated once, in
`_past_the_levels`. A test per case, and one showing that a `canonical`
that raises does so when the model is first compared, with its note.

Assisted-by: ClaudeCode:claude-fable-5-1
@d-v-b

d-v-b commented Oct 22, 2026

Copy link
Copy Markdown
Owner Author

🤖 AI text below 🤖

The agent folded this branch into feat/zarr-metadata-document-context-stack (upstream zarr-developers#4490) by fast-forward, with the merge of upstream main and the fixes from the review round, so this PR has nothing left to merge.

@d-v-b
d-v-b merged commit 20d6b0b into feat/zarr-metadata-document-context-stack Oct 22, 2026
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.

2 participants