Repository navigation
perf: cut the fill_value check on the chunk write path - #4382
Merged
d-v-b merged 4 commits intoSep 23, 2026
Merged
Conversation
`NDBuffer.all_equal` runs on every chunk write, because that is how `write_empty_chunks=False` (the default) decides a chunk is uniformly `fill_value` and can be skipped. Instrumented on an uncompressed float32 write it was 62.6% of total write time. Two independent causes. The bit-pattern comparison for a zero `fill_value`, which exists so that `-0.0` stays distinct from `0.0` (zarr-developers#3144), viewed both sides as `V{itemsize}`. NumPy has vectorised integer comparison loops and no void one, so that falls back to a generic elementwise path: 27x slower than the equivalent unsigned integer view on float32 and 33x on float64, measured on numpy 2.5.3. Viewing as an unsigned integer of the same width is equally bit-exact. Widths with no same-size integer, longdouble and complex128, keep the void form. The scan also had no early exit, so a buffer that is not uniformly `fill_value` still had to be read in full before `np.array_equal` could report the first mismatch, which on the write path is the common case. It now walks slices of the leading axis, doubling in length, and returns on the first slice that differs. The leading axis is used rather than a flattened view because a chunk is normally a strided view into a larger array and cannot be flattened without a copy; gating the early exit on contiguity makes it silently never engage. A buffer that really is uniform is still covered in the same number of element comparisons. Both `fill_value` regimes benefit, for different reasons: zarr's own default is `0.0` and takes the first change, while xarray writes `fill_value=NaN` and takes only the second. Behaviour is unchanged, including for `-0.0`, NaN, and non-contiguous buffers. Assisted-by: ClaudeCode:claude-opus-5
Assisted-by: ClaudeCode:claude-opus-5
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4382 +/- ##
=======================================
Coverage 94.22% 94.23%
=======================================
Files 92 92
Lines 12942 12961 +19
=======================================
+ Hits 12195 12214 +19
Misses 747 747
🚀 New features to boost your workflow:
|
Assisted-by: ClaudeCode:claude-opus-5
PursuitOfDataScience
force-pushed
the
perf/all-equal-write-path
branch
from
September 20, 2026 21:12
213657d to
ce1c66b
Compare
d-v-b
reviewed
Sep 23, 2026
| # Handle None fill_value for Zarr V2 | ||
| return False | ||
| @staticmethod | ||
| def _compare_all(data: NDArrayLike, other: Any, equal_nan: bool) -> bool: |
Contributor
There was a problem hiding this comment.
Instead of a static method we should have a free function that gets used in the body of a plain method
Contributor
|
this looks good, feel free to ignore my request about the static method thing because I can fix that in a follow-up |
d-v-b
approved these changes
Sep 23, 2026
This was referenced Sep 23, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Before zarr writes a chunk it asks one question: is this chunk entirely
fill_value?If it is, the chunk is skipped and no file is written.
That check is
NDBuffer.all_equal. It runs on every chunk write, becausewrite_empty_chunks=Falseis the default. On an uncompressed float32 write it was62.6% of the total time.
This PR makes the check cheap. Behaviour is unchanged.
Two things were slow
They are independent, and each one helps a different set of users.
1. The comparison used a dtype NumPy has no fast path for
When
fill_valueis zero the check compares raw bytes, not values, so that-0.0isnot mistaken for
0.0(#3144). It did that by viewing both sides asV{itemsize}, avoid dtype.
NumPy has vectorised comparison loops for integers. It has none for void, so the void
view quietly falls back to a generic element-by-element path.
An unsigned integer of the same width compares exactly the same bits and does have a
fast path. On a 16 MiB all-zero buffer that is 27x faster for float32 and 33x for
float64 on numpy 2.5.3, and 23x and 17x on numpy 2.2.6. Widths with no same-size
integer, longdouble and complex128, keep the void path.
This one helps zarr's own default, where
fill_valueis0.0.2. The scan never stopped early
If the first element already differs from
fill_value, the answer is known. The oldcode read the whole chunk anyway, because
np.array_equalbuilds a full boolean arraybefore reducing it. On the write path "not uniform" is the common case, so that cost
was paid nearly every time.
It now walks slices of the leading axis, doubling in length, and returns on the first
slice that differs. A chunk that really is uniform costs the same as before.
Why the leading axis rather than a flattened view: a chunk is normally a strided
view into a larger array, so flattening it copies. An earlier version of this patch
required a contiguous buffer, which meant the early exit never actually ran and
measured exactly 1.00x end to end. Slicing axis 0 is a view whatever the layout.
test_all_equal_strided_chunk_viewpins this.This one helps xarray, which writes
fill_value=NaNand so never reached the firstproblem at all.
Results
Paired and interleaved with alternating arm order, bootstrap 90% CI on the median
ratio, Wilcoxon signed-rank on the paired times. AMD EPYC 7702, numpy 2.5.3, 256 MiB
float32, uncompressed. Every point below is significant at p < 1e-11.
The worst single pair anywhere in the run was 1.05x, so no configuration regressed. The
win also survives the default zstd compressor at 2.0x to 3.2x, so it is not an artifact
of testing uncompressed.
Correctness
3,622 differential comparisons against the old implementation, 0 mismatches. They
cover 13 dtypes, C, F and strided layouts,
-0.0, NaN, inf, mismatches at the first,middle and last element, block boundaries, and the longdouble and complex128 fallbacks.
New tests cover agreement with a whole-buffer scan,
-0.0staying distinct (#3144),NaN, non-contiguous buffers, strided chunk views, and
shape[0] == 1, where there isno leading axis to slice and the code must fall back.
For reviewers
This is purely a performance change; behaviour is meant to be identical. Two places I
would most value a second opinion:
accepts? This is the part that could change results rather than only timings. It
is gated on itemsize (1, 2, 4, 8) with everything else left on the old path, and the
differential test covers
-0.0, NaN and inf, but a second read is worth it.read more simply, but copies on the strided views this method actually receives.
I am already confident in the
shape[0] == 1and empty-array fallbacks, both covered bytests.
Author attestation
TODO
docs/user-guide/*.mdchanges/