Skip to content

Commit 793545b

Browse files
authored
Merge pull request #336 from boutproject/feature/lazy-load
Fast loading of multi-file BOUT++ datasets
2 parents db641dd + c5498d5 commit 793545b

4 files changed

Lines changed: 431 additions & 14 deletions

File tree

xbout/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from .load import open_boutdataset, collect
2+
from .lazyload import lazy_open_boutdataset
23

34
from . import geometries
45
from .geometries import register_geometry, REGISTERED_GEOMETRIES
@@ -22,6 +23,7 @@
2223

2324
__all__ = [
2425
"open_boutdataset",
26+
"lazy_open_boutdataset",
2527
"collect",
2628
"geometries",
2729
"register_geometry",

xbout/lazyload.py

Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
1+
"""
2+
Fast lazy-loading of BOUT++ multi-file datasets.
3+
4+
Overview
5+
--------
6+
BOUT++ writes output distributed across many NetCDF files, one per processor.
7+
The standard xarray.open_mfdataset opens every file to read metadata before
8+
constructing the dataset, which is slow when there are hundreds of files on a
9+
parallel filesystem where each file open incurs a metadata server round-trip.
10+
11+
This module avoids that overhead by reading metadata from a single file and
12+
constructing a lazy dask-backed xarray Dataset without opening any other files.
13+
Data is only read from disk when explicitly requested via .compute() or .load().
14+
15+
Method
16+
------
17+
All BOUT++ output files share the same array shapes and metadata. The processor
18+
layout (NXPE x NYPE grid) and array dimensions are read from the first file
19+
(BOUT.dmp.0.nc). From this, the slice of the global array stored in each
20+
processor's file is determined, accounting for MXG/MYG guard cells.
21+
22+
A dask task graph is constructed as a Python dict, with one entry per processor
23+
file per variable. Each task uses dask's internal getter() function, which
24+
calls __getitem__ on a LazyFileArray object. LazyFileArray defers all file I/O
25+
to h5py, reading only the requested hyperslab when the task is executed.
26+
27+
Slice fusion
28+
------------
29+
Dask's _optimize_slices pass (dask/array/optimization.py) recognizes chained
30+
getter() tasks and fuses them into a single getter() call with composed slices.
31+
Each variable's task graph uses two chained tasks per file:
32+
33+
lazy task: LazyFileArray object
34+
slice task: (getter, lazy_task_key, boundary_slices, False, False)
35+
36+
The boundary slice removes MXG/MYG guard cells from the raw file data as needed,
37+
depending on keep_xboundaries and keep_yboundaries.
38+
When the user slices the resulting DataArray, dask fuses the user slice with
39+
the boundary slice into a single composed slice, which is passed through
40+
getter() to LazyFileArray.__getitem__() and then directly to h5py as an HDF5
41+
hyperslab selection. This means only the requested bytes are read from disk,
42+
with a single file open per chunk per compute() call.
43+
44+
Usage
45+
-----
46+
ds = lazy_open_boutdataset('/path/to/BOUT/dmp/files/')
47+
ds = lazy_open_boutdataset('/path/to/files/', keep_xboundaries=True)
48+
49+
# Data is not read until here:
50+
data = ds['Ne'].isel(t=slice(10, 20)).compute()
51+
"""
52+
53+
import xarray as xr
54+
import dask
55+
from dask.array.core import getter
56+
import os
57+
import h5py
58+
import warnings
59+
60+
61+
class LazyFileArray:
62+
"""Presents a numpy-like interface that defers reads to HDF5 hyperslabs."""
63+
64+
def __init__(self, filepath, varname: str, shape, dtype, info: bool = False):
65+
"""
66+
filepath: str or Path
67+
Full path to the file
68+
varname: str
69+
Name of the array to read
70+
shape: tuple
71+
The shape of the array in the file
72+
dtype
73+
Type of the elements in the array (e.g. numpy.float64)
74+
info: bool
75+
Print debugging information on read?
76+
"""
77+
self.filepath = filepath
78+
self.varname = varname
79+
self.shape = shape
80+
self.dtype = dtype
81+
self.ndim = len(shape)
82+
self.info = info
83+
84+
def __getitem__(self, slices):
85+
"""
86+
Read data hyperslice from file.
87+
No cacheing is performed, so repeated access
88+
will open and read the file again.
89+
90+
Uses h5py to access data because it has lower overhead
91+
when opening and closing files than NetCDF.
92+
"""
93+
if self.info:
94+
print(f"Reading {self.filepath}:{self.varname}:{slices}")
95+
with h5py.File(self.filepath, "r") as f:
96+
return f[self.varname][slices]
97+
98+
99+
def make_chunkinfo(
100+
metadata: dict, keep_xboundaries: bool = True, keep_yboundaries: bool = True
101+
):
102+
"""
103+
Identify processor layout and the array slices to be extracted
104+
from each file. Handles single and double-null configurations.
105+
106+
metadata: dict
107+
Dictionary of scalars read from the first processor's file
108+
keep_xboundaries: bool
109+
Keep the MXG cells on inner and outer X boundaries
110+
keep_yboundaries: bool
111+
Keep MYG cells on all Y boundaries (upper and lower targets if DN)
112+
113+
Returns a dict that is used in make_lazy_array.
114+
"""
115+
NXPE = metadata["NXPE"]
116+
NYPE = metadata["NYPE"]
117+
118+
MXG = metadata["MXG"]
119+
MYG = metadata["MYG"]
120+
121+
MXSUB = metadata["MXSUB"]
122+
MYSUB = metadata["MYSUB"]
123+
124+
# Double null if it has upper legs
125+
is_double_null = metadata["jyseps2_1"] != metadata["jyseps1_2"]
126+
127+
# Number of processors before the upper targets
128+
nyproc_inner = metadata["ny_inner"] // MYSUB
129+
130+
# Size of the (x,y) array in each file
131+
nxsub = MXSUB + 2 * MXG
132+
nysub = MYSUB + 2 * MYG
133+
134+
# Indices and size of each file's chunk
135+
xchunks = []
136+
xslices = []
137+
for i in range(NXPE):
138+
xslice = slice(
139+
0 if (i == 0 and keep_xboundaries) else MXG, # Skip guard cells
140+
nxsub if (i == (NXPE - 1) and keep_xboundaries) else nxsub - MXG,
141+
)
142+
xchunks.append(xslice.stop - xslice.start)
143+
xslices.append(xslice)
144+
xchunks = tuple(xchunks)
145+
146+
ychunks = []
147+
yslices = []
148+
for j in range(NYPE):
149+
yslice = slice(
150+
(
151+
0
152+
if (
153+
keep_yboundaries
154+
and (
155+
j == 0 # Lower inner target
156+
or (is_double_null and j == nyproc_inner)
157+
) # Upper outer target
158+
)
159+
else MYG
160+
), # Skip guard cells
161+
(
162+
nysub
163+
if (
164+
keep_yboundaries
165+
and (j == (NYPE - 1) or (is_double_null and j == nyproc_inner - 1))
166+
)
167+
else (nysub - MYG)
168+
),
169+
)
170+
ychunks.append(yslice.stop - yslice.start)
171+
yslices.append(yslice)
172+
ychunks = tuple(ychunks)
173+
174+
return {
175+
"NXPE": NXPE, # Number of processors in X
176+
"NYPE": NYPE, # Number of processors in Y
177+
"nxsub": nxsub, # X size of the array in each file
178+
"nysub": nysub, # Y size of the array in each file
179+
"xslices": xslices, # List of slices in X
180+
"xchunks": xchunks, # Tuple of slice sizes in X
181+
"yslices": yslices, # List of slices in Y
182+
"ychunks": ychunks, # Tuple of slice sizes in Y
183+
}
184+
185+
186+
def make_lazy_array(
187+
datafilepath,
188+
ds,
189+
chunkinfo: dict,
190+
varname,
191+
prefix: str = "BOUT.dmp",
192+
info: bool = False,
193+
):
194+
"""
195+
Creates a lazy-loaded array, gathering data
196+
from a collection of NetCDF files.
197+
198+
The array must have 'x' and 'y' dimensions but can
199+
have an arbitrary number of other dimensions.
200+
201+
datafilepath : str or Path
202+
Directory containing BOUT.dmp.*.nc files
203+
ds : xarray.DataSet
204+
DataSet from one file
205+
chunkinfo : dict
206+
Describes processor layouts
207+
varname : str
208+
Name of the variable to read
209+
"""
210+
NXPE = chunkinfo["NXPE"]
211+
NYPE = chunkinfo["NYPE"]
212+
xslices = chunkinfo["xslices"]
213+
yslices = chunkinfo["yslices"]
214+
xchunks = chunkinfo["xchunks"]
215+
ychunks = chunkinfo["ychunks"]
216+
217+
# Get shape and type of the array in one file.
218+
# These are assumed to be the same for all files
219+
dtype = ds[varname].dtype
220+
file_shape = ds[varname].shape
221+
222+
# Find x and y dimension indices
223+
xdim = ds[varname].dims.index("x")
224+
ydim = ds[varname].dims.index("y")
225+
ndims = len(file_shape)
226+
227+
# Check x and y dimension sizes
228+
assert file_shape[xdim] == chunkinfo["nxsub"]
229+
assert file_shape[ydim] == chunkinfo["nysub"]
230+
231+
# The name serves two purposes:
232+
# 1. Graph key prefix — it's the first element of every task key tuple
233+
# (name, i, j, ...). Dask uses these keys to identify tasks in the graph,
234+
# so the name must be unique across all arrays in a computation to avoid
235+
# accidental key collisions between arrays that would cause one array's
236+
# tasks to silently substitute for another's.
237+
# 2. Cache/fusion identity — when dask optimizes or fuses graphs, arrays with
238+
# the same name are assumed to be identical. If two Array objects share
239+
# a name, dask will treat them as the same array and only compute it once
240+
# in a joint dask.compute() call. This is the mechanism behind deduplication.
241+
name = f"load-{varname}-{dask.base.tokenize(datafilepath, varname, chunkinfo)}"
242+
243+
# Create a dict of tasks.
244+
dsk = {}
245+
for i in range(NXPE):
246+
xslice = xslices[i]
247+
for j in range(NYPE):
248+
yslice = yslices[j]
249+
250+
filepath = os.path.join(datafilepath, f"{prefix}.{j * NXPE + i}.nc")
251+
# Create a lazy-loaded array
252+
lazy = LazyFileArray(filepath, varname, file_shape, dtype, info=info)
253+
254+
# Store the lazy object as a separate key.
255+
# lazy_name resolves to an object, not an array of data.
256+
# Dask only reads data when a task returns a numpy array.
257+
lazy_name = (f"lazy-{name}", i, j)
258+
dsk[lazy_name] = lazy
259+
260+
# The integer indices in the task key tuple
261+
# (name, i0, i1, i2, i3) directly map to chunk positions
262+
chunkpos = tuple(
263+
i if d == xdim else j if d == ydim else 0 for d in range(ndims)
264+
)
265+
266+
# Keep all dimensions but slice in x and y
267+
slices = [
268+
slice(None),
269+
] * ndims
270+
slices[xdim] = xslice
271+
slices[ydim] = yslice
272+
slices = tuple(slices)
273+
274+
# Slice the LazyFileArray to remove boundary cells.
275+
# Dask can fuse slices only if they use dask.array.core.getter
276+
# The optimization is in _optimize_slices implemented here:
277+
# https://github.com/dask/dask/blob/main/dask/array/optimization.py#L94
278+
# getter then passes slices through to LazyFileArray so that only the
279+
# required data is read from disk.
280+
dsk[(name, *chunkpos)] = (
281+
getter,
282+
lazy_name,
283+
slices, # Remove boundary cells
284+
False, # asarray
285+
False, # lock
286+
)
287+
# Chunk sizes. Use file_shape except in 'x' and 'y' dimensions
288+
chunks = [(size,) for size in file_shape]
289+
chunks[xdim] = xchunks
290+
chunks[ydim] = ychunks
291+
chunks = tuple(chunks)
292+
293+
return dask.array.Array(dsk, name, chunks, dtype=dtype)
294+
295+
296+
def lazy_open_boutdataset(
297+
datapath,
298+
keep_xboundaries: bool = False,
299+
keep_yboundaries: bool = False,
300+
is_restart: bool = False,
301+
info: bool = False,
302+
**kwargs,
303+
):
304+
"""
305+
Open a multi-file dataset by only opening one file.
306+
Dask chunks are created for all processors using the
307+
metadata read from the first file.
308+
309+
datapath : str or Path
310+
Directory containing the BOUT++ data files
311+
312+
keep_xboundaries : bool, optional
313+
If true, keep x-direction boundary cells (the cells past the
314+
physical edges of the grid, where boundary conditions are
315+
set); increases the size of the x dimension in the returned
316+
data-set. If false, trim these cells.
317+
318+
keep_yboundaries : bool, optional
319+
If true, keep y-direction boundary cells (the cells past the
320+
physical edges of the grid, where boundary conditions are
321+
set); increases the size of the y dimension in the returned
322+
data-set. If false, trim these cells.
323+
324+
"""
325+
prefix = "BOUT.restart" if is_restart else "BOUT.dmp"
326+
327+
# Open first file to read metadata
328+
ds = xr.open_dataset(os.path.join(datapath, f"{prefix}.0.nc"))
329+
330+
# Extract all scalars as metadata
331+
metadata = {
332+
name: var.item() for name, var in ds.data_vars.items() if len(var.dims) == 0
333+
}
334+
335+
# Identify processor layout and the array slices from each file
336+
chunkinfo = make_chunkinfo(
337+
metadata, keep_xboundaries=keep_xboundaries, keep_yboundaries=keep_yboundaries
338+
)
339+
340+
# Process all data variables
341+
data_vars = {}
342+
for name, var in ds.data_vars.items():
343+
if "x" in var.dims and "y" in var.dims:
344+
# Array distributed over processors in x and y
345+
data_vars[name] = xr.DataArray(
346+
make_lazy_array(
347+
datapath, ds, chunkinfo, name, prefix=prefix, info=info
348+
),
349+
dims=var.dims,
350+
attrs=var.attrs,
351+
)
352+
elif len(var.dims) == 0:
353+
continue # scalars already in metadata
354+
elif ("x" not in var.dims) and ("y" not in var.dims):
355+
# Take DataArray from first processor
356+
data_vars[name] = var
357+
else:
358+
# If only 'x' or only 'y' dimension then skip
359+
warnings.warn(
360+
f"Variable '{name}' has only one of x/y dimensions and will be skipped"
361+
)
362+
363+
coords = {}
364+
if "t_array" in ds:
365+
coords["t"] = ds["t_array"].values
366+
367+
# Create a global dataset
368+
return xr.Dataset(data_vars, coords=coords, attrs={"metadata": metadata})

0 commit comments

Comments
 (0)