Skip to content

Commit f9c1438

Browse files
authored
fix: byte-order bugs and little-endian test assumptions on big-endian hosts (#4435)
* fix: byte-order bugs and little-endian test assumptions on big-endian hosts Running the suite on s390x (QEMU, Debian sid) gave 110 failures on main. Most came from one asymmetry: V3 data types without an explicit byte order defaulted to little-endian, while NumPy dtype names mean host order. On a big-endian host `dtype="float64"` and `dtype=np.float64` produced different data types, and a reopened array changed dtype. - V3 data types default to the host byte order (`HasEndianness`), because V3 metadata carries none. Stored chunks stay little-endian by default. - `ShardingCodec` defaults its inner and index `bytes` codecs to little-endian, so sharded output no longer depends on the host. - `scale_offset` computes in native byte order and restores the input's byte order; `>f8` and `>u8` arrays failed on every host. - Base64 fill values of structured data types in V3 metadata are little-endian bytes in both directions. - Tests no longer assume a little-endian host: explicit `<` dtypes and `endian="little"` where the expectation is little, and a big-endian case for migration and pipeline parity. Refs #3438 Assisted-by: ClaudeCode:claude-opus-5-5 * docs: name the changelog fragment after the PR Assisted-by: ClaudeCode:claude-opus-5-5 * ci: run byte-order-sensitive tests on s390x under QEMU A weekly and path-filtered job that runs the data type, codec and metadata tests on an emulated big-endian host, in a Debian sid image that takes numpy and numcodecs from Debian packages so nothing heavy compiles under emulation. Assisted-by: ClaudeCode:claude-opus-5-5 * fix(codecs)!: BytesCodec defaults to little-endian on every host `BytesCodec()` defaulted to `sys.byteorder`, so the same code wrote big-endian chunks on s390x and little-endian chunks elsewhere. The bytes codec originally defaulted to "little"; the host default came in as a side effect of replacing attrs with dataclasses (#1660), and was later pinned by a test that only restated it. `"little"` also matches `default_serializer_v3`. Chunks written on big-endian hosts with a bare `BytesCodec()` are now little-endian. Chunks written before this change remain readable, because the codec records its byte order in the metadata. Refs #3438 Assisted-by: ClaudeCode:claude-opus-5-5 * revert: keep the BytesCodec default out of this PR This reverts commit 8dd33e3. Changing the default of `BytesCodec()` changes what existing code writes on big-endian hosts, so it needs a deprecation cycle and will land in its own PR. Assisted-by: ClaudeCode:claude-opus-5-5
1 parent cd1e5b3 commit f9c1438

20 files changed

Lines changed: 256 additions & 52 deletions

File tree

‎.github/workflows/big-endian.yml‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Run the byte-order-sensitive tests on a big-endian host: s390x, emulated with QEMU.
2+
# Emulation makes the full suite take about 40 minutes, so this job runs only the tests
3+
# that exercise data types, codecs and metadata, and only when that code changes (plus
4+
# a weekly run to catch anything the path filter misses).
5+
6+
name: Big-endian
7+
8+
on:
9+
pull_request:
10+
branches: [ main ]
11+
paths:
12+
- "src/zarr/codecs/**"
13+
- "src/zarr/core/buffer/**"
14+
- "src/zarr/core/dtype/**"
15+
- "src/zarr/core/metadata/**"
16+
- "src/zarr/metadata/**"
17+
- "tests/test_codecs/**"
18+
- "tests/test_dtype/**"
19+
- "tests/test_metadata/**"
20+
- "ci/big-endian/**"
21+
- ".github/workflows/big-endian.yml"
22+
schedule:
23+
- cron: "0 3 * * 1"
24+
workflow_dispatch:
25+
26+
permissions:
27+
contents: read
28+
29+
concurrency:
30+
group: ${{ github.workflow }}-${{ github.ref }}
31+
cancel-in-progress: true
32+
33+
jobs:
34+
s390x:
35+
name: s390x (QEMU)
36+
runs-on: ubuntu-latest
37+
timeout-minutes: 90
38+
steps:
39+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
40+
with:
41+
fetch-depth: 0 # hatch-vcs needs the tags to resolve a version
42+
persist-credentials: false
43+
- uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
44+
with:
45+
platforms: s390x
46+
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
47+
- name: Build test image
48+
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
49+
with:
50+
context: ci/big-endian
51+
platforms: linux/s390x
52+
tags: zarr-big-endian
53+
load: true
54+
cache-from: type=gha,scope=big-endian
55+
cache-to: type=gha,scope=big-endian,mode=max
56+
- name: Run tests
57+
run: |
58+
docker run --rm --platform linux/s390x -v "$PWD":/src:ro zarr-big-endian bash -euc '
59+
cp -r /src /work && cd /work
60+
pip install -q --no-deps --no-build-isolation .
61+
python -c "import sys; assert sys.byteorder == \"big\", sys.byteorder"
62+
python -m pytest -p no:cacheprovider --hypothesis-profile ci -n auto \
63+
tests/test_dtype tests/test_dtype_registry.py \
64+
tests/test_codecs tests/test_metadata \
65+
tests/test_array.py tests/test_v2.py tests/test_info.py \
66+
tests/test_pipeline_parity.py tests/test_cli/test_migrate_v3.py
67+
'

‎changes/4435.bugfix.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Fix byte-order bugs found by running the test suite on a big-endian (s390x) host.
2+
3+
- Zarr V3 data type metadata has no byte order, so V3 data types without an explicit `endianness` (such as `Float64()`, or a data type parsed from `"float64"`) now use the host byte order instead of always little-endian. On big-endian hosts, arrays opened from V3 metadata now return native arrays, and `dtype="float64"` and `dtype=np.float64` produce the same data type. Nothing changes on little-endian hosts. Stored chunks are still little-endian by default.
4+
- `ShardingCodec` now defaults its inner and index `bytes` codecs to little-endian, so sharded arrays written with default settings are byte-identical on every host.
5+
- The `scale_offset` codec no longer fails on arrays whose byte order is not the host's, for example a `">f8"` or `">u8"` array on a little-endian host.
6+
- Base64-encoded fill values of structured data types in Zarr V3 metadata are now read and written as little-endian bytes, whatever the byte order of the fields in memory.
7+
- Tests no longer assume that the host is little-endian.

‎ci/big-endian/Dockerfile‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Test image for a big-endian host (s390x, run under QEMU by .github/workflows/big-endian.yml).
2+
# Debian packages supply numpy and numcodecs, so nothing heavy compiles under emulation;
3+
# this is also the environment Debian tests zarr in. Debian does not package Google's
4+
# crc32c library, so it is built here for the google-crc32c C extension.
5+
FROM debian:sid
6+
7+
RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends \
8+
build-essential cmake git python3 python3-dev python3-pip python3-venv \
9+
python3-crc32c python3-donfig python3-fsspec python3-hatch-vcs python3-hatchling \
10+
python3-hypothesis python3-numcodecs python3-numpy python3-numpydoc python3-packaging \
11+
python3-pytest python3-pytest-asyncio python3-pytest-benchmark python3-pytest-xdist \
12+
python3-tomlkit python3-typer python3-typing-extensions \
13+
&& rm -rf /var/lib/apt/lists/*
14+
15+
RUN git clone -q --depth 1 --branch 1.1.2 https://github.com/google/crc32c /tmp/crc32c \
16+
&& cmake -S /tmp/crc32c -B /tmp/crc32c/build -DBUILD_SHARED_LIBS=1 \
17+
-DCRC32C_BUILD_TESTS=0 -DCRC32C_BUILD_BENCHMARKS=0 -DCRC32C_USE_GLOG=0 \
18+
-DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
19+
&& cmake --build /tmp/crc32c/build -j"$(nproc)" \
20+
&& cmake --install /tmp/crc32c/build \
21+
&& ldconfig \
22+
&& rm -rf /tmp/crc32c
23+
24+
RUN python3 -m venv --system-site-packages /venv
25+
ENV PATH=/venv/bin:$PATH
26+
RUN pip install -q --no-binary google-crc32c google-crc32c msgspec pytest-accept

‎src/zarr/codecs/scale_offset.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,12 +217,33 @@ def _decode_float(
217217
return result
218218

219219

220+
def _in_native_byte_order(
221+
arr: np.ndarray[tuple[Any, ...], np.dtype[Any]],
222+
) -> np.ndarray[tuple[Any, ...], np.dtype[Any]]:
223+
"""View-or-copy `arr` in the host byte order; a no-op for native input.
224+
225+
The arithmetic below must see native arrays: numpy returns native results, so a
226+
byte-swapped input would trip the dtype-preservation check, and a byte-swapped dtype
227+
compares unequal to its native twin (`>u8 != uint64` on a little-endian host).
228+
"""
229+
return arr.astype(arr.dtype.newbyteorder("="), copy=False)
230+
231+
220232
def _encode(
221233
arr: np.ndarray[tuple[Any, ...], np.dtype[Any]],
222234
offset: np.generic,
223235
scale: np.generic,
224236
) -> np.ndarray[tuple[Any, ...], np.dtype[Any]]:
225237
"""Compute ``(arr - offset) * scale`` without silent overflow, returning ``arr.dtype``."""
238+
return _encode_native(_in_native_byte_order(arr), offset, scale).astype(arr.dtype, copy=False)
239+
240+
241+
def _encode_native(
242+
arr: np.ndarray[tuple[Any, ...], np.dtype[Any]],
243+
offset: np.generic,
244+
scale: np.generic,
245+
) -> np.ndarray[tuple[Any, ...], np.dtype[Any]]:
246+
"""`_encode` for an `arr` in the host byte order."""
226247
# uint64 is split out first because its full range (up to 2**64-1) doesn't fit in int64,
227248
# so the widening strategy used for every other integer dtype would itself overflow.
228249
if arr.dtype == np.uint64:
@@ -253,6 +274,20 @@ def _decode(
253274
scale_repr: object,
254275
) -> np.ndarray[tuple[Any, ...], np.dtype[Any]]:
255276
"""Compute ``arr / scale + offset`` without silent overflow, returning ``arr.dtype``."""
277+
native = _in_native_byte_order(arr)
278+
return _decode_native(native, offset, scale, scale_repr=scale_repr).astype(
279+
arr.dtype, copy=False
280+
)
281+
282+
283+
def _decode_native(
284+
arr: np.ndarray[tuple[Any, ...], np.dtype[Any]],
285+
offset: np.generic,
286+
scale: np.generic,
287+
*,
288+
scale_repr: object,
289+
) -> np.ndarray[tuple[Any, ...], np.dtype[Any]]:
290+
"""`_decode` for an `arr` in the host byte order."""
256291
# uint64: same reasoning as _encode — its range exceeds int64, so the Python-int path is the
257292
# only correct option. Exactness check runs first so non-divisible inputs fail before the
258293
# slower object-dtype arithmetic.

‎src/zarr/codecs/sharding.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -460,8 +460,11 @@ def __init__(
460460
self,
461461
*,
462462
chunk_shape: ShapeLike,
463-
codecs: Iterable[Codec | dict[str, JSON]] = (BytesCodec(),),
464-
index_codecs: Iterable[Codec | dict[str, JSON]] = (BytesCodec(), Crc32cCodec()),
463+
codecs: Iterable[Codec | dict[str, JSON]] = (BytesCodec(endian="little"),),
464+
index_codecs: Iterable[Codec | dict[str, JSON]] = (
465+
BytesCodec(endian="little"),
466+
Crc32cCodec(),
467+
),
465468
index_location: ShardingCodecIndexLocation | IndexLocation = "end",
466469
subchunk_write_order: SubchunkWriteOrder = "morton",
467470
) -> None:

‎src/zarr/core/array.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1805,7 +1805,7 @@ def info(self) -> Any:
18051805
>>> arr.info
18061806
Type : Array
18071807
Zarr format : 3
1808-
Data type : Float64(endianness='little')
1808+
Data type : Float64(endianness=...)
18091809
Fill value : 0.0
18101810
Shape : (3, 4, 5)
18111811
Chunk shape : (2, 2, 2)
@@ -4023,7 +4023,7 @@ def info(self) -> Any:
40234023
>>> arr.info
40244024
Type : Array
40254025
Zarr format : 3
4026-
Data type : Float32(endianness='little')
4026+
Data type : Float32(endianness=...)
40274027
Fill value : 0.0
40284028
Shape : (10,)
40294029
Chunk shape : (2,)

‎src/zarr/core/dtype/common.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import sys
34
import warnings
45
from collections.abc import Mapping, Sequence
56
from dataclasses import dataclass
@@ -200,10 +201,14 @@ class HasLength:
200201
@dataclass(frozen=True, kw_only=True)
201202
class HasEndianness:
202203
"""
203-
A mix-in class for data types with an endianness attribute
204+
A mix-in class for data types with an endianness attribute.
205+
206+
The endianness is the byte order of the in-memory array, not the byte order of
207+
stored chunks, which the `bytes` codec sets. Zarr V3 data type metadata carries
208+
no byte order, so it defaults to the byte order of the host.
204209
"""
205210

206-
endianness: EndiannessStr = "little"
211+
endianness: EndiannessStr = sys.byteorder
207212

208213

209214
@dataclass(frozen=True, kw_only=True)

‎src/zarr/core/dtype/npy/structured.py‎

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,22 @@ def default_scalar(self) -> np.void:
583583
values.append(value)
584584
return self._cast_scalar_unchecked(tuple(values))
585585

586+
def _scalar_bytes_to_void(self, data: bytes, zarr_format: ZarrFormat) -> np.void:
587+
"""Read the raw bytes of a base64 fill value as a scalar of this data type.
588+
589+
Zarr V2 metadata spells out the byte order of every field. Zarr V3 metadata has
590+
none, so V3 fill value bytes are little-endian, like the default `bytes` codec.
591+
"""
592+
dtype = self.to_native_dtype()
593+
stored = dtype if zarr_format == 2 else dtype.newbyteorder("<")
594+
return cast("np.void", np.frombuffer(data, dtype=stored).astype(dtype)[0])
595+
596+
def _void_to_scalar_bytes(self, data: np.void, zarr_format: ZarrFormat) -> bytes:
597+
"""Inverse of `_scalar_bytes_to_void`."""
598+
dtype = self.to_native_dtype()
599+
stored = dtype if zarr_format == 2 else dtype.newbyteorder("<")
600+
return np.asarray(data).astype(stored).tobytes()
601+
586602
def from_json_scalar(self, data: JSON, *, zarr_format: ZarrFormat) -> np.void:
587603
"""
588604
Read a JSON-serializable value as a NumPy structured scalar.
@@ -606,8 +622,7 @@ def from_json_scalar(self, data: JSON, *, zarr_format: ZarrFormat) -> np.void:
606622
"""
607623
if check_json_str(data):
608624
as_bytes = bytes_from_json(data, zarr_format=zarr_format)
609-
dtype = self.to_native_dtype()
610-
return cast("np.void", np.array([as_bytes]).view(dtype)[0])
625+
return self._scalar_bytes_to_void(as_bytes, zarr_format)
611626
raise TypeError(f"Invalid type: {data}. Expected a string.")
612627

613628
def to_json_scalar(self, data: object, *, zarr_format: ZarrFormat) -> str | dict[str, JSON]:
@@ -628,7 +643,9 @@ def to_json_scalar(self, data: object, *, zarr_format: ZarrFormat) -> str | dict
628643
string of the bytes that make up the scalar. Subclasses may return
629644
a dict for V3 format.
630645
"""
631-
return bytes_to_json(self.cast_scalar(data).tobytes(), zarr_format)
646+
return bytes_to_json(
647+
self._void_to_scalar_bytes(self.cast_scalar(data), zarr_format), zarr_format
648+
)
632649

633650
@property
634651
def item_size(self) -> int:
@@ -776,8 +793,7 @@ def from_json_scalar(self, data: JSON, *, zarr_format: ZarrFormat) -> np.void:
776793
return self._cast_scalar_unchecked(tuple(field_values))
777794
elif check_json_str(data):
778795
as_bytes = bytes_from_json(data, zarr_format=zarr_format)
779-
dtype = self.to_native_dtype()
780-
return cast("np.void", np.array([as_bytes]).view(dtype)[0])
796+
return self._scalar_bytes_to_void(as_bytes, zarr_format)
781797
raise TypeError(f"Invalid type: {data}. Expected a dict or base64-encoded string.")
782798

783799
def to_json_scalar(self, data: object, *, zarr_format: ZarrFormat) -> str | dict[str, JSON]:
@@ -799,7 +815,7 @@ def to_json_scalar(self, data: object, *, zarr_format: ZarrFormat) -> str | dict
799815
"""
800816
scalar = self.cast_scalar(data)
801817
if zarr_format == 2:
802-
return bytes_to_json(scalar.tobytes(), zarr_format)
818+
return bytes_to_json(self._void_to_scalar_bytes(scalar, zarr_format), zarr_format)
803819
result: dict[str, JSON] = {}
804820
for field_name, field_dtype in self.fields:
805821
result[field_name] = field_dtype.to_json_scalar(

‎tests/test_array.py‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,7 @@ async def test_chunks_initialized(
464464

465465

466466
def test_nbytes_stored() -> None:
467-
arr = zarr.create(shape=(100,), chunks=(10,), dtype="i4", codecs=[BytesCodec()])
467+
arr = zarr.create(shape=(100,), chunks=(10,), dtype="i4", codecs=[BytesCodec(endian="little")])
468468
result = arr.nbytes_stored()
469469
assert result == 502 # the size of the metadata document. This is a fragile test.
470470
arr[:50] = 1
@@ -477,7 +477,7 @@ def test_nbytes_stored() -> None:
477477

478478
async def test_nbytes_stored_async() -> None:
479479
arr = await zarr.api.asynchronous.create(
480-
shape=(100,), chunks=(10,), dtype="i4", codecs=[BytesCodec()]
480+
shape=(100,), chunks=(10,), dtype="i4", codecs=[BytesCodec(endian="little")]
481481
)
482482
result = await arr.nbytes_stored()
483483
assert result == 502 # the size of the metadata document. This is a fragile test.
@@ -542,7 +542,7 @@ def test_info_v3(self, chunks: tuple[int, int], shards: tuple[int, int] | None)
542542
_read_only=False,
543543
_store_type="MemoryStore",
544544
_compressors=(ZstdCodec(),),
545-
_serializer=BytesCodec(),
545+
_serializer=BytesCodec(endian="little"),
546546
_count_bytes=512,
547547
)
548548
assert result == expected
@@ -567,7 +567,7 @@ def test_info_complete(self, chunks: tuple[int, int], shards: tuple[int, int] |
567567
_order="C",
568568
_read_only=False,
569569
_store_type="MemoryStore",
570-
_serializer=BytesCodec(),
570+
_serializer=BytesCodec(endian="little"),
571571
_count_bytes=512,
572572
_count_chunks_initialized=0,
573573
_count_bytes_stored=521 if shards is None else 982, # the metadata?
@@ -630,7 +630,7 @@ async def test_info_v3_async(
630630
_read_only=False,
631631
_store_type="MemoryStore",
632632
_compressors=(ZstdCodec(),),
633-
_serializer=BytesCodec(),
633+
_serializer=BytesCodec(endian="little"),
634634
_count_bytes=512,
635635
)
636636
assert result == expected
@@ -657,7 +657,7 @@ async def test_info_complete_async(
657657
_order="C",
658658
_read_only=False,
659659
_store_type="MemoryStore",
660-
_serializer=BytesCodec(),
660+
_serializer=BytesCodec(endian="little"),
661661
_count_bytes=512,
662662
_count_chunks_initialized=0,
663663
_count_bytes_stored=521 if shards is None else 982, # the metadata?

‎tests/test_cli/test_migrate_v3.py‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
def test_migrate_array(local_store: LocalStore) -> None:
3636
shape = (10, 10)
3737
chunks = (10, 10)
38-
dtype = "uint16"
38+
dtype = "<u2"
3939
compressors = numcodecs.Blosc(cname="zstd", clevel=3, shuffle=1)
4040
fill_value = 2
4141
attributes = cast(dict[str, JSON], {"baz": 42, "qux": [1, 4, 7, 12]})
@@ -59,7 +59,7 @@ def test_migrate_array(local_store: LocalStore) -> None:
5959

6060
expected_metadata = ArrayV3Metadata(
6161
shape=shape,
62-
data_type=UInt16(endianness="little"),
62+
data_type=UInt16(),
6363
chunk_grid={"name": "regular", "configuration": {"chunk_shape": chunks}},
6464
chunk_key_encoding=V2ChunkKeyEncoding(separator="."),
6565
fill_value=fill_value,
@@ -292,7 +292,7 @@ def test_migrate_compressor(
292292
store=local_store,
293293
shape=(10, 10),
294294
chunks=(10, 10),
295-
dtype="uint16",
295+
dtype="<u2",
296296
compressors=compressor_v2,
297297
zarr_format=2,
298298
fill_value=0,
@@ -330,7 +330,7 @@ def test_migrate_numcodecs_compressor(local_store: LocalStore) -> None:
330330
store=local_store,
331331
shape=(10, 10),
332332
chunks=(10, 10),
333-
dtype="uint16",
333+
dtype="<u2",
334334
compressors=numcodecs.LZMA.from_config(lzma_settings),
335335
zarr_format=2,
336336
fill_value=0,
@@ -364,7 +364,7 @@ def test_migrate_filter(local_store: LocalStore) -> None:
364364
store=local_store,
365365
shape=(10, 10),
366366
chunks=(10, 10),
367-
dtype="uint16",
367+
dtype="<u2",
368368
compressors=None,
369369
filters=filter_v2,
370370
zarr_format=2,
@@ -398,7 +398,7 @@ def test_migrate_C_vs_F_order(
398398
store=local_store,
399399
shape=(10, 10),
400400
chunks=(10, 10),
401-
dtype="uint16",
401+
dtype="<u2",
402402
compressors=None,
403403
zarr_format=2,
404404
fill_value=0,
@@ -421,9 +421,10 @@ def test_migrate_C_vs_F_order(
421421
("dtype", "expected_data_type", "expected_codecs"),
422422
[
423423
("uint8", UInt8(), (BytesCodec(endian=None),)),
424-
("uint16", UInt16(), (BytesCodec(endian="little"),)),
424+
("<u2", UInt16(), (BytesCodec(endian="little"),)),
425+
(">u2", UInt16(), (BytesCodec(endian="big"),)),
425426
],
426-
ids=["single_byte", "multi_byte"],
427+
ids=["single_byte", "little_endian", "big_endian"],
427428
)
428429
def test_migrate_endian(
429430
local_store: LocalStore,

0 commit comments

Comments
 (0)