Storage Format Intelligence & I/O Optimization — dask_setup Recipe
This notebook demonstrates how to choose and benchmark storage formats and I/O strategies:
Create sample NetCDF climate data files
Benchmark
netcdf4vsh5netcdfengine performanceConvert a multi-file NetCDF dataset to Zarr with rechunking
Compare NetCDF vs Zarr on the same workload
Observe cold-cache vs warm-cache I/O effects
Summary of best practices
Setup and Imports
import importlib.util
import time
from contextlib import contextmanager
from pathlib import Path
import tempfile
import numpy as np
import xarray as xr
from dask_setup import setup_dask_client
# Check optional dependencies
try:
import zarr
ZARR_AVAILABLE = True
print(f"zarr {zarr.__version__}")
except ImportError:
ZARR_AVAILABLE = False
print("zarr not installed — Zarr sections will be skipped")
# xarray engine name -> the package that provides it
NETCDF_ENGINES = [
engine
for engine, module in (("netcdf4", "netCDF4"), ("h5netcdf", "h5netcdf"))
if importlib.util.find_spec(module) is not None
]
def blosc_encoding(cname="lz4", clevel=5):
"""Per-variable Zarr compressor encoding for whichever zarr major version is installed."""
if int(zarr.__version__.split(".")[0]) >= 3:
from zarr.codecs import BloscCodec
return {"compressors": [BloscCodec(cname=cname, clevel=clevel)]}
from numcodecs import Blosc # zarr 2
return {"compressor": Blosc(cname=cname, clevel=clevel)}
@contextmanager
def timer(label=""):
t = {"start": time.perf_counter()}
try:
yield t
finally:
t["total"] = time.perf_counter() - t["start"]
print(f" [{label}] {t['total']:.2f}s")
print("Imports OK")
print(f"NetCDF engines available: {NETCDF_ENGINES}")
zarr 3.4.0
Imports OK
NetCDF engines available: ['netcdf4', 'h5netcdf']
1. Create Sample NetCDF Files
Generate synthetic CF-compliant climate data (temperature + precipitation) for benchmarking. We use a small spatial grid and few timesteps so this runs quickly on a laptop.
# Working directory for generated files
work_dir = Path(tempfile.mkdtemp(prefix="dask_recipe_io_"))
netcdf_dir = work_dir / "sample_netcdf"
netcdf_dir.mkdir()
NUM_FILES = 10
TIME_PER_FILE = 20
LAT_SIZE, LON_SIZE = 60, 120 # smaller grid for speed
lat = np.linspace(-90, 90, LAT_SIZE)
lon = np.linspace(-180, 180, LON_SIZE)
file_paths = []
for i in range(NUM_FILES):
np.random.seed(42 + i)
time_coord = np.arange(i * TIME_PER_FILE, (i + 1) * TIME_PER_FILE)
temp = 273.15 + 15 * np.cos(2 * np.pi * time_coord / 365.25)[:, None, None] \
+ 5 * np.random.random((TIME_PER_FILE, LAT_SIZE, LON_SIZE))
precip = np.abs(np.random.gamma(2, 2, (TIME_PER_FILE, LAT_SIZE, LON_SIZE)))
ds = xr.Dataset(
{
"temperature": (["time", "lat", "lon"], temp.astype("float32"),
{"units": "K", "long_name": "Near-surface air temperature"}),
"precipitation": (["time", "lat", "lon"], precip.astype("float32"),
{"units": "mm/day", "long_name": "Daily precipitation"}),
},
coords={
"time": time_coord,
"lat": ("lat", lat, {"units": "degrees_north"}),
"lon": ("lon", lon, {"units": "degrees_east"}),
},
)
ds.attrs["conventions"] = "CF-1.8"
fp = netcdf_dir / f"sample_{i + 1:03d}.nc"
ds.to_netcdf(fp, encoding={"temperature": {"zlib": True}, "precipitation": {"zlib": True}})
file_paths.append(fp)
total_mb = sum(f.stat().st_size for f in file_paths) / 1e6
print(f"Created {NUM_FILES} NetCDF files ({total_mb:.1f} MB total)")
Created 10 NetCDF files (8.2 MB total)
2. Start a Cluster for NetCDF
For NetCDF the right choice is workload_type="cpu", even though this is file reading. The
netCDF4/HDF5 C library serialises every read behind a single process-wide lock, so the
"io" topology (one process, many threads) queues its threads on that lock. Several
single-threaded processes each have their own lock and really do read in parallel.
"io" is the right choice for Zarr, whose codecs release the GIL. dask_setup warns if you
pass a NetCDF-backed dataset with workload_type="io".
client, cluster, tmp = setup_dask_client(
workload_type="cpu",
max_workers=4,
fallback_on_detection_failure=True,
)
print(f"Workers: {len(client.nthreads())}")
print(f"Temp dir: {tmp}")
[setup_dask_client] Configuration summary
temp/spill dir: /tmp/dask-2704
Workers: 4 | threads/worker: 1 | processes: True
Memory: total ~15.6 GiB | usable ~11.6 GiB | per-worker ~2.9 GiB
Compression: spill=auto | comm=False
Workers: 4
Temp dir: /tmp/dask-2704
# What happens with the wrong topology: dask_setup inspects the dataset and logs a
# warning naming the fix (look for "WARNING [client]" in this cell's output)
ds_check = xr.open_mfdataset(file_paths[:2], chunks={})
c, cl, _, _ = setup_dask_client(workload_type="io", ds=ds_check, dashboard=False)
c.close(); cl.close()
ds_check.close()
WARNING [client] workload_type='io' is usually the wrong choice for NetCDF/HDF5 input (reason=HDF5 is not thread-safe, so xarray serialises every read through one process-wide lock; the threads queue rather than read in parallel | suggestion=use workload_type='cpu' (one lock per process) for NetCDF | note=also avoid open_mfdataset(..., parallel=True) with 'io' -- concurrent metadata reads can kill the worker and Dask's silent retries look like a hang)
[setup_dask_client] Configuration summary
temp/spill dir: /tmp/dask-2704
Workers: 1 | threads/worker: 4 | processes: False
Memory: total ~15.6 GiB | usable ~11.6 GiB | per-worker ~11.6 GiB
Compression: spill=auto | comm=False
/home/runner/work/dask_setup/dask_setup/src/dask_setup/client.py:140: UserWarning: Dimension 'time' has very small chunks (0.5 MiB). This may create high task overhead.
raw = recommend_chunks(
/home/runner/work/dask_setup/dask_setup/src/dask_setup/client.py:140: UserWarning: Dimension 'lat' has very small chunks (0.5 MiB). This may create high task overhead.
raw = recommend_chunks(
/home/runner/work/dask_setup/dask_setup/src/dask_setup/client.py:140: UserWarning: Dimension 'lon' has very small chunks (0.5 MiB). This may create high task overhead.
raw = recommend_chunks(
3. NetCDF Engine Benchmark
Compare netcdf4 and h5netcdf for single-file reading, multi-file open_mfdataset, and
a chunked spatial reduction.
engine_results = {}
for engine in NETCDF_ENGINES:
print(f"\nEngine: {engine}")
try:
# Single file
with timer(f"{engine} single-file") as t1:
ds = xr.open_dataset(file_paths[0], engine=engine)
_ = ds.temperature.values
ds.close()
# Multi-file
with timer(f"{engine} multi-file (5 files)") as t2:
ds = xr.open_mfdataset(
file_paths[:5], engine=engine,
chunks={"time": 20}, # whole lat/lon: matches how the files are stored
parallel=True, combine="by_coords",
)
_ = ds.temperature.mean().compute()
ds.close()
engine_results[engine] = {
"single_file_s": t1["total"],
"multi_file_s": t2["total"],
}
except Exception as e:
print(f" ERROR: {e}")
engine_results[engine] = {"error": str(e)}
print("\nSummary:")
for eng, r in engine_results.items():
if "error" not in r:
print(f" {eng:12s} single={r['single_file_s']:.2f}s multi={r['multi_file_s']:.2f}s")
Engine: netcdf4
[netcdf4 single-file] 0.00s
[netcdf4 multi-file (5 files)] 0.62s
Engine: h5netcdf
[h5netcdf single-file] 0.00s
ERROR: No module named 'h5py', backend not available. Please install 'h5py' into your Python environment.
Summary:
netcdf4 single=0.00s multi=0.62s
4. Convert NetCDF to Zarr
Zarr uses chunked, compressed storage that is friendlier to parallel reads and cloud access. Rechunk during conversion to optimise for analysis access patterns.
zarr_store = work_dir / "converted.zarr"
if ZARR_AVAILABLE:
engine = NETCDF_ENGINES[0] if NETCDF_ENGINES else "netcdf4"
with timer("NetCDF → Zarr conversion") as t:
ds = xr.open_mfdataset(
file_paths, engine=engine,
chunks={"time": 40},
parallel=True, combine="by_coords",
)
# Rechunk for better Zarr layout
ds = ds.chunk({"time": 60, "lat": LAT_SIZE, "lon": LON_SIZE})
# zarr.Blosc was removed in zarr 3; blosc_encoding() handles both versions
encoding = {"temperature": blosc_encoding(), "precipitation": blosc_encoding()}
ds.to_zarr(zarr_store, mode="w", consolidated=True, encoding=encoding)
ds.close()
zarr_mb = sum(f.stat().st_size for f in zarr_store.rglob("*") if f.is_file()) / 1e6
netcdf_mb = sum(f.stat().st_size for f in file_paths) / 1e6
print(f" NetCDF: {netcdf_mb:.1f} MB → Zarr: {zarr_mb:.1f} MB "
f"(ratio {zarr_mb / netcdf_mb:.2f}x)")
else:
print("Zarr not available — skipping conversion")
/opt/hostedtoolcache/Python/3.13.15/x64/lib/python3.13/site-packages/zarr/api/asynchronous.py:246: ZarrUserWarning: Consolidated metadata is currently not part in the Zarr format 3 specification. It may not be supported by other zarr implementations and may change in the future.
warnings.warn(
[NetCDF → Zarr conversion] 0.36s
NetCDF: 8.2 MB → Zarr: 8.8 MB (ratio 1.07x)
5. NetCDF vs Zarr Performance
Run the same analysis (spatial mean, annual resampling, temporal std) on both formats.
fmt_times = {}
# NetCDF
if NETCDF_ENGINES:
engine = NETCDF_ENGINES[0]
with timer("NetCDF analysis") as t:
ds = xr.open_mfdataset(
file_paths, engine=engine,
chunks={"time": 40}, parallel=True,
)
_ = ds.temperature.mean().compute()
_ = ds.temperature.std(dim="time").compute()
ds.close()
fmt_times["NetCDF"] = t["total"]
# Zarr
if ZARR_AVAILABLE and zarr_store.exists():
with timer("Zarr analysis") as t:
ds = xr.open_zarr(zarr_store, consolidated=True)
_ = ds.temperature.mean().compute()
_ = ds.temperature.std(dim="time").compute()
ds.close()
fmt_times["Zarr"] = t["total"]
if len(fmt_times) == 2:
speedup = fmt_times["NetCDF"] / fmt_times["Zarr"]
print(f"\nZarr speedup: {speedup:.2f}x")
[NetCDF analysis] 0.20s
[Zarr analysis] 0.08s
Zarr speedup: 2.38x
6. Filesystem Caching Effects
The first read of a file is slower (cold cache) than later reads (warm OS file cache). Account for this when benchmarking I/O: time a second run, or drop caches between runs.
These files were written moments ago, so they are probably already in the page cache and both reads here are “warm”. On a real dataset on Lustre (
/g/data,/scratch) the first read is usually far slower.
if NETCDF_ENGINES:
engine = NETCDF_ENGINES[0]
subset = file_paths[:3]
chunk_kw = {"time": 20}
with timer("Cold cache (1st read)") as t_cold:
ds = xr.open_mfdataset(subset, engine=engine, chunks=chunk_kw)
_ = ds.temperature.mean().compute()
ds.close()
with timer("Warm cache (2nd read)") as t_warm:
ds = xr.open_mfdataset(subset, engine=engine, chunks=chunk_kw)
_ = ds.temperature.mean().compute()
ds.close()
speedup = t_cold["total"] / t_warm["total"]
print(f"Cache speedup: {speedup:.2f}x")
[Cold cache (1st read)] 0.04s
[Warm cache (2nd read)] 0.04s
Cache speedup: 0.99x
client.close()
cluster.close()
# Clean up generated files
import shutil
shutil.rmtree(work_dir, ignore_errors=True)
print("Cluster closed, temp files removed.")
Cluster closed, temp files removed.
Storage Format & I/O Best Practices
Choose your format:
Format |
Best for |
Key advantage |
|---|---|---|
NetCDF |
Archival, sharing with collaborators |
Self-describing, CF-compliant |
Zarr |
Analysis pipelines, cloud storage |
Chunked, concurrent writes, cloud-native |
Engine selection:
netcdf4— the default, widely compatibleh5netcdf— reads through h5py rather than the netCDF-C library. It is not GIL-free: h5py wraps the same HDF5 C library and serialises calls on its own lock. Benchmark both engines on your data rather than assuming either wins.
Whichever engine you use, NetCDF input wants workload_type="cpu" (parallel processes);
Zarr input wants "io" (threads).
# Large multi-file NetCDF datasets
client, cluster, dask_tmp = setup_dask_client(workload_type="cpu")
ds = xr.open_mfdataset(files, parallel=True, combine="by_coords")
Zarr write tips:
# Consolidate metadata. zarr 3 takes `compressors` (a list);
# zarr 2 took `compressor`, and zarr.Blosc no longer exists in zarr 3.
from zarr.codecs import BloscCodec
ds.to_zarr("output.zarr", mode="w", consolidated=True,
encoding={"temp": {"compressors": [BloscCodec(cname="lz4", clevel=5)]}})
Chunk sizing: target 256–512 MiB per chunk for analysis. Use recommend_chunks() if unsure:
from dask_setup import recommend_chunks
chunks = recommend_chunks(ds, client, verbose=True)