dask-setup 2.0.0__tar.gz → 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. {dask_setup-2.0.0 → dask_setup-2.1.0}/PKG-INFO +1 -1
  2. {dask_setup-2.0.0 → dask_setup-2.1.0}/pyproject.toml +1 -1
  3. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup/__init__.py +3 -1
  4. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup/benchmark.py +48 -39
  5. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup/client.py +70 -59
  6. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup/cluster.py +3 -5
  7. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup/config.py +0 -4
  8. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup/config_manager.py +13 -13
  9. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup/io_patterns.py +41 -40
  10. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup/logging.py +4 -2
  11. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup/multinode.py +248 -5
  12. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup/parquet.py +3 -4
  13. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup/rechunk.py +87 -14
  14. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup/reporting.py +1 -1
  15. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup/resources.py +37 -1
  16. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup/topology.py +2 -2
  17. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup/tune.py +1 -1
  18. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup/workload.py +4 -4
  19. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup/xarray.py +100 -40
  20. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup.egg-info/PKG-INFO +1 -1
  21. {dask_setup-2.0.0 → dask_setup-2.1.0}/tests/test_benchmark.py +5 -5
  22. {dask_setup-2.0.0 → dask_setup-2.1.0}/tests/test_multinode.py +318 -8
  23. {dask_setup-2.0.0 → dask_setup-2.1.0}/tests/test_resources.py +83 -0
  24. {dask_setup-2.0.0 → dask_setup-2.1.0}/tests/test_xarray_chunks.py +141 -8
  25. {dask_setup-2.0.0 → dask_setup-2.1.0}/LICENSE +0 -0
  26. {dask_setup-2.0.0 → dask_setup-2.1.0}/README.md +0 -0
  27. {dask_setup-2.0.0 → dask_setup-2.1.0}/setup.cfg +0 -0
  28. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup/callbacks.py +0 -0
  29. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup/cli.py +0 -0
  30. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup/dashboard.py +0 -0
  31. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup/environment.py +0 -0
  32. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup/error_handling.py +0 -0
  33. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup/exceptions.py +0 -0
  34. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup/legacy.py +0 -0
  35. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup/py.typed +0 -0
  36. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup/schema/__init__.py +0 -0
  37. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup/schema/profile_schema.json +0 -0
  38. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup/tempdir.py +0 -0
  39. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup/types.py +0 -0
  40. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup.egg-info/SOURCES.txt +0 -0
  41. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup.egg-info/dependency_links.txt +0 -0
  42. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup.egg-info/entry_points.txt +0 -0
  43. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup.egg-info/requires.txt +0 -0
  44. {dask_setup-2.0.0 → dask_setup-2.1.0}/src/dask_setup.egg-info/top_level.txt +0 -0
  45. {dask_setup-2.0.0 → dask_setup-2.1.0}/tests/test_cli.py +0 -0
  46. {dask_setup-2.0.0 → dask_setup-2.1.0}/tests/test_client.py +0 -0
  47. {dask_setup-2.0.0 → dask_setup-2.1.0}/tests/test_cluster.py +0 -0
  48. {dask_setup-2.0.0 → dask_setup-2.1.0}/tests/test_compression.py +0 -0
  49. {dask_setup-2.0.0 → dask_setup-2.1.0}/tests/test_config.py +0 -0
  50. {dask_setup-2.0.0 → dask_setup-2.1.0}/tests/test_config_manager.py +0 -0
  51. {dask_setup-2.0.0 → dask_setup-2.1.0}/tests/test_dashboard.py +0 -0
  52. {dask_setup-2.0.0 → dask_setup-2.1.0}/tests/test_error_handling.py +0 -0
  53. {dask_setup-2.0.0 → dask_setup-2.1.0}/tests/test_exceptions.py +0 -0
  54. {dask_setup-2.0.0 → dask_setup-2.1.0}/tests/test_io_patterns.py +0 -0
  55. {dask_setup-2.0.0 → dask_setup-2.1.0}/tests/test_setup_dask_client.py +0 -0
  56. {dask_setup-2.0.0 → dask_setup-2.1.0}/tests/test_tempdir.py +0 -0
  57. {dask_setup-2.0.0 → dask_setup-2.1.0}/tests/test_topology.py +0 -0
  58. {dask_setup-2.0.0 → dask_setup-2.1.0}/tests/test_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dask_setup
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: HPC-tuned Dask helpers for single-node runs on NCI Gadi.
5
5
  Author: Sam Green
6
6
  Project-URL: Homepage, https://github.com/21centuryweather/dask_setup
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dask_setup"
7
- version = "2.0.0"
7
+ version = "2.1.0"
8
8
  description = "HPC-tuned Dask helpers for single-node runs on NCI Gadi."
9
9
  authors = [{name="Sam Green"}]
10
10
  readme = "README.md"
@@ -22,6 +22,7 @@ from .multinode import (
22
22
  detect_cluster_mode,
23
23
  generate_pbs_script,
24
24
  generate_slurm_script,
25
+ setup_interactive_cluster,
25
26
  setup_pbs_cluster,
26
27
  setup_slurm_cluster,
27
28
  )
@@ -280,7 +281,7 @@ except ImportError:
280
281
  )
281
282
 
282
283
 
283
- __version__ = "2.0.0"
284
+ __version__ = "2.1.0"
284
285
 
285
286
  __all__ = [
286
287
  # Core API — always available
@@ -326,6 +327,7 @@ __all__ = [
326
327
  "MultiNodeConfig",
327
328
  "SharedTempDir",
328
329
  "detect_cluster_mode",
330
+ "setup_interactive_cluster",
329
331
  "setup_pbs_cluster",
330
332
  "setup_slurm_cluster",
331
333
  "generate_pbs_script",
@@ -39,6 +39,7 @@ Example::
39
39
 
40
40
  from __future__ import annotations
41
41
 
42
+ import contextlib
42
43
  import statistics
43
44
  import time
44
45
  from collections.abc import Callable, Sequence
@@ -324,15 +325,38 @@ class ChunkImpactResult:
324
325
  ----------
325
326
  results : list[BenchmarkResult]
326
327
  One entry per chunk specification, in the order they were tried.
327
- chunk_sizes : list[dict[str, int]]
328
- The chunk specs that were swept.
328
+ chunk_specs : list[dict[str, int]]
329
+ The chunk specs that were swept. Alias for chunk_sizes.
329
330
  recommended_chunks : dict[str, int]
330
331
  The chunk spec with the best (lowest) wall time.
331
332
  """
332
333
 
333
334
  results: list[BenchmarkResult]
334
- chunk_sizes: list[dict[str, int]]
335
- recommended_chunks: dict[str, int]
335
+ chunk_specs: list[dict[str, int]]
336
+ recommended_chunks: dict[str, int] | None = None
337
+
338
+ def __post_init__(self) -> None:
339
+ """Compute recommended_chunks if not provided."""
340
+ if self.recommended_chunks is None:
341
+ # Find the chunk spec with the lowest wall time
342
+ if self.results and self.chunk_specs:
343
+ valid = [
344
+ (r, cs)
345
+ for r, cs in zip(self.results, self.chunk_specs, strict=False)
346
+ if r.wall_time_seconds == r.wall_time_seconds # not nan
347
+ ]
348
+ if valid:
349
+ _, best_chunks = min(valid, key=lambda x: x[0].wall_time_seconds)
350
+ self.recommended_chunks = best_chunks
351
+ else:
352
+ self.recommended_chunks = self.chunk_specs[0] if self.chunk_specs else {}
353
+ else:
354
+ self.recommended_chunks = {}
355
+
356
+ @property
357
+ def chunk_sizes(self) -> list[dict[str, int]]:
358
+ """Alias for chunk_specs for backward compatibility."""
359
+ return self.chunk_specs
336
360
 
337
361
  @property
338
362
  def wall_times(self) -> list[float]:
@@ -341,6 +365,10 @@ class ChunkImpactResult:
341
365
  def best(self) -> BenchmarkResult:
342
366
  return min(self.results, key=lambda r: r.wall_time_seconds)
343
367
 
368
+ def optimal(self) -> BenchmarkResult:
369
+ """Return the result with the best (lowest) wall time."""
370
+ return min(self.results, key=lambda r: r.wall_time_seconds)
371
+
344
372
  def summary(self) -> str:
345
373
  lines = [
346
374
  f"{'Chunks':^36} {'Wall (s)':>10} {'Mem GiB':>8} {'Tasks/s':>8}",
@@ -352,7 +380,7 @@ class ChunkImpactResult:
352
380
  f"{chunk_str:<36} {r.wall_time_seconds:>10.2f} "
353
381
  f"{r.peak_memory_gib:>8.2f} {r.tasks_per_second:>8.1f}"
354
382
  )
355
- lines.append(f"\nRecommended: {self.recommended_chunks}")
383
+ lines.append(f"\nOptimal: {self.recommended_chunks}")
356
384
  return "\n".join(lines)
357
385
 
358
386
  def to_dataframe(self) -> Any:
@@ -476,10 +504,8 @@ def _measure_one(
476
504
  errors.append(f"Task count failed: {e}")
477
505
 
478
506
  # Worker count
479
- try:
507
+ with contextlib.suppress(Exception):
480
508
  n_workers = len(client.scheduler_info().get("workers", {}))
481
- except Exception:
482
- pass
483
509
 
484
510
  # Optional warmup
485
511
  if warmup:
@@ -644,15 +670,11 @@ def benchmark_config(
644
670
  )
645
671
  finally:
646
672
  if client is not None:
647
- try:
673
+ with contextlib.suppress(Exception):
648
674
  client.close()
649
- except Exception:
650
- pass
651
675
  if cluster is not None:
652
- try:
676
+ with contextlib.suppress(Exception):
653
677
  cluster.close()
654
- except Exception:
655
- pass
656
678
 
657
679
  if result is None:
658
680
  result = BenchmarkResult(name=name, wall_time_seconds=float("nan"), errors=errors)
@@ -765,15 +787,11 @@ def scaling_analysis(
765
787
  )
766
788
  finally:
767
789
  if client is not None:
768
- try:
790
+ with contextlib.suppress(Exception):
769
791
  client.close()
770
- except Exception:
771
- pass
772
792
  if cluster is not None:
773
- try:
793
+ with contextlib.suppress(Exception):
774
794
  cluster.close()
775
- except Exception:
776
- pass
777
795
 
778
796
  raw_results.append(result)
779
797
 
@@ -805,12 +823,10 @@ def scaling_analysis(
805
823
  if plot:
806
824
  fig = scaling.plot()
807
825
  if fig is not None:
808
- try:
826
+ with contextlib.suppress(Exception):
809
827
  import matplotlib.pyplot as plt
810
828
 
811
829
  plt.show()
812
- except Exception:
813
- pass
814
830
 
815
831
  return scaling
816
832
 
@@ -882,10 +898,9 @@ def chunk_impact(
882
898
 
883
899
  # Generate chunk size sweep if not provided
884
900
  if chunk_sizes is None:
885
- if auto_chunks and dims:
886
- chunk_sizes = _generate_auto_chunks(dims)
887
- else:
888
- chunk_sizes = [{}] # single run with no rechunking
901
+ chunk_sizes = (
902
+ _generate_auto_chunks(dims) if auto_chunks and dims else [{}]
903
+ ) # single run with no rechunking
889
904
 
890
905
  raw_results: list[BenchmarkResult] = []
891
906
 
@@ -927,7 +942,7 @@ def chunk_impact(
927
942
 
928
943
  impact = ChunkImpactResult(
929
944
  results=raw_results,
930
- chunk_sizes=chunk_sizes,
945
+ chunk_specs=chunk_sizes,
931
946
  recommended_chunks=best_chunks,
932
947
  )
933
948
 
@@ -935,12 +950,10 @@ def chunk_impact(
935
950
  first_dim = next(iter(dims), None)
936
951
  fig = impact.plot(dim=first_dim)
937
952
  if fig is not None:
938
- try:
953
+ with contextlib.suppress(Exception):
939
954
  import matplotlib.pyplot as plt
940
955
 
941
956
  plt.show()
942
- except Exception:
943
- pass
944
957
 
945
958
  return impact
946
959
 
@@ -1081,8 +1094,8 @@ def run_synthetic_benchmark(
1081
1094
  if verbose:
1082
1095
  print(f"Creating synthetic array {shape} chunked {chunks} …")
1083
1096
 
1084
- np.random.seed(42)
1085
- arr = da.from_array(np.random.rand(*shape).astype(np.float32), chunks=chunks)
1097
+ rng = np.random.default_rng(42)
1098
+ arr = da.from_array(rng.random(shape).astype(np.float32), chunks=chunks)
1086
1099
 
1087
1100
  # Resolve dask operation
1088
1101
  da_ops: dict[str, Callable[[Any], Any]] = {
@@ -1147,15 +1160,11 @@ def run_synthetic_benchmark(
1147
1160
  errors.append(f"Cluster error: {e}")
1148
1161
  finally:
1149
1162
  if client is not None:
1150
- try:
1163
+ with contextlib.suppress(Exception):
1151
1164
  client.close()
1152
- except Exception:
1153
- pass
1154
1165
  if cluster_obj is not None:
1155
- try:
1166
+ with contextlib.suppress(Exception):
1156
1167
  cluster_obj.close()
1157
- except Exception:
1158
- pass
1159
1168
 
1160
1169
  valid_times = [t for t in times if t != float("inf")]
1161
1170
  wall = statistics.mean(valid_times) if valid_times else float("nan")
@@ -15,7 +15,13 @@ from .config_manager import ConfigManager
15
15
  from .dashboard import print_dashboard_info
16
16
  from .exceptions import InsufficientResourcesError
17
17
  from .logging import get_logger
18
- from .multinode import MultiNodeConfig, detect_cluster_mode
18
+ from .multinode import (
19
+ MultiNodeConfig,
20
+ detect_cluster_mode,
21
+ setup_interactive_cluster,
22
+ setup_pbs_cluster,
23
+ setup_slurm_cluster,
24
+ )
19
25
  from .resources import detect_resources
20
26
  from .tempdir import create_dask_temp_dir
21
27
  from .topology import decide_topology, validate_topology
@@ -48,13 +54,14 @@ def _compute_smart_reserve_default() -> float:
48
54
  """
49
55
  try:
50
56
  total_ram_gb = psutil.virtual_memory().total / (1024**3)
51
- return min(50.0, max(4.0, total_ram_gb * 0.20))
57
+ smart_reserve = min(50.0, max(4.0, total_ram_gb * 0.20))
58
+ # Cap the smart reserve at 50.0 GiB as a safe HPC default
59
+ return min(50.0, smart_reserve)
52
60
  except Exception:
53
61
  return 50.0 # Safe HPC fallback if psutil is unexpectedly unavailable
54
62
 
55
63
 
56
64
  def _resolve_configuration(
57
- config: DaskSetupConfig | None = None,
58
65
  profile: str | None = None,
59
66
  workload_type: str = "io",
60
67
  max_workers: int | None = None,
@@ -64,21 +71,16 @@ def _resolve_configuration(
64
71
  adaptive: bool = False,
65
72
  min_workers: int | None = None,
66
73
  suggest_chunks: bool = False,
67
- fallback_on_detection_failure: bool = False,
68
- adaptive_memory: bool = False,
69
74
  ) -> DaskSetupConfig:
70
- """Resolve final configuration from a config object, profile, and explicit parameters.
75
+ """Resolve final configuration from a profile and explicit parameters.
71
76
 
72
77
  Priority order (highest to lowest):
73
78
 
74
79
  1. Explicit keyword parameters passed to ``setup_dask_client()``
75
- 2. Config object (``config=``) **or** profile (``profile=``)
80
+ 2. Profile (``profile=``)
76
81
  3. Defaults — ``reserve_mem_gb`` uses a smart default (20 % RAM, 4–50 GiB)
77
82
 
78
- ``config`` and ``profile`` are mutually exclusive.
79
-
80
83
  Args:
81
- config: A pre-built DaskSetupConfig object to use as the base configuration.
82
84
  profile: Profile name to load from disk/builtins as the base configuration.
83
85
  workload_type: Workload type override.
84
86
  max_workers: Maximum workers cap.
@@ -96,28 +98,18 @@ def _resolve_configuration(
96
98
  The heuristic that detects "explicitly set" parameters compares each value
97
99
  against its default. Edge case: if you deliberately pass a value equal to
98
100
  the default (e.g. ``dashboard=True``) it will be treated as "not set" and a
99
- base_config value will take precedence. Use ``config=DaskSetupConfig(...)``
100
- to avoid this ambiguity entirely.
101
+ profile value will take precedence.
101
102
  """
102
- if config is not None and profile is not None:
103
- raise ValueError(
104
- "Cannot specify both 'config' and 'profile'. "
105
- "Pass a DaskSetupConfig object via 'config=' OR a profile name via 'profile=', not both."
106
- )
107
-
108
- # Build defaults using the environment-aware smart reserve
109
- smart_reserve = _compute_smart_reserve_default()
110
- defaults = DaskSetupConfig(reserve_mem_gb=smart_reserve)
111
- logger.debug("Smart reserve default computed", reserve_mem_gb=smart_reserve)
103
+ # Build defaults - use provided reserve_mem_gb or fallback to smart default
104
+ defaults = DaskSetupConfig(
105
+ reserve_mem_gb=reserve_mem_gb if reserve_mem_gb is not None else 50.0
106
+ )
107
+ logger.debug("Configuration defaults set", reserve_mem_gb=defaults.reserve_mem_gb)
112
108
 
113
- # Resolve the base configuration: either a provided config object or a loaded profile
109
+ # Resolve the base configuration: load profile if specified
114
110
  base_config = None
115
111
 
116
- if config is not None:
117
- # Caller supplied a ready-made DaskSetupConfig — use it as the profile-level base
118
- base_config = config
119
- logger.debug("Using caller-provided DaskSetupConfig as base")
120
- elif profile is not None:
112
+ if profile is not None:
121
113
  manager = ConfigManager()
122
114
  profile_obj = manager.get_profile(profile)
123
115
  if profile_obj is None:
@@ -130,9 +122,7 @@ def _resolve_configuration(
130
122
  #
131
123
  # Note: This heuristic compares each value against its default to decide whether it was
132
124
  # explicitly set. Edge case: if you deliberately pass a value that *equals* the default
133
- # (e.g. reserve_mem_gb equal to the computed smart default) it will be treated as "not set"
134
- # and a base_config value will take precedence. To avoid this, use a DaskSetupConfig
135
- # object via the 'config=' parameter instead.
125
+ # (e.g. dashboard=True) it will be treated as "not set" and a profile value will take precedence.
136
126
  explicit_params: dict[str, Any] = {}
137
127
 
138
128
  if workload_type != "io":
@@ -140,7 +130,7 @@ def _resolve_configuration(
140
130
  if max_workers is not None:
141
131
  explicit_params["max_workers"] = max_workers
142
132
  if reserve_mem_gb is not None:
143
- # None means "use the smart default"; an explicit float means "user chose this"
133
+ # Explicit float means "user chose this"
144
134
  explicit_params["reserve_mem_gb"] = reserve_mem_gb
145
135
  if max_mem_gb is not None:
146
136
  explicit_params["max_mem_gb"] = max_mem_gb
@@ -152,10 +142,6 @@ def _resolve_configuration(
152
142
  explicit_params["min_workers"] = min_workers
153
143
  if suggest_chunks is not False:
154
144
  explicit_params["suggest_chunks"] = suggest_chunks
155
- if fallback_on_detection_failure:
156
- explicit_params["fallback_on_detection_failure"] = fallback_on_detection_failure
157
- if adaptive_memory:
158
- explicit_params["adaptive_memory"] = adaptive_memory
159
145
 
160
146
  explicit_config = DaskSetupConfig(**explicit_params) if explicit_params else None
161
147
 
@@ -308,7 +294,7 @@ def setup_dask_client(
308
294
  def setup_dask_client(
309
295
  workload_type: str = "io",
310
296
  max_workers: int | None = None,
311
- reserve_mem_gb: float | None = None,
297
+ reserve_mem_gb: float = 50.0,
312
298
  max_mem_gb: float | None = None,
313
299
  dashboard: bool = True,
314
300
  adaptive: bool = False,
@@ -380,17 +366,27 @@ def setup_dask_client(
380
366
  and tightens the worker ``memory.target`` / ``memory.spill`` thresholds
381
367
  slightly, giving workers more head-room from the start. Default
382
368
  ``False``.
383
- mode : {"auto", "local", "pbs", "slurm"}
369
+ mode : {"auto", "local", "pbs", "slurm", "interactive"}
384
370
  Backend selection.
385
371
 
386
372
  - ``"local"`` — always use a single-node ``LocalCluster`` (default
387
373
  behaviour prior to v2.0).
388
- - ``"pbs"`` — launch via ``dask-jobqueue.PBSCluster``. Requires
389
- ``pip install dask-jobqueue``.
390
- - ``"slurm"`` — launch via ``dask-jobqueue.SLURMCluster``.
374
+ - ``"pbs"`` — launch via ``dask-jobqueue.PBSCluster`` (submits new
375
+ batch jobs). Requires ``pip install dask-jobqueue``.
376
+ - ``"slurm"`` — launch via ``dask-jobqueue.SLURMCluster`` (submits
377
+ new batch jobs).
378
+ - ``"interactive"`` — use resources already allocated in the current
379
+ interactive PBS (``qsub -I``) or SLURM (``salloc``) session.
380
+ Single-node allocations create a ``LocalCluster``; multi-node
381
+ allocations create an ``SSHCluster`` across all nodes in
382
+ ``PBS_NODEFILE`` / ``SLURM_NODELIST``.
391
383
  - ``"auto"`` (default) — inspect the environment and choose
392
- ``"pbs"`` if ``PBS_JOBID`` is set, ``"slurm"`` if
393
- ``SLURM_JOB_ID`` is set, or ``"local"`` otherwise.
384
+ ``"interactive"`` when inside a PBS interactive job
385
+ (``PBS_ENVIRONMENT=PBS_INTERACTIVE``) or a SLURM interactive
386
+ allocation (``SLURM_BATCH_FLAG`` not set to ``"1"``);
387
+ ``"pbs"`` when ``PBS_JOBID`` is set in a batch job;
388
+ ``"slurm"`` when ``SLURM_JOB_ID`` is set in a batch job;
389
+ ``"local"`` otherwise.
394
390
  multi_node_config : MultiNodeConfig or None
395
391
  Configuration for the multi-node backend (``mode="pbs"`` or
396
392
  ``"slurm"``). Ignored when ``mode="local"``. When ``None`` and a
@@ -462,9 +458,14 @@ def setup_dask_client(
462
458
  resolved_mode = detect_cluster_mode()
463
459
  logger.debug("Mode auto-resolved", mode=resolved_mode)
464
460
 
465
- if resolved_mode in {"pbs", "slurm"}:
466
- from .multinode import setup_pbs_cluster, setup_slurm_cluster
461
+ if resolved_mode == "interactive":
462
+ logger.info("Interactive cluster mode — using already-allocated nodes")
463
+ client, cluster, tmp_path = setup_interactive_cluster(
464
+ workload_type=workload_type,
465
+ )
466
+ return client, cluster, tmp_path # type: ignore[return-value]
467
467
 
468
+ if resolved_mode in {"pbs", "slurm"}:
468
469
  mn_cfg = multi_node_config
469
470
  if mn_cfg is None:
470
471
  # Build a minimal MultiNodeConfig from whatever was passed
@@ -496,20 +497,25 @@ def setup_dask_client(
496
497
  logger.info("Auto-selected profile", profile=profile)
497
498
 
498
499
  # Load and merge configuration
500
+ # Use explicit default for reserve_mem_gb if not provided
501
+ resolved_reserve_mem = reserve_mem_gb if reserve_mem_gb is not None else 50.0
502
+
499
503
  config = _resolve_configuration(
500
- config=config,
501
504
  profile=profile,
502
505
  workload_type=workload_type,
503
506
  max_workers=max_workers,
504
- reserve_mem_gb=reserve_mem_gb,
507
+ reserve_mem_gb=resolved_reserve_mem,
505
508
  max_mem_gb=max_mem_gb,
506
509
  dashboard=dashboard,
507
510
  adaptive=adaptive,
508
511
  min_workers=min_workers,
509
512
  suggest_chunks=suggest_chunks,
510
- fallback_on_detection_failure=fallback_on_detection_failure,
511
- adaptive_memory=adaptive_memory,
512
513
  )
514
+
515
+ # Apply additional config-level settings that don't go through _resolve_configuration
516
+ config.fallback_on_detection_failure = fallback_on_detection_failure
517
+ config.adaptive_memory = adaptive_memory
518
+
513
519
  logger.debug(
514
520
  "Configuration resolved",
515
521
  workload_type=config.workload_type,
@@ -599,19 +605,11 @@ def setup_dask_client(
599
605
  if config.dashboard and config.dashboard_port:
600
606
  dashboard_address = f":{config.dashboard_port}"
601
607
 
602
- # Map the boolean silence_logs config to a logging level.
603
- # True → suppress everything except errors (logging.ERROR)
604
- # False → show warnings and above (logging.WARNING), which is a reasonable HPC default
605
- import logging as _stdlib_logging
606
-
607
- silence_logs_level = _stdlib_logging.ERROR if config.silence_logs else _stdlib_logging.WARNING
608
-
609
608
  cluster = create_cluster(
610
609
  topology=topology,
611
610
  memory_spec=memory_spec,
612
611
  temp_dir=temp_dir,
613
612
  dashboard_address=dashboard_address,
614
- silence_logs=silence_logs_level,
615
613
  adaptive=config.adaptive,
616
614
  min_workers=config.min_workers,
617
615
  memory_target=config.memory_target,
@@ -640,10 +638,23 @@ def setup_dask_client(
640
638
  if config.dashboard:
641
639
  print_dashboard_info(client, silent=config.silence_logs)
642
640
 
643
- # Log setup summary via structured logger
641
+ # Log and print setup summary
644
642
  spill_threads_str = (
645
643
  f" | spill_threads={config.spill_threads}" if config.spill_threads is not None else ""
646
644
  )
645
+ summary_lines = [
646
+ "[setup_dask_client] Configuration summary",
647
+ f"temp/spill dir: {temp_dir}",
648
+ f"Workers: {topology.n_workers} | threads/worker: {topology.threads_per_worker} | processes: {topology.processes}",
649
+ f"Memory: total ~{memory_spec.total_mem_gib:.1f} GiB | usable ~{memory_spec.usable_mem_gb:.1f} GiB | per-worker ~{memory_spec.mem_per_worker_bytes / (1024**3):.1f} GiB",
650
+ f"Compression: spill={config.spill_compression} | comm={config.comm_compression}{spill_threads_str}",
651
+ ]
652
+
653
+ # Print summary to console
654
+ for line in summary_lines:
655
+ print(line)
656
+
657
+ # Also log via structured logger
647
658
  logger.info(f"Temp/spill dir: {temp_dir}")
648
659
  logger.info(
649
660
  f"Workers: {topology.n_workers}"
@@ -677,7 +688,7 @@ def setup_dask_client(
677
688
  workload_type=config.workload_type,
678
689
  verbose=config.suggest_chunks,
679
690
  )
680
- # recommend_chunks returns ChunkRecommendation when verbose=True, dict otherwise
691
+ # recommend_chunks always returns ChunkRecommendation
681
692
  chunk_recommendations = raw.chunks if hasattr(raw, "chunks") else raw
682
693
  logger.info("Chunk recommendations computed", chunks=str(chunk_recommendations))
683
694
 
@@ -103,12 +103,10 @@ def configure_dask_settings(
103
103
  }
104
104
 
105
105
  # Add spill threads configuration if specified.
106
- # "distributed.worker.io-threads" controls the size of each worker's I/O thread pool,
107
- # which is used for spill read/write operations.
108
- # Note: "distributed.p2p.threads" is a different setting (peer-to-peer shuffle threads)
109
- # and is intentionally NOT used here.
106
+ # "distributed.p2p.threads" controls the number of threads used for
107
+ # peer-to-peer operations and spill I/O operations.
110
108
  if spill_threads is not None:
111
- config_dict["distributed.worker.io-threads"] = spill_threads
109
+ config_dict["distributed.p2p.threads"] = spill_threads
112
110
 
113
111
  logger.debug(
114
112
  "Dask global settings applied",
@@ -252,10 +252,6 @@ class DaskSetupConfig:
252
252
  "io_access_pattern": self.io_access_pattern,
253
253
  "io_storage_location": self.io_storage_location,
254
254
  "io_compression_level": self.io_compression_level,
255
- # Resilience options
256
- "fallback_on_detection_failure": self.fallback_on_detection_failure,
257
- # Adaptive behaviour options
258
- "adaptive_memory": self.adaptive_memory,
259
255
  # Metadata
260
256
  "name": self.name,
261
257
  "description": self.description,
@@ -202,7 +202,7 @@ Use `dask-setup profile create <name>` or manually create YAML files in the prof
202
202
  def get_profile(self, name: str) -> ConfigProfile | None:
203
203
  """Get a specific profile by name.
204
204
 
205
- Search order: user profiles → site-wide profiles → builtin profiles.
205
+ Search order: builtin profiles → site-wide profiles → user profiles.
206
206
 
207
207
  Args:
208
208
  name: Profile name
@@ -210,13 +210,9 @@ Use `dask-setup profile create <name>` or manually create YAML files in the prof
210
210
  Returns:
211
211
  ConfigProfile if found, None otherwise
212
212
  """
213
- # User profiles take highest precedence
214
- profile_file = self.profiles_dir / f"{name}.yaml"
215
- if profile_file.exists():
216
- try:
217
- return self.load_profile_from_file(profile_file)
218
- except Exception as e:
219
- raise InvalidConfigurationError(f"Failed to load profile '{name}': {e}") from e
213
+ # Built-in profiles take highest precedence
214
+ if name in self.builtin_profiles:
215
+ return self.builtin_profiles[name]
220
216
 
221
217
  # Site-wide profiles
222
218
  site_file = self.site_profiles_dir / f"{name}.yaml"
@@ -226,9 +222,13 @@ Use `dask-setup profile create <name>` or manually create YAML files in the prof
226
222
  except Exception as e:
227
223
  raise InvalidConfigurationError(f"Failed to load site profile '{name}': {e}") from e
228
224
 
229
- # Built-in profiles
230
- if name in self.builtin_profiles:
231
- return self.builtin_profiles[name]
225
+ # User profiles take lowest precedence
226
+ profile_file = self.profiles_dir / f"{name}.yaml"
227
+ if profile_file.exists():
228
+ try:
229
+ return self.load_profile_from_file(profile_file)
230
+ except Exception as e:
231
+ raise InvalidConfigurationError(f"Failed to load profile '{name}': {e}") from e
232
232
 
233
233
  return None
234
234
 
@@ -455,11 +455,11 @@ Use `dask-setup profile create <name>` or manually create YAML files in the prof
455
455
  import urllib.request
456
456
 
457
457
  try:
458
- req = urllib.request.Request(
458
+ req = urllib.request.Request( # noqa: S310
459
459
  url,
460
460
  headers={"User-Agent": f"dask_setup/{PROFILE_FORMAT_VERSION}"},
461
461
  )
462
- with urllib.request.urlopen(req, timeout=30) as response:
462
+ with urllib.request.urlopen(req, timeout=30) as response: # noqa: S310
463
463
  content = response.read().decode("utf-8")
464
464
  except urllib.error.URLError as e:
465
465
  raise InvalidConfigurationError(f"Could not fetch profile from {url!r}: {e}") from e