Benchmarking & Performance Analysis
dask_setup ships a benchmarking module (dask_setup.benchmark) with three complementary tools for measuring and comparing performance on real or synthetic datasets.
Quick Overview
Function |
What it does |
|---|---|
|
A/B-test multiple |
|
Sweep worker counts and measure parallel speedup and efficiency |
|
Sweep chunk sizes on a fixed cluster to find the throughput sweet spot |
|
No-dataset synthetic benchmark — used by the CLI |
All functions share a common BenchmarkResult dataclass.
benchmark_config()
Compare multiple configurations against the same xarray operation. Each configuration gets a fresh cluster so memory state, topology, and spill behaviour are fully independent.
from dask_setup import DaskSetupConfig
from dask_setup.benchmark import benchmark_config
configs = {
"io_profile": DaskSetupConfig(workload_type="io", reserve_mem_gb=40),
"cpu_profile": DaskSetupConfig(workload_type="cpu", reserve_mem_gb=60),
"mixed": DaskSetupConfig(workload_type="mixed"),
}
results = benchmark_config(
configs,
my_ds,
operation="mean",
repeats=3,
verbose=True,
)
for r in sorted(results, key=lambda r: r.wall_time_seconds):
print(r.summary_line())
Parameters
Parameter |
Default |
Description |
|---|---|---|
|
— |
|
|
— |
xarray Dataset or DataArray (unchunked; each config rechunks independently) |
|
|
String alias or |
|
|
Timed repetitions per config — mean and std-dev are recorded |
|
|
Run one un-timed warmup pass to eliminate cold-start JIT overhead |
|
|
Print a summary line after each config completes |
Built-in operation aliases
"mean", "sum", "std", "max", "min", "var", "rechunk"
For anything else, pass a callable:
results = benchmark_config(
configs,
my_ds,
operation=lambda ds: ds.rolling(time=10).mean(),
)
scaling_analysis()
Sweep worker counts (e.g. 1, 2, 4, 8) and measure speedup and parallel efficiency relative to the single-worker baseline.
from dask_setup import DaskSetupConfig
from dask_setup.benchmark import scaling_analysis
result = scaling_analysis(
my_ds,
operation="mean",
worker_counts=(1, 2, 4, 8),
base_config=DaskSetupConfig(workload_type="cpu"),
repeats=2,
plot=True, # renders a speedup + efficiency figure if matplotlib is installed
verbose=True,
)
print(result.summary())
print(f"\nBest: {result.best().summary_line()}")
Parameters
Parameter |
Default |
Description |
|---|---|---|
|
— |
xarray Dataset or DataArray |
|
|
String alias or callable |
|
|
Worker counts to sweep; first entry is the baseline |
|
|
Config used for each run — |
|
|
Timed repetitions per worker count |
|
|
Un-timed warmup pass |
|
|
Display speedup + efficiency chart (requires matplotlib) |
|
|
Print summary line after each count |
The sweep only means something for topologies that scale with worker count.
decide_topology()pinsn_workers=1forworkload_type="io"(and for"gpu"with no GPU present) regardless ofmax_workers, because those topologies are single-process-many-threads by design. Sweeping one of those builds the same cluster at every point and the resulting curve is timing noise; a warning is logged if it happens.Changed in v2.2: the default
base_configwasDaskSetupConfig(), whoseworkload_typeis"io"— so the default sweep was exactly that degenerate case. It is now"cpu".
Efficiency is relative to the worker ratio, not the absolute count (changed in v2.2). A sweep starting anywhere other than 1 worker used to report a fraction of its true efficiency — a perfectly scaling
(4, 8)sweep scored0.25at its own baseline instead of1.0.
ScalingResult
result.summary() # formatted table: workers / wall / speedup / efficiency / tasks_per_s
result.worker_counts # [1, 2, 4, 8]
result.speedups # [1.0, 1.87, 3.4, 5.9]
result.efficiencies # [1.0, 0.94, 0.85, 0.74]
result.wall_times # [12.4, 6.6, 3.6, 2.1]
result.best() # BenchmarkResult with lowest wall time
result.to_dataframe() # pandas DataFrame (requires pandas)
result.plot() # matplotlib Figure with speedup + efficiency subplots
chunk_impact()
Fix a running cluster and sweep chunk sizes to find the throughput sweet spot. Unlike the other two functions, this one does not create or destroy clusters.
from dask_setup import setup_dask_client
from dask_setup.benchmark import chunk_impact
client, cluster, _ = setup_dask_client("cpu")
result = chunk_impact(
my_ds,
client,
operation="mean",
chunk_sizes=[
{"time": 30, "lat": 180, "lon": 360},
{"time": 60, "lat": 180, "lon": 360},
{"time": 90, "lat": 360, "lon": 720},
{"time": 120, "lat": 360, "lon": 720},
],
repeats=2,
plot=True,
verbose=True,
)
print(result.summary())
print(f"\nRecommended: {result.recommended_chunks}")
If chunk_sizes is None and auto_chunks=True (the default), a five-point geometric sweep is generated automatically across all chunkable dimensions.
Parameters
Parameter |
Default |
Description |
|---|---|---|
|
— |
xarray Dataset or DataArray with known dimension sizes |
|
— |
Connected Dask client |
|
|
String alias or callable |
|
|
List of chunk-spec dicts; auto-generated when |
|
|
Generate a 5-point geometric sweep when |
|
|
Timed repetitions per chunk spec |
|
|
Un-timed warmup pass |
|
|
Display wall-time vs chunk-size figure |
|
|
Print summary after each chunk spec |
ChunkImpactResult
result.summary() # formatted table: chunks / wall / mem / tasks_per_s
result.recommended_chunks # dict of the fastest chunk spec
result.best() # BenchmarkResult with lowest wall time
result.to_dataframe() # pandas DataFrame
result.plot(dim="time") # wall-time vs chunk size for one dimension
BenchmarkResult
All three functions return (or contain) BenchmarkResult objects.
from dask_setup.benchmark import BenchmarkResult
r: BenchmarkResult
r.name # label (config name, worker count, chunk spec, etc.)
r.wall_time_seconds # mean elapsed time across all repeats
r.wall_time_std # std-dev of per-repeat times (0.0 for a single repeat)
r.peak_memory_gib # highest total worker RSS observed *during* the timed runs
r.spill_gib # total data written to disk spill storage
r.n_tasks # number of dask-graph tasks
r.n_workers # number of active workers
r.tasks_per_second # derived: n_tasks / wall_time_seconds
r.errors # list of non-fatal error messages
r.extra # additional metadata dict
r.summary_line() # one-line human-readable summary
r.to_dict() # JSON-serialisable dict of all fields
peak_memory_gibis sampled every 0.2 s while the operation runs (changed in v2.2). It used to be read from a cluster report taken after.compute()returned, by which point the workers had already released the data — so the “peak” was near zero for exactly the workloads whose memory use matters. If in-flight sampling is unavailable (an olderdistributed), the post-run reading is used and a note saying so is appended tor.errors.
spill_gibwas likewise always0.0: it was read from worker metric keys that currentdistributeddoes not publish. It now readsspilled_bytes.
run_synthetic_benchmark()
A no-dataset synthetic benchmark backed by dask.array. No xarray or real data required. This is the backend for the dask-setup benchmark CLI subcommand.
from dask_setup.benchmark import run_synthetic_benchmark
result = run_synthetic_benchmark(
profile_name="climate_analysis",
operation="mean",
ds_size="medium",
repeats=3,
verbose=True,
)
print(result.summary())
Dataset sizes
|
Array shape |
Chunk shape |
|---|---|---|
|
(200, 200, 10) |
(50, 50, 10) |
|
(500, 500, 20) |
(100, 100, 20) |
|
(1000, 1000, 50) |
(200, 200, 25) |
|
(2000, 2000, 100) |
(400, 400, 25) |
Arrays are float32, pre-seeded with numpy.random.seed(42) for reproducibility.
SyntheticBenchmarkResult
result.profile_name # "climate_analysis"
result.operation # "mean"
result.ds_size # "medium"
result.array_shape # (1000, 1000, 50)
result.wall_time_seconds # 3.71
result.peak_memory_gib # 0.42
result.spill_gib # 0.0
result.n_tasks # 250
result.n_workers # 4
result.tasks_per_second # 67.4
result.summary() # formatted multi-line report
dask-setup benchmark CLI
Run a synthetic benchmark against any profile without writing any Python:
# Benchmark the built-in development profile (2 workers, lightweight)
dask-setup benchmark --profile development --size small --operation mean
# Benchmark a custom profile with 3 timed repeats
dask-setup benchmark --profile climate_analysis --size medium --repeats 3
# Large array, std operation
dask-setup benchmark --profile zarr_io_heavy --size large --operation std
Flags
Flag |
Default |
Description |
|---|---|---|
|
|
Profile name (builtin or user-defined) |
|
|
Dataset size: |
|
|
Operation: |
|
|
Number of timed repetitions |
Output is the SyntheticBenchmarkResult.summary() printed to stdout.
Comparing All Built-in Profiles
from dask_setup import DaskSetupConfig
from dask_setup.benchmark import run_synthetic_benchmark
profiles = ["development", "interactive", "zarr_io_heavy", "climate_analysis"]
for p in profiles:
result = run_synthetic_benchmark(profile_name=p, ds_size="medium")
print(f"{p:20s} {result.wall_time_seconds:.2f}s "
f"{result.tasks_per_second:.0f} tasks/s")
matplotlib Integration
scaling_analysis() and chunk_impact() can render comparison plots directly:
# Inline in a Jupyter notebook
import matplotlib
matplotlib.use("inline")
scaling = scaling_analysis(ds, worker_counts=(1, 2, 4, 8, 16), plot=True)
impact = chunk_impact(ds, client, plot=True)
Or generate figures manually for embedding in reports:
fig1 = scaling.plot(title="ERA5 scaling on Gadi normalsr")
fig2 = impact.plot(dim="time", title="Chunk size sweep — temperature variable")
fig1.savefig("scaling.png", dpi=150)
fig2.savefig("chunks.png", dpi=150)