Rate this Page

Source code for monarch._src.job.job

# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the BSD-style license found in the
# LICENSE file in the root directory of this source tree.

# pyre-unsafe

import contextlib
import logging
import os
import pickle
import shlex
import signal
import subprocess
import sys
import tempfile
import traceback
import uuid
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any, Dict, List, Literal, NamedTuple, Optional, Sequence

from monarch._src.actor.bootstrap import attach_to_workers
from monarch._src.job._batch_env import in_batch_job, MONARCH_BATCH_JOB_ENV
from monarch._src.job._telemetry_query_client import QueryEngineClient
from monarch._src.job.job_components import JobComponents, MeshAdminConfig
from monarch._src.job.job_sidecar import stop_job_sidecar
from monarch._src.job.telemetry_config import TelemetryConfig

# note: the jobs api is intended as a library so it should
# only be importing _public_ monarch API functions.
from monarch.actor import (
    Actor,
    current_rank,
    enable_transport,
    endpoint,
    Future,
    HostMesh,
    Port,
    this_host,
)
from typing_extensions import Self


@contextlib.contextmanager
def _redirect_stdio(stdout=None, stderr=None):
    """Redirect stdout/stderr at the OS fd level.

    Unlike contextlib.redirect_stdout/stderr, subprocesses also inherit the
    redirect because file descriptors 1 and 2 are replaced via os.dup2.

    *stdout* and *stderr* must be file objects backed by a real OS file
    descriptor (e.g. from open() or tempfile.TemporaryFile). StringIO is not
    supported.
    """
    sys.stdout.flush()
    sys.stderr.flush()

    redirects = []
    if stdout is not None:
        redirects.append((1, stdout, "stdout"))
    if stderr is not None:
        redirects.append((2, stderr, "stderr"))

    saved_fds = {}
    saved_py = {}
    for fd, new_file, attr in redirects:
        saved_fds[fd] = os.dup(fd)
        os.dup2(new_file.fileno(), fd)
        saved_py[attr] = getattr(sys, attr)
        setattr(sys, attr, new_file)
    try:
        yield
    finally:
        sys.stdout.flush()
        sys.stderr.flush()
        for fd, _, attr in redirects:
            setattr(sys, attr, saved_py[attr])
            os.dup2(saved_fds[fd], fd)
            os.close(saved_fds[fd])


class BashActor(Actor):
    """Actor that executes bash scripts on remote workers.

    Two execution modes:

    1. **Blocking** — ``run(script)`` runs the script to completion and
       returns ``{"returncode", "stdout", "stderr"}``.  Output is also
       printed on the worker for log-forwarding visibility.

    2. **Streaming** — ``start(script)`` launches the script in the
       background and returns immediately.  The client then calls
       ``poll_output()`` in a loop to receive incremental stdout/stderr
       until the process exits.
    """

    def _rank_env(self) -> Dict[str, str]:
        from monarch.actor import context

        rank = context().actor_instance.rank
        return {
            **{f"MONARCH_RANK_{k}": str(v) for k, v in dict(rank).items()},
            **{
                f"MONARCH_SIZE_{k}": str(v)
                for k, v in zip(rank.extent.keys(), rank.extent.sizes)
            },
        }

    def _expand_subdir(self, output_dir: str) -> str:
        if "$SUBDIR" not in output_dir:
            return output_dir
        from monarch.actor import context

        rank = context().actor_instance.rank
        subdir = "_".join(f"{k}_{v}" for k, v in dict(rank).items())
        return output_dir.replace("$SUBDIR", subdir)

    @endpoint
    def run(
        self,
        script: str,
        target_ranks: Optional[list] = None,
        output_dir: Optional[str] = None,
    ):
        my_rank = current_rank().rank
        if target_ranks is not None and my_rank not in target_ranks:
            return {"returncode": 0, "stdout": "", "stderr": "", "skipped": True}
        env = {**os.environ, **self._rank_env()}
        if output_dir is not None:
            output_dir = self._expand_subdir(output_dir)
        with tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=True) as f:
            f.write(script)
            f.flush()
            if output_dir is not None:
                os.makedirs(output_dir, exist_ok=True)
                with (
                    open(os.path.join(output_dir, "stdout.txt"), "w") as out,
                    open(os.path.join(output_dir, "stderr.txt"), "w") as err,
                ):
                    result = subprocess.run(
                        ["bash", f.name], stdout=out, stderr=err, env=env
                    )
                return {"returncode": result.returncode, "stdout": "", "stderr": ""}
            else:
                result = subprocess.run(
                    ["bash", f.name], capture_output=True, text=True, env=env
                )
                return {
                    "returncode": result.returncode,
                    "stdout": result.stdout,
                    "stderr": result.stderr,
                }

    @endpoint
    def run_python(
        self,
        cmd: List[str],
        env: Optional[Dict[str, str]] = None,
        workdir: Optional[str] = None,
        client_cwd: Optional[str] = None,
        output_dir: Optional[str] = None,
    ):
        from unittest.mock import patch

        os.environ.update({**self._rank_env(), **(env or {})})

        if output_dir is not None:
            output_dir = self._expand_subdir(output_dir)

        effective_workdir = workdir or (
            client_cwd if client_cwd and os.path.isdir(client_cwd) else None
        )
        with (
            contextlib.chdir(effective_workdir)
            if effective_workdir
            else contextlib.nullcontext()
        ):
            if cmd[0] == "-m":
                import importlib.util

                spec = importlib.util.find_spec(cmd[1])
                assert spec is not None and spec.origin is not None
                py_file = spec.origin
                argv = cmd[1:]
            else:
                py_file = cmd[0]
                argv = cmd

            with open(py_file) as f:
                source = f.read()
            code = compile(source, py_file, "exec")

            capture = output_dir is None
            if output_dir is not None:
                os.makedirs(output_dir, exist_ok=True)
                out_ctx = open(os.path.join(output_dir, "stdout.txt"), "w")
                err_ctx = open(os.path.join(output_dir, "stderr.txt"), "w")
            else:
                out_ctx = tempfile.TemporaryFile(mode="w+")
                err_ctx = tempfile.TemporaryFile(mode="w+")

            returncode = 1
            stdout_val = ""
            stderr_val = ""
            with out_ctx as out_f, err_ctx as err_f:
                with (
                    patch.object(sys, "argv", argv),
                    _redirect_stdio(stdout=out_f, stderr=err_f),
                ):
                    try:
                        returncode = 0
                        exec(code, {"__name__": "__main__", "__file__": py_file})
                    except Exception:
                        returncode = 1
                        traceback.print_exc()

                if capture:
                    out_f.seek(0)
                    err_f.seek(0)
                    stdout_val = out_f.read()
                    stderr_val = err_f.read()

            return {
                "returncode": returncode,
                "stdout": stdout_val,
                "stderr": stderr_val,
            }

    @endpoint
    def run_streaming(
        self,
        script: str,
        output_port: Port[str],
        target_ranks: list,
    ):
        """Run *script* on targeted ranks, streaming output via *output_port*.

        Only actors whose ``current_rank().rank`` is in *target_ranks*
        execute the script.  Non-targeted actors send ``"skip:<rank>"``
        and return immediately.

        Each message sent through the port is a tagged line:
        ``"out:<line>"`` for stdout, ``"err:<line>"`` for stderr,
        ``"rc:<code>"`` on exit, and ``"skip:<rank>"`` for skipped ranks.

        Args:
            script: Bash script text to execute.
            output_port: A :class:`Port` obtained from
                ``Channel[str].open()``.  Each line of output is pushed
                through this port as it is produced.
            target_ranks: List of flat rank indices that should run the
                script.
        """
        my_rank = current_rank().rank

        if my_rank not in target_ranks:
            output_port.send(f"skip:{my_rank}")
            return

        env = {**os.environ, **self._rank_env()}

        import selectors

        with tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=True) as f:
            f.write(script)
            f.flush()
            proc = subprocess.Popen(
                ["bash", f.name],
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
                env=env,
            )
            stdout = proc.stdout
            stderr = proc.stderr
            assert stdout is not None
            assert stderr is not None
            sel = selectors.DefaultSelector()
            sel.register(stdout, selectors.EVENT_READ, "stdout")
            sel.register(stderr, selectors.EVENT_READ, "stderr")
            while sel.get_map():
                for key, _ in sel.select():
                    # pyre-ignore[16]: fileobj is IO[str] here, not int
                    line = key.fileobj.readline()
                    if not line:
                        sel.unregister(key.fileobj)
                        continue
                    tag = "out" if key.data == "stdout" else "err"
                    output_port.send(f"{my_rank}:{tag}:{line}")
            sel.close()
            proc.wait()
        output_port.send(f"{my_rank}:rc:{proc.returncode}")


class JobState:
    """
    Container for the current state of a job.

    Provides access to the HostMesh objects for each mesh requested in the job
    specification. Each mesh is accessible as an attribute.

    Example::

        state = job.state()
        state.trainers    # HostMesh for the "trainers" mesh
        state.dataloaders # HostMesh for the "dataloaders" mesh
    """

    def __init__(
        self,
        hosts: Dict[str, HostMesh],
        query_engine: Optional[Any] = None,
        query_engine_client: Optional[QueryEngineClient] = None,
        telemetry_url: Optional[str] = None,
        dashboard_url: Optional[str] = None,
        admin_url: Optional[str] = None,
    ):
        self._hosts = hosts
        self.query_engine = query_engine
        self.query_engine_client = query_engine_client
        self.telemetry_url = telemetry_url
        self.dashboard_url = dashboard_url
        self.admin_url = admin_url

    def __getattr__(self, attr: str) -> HostMesh:
        try:
            return self._hosts[attr]
        except KeyError:
            available = ", ".join(sorted(self._hosts.keys()))
            raise AttributeError(
                f"'{attr}' is not a valid host mesh name. Available names: {available}"
            )

    def __repr__(self) -> str:
        return f"JobState(hosts={self._hosts})"


class CachedRunning(NamedTuple):
    job: "JobTrait"


logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
logger.addHandler(logging.StreamHandler(sys.stderr))
logger.propagate = False


[docs]class JobTrait(ABC): """ A job object represents a specification and set of machines that can be used to create monarch HostMeshes and run actors on them. A job object comprises a declarative specification for the job and optionally the job's *state*. The ``apply()`` operation applies the job's specification to the scheduler, creating or updating the job as required. If the job exists and there are no changes in its specification, ``apply()`` is a no-op. Once applied, we can query the job's *state*. The state of the job contains the set of hosts currently allocated, arranged into the requested host meshes. Conceptually, the state can be retrieved directly from the scheduler, but we may also cache snapshots of the state locally. The state is the interface to the job consumed by Monarch: Monarch bootstraps host meshes from the state alone, and is not concerned with any other aspect of the job. Conceptually, dynamic jobs (e.g., to enable consistently fast restarts, elasticity, etc.) can simply poll the state for changes. In practice, notification mechanisms would be developed so that polling isn't required. The model allows for late resolution of some parts of the job's *specification*. For example, a job that does not specify a name may instead resolve the name on the first ``apply()``. In this way, jobs can also be "templates". But the model also supports having the job refer to a *specific instance* by including the resolved job name in the specification itself. Note: Subclasses must NOT set ``_status`` directly. The ``state()`` method manages status transitions and pickle caching. If a subclass pre-emptively sets ``_status = "running"``, the ``state()`` method will skip the cache dump, breaking job persistence. Instead, let ``apply()`` set the status after ``_create()`` returns. """ def __init__(self): # WARNING: Do NOT add configuration arguments here. # JobTrait.__init__ must remain argument-free so subclass constructors # stay orthogonal to cross-cutting concerns like telemetry and admin. # Use enable_telemetry() / enable_admin() after construction instead. super().__init__() self._status: Literal["running", "not_running"] | CachedRunning = "not_running" self._components: JobComponents = JobComponents() self._apply_id: Optional[str] = None def _should_spawn_telemetry_worker_collector_actors(self) -> bool: """Whether sidecar telemetry should spawn per-host worker collectors. Worker collectors only add value when workers run on separate hosts. Single-host jobs (e.g. LocalJob) override this to avoid a redundant local fan-out. """ return True def _connect_host_meshes(self, running_job: "JobTrait") -> Dict[str, HostMesh]: """Run the connect phases and return the final host meshes. ``before_connect`` runs before raw host meshes are materialized; ``connect`` receives those raw meshes and returns the final, user-facing meshes. Service bring-up is deferred to :meth:`state` so it spawns on those configured meshes. ``running_job`` is the job whose scheduler-specific state materializes the raw host meshes (``self``, a cached job, or the wrapped CachedRunning job). """ self._components.before_connect(self) host_meshes = dict(running_job._state()._hosts) return self._components.connect(self, host_meshes)
[docs] def enable_telemetry( self, config: "Optional[TelemetryConfig]" = None, **kwargs, # pyrefly: ignore [not-a-type] ) -> Self: """Configure automatic telemetry startup on the next :meth:`state` call. Args: config: A :class:`TelemetryConfig` instance. If omitted, one is constructed from *kwargs* (forwarded to ``TelemetryConfig``). Returns: ``self``, for chaining. """ self._components.configure_telemetry( config if config is not None else TelemetryConfig(**kwargs) ) return self
[docs] def enable_admin( self, config: "Optional[MeshAdminConfig]" = None, **kwargs, # pyrefly: ignore [not-a-type] ) -> Self: """Configure automatic mesh admin agent startup on the next :meth:`state` call. Args: config: A :class:`MeshAdminConfig` instance. If omitted, one is constructed from *kwargs*. Returns: ``self``, for chaining. """ self._components.configure_admin( config if config is not None else MeshAdminConfig(**kwargs) ) return self
@property def _running(self) -> "Optional[JobTrait]": match self._status: case "not_running": return None case "running": return self case CachedRunning(job=job): return job
[docs] def apply(self, client_script: Optional[str] = None): """ Request the job as specified is brought into existence or modified to the current specification/ The worker machines launched in the job should call run_worker_forever to join the job. Calling apply when the job as specified has already been applied is a no-op. If client_script is not None, then creating the job arranges for the job to run train.py as the client. Implementation note: To batch launch the job, we will first write .monarch/job_state.pkl with a Job that instructs the client to connect to the job that it is running in. Then we will schedule the job including that .monarch/job_state.pkl. When the client calls `.state()`, it will find the .monarch/job_state.pkl and connect to it. """ if self._running is None: self._create(client_script) self._apply_id = str(uuid.uuid4()) self._status = "running"
@property def apply_id(self) -> Optional[str]: """A UUID identifying the current allocation of this job. Generated fresh each time :meth:`apply` creates a new allocation. ``None`` if the job has not been applied yet. When a job is loaded from a cached file, the original ``apply_id`` is preserved. """ running = self._running return running._apply_id if running is not None else None @property def active(self) -> bool: return self._running is not None def _connect( self, cached_path: Optional[str] = ".monarch/job_state.pkl" ) -> "JobState": """ Get the current state of this job, containing the host mesh objects of its requires that were requested host_meshes = self._connect() # properties of state hold the requested host meshes: host_meshes.trainers host_meshes.dataloaders This is a dictionary so that meshes can hold different machine types. cached_path: if cached_path is not None and the job has yet to be applied, we will first check `cached_path` for an existing created job state. If it exists and `saved_job.can_run(self)`, we will connect to the cached job. Otherwise, we will apply this job and connect to it, saving the job in `cached_path` if it is not None. Raises: JobExpiredException - when the job has finished and this connection cannot be made. """ # this is implemented uniquely for each scheduler, but it will ultimately make # calls to attach_to_workers and return the HostMeshes running_job = self._running if running_job is not None: logger.info("Job is running, returning current state") return JobState(self._connect_host_meshes(running_job)) cached = self._load_cached(cached_path) if cached is not None: self._status = CachedRunning(cached) logger.info("Connecting to cached job") return JobState(self._connect_host_meshes(cached)) logger.info("Applying current job") self.apply() logger.info("Job has started, connecting to current state") host_meshes = self._connect_host_meshes(self) if cached_path is not None: # Create the directory for cached_path if it doesn't exist cache_dir = os.path.dirname(cached_path) if cache_dir: # Only create if there's a directory component os.makedirs(cache_dir, exist_ok=True) logger.info("Saving job to cache at %s", cached_path) self.dump(cached_path) return JobState(host_meshes)
[docs] def state( self, cached_path: Optional[str] = ".monarch/job_state.pkl" ) -> "JobState": """Connect and run component state hooks on the final host meshes.""" job_state = self._connect(cached_path) self._components.state(self, job_state) return job_state
def _load_cached(self, cached_path: Optional[str]) -> "Optional[JobTrait]": if cached_path is None: logger.info("No cached path provided") return None try: job = job_load(cached_path) logger.info("Found cached job at path: %s", cached_path) except FileNotFoundError: logger.info("No cached job found at path: %s", cached_path) return None running = job._running if running is None: logger.info("Cached job is not running") return None if not running.can_run(self): logger.info("Cached job cannot run this spec, removing cache") try: running._kill() except NotImplementedError as e: logger.info("Failed to kill cached job: %s", e) # Remove the actual state file, not the symlink, so the context # (symlink) remains intact for future applies. state_file = ( os.path.realpath(cached_path) if os.path.islink(cached_path) else cached_path ) try: os.remove(state_file) except FileNotFoundError: pass return None return job
[docs] def dump(self, filename: str) -> None: """Save job to a file, following any symlink at *filename*. If *filename* is a symlink, writes to the symlink target rather than replacing the link itself. Creates the target's parent directory if it does not yet exist. """ path = os.path.realpath(filename) if os.path.islink(filename) else filename os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) with open(path, "wb") as f: # @lint-ignore PYTHONPICKLEISBAD pickle.dump(self, f)
[docs] def dumps(self) -> bytes: # @lint-ignore PYTHONPICKLEISBAD return pickle.dumps(self)
[docs] def kill(self): apply_id = self.apply_id running = self._running self._components.reset_runtime() if apply_id is not None: stop_job_sidecar(apply_id) if running is not None: running._kill() self._status = "not_running"
[docs] def remote_mount( self, source: str, mntpoint: Optional[str] = None, meshes: Optional[List[str]] = None, python_exe: Optional[str] = ".venv/bin/python", **kwargs: Any, ) -> None: """Declare a local directory to be mounted on workers via FUSE. This is configuration-only — no mount is established immediately. The mount is applied (and re-applied on reconnect) on the next call to :meth:`state`. Args: source: Local directory path to mount. mntpoint: Mount point on workers. Defaults to ``source``. meshes: Names of meshes to mount on. ``None`` means all meshes returned by :meth:`state`. python_exe: Path to the Python executable relative to the mount point, used to set ``python_executable`` on the returned mesh. Set to ``None`` to skip. Defaults to ``".venv/bin/python"``. **kwargs: Forwarded to :func:`remotemount`. """ self._components.mounts.remote_mount( source=source, mntpoint=mntpoint, meshes=meshes, python_exe=python_exe, **kwargs, )
[docs] def gather_mount( self, remote_mount_point: str, local_mount_point: str, meshes: Optional[List[str]] = None, ) -> None: """Declare a remote directory to be mounted locally via gather mount. This is configuration-only — no mount is established immediately. The mount is applied (and re-applied on reconnect) on the next call to :meth:`state`. Args: remote_mount_point: Path on workers to expose. The token ``$SUBDIR`` is replaced with each host's mesh-coordinate key (e.g. ``hosts_0``). local_mount_point: Local path where the remote directory will be mounted. meshes: Names of meshes to gather from. ``None`` means all meshes returned by :meth:`state`. """ self._components.mounts.gather_mount( remote_mount_point=remote_mount_point, local_mount_point=local_mount_point, meshes=meshes, )
@abstractmethod def _state(self) -> JobState: ... @abstractmethod def _create(self, client_script: Optional[str]): """Create the job resources. Called by `apply()` when the job is not yet running. Implementations should schedule the job with the appropriate scheduler but must NOT set `_status` directly; `apply()` handles status transitions after this method returns. """ ...
[docs] @abstractmethod def can_run(self, spec: "JobTrait") -> bool: """ Is this job capable of running the job spec? This is used to check if a cached job can be used to run `spec` instead of creating a new reserveration. It is also used by the batch run infrastructure to indicate that the batch job can certainly run itself. """ ...
@abstractmethod def _kill(self): """ Stop the job/reservation. """ ...
[docs]def job_loads(data: bytes) -> JobTrait: """ Deserialize a job from bytes. Args: data: Pickled job bytes, typically from :meth:`JobTrait.dumps`. Returns: The deserialized job object. """ # @lint-ignore PYTHONPICKLEISBAD return pickle.loads(data)
DEFAULT_JOB_PATH: str = ".monarch/job_state.pkl"
[docs]def job_load(filename: str = DEFAULT_JOB_PATH) -> JobTrait: """ Load a job from a file. Args: filename: Path to the pickled job file, typically from :meth:`JobTrait.dump`. Defaults to ``.monarch/job_state.pkl``. Returns: The deserialized job object. """ with open(filename, "rb") as file: # @lint-ignore PYTHONPICKLEISBAD job: "JobTrait" = pickle.load(file) return job
_MONARCH_DIR: str = ".monarch" _CONTEXT_STATE_FILE: str = "state.pkl" _CONTEXT_SPEC_FILE: str = "spec" def _current_spec_file() -> Path: """Return the spec file path for the current context. Reads the symlink at ``.monarch/job_state.pkl`` to determine which context is active. Falls back to ``default/spec`` when the symlink does not exist. """ link = Path(DEFAULT_JOB_PATH) if link.is_symlink(): target = Path(os.readlink(str(link))) return Path(_MONARCH_DIR) / target.parent / _CONTEXT_SPEC_FILE return Path(_MONARCH_DIR) / "default" / _CONTEXT_SPEC_FILE def _import_job_from_spec(module_path: str) -> JobTrait: """Import and return the ``JobTrait`` at the dotted *module_path*. Args: module_path: Dotted import path of the form ``module.attr`` (e.g. ``myjob.job``). Raises: ValueError: if *module_path* does not contain a ``'.'``. AttributeError: if the named attribute does not exist in the module. TypeError: if the attribute is not a :class:`JobTrait`. """ import importlib if "." not in module_path: raise ValueError(f"module_path must be 'module.attr', got {module_path!r}") mod_name, attr_name = module_path.rsplit(".", 1) mod = importlib.import_module(mod_name) job = getattr(mod, attr_name, None) if job is None: raise AttributeError(f"Module '{mod_name}' has no '{attr_name}' attribute") if not isinstance(job, JobTrait): raise TypeError( f"'{mod_name}.{attr_name}' must be a JobTrait, got {type(job).__name__}" ) return job def set_current_job(module_path: str) -> None: """Save *module_path* as the spec for the current context. Ensures ``.monarch/`` exists and that ``job_state.pkl`` is a symlink pointing to the active context's ``state.pkl`` (sets up the ``default`` context and symlink on first run; migrates a legacy plain-file ``job_state.pkl`` to ``default/state.pkl`` for backward compatibility). """ link = Path(DEFAULT_JOB_PATH) Path(_MONARCH_DIR).mkdir(parents=True, exist_ok=True) if not link.is_symlink(): default_dir = Path(_MONARCH_DIR) / "default" default_dir.mkdir(parents=True, exist_ok=True) if link.exists(): # Migrate legacy plain file into the default context. link.rename(default_dir / _CONTEXT_STATE_FILE) link.symlink_to(Path("default") / _CONTEXT_STATE_FILE) spec_file = _current_spec_file() spec_file.parent.mkdir(parents=True, exist_ok=True) spec_file.write_text(module_path) def load_current_job() -> JobTrait: """Return a fresh job object for the current context's spec. Reads the dotted module path from the current context's ``spec`` file and imports it via :func:`_import_job_from_spec`. The returned object is a plain spec — not yet connected to any workers. Call ``.state()`` on it to connect (or apply) the job; that call may load the cached ``state.pkl`` if it is still valid. Raises: FileNotFoundError: if no spec file exists in the current context. """ spec_file = _current_spec_file() try: module_path = spec_file.read_text().strip() except FileNotFoundError: raise FileNotFoundError( f"No spec found at {spec_file}. Run 'monarch apply <module.path>' first." ) from None return _import_job_from_spec(module_path) def exec_command( host_mesh: HostMesh, cmd: List[str], env: Optional[Dict[str, str]] = None, workdir: Optional[str] = None, output_dir: Optional[str] = None, rank: Optional[int] = None, point: Optional[Dict[str, int]] = None, per_host: Optional[Dict[str, int]] = None, ) -> "Future[int]": """Run a command on *host_mesh* via BashActor. Args: host_mesh: The HostMesh to execute on. cmd: Command and arguments. env: Extra environment variables. workdir: Working directory on workers. output_dir: If set, redirect stdout/stderr to files in this directory on each worker (``stdout.txt`` / ``stderr.txt``). If ``None``, stream stdout/stderr to the caller's terminal. rank: Flat rank to execute on (applied after ``flatten("rank")``). ``None`` executes on all ranks. point: Coordinate dict to slice the process mesh (e.g. ``{"host": 4, "gpu": 3}``). ``None`` executes on all ranks. Mutually exclusive with *rank*. per_host: If set, spawn multiple processes per host with the given dimension sizes (e.g. ``{"gpu": 4}``). Passed as ``per_host`` to :meth:`~monarch.actor.HostMesh.spawn_procs`. Returns: A Future resolving to the maximum return code across all ranks (0 = success). """ async def _impl() -> int: if point is not None: host_mesh_s = host_mesh.slice(**point) elif rank is not None: host_mesh_s = host_mesh.flatten("rank").slice(rank=rank) else: host_mesh_s = host_mesh procs = host_mesh_s.spawn_procs(per_host=per_host) try: bash_actors = procs.spawn("BashActor", BashActor) client_cwd = os.getcwd() if cmd[0].endswith(".py") or cmd[0] == "-m": results = await bash_actors.run_python.call( cmd, env=env, workdir=workdir, client_cwd=client_cwd, output_dir=output_dir, ) else: lines: List[str] = ["#!/bin/bash"] if env: for k, v in env.items(): lines.append(f"export {k}={shlex.quote(v)}") if workdir: lines.append(f"cd {shlex.quote(workdir)}") elif client_cwd: lines.append( f"[ -d {shlex.quote(client_cwd)} ] && cd {shlex.quote(client_cwd)}" ) lines.append(shlex.join(cmd)) script = "\n".join(lines) + "\n" results = await bash_actors.run.call(script, output_dir=output_dir) max_rc = 0 for _rank_key, result in results: rc = result.get("returncode", 1) max_rc = max(max_rc, rc) if output_dir is None: stdout = result.get("stdout", "") stderr = result.get("stderr", "") if stdout: print(stdout, end="") if stderr: print(stderr, end="", file=sys.stderr) # pyrefly: ignore [bad-return] return max_rc finally: await procs.stop() return Future(coro=_impl())
[docs]class LocalJob(JobTrait): """ Job that runs on the local host. This job calls ``this_host()`` for each host mesh requested. It serves as a stand-in in configuration so a job can be switched between remote and local execution by changing the job configuration. """ def __init__(self, hosts: Sequence["str"] = ("hosts",)): """ Args: hosts: Names of the host meshes to create. """ self._host_names = hosts # if launched with client_script, the proc corresponding to the # locally running client, and the log_dir it is writing to. self._proc: Optional[subprocess.Popen] = None self._log_dir: Optional[str] = None super().__init__() def _kill(self): pass
[docs] def can_run(self, spec: "JobTrait"): """ Local jobs are the same regardless of what was saved, so just use the spec, which has the correct 'hosts' sequence. """ return False
def _should_spawn_telemetry_worker_collector_actors(self) -> bool: # LocalJob runs everything on one host; a worker collector fan-out # would duplicate the client collector, so skip it. return False def _state(self) -> JobState: return JobState({k: this_host() for k in self._host_names}) def _create(self, client_script: Optional[str]): if client_script is None: return # noop, because LocalJob always 'exists' b = BatchJob(self) b.dump(".monarch/job_state.pkl") log_dir = self._setup_log_directory() self._run_client_as_daemon(client_script, log_dir) logger.info( "Started client script %s with PID: %d", client_script, self.process.pid ) logger.info("Logs available at: %s", log_dir) def _setup_log_directory(self) -> str: """Create a log directory for the batch job.""" log_base_dir = ".monarch/logs" os.makedirs(log_base_dir, exist_ok=True) # Create a unique subdirectory for this job run self._log_dir = tempfile.mkdtemp(prefix="job_", dir=log_base_dir) return self._log_dir def _run_client_as_daemon(self, client_script: str, log_dir: str) -> None: """ Run the client script as a daemon process. Args: client_script: Path to the client script to run log_dir: Directory to store log files Returns: The process ID of the daemon """ # Prepare log files stdout_log = os.path.join(log_dir, "stdout.log") stderr_log = os.path.join(log_dir, "stderr.log") # Create environment with the batch-mode marker set. env = os.environ.copy() env[MONARCH_BATCH_JOB_ENV] = "1" # Open log files with open(stdout_log, "w") as stdout_file, open(stderr_log, "w") as stderr_file: # Start the process with Python interpreter self._proc = subprocess.Popen( [sys.executable, client_script], env=env, stdout=stdout_file, stderr=stderr_file, # Detach the process from parent (daemonize) start_new_session=True, ) @property def process(self): if self._proc is None: raise ValueError("no local batch job") return self._proc
class BatchJob(JobTrait): """ Wrapper that can be put around other job traits to make make it always load from the job_state.pkl when MONARCH_BATCH_JOB is set. """ def __init__(self, job: JobTrait): super().__init__() self._job = job # BatchJob is a scheduler-state proxy; component config and runtime stay # owned by the wrapped job so there is only one source of truth. self._components = job._components self._apply_id = job.apply_id or str(uuid.uuid4()) def _should_spawn_telemetry_worker_collector_actors(self) -> bool: return self._job._should_spawn_telemetry_worker_collector_actors() def _connect_host_meshes(self, running_job: "JobTrait") -> Dict[str, HostMesh]: self._components.before_connect(self) host_meshes = dict(running_job._state()._hosts) return self._components.connect(self, host_meshes) def state( self, cached_path: Optional[str] = ".monarch/job_state.pkl" ) -> "JobState": job_state = self._connect(cached_path) self._components.state(self, job_state) return job_state def can_run(self, spec: JobTrait): if in_batch_job(): import atexit atexit.register(self._kill) return True return False @property def _running(self) -> Optional[JobTrait]: return self def _state(self): return self._job._state() def _create(self, client_script: Optional[str] = None): return self._job._create(client_script) def _kill(self): logger.info("Stopping Batch Job") return self._job._kill() class ProcessState(NamedTuple): pid: int channel: str class LoginJob(JobTrait): """ Makes a connections directly to hosts via an explicit list. """ def __init__(self): super().__init__() self._meshes: Dict[str, List[str]] = {} self._host_to_pid: Dict[str, ProcessState] = {} def add_mesh(self, name: str, hosts: List[str]): self._meshes[name] = hosts def _state(self) -> JobState: if not self._pids_active(): raise RuntimeError("lost connection") hosts = { name: attach_to_workers( name=name, ca="trust_all_connections", workers=[self._host_to_pid[v].channel for v in values], ) for name, values in self._meshes.items() } return JobState(hosts) def _create(self, client_script: Optional[str]): if client_script is not None: raise RuntimeError("LoginJob cannot run batch-mode scripts") for hosts in self._meshes.values(): for host in hosts: self._host_to_pid[host] = self._start_host(host) @abstractmethod def _start_host(self, host: str) -> ProcessState: ... def can_run(self, spec: "JobTrait") -> bool: """ Is this job capable of running the job spec? This is used to check if a cached job can be used to run `spec` instead of creating a new reserveration. It is also used by the batch run infrastructure to indicate that the batch job can certainly run itself. """ return ( isinstance(spec, LoginJob) and spec._meshes == self._meshes and self._pids_active() ) def _pids_active(self) -> bool: if not self.active: return False for _, p in self._host_to_pid.items(): try: # Check if process exists by sending signal 0 os.kill(p.pid, 0) except OSError: # Process doesn't exist or we don't have permission to signal it return False return True def _kill(self): for p in self._host_to_pid.values(): try: os.kill(p.pid, signal.SIGKILL) except OSError: pass class SSHJob(LoginJob): """Connect to hosts via SSH and start monarch workers. Args: python_exe: Python executable on remote hosts. ssh_args: Extra arguments passed to ssh. monarch_port: Port the worker listens on. transport: Transport type for worker communication. Supported values are "tcp", "metatls" and "metatls-hostname" (see enable_transport). """ def __init__( self, python_exe: str = "python", ssh_args: Sequence[str] = (), monarch_port: int = 22222, transport: str = "tcp", ): if transport not in ("tcp", "metatls", "metatls-hostname"): raise ValueError( f"SSHJob only supports tcp, metatls, and metatls-hostname transport types, got {transport!r}" ) enable_transport(transport) self._python_exe = python_exe self._ssh_args = ssh_args self._port = monarch_port self._transport = transport self._scheme = ( "metatls" if transport in ("metatls", "metatls-hostname") else "tcp" ) super().__init__() def _start_host(self, host: str) -> ProcessState: addr = f"{self._scheme}://{host}:{self._port}" startup = f'from monarch.actor import run_worker_loop_forever; run_worker_loop_forever(address={repr(addr)}, ca="trust_all_connections")' command = f"{shlex.quote(self._python_exe)} -c {shlex.quote(startup)}" proc = subprocess.Popen( ["ssh", *self._ssh_args, host, "-n", command], start_new_session=True, ) return ProcessState(proc.pid, addr) def can_run(self, spec): return ( isinstance(spec, SSHJob) and spec._python_exe == self._python_exe and self._port == spec._port and self._ssh_args == spec._ssh_args and self._transport == spec._transport and super().can_run(spec) )