"""Drive an Optuna study whose trials are solved somewhere else.
A geometry sweep is a queue of independent solver runs, so the only real
questions are where a trial runs and how many run at once. Those are separate
concerns here: a *runner* knows how to start one trial and how to collect it,
and :func:`run_study` keeps a fixed number of them in flight while feeding
results back to the sampler.
Keeping trials in flight rather than evaluating fixed batches matters twice
over. A batch can only advance when its slowest trial finishes, so a wave of
twelve solves idles most of its allocation waiting for one big mesh; and the
sampler learns nothing until the whole wave lands, when it could have been told
about each result as it arrived.
Two runners come with the module and the difference between them is only where
the solve happens:
- :class:`SlurmRunner` submits one job per trial. A small job starts on any node
with room, so the sweep fills whatever the cluster has free.
- :class:`RayRunner` dispatches a Ray task per trial, for when the study already
owns an allocation.
"""
from __future__ import annotations
import json
import os
import shlex
import time
from collections.abc import Callable, Sequence
from dataclasses import dataclass, field
from importlib import import_module
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol
from qpdk import logger
from qpdk.simulation.cluster import SlurmCluster, SlurmJobError, job_state, submit
if TYPE_CHECKING:
import optuna
__all__ = ["RayRunner", "SlurmRunner", "TrialRunner", "run_study"]
[docs]
class TrialRunner(Protocol):
"""Starts one trial somewhere and collects it when it is done."""
[docs]
def start(self, name: str, params: dict[str, float]) -> Any:
"""Begin evaluating ``params`` and return a handle to it.
Args:
name: Unique name for the trial, usable as a directory name.
params: The geometry to evaluate.
Returns:
A handle to pass back to :meth:`collect`.
"""
...
[docs]
def collect(self, handle: Any) -> dict[str, Any] | None:
"""Return a trial's row, or ``None`` while it is still running.
Args:
handle: The value :meth:`start` returned.
Returns:
The result row, carrying ``error`` if the trial failed, or ``None``
if it has not finished yet.
"""
...
[docs]
@dataclass(slots=True)
class SlurmRunner:
"""Run each trial as its own Slurm job.
One script serves the whole study: Slurm forwards the arguments after the
script name to the script, so each submission differs only in the parameter
file it is given.
Attributes:
cluster: Site settings the job is sized and submitted with.
run_root: Shared directory the trials are written under.
ranks: MPI ranks to give each solve.
evaluator: Callable to serialize for worker jobs, or an importable
``module:function`` name.
timeout: Seconds before a solve is abandoned. Keep it under the job's
own wall clock, or Slurm kills the job first and the trial reports
nothing.
"""
cluster: SlurmCluster
run_root: Path
ranks: int
evaluator: str | Callable[..., dict[str, Any]]
timeout: float = 1800.0
_script: Path | None = field(default=None, init=False)
_missing_since: dict[str, float] = field(default_factory=dict, init=False)
_query_failures: dict[str, tuple[int, float]] = field(
default_factory=dict, init=False
)
def __post_init__(self) -> None:
"""Validate the solver ranks before writing or submitting anything.
Raises:
ValueError: If the ranks do not fit the trial allocation.
"""
if (
self.ranks <= 0
or self.ranks > self.cluster.solver_cores
or self.ranks % self.cluster.trial_nodes
):
raise ValueError(
"ranks must be positive, fit cluster.solver_cores and divide "
"evenly across cluster.trial_nodes"
)
def _ensure_script(self) -> Path:
"""Write the one-trial script once and reuse it for every submission."""
if self._script is None:
self.run_root.mkdir(parents=True, exist_ok=True)
# Reusing parameters could admit results from a previous study.
(self.run_root / "params").mkdir()
if isinstance(self.evaluator, str):
evaluator_arg = f"--evaluator {shlex.quote(self.evaluator)}"
else:
import cloudpickle
self.run_root.mkdir(parents=True, exist_ok=True)
evaluator_path = (self.run_root / "evaluator.pkl").resolve()
evaluator_path.write_bytes(cloudpickle.dumps(self.evaluator))
evaluator_arg = f"--evaluator-file {shlex.quote(str(evaluator_path))}"
command = (
"python -m qpdk.simulation.trial "
f'"$1" --run-root {shlex.quote(str(self.run_root))} '
f"--ranks {self.ranks} --timeout {self.timeout} {evaluator_arg}"
)
self._script = self.cluster.write_task_script(
self.run_root / "trial.sbatch", command
)
return self._script
[docs]
def start(self, name: str, params: dict[str, float]) -> tuple[str, Path]:
"""Submit one job for ``params``.
Args:
name: Unique trial name, used for the parameter file.
params: The geometry to evaluate.
Returns:
The Slurm job id and the parameter file's path.
"""
script = self._ensure_script()
params_dir = self.run_root / "params"
params_path = params_dir / f"{name}.json"
with params_path.open("x", encoding="utf-8") as stream:
json.dump(params, stream)
job_id = submit(script, str(params_path))
return job_id, params_path
[docs]
def collect(self, handle: tuple[str, Path]) -> dict[str, Any] | None:
"""Read a finished job's result, if it has one yet.
Args:
handle: The job id and parameter path from :meth:`start`.
Returns:
The row, or ``None`` while the job is still queued or running.
Raises:
SlurmJobError: If the scheduler query fails after ten spaced retries.
"""
job_id, params_path = handle
result_path = params_path.with_suffix(".result.json")
if result_path.exists():
return json.loads(result_path.read_text())
if job_id in self._query_failures:
_, retry_after = self._query_failures[job_id]
if time.monotonic() < retry_after:
return None
try:
state = job_state(job_id)
except SlurmJobError:
self._missing_since.pop(job_id, None)
failures = self._query_failures.get(job_id, (0, 0.0))[0] + 1
if failures > 10:
raise
self._query_failures[job_id] = (failures, time.monotonic() + 20.0)
return None
self._query_failures.pop(job_id, None)
if state is not None:
self._missing_since.pop(job_id, None)
return None
now = time.monotonic()
missing_since = self._missing_since.setdefault(job_id, now)
# Other completed trials can trigger immediate polls without sleeping.
if now - missing_since < 60.0:
return None
if result_path.exists():
return json.loads(result_path.read_text())
# Gone from the queue with nothing written: killed, out of wall clock,
# or it never started. Distinguishable from a solver failure, which
# writes its reason to the result file.
return {"error": f"job {job_id} left the queue without writing a result"}
[docs]
@dataclass(slots=True)
class RayRunner:
"""Run each trial as a Ray task inside an allocation the study owns.
Attributes:
run_root: Shared directory the trials are written under.
ranks: MPI ranks to give each solve, and the CPUs each task reserves.
evaluator: Callable serialized by Ray or an importable
``module:function`` name.
"""
run_root: Path
ranks: int
evaluator: str | Callable[..., dict[str, Any]]
[docs]
def start(self, name: str, params: dict[str, float]) -> Any:
"""Dispatch one Ray task for ``params``.
Args:
name: Unique trial name, used for the trial's directory.
params: The geometry to evaluate.
Returns:
The Ray object reference for the task.
Raises:
RuntimeError: If ``RAY_ADDRESS`` is unset, i.e. this is not running
inside a Ray cluster.
"""
import ray
if not os.environ.get("RAY_ADDRESS"):
# Without it ray.init() starts a local instance, where a task
# asking for `ranks` cores on a one-core driver never becomes
# schedulable and the study hangs instead of failing.
msg = (
"RAY_ADDRESS is not set, so this is not inside a Ray cluster. "
"Submit the script written by SlurmCluster.write_sbatch_script,"
" which starts the head and workers and exports it."
)
raise RuntimeError(msg)
ray.init(address=os.environ["RAY_ADDRESS"], ignore_reinit_error=True)
run_root, ranks = self.run_root.resolve(), self.ranks
evaluator = self.evaluator
env_vars = {
key: os.environ[key] for key in ("QPDK_PALACE_SIF",) if key in os.environ
}
@ray.remote(num_cpus=ranks, runtime_env={"env_vars": env_vars})
def evaluate(params: dict[str, float], name: str) -> dict[str, Any]:
if isinstance(evaluator, str):
module_name, _, function_name = evaluator.partition(":")
evaluate_layout = getattr(import_module(module_name), function_name)
else:
evaluate_layout = evaluator
return evaluate_layout(
params, run_root, ranks=ranks, sim_dir=run_root / name
)
return evaluate.remote(params, name)
[docs]
@staticmethod
def collect(handle: Any) -> dict[str, Any] | None:
"""Return a task's row if it has finished.
Args:
handle: The object reference from :meth:`start`.
Returns:
The row, or ``None`` while the task is still running.
"""
import ray
ready, _ = ray.wait([handle], timeout=0)
if not ready:
return None
try:
return ray.get(handle)
except Exception as error:
return {"error": f"{type(error).__name__}: {error}"}
[docs]
def run_study(
study: optuna.Study,
runner: TrialRunner,
*,
suggest: Callable[[optuna.Trial], None],
objectives: Callable[[dict[str, Any]], Sequence[float]],
n_trials: int,
max_in_flight: int,
poll_seconds: float = 20.0,
) -> list[dict[str, Any]]:
"""Run ``study`` to ``n_trials``, keeping trials in flight as they finish.
The sampler is asked for a new trial as soon as one comes back rather than
at a batch boundary, so no part of the study waits on its slowest solve and
every result informs the next suggestion.
Args:
study: The study to advance. Its sampler decides what to try next.
runner: Starts and collects a trial.
suggest: Fills a trial's parameters, usually with ``trial.suggest_*``.
objectives: Maps a result row to the values ``study`` was created for.
n_trials: Total trials to evaluate.
max_in_flight: How many trials may be running at once. On Slurm this is
the real concurrency; under Ray the cluster's cores also bound it.
poll_seconds: Seconds between checks for finished trials.
Returns:
One row per successful trial, carrying its number and parameters.
Raises:
ModuleNotFoundError: If Optuna is not installed. Use ``qpdk[models]``.
ValueError: If ``max_in_flight`` is not positive.
RuntimeError: If every trial failed, since there would be nothing to
analyse.
"""
try:
import optuna
except ModuleNotFoundError as error:
if error.name != "optuna":
raise
raise ModuleNotFoundError(
"run_study needs Optuna; install qpdk[models]"
) from error
if max_in_flight <= 0:
raise ValueError("max_in_flight must be positive")
rows: list[dict[str, Any]] = []
in_flight: dict[int, tuple[optuna.Trial, Any]] = {}
asked = failed = 0
started = time.monotonic()
while asked < n_trials or in_flight:
while asked < n_trials and len(in_flight) < max_in_flight:
trial = study.ask()
suggest(trial)
handle = runner.start(f"trial_{trial.number:04d}", dict(trial.params))
in_flight[trial.number] = (trial, handle)
asked += 1
finished = [
(number, trial, row)
for number, (trial, handle) in list(in_flight.items())
if (row := runner.collect(handle)) is not None
]
for number, trial, row in finished:
del in_flight[number]
if "error" in row:
failed += 1
logger.warning(f"trial {number} failed: {row['error']}")
study.tell(trial, state=optuna.trial.TrialState.PRUNED)
continue
study.tell(trial, objectives(row))
rows.append(row | dict(trial.params) | {"trial": trial.number})
logger.info(
f"trial {number} done ({len(rows)} ok, {failed} failed, "
f"{len(in_flight)} in flight, {time.monotonic() - started:.0f} s)"
)
if in_flight and not finished:
time.sleep(poll_seconds)
if not rows:
msg = f"all {failed} trials failed; check the job logs under the run root"
raise RuntimeError(msg)
return rows