Source code for modelly.experiments.executors.local
"""Executors on this machine: serial, a thread pool and a process pool.
The process pool starts its workers with ``forkserver`` where it exists and
``spawn`` elsewhere (Windows, macOS), as ``modelly.batch.run_batch`` does: each
worker imports the model by its module path, so the same script runs on every
platform. Scripts need the usual ``if __name__ == "__main__":`` guard.
"""
from __future__ import annotations
from collections.abc import Iterator, Sequence
from concurrent.futures import (
Executor as PoolExecutor,
)
from concurrent.futures import (
ProcessPoolExecutor,
ThreadPoolExecutor,
as_completed,
)
from modelly.batch.runs import default_workers, worker_context
from modelly.experiments.executors import Executor
from modelly.experiments.tasks import RunOutcome, RunTask, run_task
[docs]
class SerialExecutor(Executor):
"""Every run in this process, in task order."""
name = "serial"
in_process = True
[docs]
def run(
self, tasks: Sequence[RunTask], max_parallel: int | None = None
) -> Iterator[RunOutcome]:
"""Run the tasks one after another."""
for task in tasks:
yield run_task(task)
def _pool_outcomes(
pool: PoolExecutor, tasks: Sequence[RunTask]
) -> Iterator[RunOutcome]:
"""Submit every task to ``pool``; yield outcomes as they finish; cancel the rest on exit."""
try:
futures = [pool.submit(run_task, task) for task in tasks]
for future in as_completed(futures):
yield future.result()
finally:
pool.shutdown(wait=True, cancel_futures=True)
def _workers(max_parallel: int | None, tasks: Sequence[RunTask]) -> int:
"""``max_parallel`` (else ``default_workers()``), at most one per task."""
workers = default_workers() if max_parallel is None else max_parallel
return max(1, min(workers, len(tasks)))
[docs]
class ThreadExecutor(Executor):
"""A thread pool: parallel on free-threaded Python, concurrent otherwise."""
name = "threads"
in_process = True
[docs]
def run(
self, tasks: Sequence[RunTask], max_parallel: int | None = None
) -> Iterator[RunOutcome]:
"""Run the tasks on up to ``max_parallel`` threads."""
if not tasks:
return iter(())
pool = ThreadPoolExecutor(max_workers=_workers(max_parallel, tasks))
return _pool_outcomes(pool, tasks)
[docs]
class ProcessExecutor(Executor):
"""A pool of worker processes that import the model (spawn-safe)."""
name = "processes"
[docs]
def run(
self, tasks: Sequence[RunTask], max_parallel: int | None = None
) -> Iterator[RunOutcome]:
"""Run the tasks on up to ``max_parallel`` processes."""
if not tasks:
return iter(())
self.check(tasks)
pool = ProcessPoolExecutor(
max_workers=_workers(max_parallel, tasks), mp_context=worker_context()
)
return _pool_outcomes(pool, tasks)