Skip to content

Commit 85eba0e

Browse files
authored
Optimise memory allocation in batchprocessing (#501)
* update batch_processing dask config * update dask defination in batch processing script * pre-commit fix * Batch Processing Dask Optimisation — Version 2
1 parent 2820bbd commit 85eba0e

5 files changed

Lines changed: 43 additions & 14 deletions

File tree

src/access_moppy/base.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -486,10 +486,19 @@ def _preprocess(ds):
486486
# combine when multiple required variables are requested.
487487
prefer_by_coords = bool(required_vars and len(required_vars) > 1)
488488

489+
# One dask chunk per file along time. Sub-monthly inputs (e.g.
490+
# daily tasmax/tasmin) are stored with per-timestep on-disk HDF5
491+
# chunking; the default chunks={} inherits that, so a 31-day file
492+
# becomes 31 chunks and the task graph explodes (~650k tasks over
493+
# a multi-decade run), which is what drives the distributed
494+
# workers out of memory on those variables. Collapsing to one
495+
# chunk per file cuts the graph ~26x and the compute memory ~5x
496+
# with bit-identical results; monthly inputs (time size 1) are
497+
# unaffected.
489498
common_kwargs = {
490499
"engine": "netcdf4",
491500
"decode_cf": False,
492-
"chunks": {},
501+
"chunks": {"time": -1},
493502
"data_vars": "minimal",
494503
"coords": "minimal",
495504
"compat": "override",

src/access_moppy/derivations/calc_utils.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,11 @@ def _iter_markers(value):
233233
if np.isfinite(marker):
234234
# Match both exact values and float32-rounded encodings (e.g. 1e20).
235235
atol = max(1e-12, abs(float(np.spacing(np.float32(marker)))))
236-
condition = np.isclose(da, marker, rtol=0.0, atol=atol)
236+
# Use abs(da - marker) <= atol, not np.isclose: np.isclose is not a
237+
# ufunc and eagerly computes a Dask-backed array to NumPy (loading
238+
# the whole series into memory). This form is Dask-native/lazy and,
239+
# with rtol=0, is equivalent to np.isclose(da, marker, atol=atol).
240+
condition = abs(da - marker) <= atol
237241
else:
238242
condition = da == marker
239243
mask = condition if mask is None else (mask | condition)

src/access_moppy/executors/dask_config.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,21 @@
3131
# Per-worker memory floors (GB) for the three intensity tiers. These are the
3232
# minimum RAM a single worker needs to process a variable of that class without
3333
# being killed -- a property of the *workload*, not the node -- so they are
34-
# absolute GB, not fractions of the allocation. The defaults are calibrated from
35-
# real runs (daily variables were killed at ~11GB/worker; carbon pools paused
36-
# hard at ~11GB; light variables were fine), and each can be overridden per job
34+
# absolute GB, not fractions of the allocation. Each can be overridden per job
3735
# via an environment variable set in the batch config's worker_init, so nothing
3836
# is locked in code.
37+
#
38+
# Calibrated from full-scale (5352-file / ~446-year) runs once the pipeline was
39+
# made fully streaming (lazy missing-value masks + chunked reads/writes). At that
40+
# point measured per-worker peaks were ~12.5GB (daily), ~10GB (carbon pools) and
41+
# ~6GB (light single-field), all with zero memory-pressure warnings, so the
42+
# floors sit ~1.3-1.5x above the observed peaks. Before streaming, daily
43+
# variables materialised the whole ~18GB series into one worker and needed a
44+
# 28GB floor; that is no longer the case.
3945
_FLOOR_ENV = {
4046
"light": ("MOPPY_WORKER_GB_LIGHT", 12),
41-
"medium": ("MOPPY_WORKER_GB_MEDIUM", 20),
42-
"heavy": ("MOPPY_WORKER_GB_HEAVY", 28),
47+
"medium": ("MOPPY_WORKER_GB_MEDIUM", 14),
48+
"heavy": ("MOPPY_WORKER_GB_HEAVY", 16),
4349
}
4450

4551
# A variable reading at least this many MB of model-variable data per file is

src/access_moppy/templates/cmor_python_script.j2

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,13 @@ def main():
123123
drs_root=drs_root,
124124
cmip_version=cmip_version,
125125
enable_resampling={{ 'True' if (config['enable_resampling'] | default(true)) else 'False' }},
126+
# Stream the write in time-chunks instead of materialising the whole
127+
# variable in one .compute(). Without this the write is a single
128+
# monolithic gather ('finalize') over the entire time series, which
129+
# exceeds a worker's memory on long (multi-century) daily runs and
130+
# kills the job. Chunked writing bounds per-worker memory regardless
131+
# of dataset length.
132+
enable_chunking={{ 'True' if (config['enable_chunking'] | default(true)) else 'False' }},
126133
)
127134

128135
cmoriser.run()

src/access_moppy/vocabulary_processors.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -633,17 +633,22 @@ def normalize_dataset_missing_values(dataset):
633633
current_missing = var.attrs.get("missing_value")
634634
current_fill = var.attrs.get("_FillValue")
635635

636-
# Build conditions for values that should become NaN
636+
# Build conditions for values that should become NaN.
637+
# NOTE: use ``abs(var - m) <= atol`` rather than np.isclose(var, m).
638+
# np.isclose is not a ufunc, so on a Dask-backed array it does not
639+
# dispatch lazily — it eagerly materialises the whole array to NumPy.
640+
# For long daily variables that means loading the entire (multi-GB)
641+
# series into one worker and OOM-killing it. ``abs`` / ``<=`` are
642+
# Dask-native and stay lazy. With rtol=0, this is exactly np.isclose.
637643
nan_conditions = []
644+
atol = 1e-3
638645

639646
# Check for current missing_value
640647
if current_missing is not None:
641648
try:
642649
current_missing = float(current_missing)
643650
if not np.isnan(current_missing): # Don't double-convert NaN
644-
nan_conditions.append(
645-
np.isclose(var, current_missing, rtol=0, atol=1e-3)
646-
)
651+
nan_conditions.append(abs(var - current_missing) <= atol)
647652
except (ValueError, TypeError):
648653
pass
649654

@@ -652,9 +657,7 @@ def normalize_dataset_missing_values(dataset):
652657
try:
653658
current_fill = float(current_fill)
654659
if not np.isnan(current_fill): # Don't double-convert NaN
655-
nan_conditions.append(
656-
np.isclose(var, current_fill, rtol=0, atol=1e-3)
657-
)
660+
nan_conditions.append(abs(var - current_fill) <= atol)
658661
except (ValueError, TypeError):
659662
pass
660663

0 commit comments

Comments
 (0)