Skip to content

Commit ec0c181

Browse files
authored
Speed up batch CMORisation: multi-process Dask, per-variable worker sizing, and skip redundant daily-file validation (#499)
* update batch_processing dask config * update dask defination in batch processing script * pre-commit fix
1 parent 6794346 commit ec0c181

3 files changed

Lines changed: 195 additions & 48 deletions

File tree

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
"""Adaptive Dask client sizing for batch CMORisation.
2+
3+
Each variable is CMORised in its own PBS job. The job's cost is dominated by
4+
reading thousands of netCDF4 files, and the netCDF4/HDF5 library serialises
5+
every read through a single global lock, so a threaded client (processes=False)
6+
collapses to ~1 core. Multi-process workers each hold their own HDF5 lock, so
7+
reads parallelise across workers.
8+
9+
Worker *count* is a memory trade-off, and the right count depends on the
10+
variable:
11+
12+
* Light single-field variables (e.g. ``Amon.tas``) read a fraction of a MB per
13+
file, so the allocation can be split into many small workers -> maximum read
14+
parallelism.
15+
* Carbon-pool variables (``cVeg``/``cSoil``/``nbp``) read several tiled model
16+
variables per file and need a moderate per-worker budget.
17+
* Daily-input variables that resample to monthly (``tasmax``/``tasmin``) push
18+
large time-axis intermediates plus unmanaged HDF5 buffers through a worker and
19+
need a generous per-worker budget, or a worker exceeds its limit and is killed.
20+
21+
Rather than hard-code a worker count, :func:`recommend_dask_config` probes one
22+
input file for the variable's actual read footprint and picks the largest worker
23+
count whose per-worker budget covers that footprint.
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import os
29+
import sys
30+
31+
# Per-worker memory floors (GB) for the three intensity tiers. These are the
32+
# minimum RAM a single worker needs to process a variable of that class without
33+
# 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
37+
# via an environment variable set in the batch config's worker_init, so nothing
38+
# is locked in code.
39+
_FLOOR_ENV = {
40+
"light": ("MOPPY_WORKER_GB_LIGHT", 12),
41+
"medium": ("MOPPY_WORKER_GB_MEDIUM", 20),
42+
"heavy": ("MOPPY_WORKER_GB_HEAVY", 28),
43+
}
44+
45+
# A variable reading at least this many MB of model-variable data per file is
46+
# treated as at least "medium" intensity (carbon pools read ~8-10 MB/file).
47+
# Overridable for unusual mappings via MOPPY_MEDIUM_MB_PER_FILE.
48+
_MEDIUM_MB_PER_FILE_DEFAULT = 4.0
49+
50+
51+
def _floor_gb(tier):
52+
"""Per-worker memory floor (GB) for a tier, honouring an env override."""
53+
env_name, default = _FLOOR_ENV[tier]
54+
try:
55+
return max(1, int(os.environ.get(env_name, default)))
56+
except (TypeError, ValueError):
57+
return default
58+
59+
60+
def _estimate_worker_memory_gb(variable, input_files, model_id):
61+
"""Estimate the per-worker memory floor (GB) for ``variable``.
62+
63+
Probes the first input file for (a) the number of time steps per file --
64+
sub-monthly input implies a resample-to-monthly with large time-axis
65+
intermediates -- and (b) the total size of the variable's model variables,
66+
which captures tiled / multi-pool variables. Falls back to the heavy tier if
67+
anything about the probe is uncertain, so a bad estimate never under-sizes
68+
memory.
69+
"""
70+
if not input_files:
71+
return _floor_gb("heavy")
72+
try:
73+
import xarray as xr
74+
75+
from access_moppy.utilities import load_model_mappings
76+
77+
_, cmor_name = variable.split(".", 1)
78+
mapping = load_model_mappings(variable, model_id)
79+
model_vars = (mapping.get(cmor_name, {}) or {}).get("model_variables", []) or []
80+
81+
with xr.open_dataset(input_files[0], decode_cf=False) as ds0:
82+
steps_per_file = int(ds0.sizes.get("time", 1))
83+
present = [v for v in model_vars if v in ds0.variables]
84+
per_file_mb = (
85+
sum(ds0[v].size * ds0[v].dtype.itemsize for v in present) / 1e6
86+
)
87+
88+
try:
89+
medium_mb = float(
90+
os.environ.get("MOPPY_MEDIUM_MB_PER_FILE", _MEDIUM_MB_PER_FILE_DEFAULT)
91+
)
92+
except (TypeError, ValueError):
93+
medium_mb = _MEDIUM_MB_PER_FILE_DEFAULT
94+
95+
# Sub-monthly input -> monthly resampling: fat workers.
96+
if steps_per_file > 1:
97+
return _floor_gb("heavy")
98+
# Multiple tiled model variables (carbon pools): moderate workers.
99+
if per_file_mb >= medium_mb:
100+
return _floor_gb("medium")
101+
return _floor_gb("light")
102+
except Exception as exc: # noqa: BLE001 - never let sizing crash the job
103+
print(
104+
f"Worker-memory estimate failed ({exc}); assuming heavy tier.",
105+
file=sys.stderr,
106+
)
107+
return _floor_gb("heavy")
108+
109+
110+
def recommend_dask_config(variable, input_files, model_id, n_cpus=None, mem_gb=None):
111+
"""Return ``dask.distributed.Client`` kwargs sized to ``variable``.
112+
113+
Parameters mirror what the generated batch script already has to hand. The
114+
result is a dict with ``n_workers``, ``threads_per_worker`` and
115+
``memory_limit`` (per worker), for use as ``Client(processes=True, **cfg)``.
116+
"""
117+
import psutil
118+
119+
if n_cpus is None:
120+
n_cpus = int(os.environ.get("PBS_NCPUS", psutil.cpu_count() or 1))
121+
if mem_gb is None:
122+
mem_gb = int(os.environ.get("PBS_MEM_GB", "16"))
123+
124+
system_memory_gb = psutil.virtual_memory().total / (1024**3)
125+
effective_memory = min(mem_gb, system_memory_gb * 0.9)
126+
127+
floor_gb = _estimate_worker_memory_gb(variable, input_files, model_id)
128+
129+
# Largest worker count whose per-worker share still meets the floor, capped
130+
# by CPU count. All parallelism comes from *processes*: netCDF4/HDF5 reads
131+
# serialise on a global lock (and the GIL), so extra threads per worker add
132+
# no read throughput while doubling the in-flight data resident per worker --
133+
# the opposite of what the memory-limited heavy tier needs. Hence one thread
134+
# per worker; memory-limited variables simply use fewer cores.
135+
n_workers = max(1, min(n_cpus, int(effective_memory // floor_gb)))
136+
threads_per_worker = 1
137+
per_worker_gb = max(1, int(effective_memory / n_workers))
138+
139+
idle_cores = max(0, n_cpus - n_workers)
140+
print(
141+
f"Dask sizing for {variable}: {n_cpus} CPUs, {effective_memory:.0f}GB usable, "
142+
f"per-worker floor {floor_gb}GB -> {n_workers} workers x {threads_per_worker} "
143+
f"thread, {per_worker_gb}GB each"
144+
+ (f" ({idle_cores} cores idle: memory-limited)" if idle_cores else "")
145+
)
146+
147+
return {
148+
"n_workers": n_workers,
149+
"threads_per_worker": threads_per_worker,
150+
"memory_limit": f"{per_worker_gb}GB",
151+
}

src/access_moppy/templates/cmor_python_script.j2

Lines changed: 28 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -6,35 +6,15 @@ Generated automatically by ACCESS-MOPPy batch processing
66

77
import os
88
import glob
9+
import logging
910
import sys
1011
from pathlib import Path
1112
import dask.distributed as dd
12-
import psutil
1313

1414
from access_moppy import ACCESS_ESM_CMORiser
15+
from access_moppy.executors.dask_config import recommend_dask_config
1516
from access_moppy.tracking import TaskTracker
1617

17-
def detect_optimal_dask_config():
18-
"""Detect Dask configuration for single-node processes=False mode.
19-
20-
All workers share one Python process, so memory_limit must reflect
21-
the full process budget. n_workers=1 ensures the single worker's
22-
limit equals the PBS allocation rather than a fractional share.
23-
"""
24-
n_cpus = int(os.environ.get('PBS_NCPUS', psutil.cpu_count()))
25-
allocated_memory_gb = int(os.environ.get('PBS_MEM_GB', '16'))
26-
27-
system_memory_gb = psutil.virtual_memory().total / (1024**3)
28-
effective_memory = min(allocated_memory_gb, system_memory_gb * 0.9)
29-
30-
print(f"Detected: {n_cpus} CPUs, {allocated_memory_gb}GB allocated, {system_memory_gb:.1f}GB system")
31-
32-
return {
33-
'n_workers': 1,
34-
'threads_per_worker': n_cpus,
35-
'memory_per_worker': f"{int(effective_memory * 0.9)}GB",
36-
}
37-
3818

3919
def main():
4020
import dask
@@ -44,24 +24,11 @@ def main():
4424
'distributed.worker.memory.pause': 0.8,
4525
})
4626

47-
dask_config = detect_optimal_dask_config()
48-
49-
n_workers = dask_config['n_workers']
50-
threads_per_worker = dask_config['threads_per_worker']
51-
memory_per_worker = dask_config['memory_per_worker']
52-
53-
print(f"Optimal Dask Config: {n_workers} workers, {threads_per_worker} threads each, {memory_per_worker} per worker")
54-
55-
client = dd.Client(
56-
processes=False,
57-
threads_per_worker=threads_per_worker,
58-
n_workers=n_workers,
59-
memory_limit=memory_per_worker,
60-
silence_logs=False
61-
)
62-
63-
64-
print(f'Dask dashboard: {client.dashboard_link}')
27+
# The Dask client is created *after* input files are discovered, so it can
28+
# be sized to this variable's actual read footprint (recommend_dask_config
29+
# reads PBS_NCPUS / PBS_MEM_GB, so it adapts to whatever the job requested).
30+
client = None
31+
tracker = None
6532

6633
try:
6734
# Get values from environment
@@ -97,7 +64,6 @@ def main():
9764
# Auto-discover using the model mapping file_discovery config
9865
from access_moppy.file_discovery import (
9966
FileDiscoveryError,
100-
_diagnose_no_files,
10167
discover_files,
10268
)
10369
try:
@@ -113,6 +79,7 @@ def main():
11379
) from exc
11480

11581
if not input_files:
82+
from access_moppy.file_discovery import _diagnose_no_files
11683
diag = _diagnose_no_files(
11784
Path(input_folder), variable, model_id,
11885
start_year=start_year, end_year=end_year,
@@ -121,6 +88,19 @@ def main():
12188

12289
print(f'Processing {variable} with {len(input_files)} files')
12390

91+
# Size the Dask client to this variable's footprint, then start it.
92+
# processes=True: each worker is a separate process with its own HDF5
93+
# lock, so netCDF4 reads run in parallel across workers (a threaded
94+
# client serialises them on the global HDF5 lock and collapses to ~1
95+
# core). Worker count / memory adapt to PBS_NCPUS / PBS_MEM_GB.
96+
dask_config = recommend_dask_config(variable, input_files, model_id)
97+
client = dd.Client(
98+
processes=True,
99+
silence_logs=logging.WARNING,
100+
**dask_config,
101+
)
102+
print(f'Dask dashboard: {client.dashboard_link}')
103+
124104
# Initialize tracker
125105
tracker = TaskTracker(Path(db_path))
126106

@@ -142,7 +122,7 @@ def main():
142122
output_path=output_folder,
143123
drs_root=drs_root,
144124
cmip_version=cmip_version,
145-
enable_resampling=True,
125+
enable_resampling={{ 'True' if (config['enable_resampling'] | default(true)) else 'False' }},
146126
)
147127

148128
cmoriser.run()
@@ -163,11 +143,12 @@ def main():
163143
# Release the sqlite handle before the process exits so the journal
164144
# file (DELETE mode) is unlinked promptly. On Lustre, lingering
165145
# handles can leave .db-journal behind and confuse downstream tools.
166-
try:
167-
tracker.close()
168-
except Exception:
169-
pass
170-
if client.status != 'closed':
146+
if tracker is not None:
147+
try:
148+
tracker.close()
149+
except Exception:
150+
pass
151+
if client is not None and client.status != 'closed':
171152
client.close()
172153

173154
if __name__ == '__main__':

src/access_moppy/utilities.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -956,7 +956,22 @@ def _validate_monthly_compatibility(
956956
monthly_max = 35 * 86400 # 35 days in seconds
957957

958958
if not (monthly_min <= freq_seconds <= monthly_max):
959-
# If concatenated detection doesn't give monthly range, validate individual files
959+
# Sub-monthly input (e.g. daily) is expected and valid for variables
960+
# that aggregate to monthly (tasmax/tasmin). The sampled concatenated
961+
# detection already confirms the frequency, so accept it directly
962+
# rather than re-opening every input file one-by-one -- that
963+
# exhaustive per-file loop can take tens of minutes for a multi-year
964+
# daily run while verifying something already known.
965+
if allow_submonthly and freq_seconds < monthly_min:
966+
logger.debug(
967+
"Sub-monthly frequency (%s) confirmed from sample - valid for "
968+
"monthly aggregation, skipping per-file validation",
969+
detected_freq,
970+
)
971+
return detected_freq
972+
973+
# Otherwise the frequency is unexpected: fall back to opening each
974+
# file individually so any inconsistent/rogue file can be pinpointed.
960975
logger.debug(
961976
"Concatenated frequency (%s) not in monthly range, validating individual files...",
962977
detected_freq,

0 commit comments

Comments
 (0)