|
| 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