Skip to content

Commit ab8cd71

Browse files
feat: Make anemoi-datasets agnostic to Zarr version (Optional support Zarr3) (#220)
## Description Anemoi-datasets should be agnostic to the version of zarr. It should run with zarr3 installed or with zarr2 installed. We should also take into account that zarr2 code cannot read datasets created by zarr3. So, ideally, we should 1 - update anemoi-datasets to work with zarr2 and zarr3 2 - keep using zarr2 to build datasets (dependency `anemoi-datasets[create]` on zarr<=2, but have zarr2 or 3 when reading (dependency of `anemoi-datasets` on zarr 3 - when user have updated their environment (6 months?), start building zarr3 datasets This PR addresses the first point, making anemoi-datasets detect the version of zarr and adapt to it. We still pin the version to zarr 2 in the pyproject.toml because of performance issues with zarr version 3. <!-- readthedocs-preview anemoi-datasets start --> ---- 📚 Documentation preview 📚: https://anemoi-datasets--220.org.readthedocs.build/en/220/ <!-- readthedocs-preview anemoi-datasets end --> --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent f9efe39 commit ab8cd71

16 files changed

Lines changed: 555 additions & 201 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ dynamic = [
4343
]
4444
dependencies = [
4545
"anemoi-transform>=0.1.27",
46-
"anemoi-utils>=0.5",
46+
"anemoi-utils[s3]>=0.5",
4747
"cfunits",
4848
"glom",
4949
"jsonschema",

src/anemoi/datasets/commands/copy.py

Lines changed: 151 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,20 @@
1111
import logging
1212
import os
1313
import sys
14+
import time
1415
from concurrent.futures import ThreadPoolExecutor
1516
from concurrent.futures import as_completed
1617
from typing import Any
1718

19+
import numpy as np
1820
import tqdm
1921
from anemoi.utils.remote import Transfer
2022
from anemoi.utils.remote import TransferMethodNotImplementedError
2123

24+
from anemoi.datasets.compat import ZarrFileNotFoundError
25+
from anemoi.datasets.compat import zarr_append_mode
26+
from anemoi.datasets.compat import zarr_private_files
27+
from anemoi.datasets.compat import zarr_version
2228
from anemoi.datasets.misc.check import check_zarr
2329
from anemoi.datasets.usage.store import dataset_lookup
2430
from anemoi.datasets.usage.store import open_zarr
@@ -63,6 +69,8 @@ class ZarrCopier:
6369
Flag to obfuscate the data during transfer. This will generate random data that match the statistics. Useful for testing and benchmarking.
6470
rechunk : str
6571
Rechunk size for the target data array.
72+
reshard : str
73+
Reshard size for the target data array.
6674
"""
6775

6876
def __init__(
@@ -77,6 +85,7 @@ def __init__(
7785
nested: bool,
7886
obfuscate: bool,
7987
rechunk: str,
88+
reshard: str = None,
8089
**kwargs: Any,
8190
) -> None:
8291
"""Initialize the ZarrCopier.
@@ -103,6 +112,8 @@ def __init__(
103112
Flag to obfuscate the data during transfer.
104113
rechunk : str
105114
Rechunk size for the target data array.
115+
reshard : str
116+
Reshard size for the target data array.
106117
**kwargs : Any
107118
Additional keyword arguments.
108119
"""
@@ -118,6 +129,7 @@ def __init__(
118129
self.obfuscate = obfuscate
119130

120131
self.rechunking = rechunk.split(",") if rechunk else []
132+
self.resharding = reshard.split(",") if reshard else []
121133

122134
source_is_ssh = self.source.startswith("ssh://")
123135
target_is_ssh = self.target.startswith("ssh://")
@@ -202,6 +214,25 @@ def copy_chunk(self, n: int, m: int, source: Any, target: Any, _copy: Any, verbo
202214

203215
return slice(n, m)
204216

217+
def _parse_reshaping(self, new, old, shape) -> tuple:
218+
if old is not None:
219+
old = list(old)
220+
221+
if new is None:
222+
return old
223+
224+
result = [s for s in (shape if old is None else old)]
225+
226+
for i, c in enumerate(new):
227+
if c in ("full", "-1", ""):
228+
continue
229+
c = int(c)
230+
c = min(c, shape[i])
231+
result[i] = c
232+
233+
result = tuple(result)
234+
return result
235+
205236
def parse_rechunking(self, rechunking: list[str], source_data: Any) -> tuple:
206237
"""Parse the rechunking configuration.
207238
@@ -217,25 +248,35 @@ def parse_rechunking(self, rechunking: list[str], source_data: Any) -> tuple:
217248
tuple
218249
Parsed chunk sizes.
219250
"""
220-
shape = source_data.shape
221-
chunks = list(source_data.chunks)
222-
for i, c in enumerate(rechunking):
223-
if not c:
224-
continue
225-
elif c == "full":
226-
chunks[i] = shape[i]
227-
continue
228-
c = int(c)
229-
c = min(c, shape[i])
230-
chunks[i] = c
231-
chunks = tuple(chunks)
251+
chunks = self._parse_reshaping(new=rechunking, old=source_data.chunks, shape=source_data.shape)
232252

233253
if chunks != source_data.chunks:
234254
LOG.info(f"Rechunking data from {source_data.chunks} to {chunks}")
235-
# if self.transfers > 1:
236-
# raise NotImplementedError("Rechunking with multiple transfers is not implemented")
255+
237256
return chunks
238257

258+
def parse_resharding(self, resharding: list[str], source_data: Any) -> tuple:
259+
"""Parse the resharding configuration.
260+
261+
Parameters
262+
----------
263+
resharding : list of str
264+
List of reshard sizes.
265+
source_data : Any
266+
Source data.
267+
268+
Returns
269+
-------
270+
tuple
271+
Parsed shard sizes.
272+
"""
273+
shards = self._parse_reshaping(new=resharding, old=source_data.shards, shape=source_data.shape)
274+
275+
if shards != source_data.shards:
276+
LOG.info(f"Resharding data from {source_data.shards} to {shards} (shape is {source_data.shape})")
277+
278+
return shards
279+
239280
def copy_data(self, source: Any, target: Any, _copy: Any, verbosity: int) -> None:
240281
"""Copy data from source to target.
241282
@@ -252,25 +293,72 @@ def copy_data(self, source: Any, target: Any, _copy: Any, verbosity: int) -> Non
252293
"""
253294
LOG.info("Copying data")
254295
source_data = source["data"]
296+
start = time.time()
297+
298+
extra = {}
299+
if self.rechunking:
300+
extra["chunks"] = self.parse_rechunking(self.rechunking, source_data)
301+
302+
if self.resharding:
303+
extra["shards"] = self.parse_resharding(self.resharding, source_data)
304+
extra["chunks"] = (
305+
self.parse_rechunking(self.rechunking, source_data) if self.rechunking else source_data.chunks
306+
)
307+
ratio = []
308+
for shard, chunk in zip(extra["shards"], extra["chunks"]):
309+
if shard % chunk != 0:
310+
raise ValueError(f"Shard size {shard} is not a multiple of chunk size {chunk}.")
311+
ratio.append(shard // chunk)
255312

256-
self.data_chunks = self.parse_rechunking(self.rechunking, source_data)
313+
LOG.info(f"Shards for target data array: {extra['shards']} (ratio={ratio})")
314+
315+
LOG.info(f"Chunks: source={source_data.chunks}")
316+
if zarr_version >= 3:
317+
LOG.info(f"Shards: source={source_data.shards}")
318+
319+
if extra:
320+
LOG.info(f"Using extra parameters for target data array: {extra}")
321+
322+
self.data_chunks = extra.get("chunks", source_data.chunks)
257323

258324
if self.block_size is None:
259325
self.block_size = max(self.data_chunks[0], 100)
260326

261-
target_data = (
262-
target["data"]
263-
if "data" in target
264-
else target.create_dataset(
327+
if "data" in target:
328+
target_data = target["data"]
329+
if extra:
330+
LOG.warning("Target data array already exists, ignoring resharding/rechunking parameters.")
331+
LOG.warning(f"Existing target data array chunks: {target_data.chunks}")
332+
if zarr_version >= 3:
333+
LOG.warning(f"Existing target data array shards: {target_data.shards}")
334+
else:
335+
extra.setdefault("chunks", source_data.chunks)
336+
target_data = target.create_array(
265337
"data",
266338
shape=source_data.shape,
267-
chunks=self.data_chunks,
268339
dtype=source_data.dtype,
269340
fill_value=source_data.fill_value,
341+
**extra,
270342
)
271-
)
272343

273-
LOG.info(f"Using {self.transfers} parallel transfers and block size of {self.block_size}")
344+
size = 1
345+
size = target_data.chunks[0] if target_data.chunks else size
346+
if zarr_version >= 3:
347+
size = target_data.shards[0] if target_data.shards else size
348+
349+
block_size = self.block_size
350+
351+
block_size = (block_size // size) * size
352+
if block_size < size:
353+
block_size = size
354+
355+
if block_size != self.block_size:
356+
LOG.info(
357+
f"Adjusted block size from {self.block_size} to {block_size} to be multiple of chunk/shard size {size} {target_data.chunks}."
358+
)
359+
self.block_size = block_size
360+
361+
LOG.info(f"Using block size {self.block_size}, parallel transfers {self.transfers}")
274362

275363
executor = ThreadPoolExecutor(max_workers=self.transfers)
276364
tasks = []
@@ -297,7 +385,8 @@ def copy_data(self, source: Any, target: Any, _copy: Any, verbosity: int) -> Non
297385

298386
target["_copy"] = _copy
299387

300-
LOG.info("Copied data")
388+
end = time.time()
389+
LOG.info(f"Copied data in {end - start:.2f} seconds")
301390

302391
def copy_array(self, name: str, source: Any, target: Any, _copy: Any, verbosity: int) -> None:
303392
"""Copy an array from source to target.
@@ -325,10 +414,22 @@ def copy_array(self, name: str, source: Any, target: Any, _copy: Any, verbosity:
325414
self.copy_data(source, target, _copy, verbosity)
326415
return
327416

328-
LOG.info(f"Copying {name}")
329-
target[name] = source[name]
417+
LOG.info(f"Copying {name} {source[name].shape}")
418+
data = source[name][...]
419+
if name in target:
420+
del target[name]
421+
target.create_dataset(name, data=data, shape=data.shape)
330422
LOG.info(f"Copied {name}")
331423

424+
def children(self, group):
425+
"""Return sorted child keys, filtering out zarr private files."""
426+
children = list(group.keys())
427+
# https://github.com/zarr-developers/zarr-python/issues/3575
428+
children = [k for k in children if k != ""]
429+
children = [k for k in children if k not in zarr_private_files]
430+
children = sorted(children)
431+
return children
432+
332433
def copy_group(self, source: Any, target: Any, _copy: Any, verbosity: int) -> None:
333434
"""Copy a group from source to target.
334435
@@ -355,21 +456,21 @@ def copy_group(self, source: Any, target: Any, _copy: Any, verbosity: int) -> No
355456
LOG.info(f"Copying attribute {k} = {textwrap.shorten(str(v), 40)}")
356457
target.attrs[k] = v
357458

358-
source_keys = list(source.keys())
459+
source_keys = self.children(source)
359460

360461
if not source_keys:
361462
raise ValueError(f"Source group {source} is empty.")
362463

363464
if self.verbosity > 1:
364465
LOG.info(f"Keys {source_keys}")
365466

366-
for name in sorted(source_keys):
467+
for name in source_keys:
367468
if name.startswith("."):
368469
if self.verbosity > 1:
369470
LOG.info(f"Skipping {name}")
370471
continue
371472

372-
if isinstance(source[name], zarr.hierarchy.Group):
473+
if isinstance(source[name], zarr.Group):
373474
group = target[name] if name in target else target.create_group(name)
374475
self.copy_group(
375476
source[name],
@@ -398,19 +499,17 @@ def copy(self, source: Any, target: Any, verbosity: int) -> None:
398499
verbosity : int
399500
Verbosity level of logging.
400501
"""
401-
import zarr
402502

403503
if "_copy" not in target:
404-
target["_copy"] = zarr.zeros(
405-
source["data"].shape[0],
504+
target.create_dataset(
505+
"_copy",
506+
shape=(source["data"].shape[0],),
406507
dtype=bool,
407508
)
408509
_copy = target["_copy"]
409510
_copy_np = _copy[:]
410511

411512
if self.verbosity > 1:
412-
import numpy as np
413-
414513
LOG.info(f"copy {np.sum(_copy_np)} of {len(_copy_np)}")
415514

416515
self.filter = Identity()
@@ -431,27 +530,30 @@ def run(self) -> None:
431530
# assert ext == ".zarr", ext
432531
# assert "." not in base, base
433532
LOG.info(f"Copying {self.source} to {self.target}")
533+
LOG.info(f"Zarr version {zarr.__version__}")
434534

435535
def target_exists() -> bool:
436536
try:
437537
zarr.open(self._store(self.target), mode="r")
438538
return True
439-
except ValueError:
539+
except (ValueError, ZarrFileNotFoundError):
440540
return False
441541

442542
def target_finished() -> bool:
443543
target = zarr.open(self._store(self.target), mode="r")
544+
source = zarr.open(self._store(self.source), mode="r")
545+
last_key = self.children(source)[-1]
444546
if "_copy" in target:
445547
done = sum(1 if x else 0 for x in target["_copy"])
446-
todo = len(target["_copy"])
548+
todo = target["_copy"].shape[0]
447549
LOG.info(
448550
"Resuming copy, done %s out or %s, %s%%",
449551
done,
450552
todo,
451553
int(done / todo * 100 + 0.5),
452554
)
453555
return False
454-
elif "sums" in target and "data" in target: # sums is copied last
556+
elif last_key in target and "data" in target:
455557
return True
456558
return False
457559

@@ -470,7 +572,7 @@ def open_target() -> Any:
470572
sys.exit(0)
471573

472574
LOG.error("Target already exists, resuming copy.")
473-
return zarr.open(self._store(self.target, self.nested), mode="w+")
575+
return zarr.open(self._store(self.target, self.nested), mode=zarr_append_mode)
474576

475577
LOG.error("Target already exists, use either --overwrite or --resume.")
476578
sys.exit(1)
@@ -540,6 +642,12 @@ def add_arguments(self, command_parser: Any) -> None:
540642
action="store_true",
541643
help="Obfuscate the data during transfer. This will generate random data that match the statistics. Useful for testing and benchmarking.",
542644
)
645+
646+
if zarr_version >= 3:
647+
command_parser.add_argument(
648+
"--reshard",
649+
help="Reshard the target data array. This option will adjust --block-size to that it is divisible by the reshard size. Zarr 3 only.",
650+
)
543651
command_parser.add_argument("source", help="Source location.")
544652
command_parser.add_argument("target", help="Target location.")
545653

@@ -564,7 +672,12 @@ def run(self, args: Any) -> None:
564672
if not basename.endswith(".zarr") or basename == ".zarr":
565673
raise ValueError(f"{name} path must match '*.zarr' pattern: {path!r}")
566674

567-
if not args.rechunk and not args.obfuscate:
675+
if zarr_version >= 3:
676+
reshaping_requested = args.rechunk or getattr(args, "reshard", None)
677+
else:
678+
reshaping_requested = args.rechunk
679+
680+
if not reshaping_requested and not args.obfuscate:
568681
# rechunking is only supported for ZARR datasets, it is implemented in this package
569682
try:
570683
if args.source.startswith("s3://") and not args.source.endswith("/"):

0 commit comments

Comments
 (0)