Source code for modelly.experiments.executors
"""Executors: where an experiment's runs execute, chosen by name.
============= ============================================================
``serial`` in this process, one run after another
``threads`` a thread pool (parallel on free-threaded Python builds)
``processes`` a process pool started with ``forkserver`` or ``spawn``
``dask`` a ``dask.distributed`` cluster (a local one by default)
``ray`` a Ray cluster (``modelly-abm[ray]``)
``slurm`` a SLURM job array submitted with ``sbatch``
``pbs`` a PBS job array submitted with ``qsub``
============= ============================================================
Every executor calls :func:`modelly.experiments.tasks.run_task` once per run
and yields its :class:`~modelly.experiments.tasks.RunOutcome`; the worker
writes the run's Parquet file itself, so the results never depend on the
executor. A plugin adds an executor through the ``modelly.executors`` entry
point group (``name = "package.module:ExecutorClass"``) or
:func:`register_executor`.
"""
from __future__ import annotations
import importlib
from collections.abc import Callable, Iterator, Sequence
from importlib.metadata import entry_points
from typing import Any
from modelly.experiments.tasks import RunOutcome, RunTask, spawn_safe
ENTRY_POINT_GROUP = "modelly.executors"
# Built-in executors, imported on first use (dask and ray stay optional).
BUILTIN = {
"serial": "modelly.experiments.executors.local:SerialExecutor",
"threads": "modelly.experiments.executors.local:ThreadExecutor",
"processes": "modelly.experiments.executors.local:ProcessExecutor",
"dask": "modelly.experiments.executors.dask_executor:DaskExecutor",
"ray": "modelly.experiments.executors.ray_executor:RayExecutor",
"slurm": "modelly.experiments.executors.cluster:SlurmExecutor",
"pbs": "modelly.experiments.executors.cluster:PBSExecutor",
}
_REGISTRY: dict[str, Callable[..., Executor]] = {}
[docs]
class Executor:
"""Base class: ``run(tasks, max_parallel)`` yields one outcome per task.
``in_process`` executors share this interpreter, so they can run models
and outputs a fresh process could not import (a notebook class, a lambda).
"""
name = "executor"
in_process = False
def __init__(self, **options: Any) -> None:
"""Keep the executor's options; unknown ones are an error."""
if options:
raise TypeError(
f"{self.name} executor takes no option(s) {sorted(options)}"
)
[docs]
def run(
self, tasks: Sequence[RunTask], max_parallel: int | None = None
) -> Iterator[RunOutcome]:
"""Run every task, yielding each outcome as it finishes."""
raise NotImplementedError
[docs]
def check(self, tasks: Sequence[RunTask]) -> None:
"""Refuse tasks naming a model or output a worker process cannot import."""
if self.in_process or not tasks:
return
task = tasks[0]
refs = [task.model, *(s for s in task.outputs.values() if ":" in s)]
bad = [ref for ref in refs if not spawn_safe(ref)]
if bad:
raise TypeError(
f"the {self.name} executor starts fresh processes that cannot import "
f"{bad}: define them at module level in an importable file, or use "
"executor='threads' or 'serial'"
)
[docs]
def register_executor(name: str, factory: Callable[..., Executor]) -> None:
"""Make ``factory(**options)`` available as ``executor=name``."""
_REGISTRY[name] = factory
def _load(ref: str) -> Callable[..., Executor]:
"""The class a ``module:name`` reference names."""
module, _, attribute = ref.partition(":")
return getattr(importlib.import_module(module), attribute)
def _factory(name: str) -> Callable[..., Executor]:
"""The registered, built-in or entry-point factory for ``name``."""
if name in _REGISTRY:
return _REGISTRY[name]
if name in BUILTIN:
return _load(BUILTIN[name])
for point in entry_points(group=ENTRY_POINT_GROUP):
if point.name == name:
return point.load()
raise ValueError(
f"unknown executor {name!r}; available: {', '.join(available_executors())}"
)
[docs]
def get_executor(executor: str | Executor, **options: Any) -> Executor:
"""An executor instance from its name (with ``options``) or as given.
Example:
>>> get_executor("threads").name
'threads'
"""
if isinstance(executor, Executor):
if options:
raise TypeError("options go to the executor's constructor, not run()")
return executor
return _factory(executor)(**options)
[docs]
def available_executors() -> list[str]:
"""Every executor name: built-in, registered and from entry points."""
plugins = {point.name for point in entry_points(group=ENTRY_POINT_GROUP)}
return sorted(set(BUILTIN) | set(_REGISTRY) | plugins)
__all__ = [
"ENTRY_POINT_GROUP",
"Executor",
"available_executors",
"get_executor",
"register_executor",
]