Source code for domyn_swarm.config.swarm

# SPDX-FileCopyrightText: 2025-2026 Domyn
# SPDX-License-Identifier: Apache-2.0

import io
import math
from pathlib import Path
from typing import Annotated, Any

from pydantic import (
    BaseModel,
    Field,
    PrivateAttr,
    StringConstraints,
    field_validator,
    model_validator,
)
import yaml

from domyn_swarm import utils
from domyn_swarm.config.backend import BackendConfig
from domyn_swarm.config.defaults import default_for
from domyn_swarm.config.plan import DeploymentPlan, PlanBuilder
from domyn_swarm.config.settings import get_settings
from domyn_swarm.config.watchdog import WatchdogConfig
from domyn_swarm.helpers.io import to_path
from domyn_swarm.helpers.logger import setup_logger

logger = setup_logger(__name__)


[docs] class DomynLLMSwarmConfig(BaseModel): # model / revision -------------------------------------------------------- model: str = Field( description=( "Hugging Face model ID or local path. Passed verbatim to `vllm serve`; " "must resolve to a local directory or an offline Hugging Face model in " "`HF_HOME`." ), ) name: Annotated[ str, StringConstraints(strip_whitespace=True, to_lower=True, max_length=38), ] = Field( description=( "Name of the swarm. Stripped of surrounding whitespace, lower-cased and " "limited to 38 characters so it fits backend resource-name limits. The " "swarm's unique id is this name plus a short suffix." ), ) revision: str | None = Field( default=None, description="Git tag or commit for the model, when loading it from Hugging Face.", ) # resources --------------------------------------------------------------- replicas: int = Field( default=1, description=("How many independent vLLM clusters to launch. Useful for A/B tests."), ) gpus_per_replica: int = Field( default=4, description=("How many GPUs each replica uses. Also sets vLLM's `--tensor-parallel-size`."), ) gpus_per_node: int = Field( description="Number of GPUs per node (vLLM)", default=4, ge=1, le=4, ) replicas_per_node: int | None = Field( description="Number of model replicas per node (vLLM)", default=None ) nodes: int | None = Field( description="Number of nodes to use for the swarm (vLLM)", default=None ) cpus_per_task: int | None = Field( description="Number of CPUs per task (vLLM)", ge=1, default=None, ) mem_per_cpu: str | None = Field( default=None, description=( "Memory to request per CPU, e.g. `4GB`. Reserved: no backend currently " "reads this field." ), ) wait_endpoint_s: int = Field( default=1200, description=( "Seconds the load-balancer script waits for the endpoint to come up before giving up." ), ) image: str | utils.EnvPath = Field( default_factory=default_for("image"), description=( "Container image for the vLLM replicas: a path to a Singularity image " "on the Slurm backend, or a Docker image on Lepton." ), ) args: str = Field( default="", description=( "Extra CLI flags passed verbatim to `python -m vllm.entrypoints.openai.api_server`." ), ) port: int = Field( default=8000, description="Port on which each replica's OpenAI-compatible API listens.", ) home_directory: utils.EnvPath = Field( default_factory=lambda: utils.EnvPath(get_settings().home), description="Home directory where logs and state are stored", ) backend: BackendConfig | None = Field( description="Backend configuration for the swarm", ) _plan: DeploymentPlan | None = PrivateAttr(default=None) env: dict[str, str] | None = Field( default=None, description=( "Environment variables to set on the replica processes, as a mapping of name to value." ), ) watchdog: WatchdogConfig = Field( default_factory=WatchdogConfig, description=( "Watchdog settings governing how the spawned vLLM replicas are monitored and restarted." ), ) # Convenience accessor
[docs] def get_deployment_plan(self) -> DeploymentPlan | None: return self._plan
[docs] def build_plan(self) -> DeploymentPlan: builder = PlanBuilder(self) self._plan = builder.build() return self._plan
[docs] @field_validator("backend") @classmethod def not_empty(cls, v: BackendConfig | None) -> BackendConfig: if not v: raise ValueError("At least one backend must be configured") return v
@staticmethod def _resolve_ray_metrics(backend: dict, requires_ray: bool) -> None: """Auto-resolve ``monitoring.ray_metrics.enabled`` on a backend dict. Mirrors how ``watchdog.ray.enabled`` is derived: when monitoring is on and ``ray_metrics.enabled`` hasn't been set explicitly, it becomes ``True`` iff the deployment requires Ray; explicit values are left untouched. Args: backend: The (possibly mutated in place) backend config dict. requires_ray: Whether this deployment requires Ray. """ endpoint = backend.get("endpoint") if not isinstance(endpoint, dict): return mon = endpoint.get("monitoring") if not (isinstance(mon, dict) and mon.get("enabled")): return rm = mon.setdefault("ray_metrics", {}) if isinstance(rm, dict) and rm.get("enabled") is None: rm["enabled"] = bool(requires_ray)
[docs] @classmethod def read(cls, path: str) -> "DomynLLMSwarmConfig": config_path = to_path(path) return _load_swarm_config(config_path.open())
[docs] def persist(self, path: str | Path) -> None: config_path = to_path(path) with config_path.open("w") as f: yaml.safe_dump(self.model_dump(mode="json"), f)
[docs] @model_validator(mode="before") @classmethod def validate_resource_allocations(cls, data: Any) -> "DomynLLMSwarmConfig": """Validate and auto-compute all derived resource allocation fields.""" replicas = data.get("replicas", 1) gpus_per_replica = data.get("gpus_per_replica", 4) gpus_per_node = data.get("gpus_per_node", 4) replicas_per_node = data.get("replicas_per_node") # Replicas per node if replicas_per_node is None: if gpus_per_replica <= gpus_per_node: capacity = gpus_per_node // gpus_per_replica replicas_per_node = min(capacity, replicas) else: replicas_per_node = None # Nodes if replicas_per_node: nodes = math.ceil(replicas / replicas_per_node) else: nodes = math.ceil((replicas * gpus_per_replica) / gpus_per_node) if nodes < 1: raise ValueError("Number of nodes must be >= 1") # CPUs per task cpus_per_task = data.get("cpus_per_task") if cpus_per_task is None: cpus_per_task = max(1, 32 // replicas_per_node) if replicas_per_node else 32 # Requires Ray? requires_ray = gpus_per_replica > gpus_per_node and nodes > 1 # Ensure watchdog config exists and update ray settings if "watchdog" not in data: data["watchdog"] = {} if "ray" not in data["watchdog"]: data["watchdog"]["ray"] = {} data["watchdog"]["ray"]["enabled"] = requires_ray if requires_ray and gpus_per_replica % gpus_per_node != 0: raise ValueError( "When gpus_per_replica > gpus_per_node, gpus_per_replica " "must be a multiple of gpus_per_node" ) # Fill computed fields data["replicas_per_node"] = replicas_per_node data["nodes"] = nodes data["cpus_per_task"] = cpus_per_task # Update backend configurations with computed values backend = data.get("backend", []) if backend: if not isinstance(backend, dict): backend = backend.model_dump() if backend.get("type") == "slurm" and "requires_ray" not in backend: backend["requires_ray"] = requires_ray # Ray multi-node deployments render from a dedicated template. # Select it automatically unless the user pinned a custom path. if backend.get("type") == "slurm" and requires_ray and "template_path" not in backend: from domyn_swarm.config import slurm as _slurm_mod backend["template_path"] = ( utils.EnvPath(_slurm_mod.__file__).parent.parent / "templates" / "llm_swarm_ray.sh.j2" ) cls._resolve_ray_metrics(backend, requires_ray) data["backend"] = backend return data
@model_validator(mode="after") def _finalize_ray_metrics(self) -> "DomynLLMSwarmConfig": """Resolve ``ray_metrics.enabled`` to a concrete bool in all cases. The before-validator (``validate_resource_allocations``) only auto-resolves ``ray_metrics.enabled`` when a monitoring block is present and enabled. This after-validator runs once all nested objects exist (with their defaults) and is the final authority: it guarantees ``ray_metrics.enabled`` is never left as ``None`` after a full config is validated, regardless of whether monitoring is disabled or the monitoring block was absent entirely. Explicit ``True``/``False`` values set by the user are always respected. """ from domyn_swarm.config.slurm import SlurmConfig be = self.backend if isinstance(be, SlurmConfig): rm = be.endpoint.monitoring.ray_metrics if rm.enabled is None: rm.enabled = bool(be.endpoint.monitoring.enabled and be.requires_ray) return self
def _load_swarm_config( config_file: io.TextIOWrapper, *, replicas: int | None = None, ) -> DomynLLMSwarmConfig: """Load YAML, inject driver_script if given, apply replicas override.""" cfg_dict = yaml.safe_load(config_file) cfg = DomynLLMSwarmConfig.model_validate(cfg_dict) # override default only if user passed something truthy if replicas: cfg.replicas = replicas return cfg