Getting started with dask_setup
HPC-aware Dask clusters in one line, for NCI Gadi (PBS) and SLURM sites.
This notebook works through dask_setup from the one-line quick start down to profiles and
multi-node job scripts. Every cell runs on a laptop; where behaviour differs on Gadi, the notebook
simulates the PBS environment so you can see what would happen on a compute node.
# |
Section |
You will learn |
|---|---|---|
1 |
Why |
What goes wrong with a bare |
2 |
Quick start |
The one-line setup, and cleaning up |
3 |
What it decided |
Resource detection, worker topology, memory limits |
4 |
Choosing a workload type |
|
5 |
Context manager & reports |
|
6 |
Working with xarray |
|
7 |
Configuration & profiles |
|
8 |
Memory tuning |
|
9 |
Beyond one node |
|
10 |
When things go wrong |
Errors that tell you how to fix them |
Prerequisites: pip install "dask-setup" xarray numpy (add dask-setup[multinode] for section 9
on a real cluster).
1. Why dask_setup?
A bare LocalCluster() on an HPC compute node usually makes three bad decisions:
It sizes itself to the whole machine, not your job. A Gadi
normalsrnode has 104 cores, but if PBS gave youncpus=48,psutilstill reports 104. You oversubscribe the node and your job gets throttled or killed.It spills to the wrong disk. Spill files land in the current directory, which is often
/scratchor/homeon Lustre, the slowest and most contended filesystem on the machine. The node-local SSD at$PBS_JOBFSsits unused.It picks one topology for every workload. Reading NetCDF, crunching NumPy and writing Zarr want very different process/thread layouts.
setup_dask_client() reads the scheduler’s environment variables, routes spill to $PBS_JOBFS,
and chooses the worker layout from the kind of work you tell it you’re doing.
import dask
import dask.array as da
import distributed
import dask_setup
from dask_setup import setup_dask_client
print(f"dask_setup {dask_setup.__version__}")
print(f"dask {dask.__version__}")
print(f"distributed {distributed.__version__}")
dask_setup 2.3.0
dask 2026.8.0
distributed 2026.8.0
2. Quick start
The whole API for most users is one call:
client, cluster, dask_tmp = setup_dask_client(workload_type="cpu", max_workers=4)
[setup_dask_client] Configuration summary
temp/spill dir: /tmp/dask-3457
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
It returns three things:
client: a normaldask.distributed.Client, so everything you already know about Dask works unchanged.cluster: the underlyingLocalCluster.dask_tmp: the directory where workers spill to disk. On Gadi this is under$PBS_JOBFS.
With dashboard=True (the default) it also prints the dashboard address and, on a remote node, the
exact ssh -N -L ... command to tunnel it back to your laptop.
max_workers=4just keeps this demo small. On a real job, leave it out anddask_setupuses every core PBS/SLURM gave you.⚠️ In a PBS/SLURM batch job, add
mode="local". The defaultmode="auto"detects the batch job and switches to the multi-node backend (section 9), which submits more jobs.
client
Client
Client-ca797d9a-bd41-11f1-8d81-6045bd34f621
| Connection method: Cluster object | Cluster type: distributed.LocalCluster |
| Dashboard: http://127.0.0.1:39157/status |
Cluster Info
LocalCluster
e7a0286e
| Dashboard: http://127.0.0.1:39157/status | Workers: 4 |
| Total threads: 4 | Total memory: 11.61 GiB |
| Status: running | Using processes: True |
Scheduler Info
Scheduler
Scheduler-f011f3b0-0217-4f7f-8c25-59d9a99908c1
| Comm: tcp://127.0.0.1:43745 | Workers: 0 |
| Dashboard: http://127.0.0.1:39157/status | Total threads: 0 |
| Started: Just now | Total memory: 0 B |
Workers
Worker: 0
| Comm: tcp://127.0.0.1:46879 | Total threads: 1 |
| Dashboard: http://127.0.0.1:40969/status | Memory: 2.90 GiB |
| Nanny: tcp://127.0.0.1:33647 | |
| Local directory: /tmp/dask-3457/dask-scratch-space/worker-bie_aqtr | |
Worker: 1
| Comm: tcp://127.0.0.1:39603 | Total threads: 1 |
| Dashboard: http://127.0.0.1:35453/status | Memory: 2.90 GiB |
| Nanny: tcp://127.0.0.1:35009 | |
| Local directory: /tmp/dask-3457/dask-scratch-space/worker-7q45ku2m | |
Worker: 2
| Comm: tcp://127.0.0.1:46553 | Total threads: 1 |
| Dashboard: http://127.0.0.1:40609/status | Memory: 2.90 GiB |
| Nanny: tcp://127.0.0.1:46767 | |
| Local directory: /tmp/dask-3457/dask-scratch-space/worker-59xnotxf | |
Worker: 3
| Comm: tcp://127.0.0.1:35097 | Total threads: 1 |
| Dashboard: http://127.0.0.1:40197/status | Memory: 2.90 GiB |
| Nanny: tcp://127.0.0.1:42355 | |
| Local directory: /tmp/dask-3457/dask-scratch-space/worker-066ctm7d | |
print("Spill directory:", dask_tmp)
# Any Dask code now runs on this cluster
x = da.random.random((20_000, 20_000), chunks=(2_000, 2_000))
result = (x + x.T).mean().compute()
print(f"Result: {result:,.2f}")
Spill directory: /tmp/dask-3457
Result: 1.00
Always close the client and cluster when you’re done, otherwise worker processes stay behind in your job. Section 5 shows a context manager that does this for you.
client.close()
cluster.close()
3. What did it decide?
setup_dask_client() orchestrates a small pipeline of decision functions. You can call each one
yourself to see (or debug) what it will do:
detect_resources() SLURM env → PBS env → psutil
create_dask_temp_dir() $PBS_JOBFS → $TMPDIR → /tmp
decide_topology() workload type + cores → n_workers × threads_per_worker
calculate_memory_spec() total RAM − reserve → memory limit per worker
3a. Resource detection
On a laptop this falls through to psutil:
from dask_setup.resources import detect_resources
res = detect_resources()
print(f"Detected via : {res.detection_method}")
print(f"Cores : {res.total_cores}")
print(f"Memory : {res.total_mem_bytes / 2**30:.1f} GiB")
Detected via : psutil
Cores : 4
Memory : 15.6 GiB
Now let’s pretend we’re inside a Gadi PBS job with ncpus=48, mem=190gb. PBS exports these as
environment variables, and dask_setup reads them in preference to what the hardware reports.
Tip: use the same trick to test PBS behaviour from a login node or laptop:
NCPUS=48 PBS_MEM=190gb PBS_JOBFS=/tmp python my_script.py
import os
from contextlib import contextmanager
@contextmanager
def simulated_env(**env):
# Temporarily set environment variables, restoring the originals afterwards.
saved = {k: os.environ.get(k) for k in env}
os.environ.update(env)
try:
yield
finally:
for k, v in saved.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
with simulated_env(NCPUS="48", PBS_MEM="190gb"):
gadi = detect_resources()
print(f"Detected via : {gadi.detection_method}")
print(f"Cores : {gadi.total_cores}")
print(f"Memory : {gadi.total_mem_bytes / 2**30:.1f} GiB")
Detected via : PBS
Cores : 48
Memory : 177.0 GiB
3b. Worker topology
Given those 48 cores, here is the layout each workload type produces:
from dask_setup.topology import decide_topology
print(f"{'workload':<10}{'workers':>9}{'threads/worker':>16}{'processes':>11}")
print("-" * 46)
for wt in ["cpu", "mixed", "io"]:
t = decide_topology(wt, total_cores=gadi.total_cores)
print(f"{wt:<10}{t.n_workers:>9}{t.threads_per_worker:>16}{str(t.processes):>11}")
workload workers threads/worker processes
----------------------------------------------
cpu 48 1 True
mixed 24 2 True
io 1 16 False
3c. Memory per worker
dask_setup holds back reserve_mem_gb for the OS, page cache and filesystem clients, then splits
the rest evenly. Each worker gets a hard limit, and Dask’s spill thresholds (target 75%, spill 85%,
pause 92%, terminate 98%) are measured against that limit.
from dask_setup.cluster import calculate_memory_spec
topo = decide_topology("cpu", total_cores=gadi.total_cores)
mem = calculate_memory_spec(gadi.total_mem_bytes, topo.n_workers, reserve_mem_gb=20)
print(f"Node memory : {mem.total_mem_gib:.1f} GiB")
print(f"Reserved for OS : {mem.reserved_mem_gb:.1f} GiB")
print(f"Usable by Dask : {mem.usable_mem_gb:.1f} GiB")
print(f"Per worker (x{topo.n_workers}) : {mem.mem_per_worker_bytes / 2**30:.2f} GiB")
Node memory : 177.0 GiB
Reserved for OS : 20.0 GiB
Usable by Dask : 157.0 GiB
Per worker (x48) : 3.27 GiB
4. Choosing a workload type
This is the most important choice you make, and the rule is about the GIL (Python’s Global Interpreter Lock), not about whether the work “feels like” I/O or compute.
|
Layout |
Use when the hot code… |
Typical examples |
|---|---|---|---|
|
many processes × 1 thread |
holds the GIL |
NetCDF / HDF5 reads, pure-Python loops, pandas |
|
1 process × 8–16 threads |
releases the GIL |
Zarr reads/writes, NumPy/BLAS, cloud object storage |
|
processes × 2 threads |
does a bit of both |
general analysis pipelines |
|
1 process per GPU |
runs on GPUs |
CuPy / RAPIDS |
|
inferred |
you pass |
see section 6 |
⚠️ The NetCDF trap
It seems natural to call “reading lots of NetCDF files” an I/O workload. It isn’t, for Dask.
The netCDF4/HDF5 C library serialises every read behind a single process-wide lock. With "io"
you get one process and 16 threads, and only one of those threads can read at a time.
NetCDF / HDF5 input →
"cpu"(each process has its own lock, so reads run in parallel)Zarr input →
"io"(Zarr’s codecs release the GIL, so threads really run concurrently)
dask_setup warns you if you pass a NetCDF-backed dataset with workload_type="io".
5. Context manager and run reports
DaskClientContext takes exactly the same arguments as setup_dask_client() and guarantees
cleanup, even if your analysis raises halfway through. Prefer it in scripts and batch jobs.
DaskClientContext also logs a run summary on exit. cluster_report() gives you the same summary of the run (wall time, workers, memory, spill), which is
handy at the end of a PBS job log.
import time
from dask_setup import DaskClientContext, cluster_report
start = time.monotonic() # cluster_report measures against time.monotonic()
with DaskClientContext(workload_type="mixed", max_workers=2, dashboard=False) as (
client,
cluster,
tmp,
):
x = da.random.random((10_000, 10_000), chunks=(1_000, 1_000))
std = x.std().compute()
print(f"std = {std:.4f}")
report = cluster_report(client, start_time=start)
print(report.summary_line())
# Client and cluster are closed here, even if an exception was raised above
[setup_dask_client] Configuration summary
temp/spill dir: /tmp/dask-3457
Workers: 2 | threads/worker: 2 | processes: True
Memory: total ~15.6 GiB | usable ~11.6 GiB | per-worker ~5.8 GiB
Compression: spill=auto | comm=False
std = 0.2887
wall=1.2s | workers=2 | mem=0.06 GiB/max
6. Working with xarray
Most dask_setup users are working with gridded climate/weather data in xarray. Pass your dataset
as ds= and setup_dask_client() will:
Validate its current chunks against per-worker memory (warning about OOM-sized or tiny chunks)
Recommend chunk sizes for this cluster
Return a 4-tuple:
(client, cluster, dask_tmp, chunks)
Return arity:
ds=None→ 3 values,ds=<dataset>→ 4 values. This is the one thing people trip over. Unpack accordingly.
Let’s build a synthetic “climate” dataset: 10 years of daily data on a 0.25° global grid, lazily.
We chunk it one year per chunk, which is what you often get from xr.open_mfdataset over yearly
files. Each chunk is ~1.4 GiB, far too big for comfortable parallel work.
import numpy as np
import pandas as pd
import xarray as xr
time_ = pd.date_range("2000-01-01", periods=3650, freq="D")
lat = np.linspace(-90, 90, 721)
lon = np.linspace(0, 359.75, 1440)
shape = (len(time_), len(lat), len(lon))
ds = xr.Dataset(
{
"tas": (("time", "lat", "lon"), da.zeros(shape, dtype="float32", chunks=(365, 721, 1440))),
"pr": (("time", "lat", "lon"), da.zeros(shape, dtype="float32", chunks=(365, 721, 1440))),
},
coords={"time": time_, "lat": lat, "lon": lon},
)
print(f"Dataset size: {ds.nbytes / 2**30:.1f} GiB (lazy, nothing is allocated)")
chunk_shape = ds.tas.data.chunksize
print(f"Current chunk shape: {chunk_shape} = {np.prod(chunk_shape) * 4 / 2**30:.2f} GiB per chunk")
Dataset size: 28.2 GiB (lazy, nothing is allocated)
Current chunk shape: (365, 721, 1440) = 1.41 GiB per chunk
Let dask_setup guess the workload type
infer_workload_type() looks at dtypes, dimension names and sizes. It’s what workload_type="auto"
uses under the hood.
from dask_setup import infer_workload_type
print("Inferred workload type:", infer_workload_type(ds))
Inferred workload type: cpu
Get a cluster and chunk recommendation together
client, cluster, dask_tmp, chunks = setup_dask_client(
workload_type="cpu",
max_workers=4,
ds=ds,
dashboard=False,
)
print("Recommended chunks:", chunks)
[setup_dask_client] Configuration summary
temp/spill dir: /tmp/dask-3457
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
Recommended chunks: {'time': 365, 'lat': 360, 'lon': 720}
ds_rechunked = ds.chunk(chunks)
new_shape = ds_rechunked.tas.data.chunksize
print(f"New chunk shape: {new_shape}")
print(f"Size per chunk: {np.prod(new_shape) * 4 / 2**20:.0f} MiB")
print(
f"Number of tasks per variable: {ds.tas.data.npartitions:,} → {ds_rechunked.tas.data.npartitions:,}"
)
New chunk shape: (365, 360, 720)
Size per chunk: 361 MiB
Number of tasks per variable: 10 → 60
The recommendation depends on the workload type. "cpu" cuts the data into square-ish blocks, which
suits compute. "io" keeps each time step whole and slices only along time, which suits streaming
reads and writes:
from dask_setup import recommend_chunks, validate_chunks
for wt in ["cpu", "io"]:
print(f"{wt:>4}: {dict(recommend_chunks(ds, client=client, workload_type=wt))}")
cpu: {'time': 365, 'lat': 360, 'lon': 720}
io: {'time': 129}
You can also call the chunking helpers directly against a running client. verbose=True prints a
report and returns a ChunkRecommendation object.
rec = recommend_chunks(ds, client=client, workload_type="cpu", verbose=True)
print(rec)
# validate_chunks() flags chunks that risk OOM (> half a worker's memory) or are tiny (< 10 MiB)
issues = validate_chunks(ds, client=client)
print("\nIssues with the ORIGINAL chunks:", issues or "none (big, but they still fit in a worker)")
📊 Xarray Chunking Recommendations
===================================
Recommended chunks: {'time': 365, 'lat': 360, 'lon': 720}
Estimated chunk size: 360.9 MiB
Total chunks: 60
Current vs Recommended:
-------------------------
lon: (1440,) → 720
lat: (721,) → 360
time: (365, 365, 365, 365, 365, 365, 365, 365, 365, 365) → 365
Usage:
ds_chunked = ds.chunk({'time': 365, 'lat': 360, 'lon': 720})
ChunkRecommendation(chunks={'time': 365, 'lat': 360, 'lon': 720}, estimated_chunk_mb=360.9, total_chunks=60)
Issues with the ORIGINAL chunks: none (big, but they still fit in a worker)
client.close()
cluster.close()
7. Configuration and profiles
Passing six keyword arguments in every notebook gets old fast. dask_setup has three layers of
configuration, resolved lowest → highest priority:
library defaults < config= or profile= < explicit keyword arguments
An argument you pass explicitly always wins, even if it equals the default. Anything you leave out is inherited from the profile/config.
7a. A reusable config object
from dask_setup import DaskSetupConfig
my_config = DaskSetupConfig(
workload_type="cpu",
max_workers=4,
reserve_mem_gb=4.0,
dashboard=False,
memory_spill=0.80, # start spilling a little earlier than the 0.85 default
)
# Explicit keyword beats the config: we get 2 workers, not 4
with DaskClientContext(config=my_config, max_workers=2) as (client, cluster, tmp):
print("Workers:", len(client.nthreads()))
[setup_dask_client] Configuration summary
temp/spill dir: /tmp/dask-3457
Workers: 2 | threads/worker: 1 | processes: True
Memory: total ~15.6 GiB | usable ~11.6 GiB | per-worker ~5.8 GiB
Compression: spill=auto | comm=False
Workers: 2
7b. Built-in profiles
dask_setup ships five profiles for common situations:
from dask_setup import ConfigManager
mgr = ConfigManager()
for name, prof in mgr.list_profiles().items():
c = prof.config
tag = "builtin" if prof.builtin else "user/site"
print(f"{name:<18} [{tag:<9}] workload={c.workload_type:<5} {prof.description}")
climate_analysis [builtin ] workload=cpu Optimized for climate data analysis with large arrays and heavy compute
zarr_io_heavy [builtin ] workload=io Optimized for heavy Zarr I/O operations with many files
development [builtin ] workload=mixed Lightweight configuration for development and testing
production [builtin ] workload=mixed Robust production configuration with conservative memory usage
interactive [builtin ] workload=mixed Optimized for interactive Jupyter notebook usage
Use one by name. profile="auto" picks one based on the detected resources (e.g.
climate_analysis on a large Gadi node, development on a laptop).
with DaskClientContext(profile="development", dashboard=False) as (client, cluster, tmp):
print(f"development profile → {len(client.nthreads())} worker(s)")
print("profile='auto' would pick:", mgr.auto_select_profile())
[setup_dask_client] Configuration summary
temp/spill dir: /tmp/dask-3457
Workers: 2 | threads/worker: 2 | processes: True
Memory: total ~15.6 GiB | usable ~7.6 GiB | per-worker ~3.8 GiB
Compression: spill=auto | comm=False
development profile → 2 worker(s)
profile='auto' would pick: interactive
7c. Your own profiles
Save a profile once, then use it from any notebook or script. Profiles are YAML files looked up in this order, so yours shadow the site’s and the site’s shadow the builtins:
~/.dask_setup/profiles/(you)$DASK_SETUP_PROFILE_DIRor/etc/dask_setup/profiles/(site-wide, set up by admins)Built-ins
To keep this tutorial from touching your real
~/.dask_setup, we point the manager at a throwaway directory. In practice you’d just useConfigManager().
import tempfile
from pathlib import Path
from dask_setup.config import ConfigProfile
demo_dir = Path(tempfile.mkdtemp(prefix="dask_setup_demo_"))
demo_mgr = ConfigManager(config_dir=demo_dir)
demo_mgr.save_profile(
ConfigProfile(
name="era5_netcdf",
config=DaskSetupConfig(
workload_type="cpu", # NetCDF → cpu (see section 4)
reserve_mem_gb=20.0,
memory_spill=0.80,
description="ERA5 NetCDF processing on Gadi normalsr",
tags=["era5", "netcdf", "gadi"],
),
)
)
print((demo_dir / "profiles" / "era5_netcdf.yaml").read_text())
builtin: false
config:
adaptive: false
comm_compression: false
dashboard: true
dashboard_port: null
description: ERA5 NetCDF processing on Gadi normalsr
io_access_pattern: auto
io_compression_level: null
io_format: null
io_storage_location: auto
io_target_chunk_mb:
- 128
- 512
max_mem_gb: null
max_workers: null
memory_pause: 0.92
memory_spill: 0.8
memory_target: 0.75
memory_terminate: 0.98
min_workers: null
name: era5_netcdf
reserve_mem_gb: 20.0
silence_logs: false
spill_compression: auto
spill_threads: null
suggest_chunks: false
tags:
- era5
- netcdf
- gadi
temp_base_dir: null
workload_type: cpu
created_at: '2026-10-01T02:42:55.117828'
modified_at: '2026-10-01T02:42:55.117828'
name: era5_netcdf
version: '1.7'
7d. The dask-setup CLI
The same profiles are manageable from the shell:
dask-setup list # all profiles, with where each comes from
dask-setup show climate_analysis # full settings for one profile
dask-setup create my_profile # interactive wizard
dask-setup validate # check every profile parses
dask-setup schema > profile.json # JSON Schema for editor autocomplete on YAML profiles
!dask-setup list
Built-in Profiles:
climate_analysis - Optimized for climate data analysis with large arrays and heavy compute [climate, cpu-heavy, large-memory, analysis]
development - Lightweight configuration for development and testing [development, testing, lightweight, local]
interactive - Optimized for interactive Jupyter notebook usage [interactive, jupyter, notebook, balanced]
production - Robust production configuration with conservative memory usage [production, robust, conservative, adaptive]
zarr_io_heavy - Optimized for heavy Zarr I/O operations with many files [zarr, io-heavy, files, storage]
8. Memory tuning
If a job spills a lot, workers spend their time writing to disk. If it never spills, you’re leaving
headroom unused. tune_memory_thresholds() reads the spill metrics from a running cluster and nudges
memory.target / memory.spill on every worker:
little/no spill → tighten (spill earlier, keep more headroom against OOM)
heavy spill → loosen (let workers hold more in memory before spilling)
Call it between phases of a long pipeline, once there is real spill to react to.
(setup_dask_client(adaptive_memory=True) runs it once at startup, before anything has spilled, so
it only ever tightens.)
from dask_setup import tune_memory_thresholds
with DaskClientContext(workload_type="cpu", max_workers=2, dashboard=False) as (
client,
cluster,
tmp,
):
da.random.random((5_000, 5_000), chunks=1_000).sum().compute()
result = tune_memory_thresholds(client)
print(result.summary())
[setup_dask_client] Configuration summary
temp/spill dir: /tmp/dask-3457
Workers: 2 | threads/worker: 1 | processes: True
Memory: total ~15.6 GiB | usable ~11.6 GiB | per-worker ~5.8 GiB
Compression: spill=auto | comm=False
Memory thresholds adjusted on 2 worker(s): target 75%→70%, spill 85%→80% (low spill (0.00 GiB < 0.50 GiB threshold); tightened thresholds for extra head-room)
9. Beyond one node
When one node isn’t enough, dask_setup drives dask-jobqueue to submit worker jobs to
PBS or SLURM. The same setup_dask_client() call handles it through mode=:
|
What happens |
|---|---|
|
Uses |
|
Always a single-node |
|
Scheduler runs here; workers are submitted as separate batch jobs |
|
Inside an interactive job ( |
So for the everyday case, one batch job using the cores it was given, pass mode="local".
Describe the worker jobs with a MultiNodeConfig:
from dask_setup import MultiNodeConfig, detect_cluster_mode
mn = MultiNodeConfig(
workload_type="cpu",
workers_per_node=12,
cores_per_worker=4,
mem_per_worker_gb=15,
walltime="04:00:00",
queue="normalsr",
project="ab12",
job_extra_directives=["-l storage=gdata/rt52+scratch/ab12", "-l jobfs=200GB"],
env_extra=["module use /g/data/xp65/public/modules", "module load conda/analysis3"],
adaptive=True,
min_jobs=1,
max_jobs=8,
)
print(f"Per job: {mn.total_cores_per_job} cores, {mn.total_mem_gb_per_job:.0f} GB")
print("Mode detected here:", detect_cluster_mode())
Per job: 48 cores, 180 GB
Mode detected here: local
On Gadi you’d then run:
client, cluster, dask_tmp = setup_dask_client(mode="pbs", multi_node_config=mn)
That needs a real PBS scheduler, so here we’ll generate the equivalent job script instead. This is also a quick way to submit a whole analysis script as a batch job:
from dask_setup import generate_pbs_script
print(generate_pbs_script(mn, script_path="analysis.py"))
#!/bin/bash
#PBS -l ncpus=48,mem=180GB,walltime=04:00:00
#PBS -q normalsr
#PBS -P ab12
#PBS -l storage=gdata/rt52+scratch/ab12
#PBS -l jobfs=200GB
# --- Environment ---
set -euo pipefail
module use /g/data/xp65/public/modules
module load conda/analysis3
# --- Run ---
python3 analysis.py
generate_slurm_script() does the same for SLURM sites, and dask-setup submit wraps both from the
command line.
10. When things go wrong
dask_setup raises specific exception types whose messages name a concrete fix. All inherit from
DaskSetupError, so you can catch that one type if you want to.
from dask_setup.exceptions import DaskSetupError
try:
DaskSetupConfig(workload_type="gpu-please", max_workers=0)
except DaskSetupError as e:
print(type(e).__name__)
print(e)
ConfigurationValidationError
❌ [CONFIG_VALIDATION] Configuration validation failed:
• workload_type: must be one of ['auto', 'cpu', 'gpu', 'io', 'mixed'] (got 'gpu-please') (current: gpu-please)
• max_workers: must be positive (got 0) (current: 0)
💡 Suggestions:
1. Use workload_type='io' for I/O intensive tasks, 'cpu' for compute, or 'mixed'
2. Set max_workers to a value between 1 and 4
📖 Documentation: https://github.com/dask-contrib/dask_setup#configuration
Every problem is reported at once, not one per attempt, and each comes with a suggested fix.
Where it can, dask_setup adapts rather than fails. Here we ask for 4 workers but cap Dask at
2 GiB, which is not enough for 4 workers with at least 1 GiB each. The worker count drops to fit and
the message tells you which setting to change:
with DaskClientContext(
workload_type="cpu", max_workers=4, max_mem_gb=2, reserve_mem_gb=1, dashboard=False
) as (client, cluster, tmp):
print("Workers actually started:", len(client.nthreads()))
WARNING [client] Reducing worker count to fit available memory (requested_workers=4 | fitted_workers=1 | usable_mem_gib=1.0 | min_per_worker_gib=1.0 | hint=raise max_mem_gb or lower reserve_mem_gb for more workers)
[setup_dask_client] Reduced workers 4 -> 1 so each keeps at least 1.0 GiB (1.0 GiB usable).
[setup_dask_client] Configuration summary
temp/spill dir: /tmp/dask-3457
Workers: 1 | threads/worker: 1 | processes: True
Memory: total ~15.6 GiB | usable ~1.0 GiB | per-worker ~1.0 GiB
Compression: spill=auto | comm=False
Workers actually started: 1
Debugging tip: turn on diagnostic logging to see every decision dask_setup makes:
export DASK_SETUP_LOG_LEVEL=DEBUG
or in Python, dask_setup.configure_logging(level="DEBUG").
Cheat sheet
from dask_setup import setup_dask_client, DaskClientContext
# Quick start (script or notebook)
client, cluster, dask_tmp = setup_dask_client(workload_type="cpu")
# Inside a PBS/SLURM batch job: stay on this node
client, cluster, dask_tmp = setup_dask_client(workload_type="cpu", mode="local")
# Safe cleanup
with DaskClientContext(workload_type="io") as (client, cluster, dask_tmp):
...
# With an xarray dataset → 4-tuple including recommended chunks
client, cluster, dask_tmp, chunks = setup_dask_client(workload_type="cpu", ds=ds)
ds = ds.chunk(chunks)
# Saved or built-in profile, overriding one setting
client, cluster, dask_tmp = setup_dask_client(profile="climate_analysis", max_workers=24)
# Multi-node on PBS
client, cluster, dask_tmp = setup_dask_client(mode="pbs", multi_node_config=mn)
Your data / code |
|
|---|---|
NetCDF / HDF5 files |
|
Zarr stores, cloud object storage |
|
NumPy-heavy maths on in-memory arrays |
|
Mixed pipeline, not sure |
|
Have a dataset, want a guess |
|
Exercises
Re-run section 3 with
simulated_env(NCPUS="104", PBS_MEM="500gb")(a fullnormalsrnode). How many workers does"cpu"give you, and how much memory does each get?Change the synthetic dataset in section 6 to 1° resolution (
181 × 360). How do the recommended chunks change, and why?Save a profile for your own project’s data and load it with
setup_dask_client(profile=...).Generate a SLURM script for the same
MultiNodeConfigwithgenerate_slurm_script().
Further reading
Recipe notebooks:
examples/recipes/notebooks/(deep dives on each topic above)Project wiki: https://github.com/21centuryweather/dask_setup/wiki
Dask dashboard guide: https://docs.dask.org/en/stable/dashboard.html