Multi-Node Support (v2.0)
dask_setup v2.0 extends the same simple API to multi-node HPC jobs. Two distinct workflows are supported:
Situation |
Correct function |
What it does |
|---|---|---|
Interactive session ( |
|
Starts workers directly on already-allocated nodes — no new job submission |
Batch job submitting worker jobs |
|
Submits new PBS/SLURM child jobs via |
setup_dask_client() defaults to mode="interactive": it uses whatever the current job already has and never submits jobs. Pass mode="pbs" / mode="slurm" (or mode="auto") to submit worker jobs. Changed in v2.3 — the default was "auto", which submitted worker jobs whenever it was called from inside a batch job.
Call |
Laptop / login node |
Single-node job |
Multi-node job (interactive or batch) |
|---|---|---|---|
|
|
|
|
|
|
interactive → |
interactive → |
|
submits jobs |
submits jobs |
submits jobs |
Requires: pip install dask-jobqueue or conda install -c conda-forge dask-jobqueue (only for the batch/PBSCluster/SLURMCluster path)
Quick Start
Interactive session (Jupyter notebook inside qsub -I or salloc)
from dask_setup import setup_interactive_cluster
# Reads PBS_NODEFILE / SLURM_NODELIST to discover allocated nodes.
# Single node → LocalCluster. Multiple nodes → SSHCluster.
client, cluster, dask_tmp = setup_interactive_cluster(workload_type="cpu")
try:
result = ds.mean().compute()
finally:
client.close()
cluster.close()
Or via setup_dask_client, whose default mode="interactive" does the same thing:
from dask_setup import setup_dask_client
client, cluster, dask_tmp = setup_dask_client(workload_type="cpu")
On a single node (or outside any job) this takes the ordinary local path, so ds=-based workload_type="auto" inference, fallback_on_detection_failure and adaptive_memory all apply. On a multi-node allocation it calls setup_interactive_cluster for you.
Batch job — submitting worker jobs
from dask_setup import setup_dask_client, MultiNodeConfig
client, cluster, shared_tmp = setup_dask_client(
mode="auto", # picks "pbs" / "slurm" from environment in a batch job;
# without it, the default "interactive" would not submit jobs
multi_node_config=MultiNodeConfig(
workers_per_node=4,
cores_per_worker=12,
mem_per_worker_gb=32.0,
walltime="04:00:00",
queue="normal",
),
)
try:
result = ds.mean().compute()
finally:
client.close()
cluster.close()
Explicit PBS or SLURM (batch)
# PBS
client, cluster, shared_tmp = setup_dask_client(mode="pbs", multi_node_config=cfg)
# SLURM
client, cluster, shared_tmp = setup_dask_client(mode="slurm", multi_node_config=cfg)
Direct helpers
from dask_setup import setup_pbs_cluster, setup_slurm_cluster, MultiNodeConfig
cfg = MultiNodeConfig(workers_per_node=2, cores_per_worker=24, mem_per_worker_gb=64.0, walltime="06:00:00")
# PBS
client, cluster, shared_tmp = setup_pbs_cluster(cfg, n_workers=4)
# SLURM
client, cluster, shared_tmp = setup_slurm_cluster(cfg, n_workers=4)
Worker initialisation — why the client can report 0 workers
cluster.scale(n) only submits jobs to the PBS/SLURM scheduler — it does not wait for them to be allocated and started. Client(cluster) connects to the Dask scheduler process immediately, but the scheduler has no workers yet because the HPC jobs are still queued. Without an explicit wait, client.scheduler_info() returns {"workers": {}}.
setup_pbs_cluster and setup_slurm_cluster handle this automatically: they block until at least one worker has connected before returning. Two parameters control the behaviour:
Parameter |
Default |
Description |
|---|---|---|
|
|
Block until workers are online. Set to |
|
|
Seconds to wait. Raise this on queues with long wait times. |
# Default — blocks up to 5 minutes
client, cluster, _ = setup_pbs_cluster(cfg, n_workers=4)
# Long queue — wait up to 20 minutes
client, cluster, _ = setup_pbs_cluster(cfg, n_workers=4, worker_timeout=1200.0)
# Return immediately and wait manually
client, cluster, _ = setup_pbs_cluster(cfg, n_workers=4, wait_for_workers=False)
client.wait_for_workers(cfg.workers_per_node * 4, timeout=600)
If no worker connects within worker_timeout seconds, a TimeoutError is raised with a diagnostic message listing the number of jobs submitted, expected worker count, scheduler address, and common remediation steps (checking the queue with qstat/squeue, verifying scheduler port reachability, and inspecting worker job logs).
setup_interactive_cluster — Interactive Sessions
Use this when you already have resources allocated (Jupyter notebook inside qsub -I or salloc). It does not submit new jobs — it starts workers directly on the nodes assigned to your session.
from dask_setup import setup_interactive_cluster
client, cluster, dask_tmp = setup_interactive_cluster(
workload_type="cpu", # "cpu", "io", "mixed", or "auto"
wait_for_workers=True, # block until workers are ready (default)
worker_timeout=60.0, # seconds — workers start almost immediately
)
Node discovery
setup_interactive_cluster reads PBS_NODEFILE (PBS) or SLURM_NODELIST (SLURM) to find the allocated nodes:
Unique nodes found |
Cluster type |
Notes |
|---|---|---|
0 or 1 |
|
Uses |
2+ |
|
Workers started on all nodes via passwordless SSH |
On NCI Gadi, PBS_NODEFILE has one line per CPU — e.g. 48 lines of gadi-cpu-clx-0012 for a 48-core node. The parser deduplicates and counts to determine cores per node.
Worker topology (multi-node SSH path)
Worker count and thread count per node are derived from workload_type and the core count read from the nodefile:
|
Workers per node |
Threads per worker |
|---|---|---|
|
|
1 |
|
1 |
|
|
|
2 |
Override with workers_per_node= and threads_per_worker= if needed.
mode="auto" detection
mode="auto" is opt-in (the default is "interactive"). setup_dask_client(mode="auto") calls detect_cluster_mode() which distinguishes interactive from batch:
Environment |
Detected mode |
Backend |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
None of the above |
|
|
MultiNodeConfig
MultiNodeConfig holds all the configuration needed for a multi-node PBS or SLURM job. It is a companion to DaskSetupConfig — not a subclass.
from dask_setup import MultiNodeConfig
cfg = MultiNodeConfig(
workload_type="cpu", # "cpu", "io", "mixed", "gpu", "auto"
workers_per_node=4, # Dask worker processes per submitted job
cores_per_worker=12, # CPU cores per worker
mem_per_worker_gb=32.0, # RAM per worker (GiB)
walltime="04:00:00", # HH:MM:SS
queue="normal", # PBS queue / SLURM partition
project="ab01", # PBS -P / SLURM --account
job_extra_directives=["-l storage=gdata/hh5"], # extra #PBS / #SBATCH lines
n_nodes=1, # nodes per submitted job (usually 1)
shared_tmp_dir="/scratch/project/tmp", # Lustre/GPFS path for shared stores
env_extra=["module load python3"], # shell lines, inserted verbatim
adaptive=False, # True → cluster.adapt(min_jobs, max_jobs)
min_jobs=1,
max_jobs=10,
)
env_extra
Entries are shell statements inserted verbatim near the top of the worker
job script, before the worker starts. Write the whole statement, including
export where you need it:
env_extra=[
"module load conda/analysis3", # not an assignment — no export
"source activate myenv",
"export OMP_NUM_THREADS=1", # assignment — export it yourself
]
Changed in v2.2. The script generators used to prefix every entry with
export, turningmodule load conda/analysis3intoexport module load conda/analysis3, while the same list passed throughdask-jobqueuewas emitted correctly — so the one list meant two different things depending on the path. Both now emit verbatim. If you were writing bareFOO=barentries and relying on the implicit prefix, addexport.
Useful properties
cfg.total_cores_per_job # workers_per_node * cores_per_worker
cfg.total_mem_gb_per_job # workers_per_node * mem_per_worker_gb
cores_per_worker and mem_per_worker_gb are per worker; the totals above
are what the job reserves. Changed in v2.2 — the per-worker figures were
being handed to dask-jobqueue, which divides its cores/memory arguments
by processes. A 4×12-core, 4×32 GiB job therefore started workers with
--nthreads 3 --memory-limit 7.45GiB instead of --nthreads 12 --memory-limit 29.80GiB: a 4× under-provision against the ncpus=48,mem=128GB
the job had actually reserved.
Adaptive scaling
cfg = MultiNodeConfig(
workers_per_node=4,
cores_per_worker=12,
mem_per_worker_gb=32.0,
walltime="04:00:00",
adaptive=True,
min_jobs=1,
max_jobs=8,
)
client, cluster, _ = setup_pbs_cluster(cfg)
# cluster automatically submits/cancels jobs based on task queue
GPU Topology
workload_type="gpu" configures one worker process per CUDA-capable GPU with several CPU threads each for data loading and preprocessing.
from dask_setup import setup_dask_client, DaskSetupConfig
# Single-node GPU setup
client, cluster, dask_tmp = setup_dask_client(
workload_type="gpu",
# CUDA_VISIBLE_DEVICES=0,1,2,3 → 4 workers, one per GPU
)
GPU detection order:
CUDA_VISIBLE_DEVICESenvironment variable (e.g."0,1,2,3")cupy.cuda.runtime.getDeviceCount()(if CuPy is installed)Graceful fallback to single-process multi-thread (like
"io") with a warning when no GPUs are found
Thread allocation: max(2, min(8, total_cores // n_gpus)) threads per worker.
For multi-node GPU jobs, set workload_type="gpu" in your MultiNodeConfig and add --gres=gpu:4 (SLURM) or -l ngpus=4 (PBS) to job_extra_directives:
cfg = MultiNodeConfig(
workload_type="gpu",
workers_per_node=4, # one per GPU
cores_per_worker=6, # CPU threads per GPU worker
mem_per_worker_gb=32.0,
walltime="02:00:00",
queue="gpuvolta",
job_extra_directives=["-l ngpus=4"],
)
Generating Job Scripts
The dask-setup submit CLI subcommand generates a ready-to-run PBS or SLURM job script without submitting it:
# PBS script (prints to stdout)
dask-setup submit my_analysis.py \
--scheduler pbs \
--workers-per-node 4 \
--cores-per-worker 12 \
--mem-per-worker 32 \
--walltime 04:00:00 \
--queue normal \
--project ab01 \
--extra-directive "-l storage=gdata/hh5+gdata/gb02"
# Write to file
dask-setup submit my_analysis.py --scheduler pbs ... --output job.sh
qsub job.sh
# SLURM script
dask-setup submit my_analysis.py \
--scheduler slurm \
--workers-per-node 2 \
--cores-per-worker 20 \
--mem-per-worker 64 \
--walltime 08:00:00 \
--queue compute \
--project myaccount \
--extra-directive "--gres=gpu:2"
Full flag reference:
Flag |
Default |
Description |
|---|---|---|
|
|
|
|
|
|
|
|
Dask worker processes per node |
|
|
CPU cores per worker |
|
|
RAM per worker (GiB) |
|
|
|
|
|
Queue / partition |
|
Account / project code |
|
|
|
Nodes per job |
|
Shared filesystem temp dir |
|
|
Extra directive (repeatable) |
|
|
|
Python executable in job script |
|
(stdout) |
Write script to file |
In Python:
from dask_setup import MultiNodeConfig
from dask_setup.multinode import generate_pbs_script, generate_slurm_script
cfg = MultiNodeConfig(workers_per_node=4, cores_per_worker=12, mem_per_worker_gb=32.0, walltime="04:00:00")
pbs_script = generate_pbs_script(cfg, script_path="my_analysis.py")
slurm_script = generate_slurm_script(cfg, script_path="my_analysis.py")
with open("job.sh", "w") as f:
f.write(pbs_script)
Environment Detection
detect_cluster_mode() inspects environment variables:
from dask_setup import detect_cluster_mode
mode = detect_cluster_mode() # "pbs", "slurm", or "local"
Indicator |
Mode |
|---|---|
|
|
|
|
None of the above |
|
SLURM indicators take priority when both are set.
Complete PBS Example
PBS job script (or use dask-setup submit to generate one):
#!/bin/bash
#PBS -q normal
#PBS -l ncpus=48,mem=128GB,walltime=04:00:00
#PBS -l storage=gdata/hh5+scratch/ab01
#PBS -P ab01
#PBS -l wd
module load python3
python my_analysis.py
my_analysis.py:
import xarray as xr
from dask_setup import setup_dask_client, MultiNodeConfig, recommend_io_chunks
client, cluster, shared_tmp = setup_dask_client(
mode="auto", # picks "pbs" automatically when PBS_JOBID is set;
# required, since the default "interactive" never submits jobs
multi_node_config=MultiNodeConfig(
workers_per_node=4,
cores_per_worker=12,
mem_per_worker_gb=32.0,
walltime="04:00:00",
queue="normal",
project="ab01",
job_extra_directives=["-l storage=gdata/hh5"],
shared_tmp_dir="/scratch/ab01/tmp",
),
)
ds = xr.open_zarr("s3://bucket/era5.zarr")
rec = recommend_io_chunks(ds, path_or_url="s3://bucket/era5.zarr", access_pattern="compute")
result = ds.chunk(rec.chunks).mean(["lat", "lon"]).compute()
result.to_netcdf("/scratch/ab01/output/result.nc")
client.close()
cluster.close()
Notes
Multi-node clusters require
dask-jobqueue(pip install dask-jobqueue). The package imports cleanly without it; a helpfulImportErroris raised only whensetup_pbs_cluster/setup_slurm_clusterare called.The returned
clusterobject is a livePBSCluster/SLURMCluster. Usecluster.scale(n)to change the number of active jobs, orcluster.adapt(minimum=..., maximum=...)for elastic scaling.setup_dask_client()returns a 4-tuple(client, cluster, tmp_path, chunks)wheneverds=is passed, in every mode includingpbs,slurmandinteractive; a 3-tuple(client, cluster, tmp_path)otherwise. Changed in v2.2 — the multi-node paths used to return a 3-tuple even withds=, so unpacking four values raisedValueError: not enough values to unpack.For GPU multi-node jobs, ensure
CUDA_VISIBLE_DEVICESis set correctly inside each submitted job by adding it toenv_extra.setup_pbs_cluster/setup_slurm_clusterblock by default until at least one worker is online (wait_for_workers=True). If your queue has a long wait time and you want to return immediately, passwait_for_workers=Falseand callclient.wait_for_workers()yourself. See Worker initialisation above.Inside an interactive session (
qsub -I/salloc), usesetup_interactive_clusterinstead ofsetup_pbs_cluster/setup_slurm_cluster. The batch helpers submit new PBS/SLURM jobs, which are queued and will not start immediately (or at all, depending on scheduler policy for nested jobs).setup_interactive_clusterstarts workers directly on already-allocated nodes.setup_dask_client()does this by default (mode="interactive"), andmode="auto"selects it whenPBS_ENVIRONMENT=PBS_INTERACTIVEis detected.