chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
@@ -0,0 +1,16 @@
from .accelerators import AcceleratorSetupCallback
from .backend_setup import BackendSetupCallback
from .datasets import DatasetsCallback
from .state_manager import StateManagerCallback
from .working_dir_setup import WorkingDirectorySetupCallback
__all__ = [
"AcceleratorSetupCallback",
"BackendSetupCallback",
"DatasetsCallback",
"StateManagerCallback",
"WorkingDirectorySetupCallback",
]
# DO NOT ADD ANYTHING AFTER THIS LINE.
@@ -0,0 +1,160 @@
import logging
import os
from collections import defaultdict
from typing import TYPE_CHECKING, Any, Dict, List
import ray
import ray._private.ray_constants as ray_constants
from ray._private.accelerators.nvidia_gpu import CUDA_VISIBLE_DEVICES_ENV_VAR
from ray._private.ray_constants import env_bool
from ray.train import BackendConfig
from ray.train.constants import ENABLE_SHARE_CUDA_VISIBLE_DEVICES_ENV
from ray.train.v2._internal.execution.callback import WorkerGroupCallback
from ray.train.v2._internal.execution.worker_group import ActorMetadata
from ray.train.v2.api.config import ScalingConfig
if TYPE_CHECKING:
from ray.train.v2._internal.execution.worker_group.worker import Worker
logger = logging.getLogger(__name__)
class AcceleratorSetupCallback(WorkerGroupCallback):
"""Perform accelerator setup for workers.
For example, this callback can be used to share CUDA_VISIBLE_DEVICES
among workers on the same node.
"""
def __init__(self, backend_config: BackendConfig, scaling_config: ScalingConfig):
self._backend = backend_config.backend_cls()
self._scaling_config = scaling_config
def before_init_train_context(
self, workers: List["Worker"]
) -> Dict[str, List[Any]]:
self._maybe_share_cuda_visible_devices(workers)
# TODO: Add support for sharing other accelerator resources.
return {}
def _maybe_share_cuda_visible_devices(self, workers: List["Worker"]):
"""Set CUDA visible devices environment variables on workers."""
share_cuda_visible_devices_enabled = env_bool(
ENABLE_SHARE_CUDA_VISIBLE_DEVICES_ENV,
self._backend.share_cuda_visible_devices,
)
if (
self._scaling_config._resources_per_worker_not_none.get("GPU", 0) > 0
and share_cuda_visible_devices_enabled
):
_share_cuda_visible_devices(workers)
def _share_cuda_visible_devices(workers: List["Worker"]):
"""Sets CUDA_VISIBLE_DEVICES on all workers.
For each worker, CUDA_VISIBLE_DEVICES will be set to the GPU IDs
visible to all workers on that worker's node.
This allows GPU workers on the same node to communicate with one
another.
Example:
Setup:
- Node1:
- Worker1: {0, 1}
- Worker2: {2, 3}
- Node2:
- Worker3: {0, 1}
CUDA_VISIBLE_DEVICES:
- Worker1: "0,1,2,3"
- Worker2: "0,1,2,3"
- Worker3: "0,1"
Args:
workers: List of worker objects.
"""
_share_accelerator_ids(workers, ray_constants.GPU, CUDA_VISIBLE_DEVICES_ENV_VAR)
def _share_accelerator_ids(
workers: List["Worker"], accelerator_name: str, env_var: str
):
"""Sets the given env_var on all workers.
For each worker, the cores/devices are visible to all the
workers on that worker's node. This allows workers on the
same node to communicate with one another.
Example:
Setup:
- Node1:
- Worker1: {0, 1}
- Worker2: {2, 3}
- Node2:
- Worker3: {0, 1}
NEURON_RT_VISIBLE_CORES/TPU_VISIBLE_CHIPS/...:
- Worker1: "0,1,2,3"
- Worker2: "0,1,2,3"
- Worker3: "0,1"
Args:
workers: List of worker objects.
accelerator_name: The name of the accelerator.
env_var: The name of the environment variable to set.
"""
worker_metadatas = [worker.metadata for worker in workers]
visible_accelerator_ids_per_worker = _get_visible_accelerator_ids_per_worker(
worker_metadatas=worker_metadatas, accelerator_name=accelerator_name
)
def set_accelerator_ids(accelerator_ids):
os.environ[env_var] = accelerator_ids
futures = []
for rank, visible_accelerator_ids in enumerate(visible_accelerator_ids_per_worker):
futures.append(
workers[rank].execute_async(
set_accelerator_ids, accelerator_ids=visible_accelerator_ids
)
)
ray.get(futures)
def _get_visible_accelerator_ids_per_worker(
worker_metadatas: List[ActorMetadata], accelerator_name: str
) -> List[str]:
"""Returns a list of comma-separated accelerator IDs visible to each worker.
All workers on a node should have the same set of visible accelerators,
which is the union of accelerator ids of the workers.
Args:
worker_metadatas: The actor metadata for each worker.
accelerator_name: The name of the accelerator resource to inspect.
Returns:
A list of comma-separated accelerator ID strings. This list is the
same length as the number of workers.
"""
for metadata in worker_metadatas:
if accelerator_name not in metadata.accelerator_ids:
raise ValueError(
f"Accelerator '{accelerator_name}' is not available on all workers. "
f"Got these available accelerators instead: {metadata.accelerator_ids}"
)
node_id_to_accelerator_ids = defaultdict(set)
for metadata in worker_metadatas:
node_id_to_accelerator_ids[metadata.node_id].update(
metadata.accelerator_ids[accelerator_name]
)
visible_accelerator_ids_per_worker = []
for worker_id in range(len(worker_metadatas)):
node_id = worker_metadatas[worker_id].node_id
accelerator_ids = sorted(node_id_to_accelerator_ids[node_id])
all_resource_ids = ",".join([str(id) for id in accelerator_ids])
visible_accelerator_ids_per_worker.append(all_resource_ids)
return visible_accelerator_ids_per_worker
@@ -0,0 +1,33 @@
import logging
from ray.exceptions import RayActorError, RayTaskError
from ray.train.backend import BackendConfig
from ray.train.v2._internal.execution.callback import (
ReplicaGroupCallback,
WorkerGroupCallback,
)
from ray.train.v2._internal.execution.worker_group import (
ExecutionGroup,
)
logger = logging.getLogger(__name__)
class BackendSetupCallback(ReplicaGroupCallback, WorkerGroupCallback):
def __init__(self, backend_config: BackendConfig):
self._backend_config = backend_config
self._backend = backend_config.backend_cls()
def after_execution_group_start(self, execution_group: ExecutionGroup):
self._backend.on_start(execution_group, self._backend_config)
self._backend.on_training_start(execution_group, self._backend_config)
def before_execution_group_shutdown(self, execution_group: ExecutionGroup):
try:
self._backend.on_shutdown(execution_group, self._backend_config)
except (RayActorError, RayTaskError):
logger.warning(
"Graceful shutdown of backend failed. This is "
"expected if one of the workers has crashed.",
exc_info=True,
)
@@ -0,0 +1,175 @@
import copy
import logging
from typing import TYPE_CHECKING, Dict, List, Optional
import ray
import ray.train
from ray.train.v2._internal.data_integration.interfaces import (
DatasetShardMetadata,
DatasetShardProvider,
GenDataset,
)
from ray.train.v2._internal.execution.callback import WorkerGroupCallback
from ray.train.v2._internal.execution.context import TrainRunContext
from ray.train.v2._internal.execution.worker_group.worker_group import (
Worker,
WorkerGroup,
WorkerGroupContext,
)
from ray.util.scheduling_strategies import NodeAffinitySchedulingStrategy
if TYPE_CHECKING:
from ray.data import DataIterator, Dataset, NodeIdStr
from ray.data.context import DataContext
logger = logging.getLogger(__name__)
class RayDatasetShardProvider:
def __init__(
self,
datasets: Dict[str, GenDataset],
data_config: ray.train.DataConfig,
data_context: "DataContext",
world_size: int,
worker_node_ids: List["NodeIdStr"],
):
from ray.train.v2._internal.data_integration.dataset_manager import (
DatasetManager,
)
self._dataset_names = set(datasets)
self._dataset_manager = (
ray.remote(DatasetManager)
.options(
num_cpus=0,
scheduling_strategy=NodeAffinitySchedulingStrategy(
ray.get_runtime_context().get_node_id(), soft=False
),
)
.remote(
datasets=datasets,
data_config=data_config,
data_context=data_context,
world_size=world_size,
worker_node_ids=worker_node_ids,
)
)
self._cached_dataset_shards: Dict[str, "DataIterator"] = {}
def get_dataset_shard(self, dataset_info: DatasetShardMetadata) -> "DataIterator":
dataset_name = dataset_info.dataset_name
if dataset_name not in self._dataset_names:
raise KeyError(
f"Dataset shard for '{dataset_name}' not found. "
"Please ensure that the dataset is passed through the Trainer `datasets` "
"argument."
)
if dataset_name not in self._cached_dataset_shards:
self._cached_dataset_shards[dataset_name] = ray.get(
self._dataset_manager.get_dataset_shard.remote(dataset_info)
)
return self._cached_dataset_shards[dataset_name]
def shutdown_data_executors(self) -> None:
"""
Attempts to eagerly shutdown the data executors for datasets, freeing resources allocated to data execution.
"""
try:
self._dataset_manager.shutdown_data_executors.remote()
except Exception:
logger.debug("Failed to invoke remote cleanup of Dataset Manager.")
class DatasetsCallback(WorkerGroupCallback):
"""A callback for managing Ray Datasets for the worker group."""
def __init__(
self,
train_run_context: TrainRunContext,
datasets: Dict[str, "Dataset"],
):
self._datasets = datasets
self._data_config = copy.deepcopy(train_run_context.dataset_config)
self._scaling_config = train_run_context.scaling_config
self._dataset_shard_provider: Optional[RayDatasetShardProvider] = None
# Capture the current DataContext to propagate it to
# the Train workers later.
# The propagation works in the following way:
# 1. This callback is created when user create the Trainer.
# 2. Then this callback will be passed to the Controller actor.
# 3. Lastly, when the worker group is initialized, the Controller
# will call the `after_worker_group_start` callback to propagate
# the DataContext to Train workers.
from ray.data.context import DataContext
self._data_context = copy.deepcopy(DataContext.get_current())
def get_train_total_resources(
self, scaling_config: ray.train.ScalingConfig
) -> Dict[str, float]:
"""Return the resources reserved for training, so that Data can exclude
these resources logically from its available pool."""
if scaling_config.elasticity_enabled:
# If Train is running with a variable number of workers,
# we can't provide a fixed number of resources to exclude.
# Instead, Train and Data should coordinate via the autoscaling
# coordinator to allocate resources dynamically.
return {}
return scaling_config.total_resources
# --------------------------
# WorkerGroupCallback
# --------------------------
def before_init_train_context(
self, workers: List[Worker]
) -> Dict[str, List[DatasetShardProvider]]:
world_size = len(workers)
worker_node_ids = [worker.metadata.node_id for worker in workers]
datasets = {k: v() if callable(v) else v for k, v in self._datasets.items()}
# TODO: Move this to the constructor.
# Notify the DataConfig about the total resources reserved for training.
total_train_resources = self.get_train_total_resources(self._scaling_config)
self._data_config.set_train_total_resources(
total_train_resources.get("CPU", 0), total_train_resources.get("GPU", 0)
)
self._dataset_shard_provider = RayDatasetShardProvider(
datasets=datasets,
data_config=self._data_config,
data_context=self._data_context,
world_size=world_size,
worker_node_ids=worker_node_ids,
)
return {"dataset_shard_provider": [self._dataset_shard_provider] * world_size}
def after_worker_group_start(self, worker_group: WorkerGroup):
# Propagate DataContext
from ray.data.context import DataContext
def _propagate_data_context(ctx: "DataContext"):
DataContext._set_current(ctx)
worker_group.execute(
_propagate_data_context,
self._data_context,
)
def after_worker_group_shutdown(
self, worker_group_context: WorkerGroupContext
) -> None:
shard_provider = self._dataset_shard_provider
if shard_provider:
shard_provider.shutdown_data_executors()
def after_worker_group_abort(
self, worker_group_context: WorkerGroupContext
) -> None:
shard_provider = self._dataset_shard_provider
if shard_provider:
shard_provider.shutdown_data_executors()
@@ -0,0 +1,39 @@
import importlib
import os
from typing import List
from ray.train.v2._internal.constants import RAY_TRAIN_CALLBACKS_ENV_VAR
from ray.train.v2._internal.execution.callback import RayTrainCallback
def _initialize_env_callbacks() -> List[RayTrainCallback]:
"""Initialize callbacks from environment variable.
Returns:
List of callbacks initialized from environment variable.
"""
callbacks = []
callbacks_str = os.environ.get(RAY_TRAIN_CALLBACKS_ENV_VAR, "")
if not callbacks_str:
return callbacks
for callback_path in callbacks_str.split(","):
callback_path = callback_path.strip()
if not callback_path:
continue
try:
module_path, class_name = callback_path.rsplit(".", 1)
module = importlib.import_module(module_path)
callback_cls = getattr(module, class_name)
if not issubclass(callback_cls, RayTrainCallback):
raise TypeError(
f"Callback class '{callback_path}' must be a subclass of "
f"RayTrainCallback, got {type(callback_cls).__name__}"
)
callback = callback_cls()
callbacks.append(callback)
except (ImportError, AttributeError, ValueError, TypeError) as e:
raise ValueError(f"Failed to import callback from '{callback_path}'") from e
return callbacks
@@ -0,0 +1,124 @@
from contextlib import contextmanager
from typing import Dict, Optional
import ray
from ray.train.v2._internal.execution.callback import (
ControllerCallback,
TrainContextCallback,
WorkerCallback,
WorkerGroupCallback,
)
from ray.train.v2._internal.execution.context import TrainRunContext, get_train_context
from ray.train.v2._internal.execution.controller.state import (
TrainControllerState,
TrainControllerStateType,
)
from ray.train.v2._internal.metrics.base import Metric
from ray.train.v2._internal.metrics.controller import ControllerMetrics
from ray.train.v2._internal.metrics.worker import WorkerMetrics
from ray.train.v2._internal.util import time_monotonic
class ControllerMetricsCallback(ControllerCallback, WorkerGroupCallback):
"""Callback that records controller-specific metrics."""
def after_controller_start(self, train_run_context: TrainRunContext):
"""Initialize metrics after controller starts."""
self._run_name = train_run_context.get_run_config().name
self._run_id = train_run_context.run_id
self._metrics: Dict[str, Metric] = ControllerMetrics.get_controller_metrics(
self._run_name, self._run_id
)
# Record initial state
self._metrics[ControllerMetrics.CONTROLLER_STATE].record(
TrainControllerStateType.INITIALIZING
)
async def before_controller_shutdown(self):
"""Shutdown metrics before controller shuts down."""
for metric in self._metrics.values():
metric.reset()
def after_controller_state_update(
self,
previous_state: TrainControllerState,
current_state: TrainControllerState,
):
"""Record state transitions after controller state updates."""
self._metrics[ControllerMetrics.CONTROLLER_STATE].record(
current_state._state_type
)
@contextmanager
def on_worker_group_start(self):
"""Measure time taken to start worker group."""
start_time_s = time_monotonic()
yield
elapsed_time_s = time_monotonic() - start_time_s
self._metrics[ControllerMetrics.WORKER_GROUP_START_TOTAL_TIME_S].record(
elapsed_time_s
)
@contextmanager
def on_worker_group_shutdown(self):
"""Measure time taken to shutdown worker group."""
start_time_s = time_monotonic()
yield
elapsed_time_s = time_monotonic() - start_time_s
self._metrics[ControllerMetrics.WORKER_GROUP_SHUTDOWN_TOTAL_TIME_S].record(
elapsed_time_s
)
class WorkerMetricsCallback(WorkerCallback, TrainContextCallback):
"""Callback that records worker-specific metrics."""
def __init__(self, train_run_context: TrainRunContext):
self._run_name = train_run_context.get_run_config().name
self._run_id = train_run_context.run_id
self._metrics: Optional[Dict[str, Metric]] = None
def after_init_train_context(self):
"""Initialize metrics after train context is initialized."""
train_context = get_train_context()
core_context = ray.runtime_context.get_runtime_context()
world_rank = train_context.get_world_rank()
worker_actor_id = core_context.get_actor_id()
self._metrics = WorkerMetrics.get_worker_metrics(
self._run_name, self._run_id, world_rank, worker_actor_id
)
def before_worker_shutdown(self):
"""Shutdown metrics before shutdown."""
if self._metrics:
for metric in self._metrics.values():
metric.reset()
@contextmanager
def on_report(self):
"""
Context manager to measure the time taken to report a checkpoint to the storage.
"""
start_time_s = time_monotonic()
yield
elapsed_time_s = time_monotonic() - start_time_s
self._metrics[WorkerMetrics.REPORT_TOTAL_BLOCKED_TIME_S].record(elapsed_time_s)
@contextmanager
def on_checkpoint_sync(self):
"""Measure time spent in the cross-rank barrier that synchronizes the
checkpoint directory name across all workers."""
start_time_s = time_monotonic()
yield
elapsed_time_s = time_monotonic() - start_time_s
self._metrics[WorkerMetrics.CHECKPOINT_SYNC_TOTAL_TIME_S].record(elapsed_time_s)
@contextmanager
def on_checkpoint_transfer(self):
"""Measure time spent transferring checkpoint files to storage."""
start_time_s = time_monotonic()
yield
elapsed_time_s = time_monotonic() - start_time_s
self._metrics[WorkerMetrics.CHECKPOINT_TRANSFER_TOTAL_TIME_S].record(
elapsed_time_s
)
@@ -0,0 +1,131 @@
import logging
from typing import TYPE_CHECKING, Optional
import ray
from ray.exceptions import RayActorError
from ray.train.v2._internal.constants import GET_ACTOR_TIMEOUT_S
from ray.train.v2._internal.execution.callback import (
ControllerCallback,
WorkerGroupCallback,
)
from ray.train.v2._internal.execution.controller.placement_group_cleaner import (
PlacementGroupCleaner,
)
if TYPE_CHECKING:
from ray.train.v2._internal.execution.context import TrainRunContext
from ray.train.v2._internal.execution.worker_group import WorkerGroup
logger = logging.getLogger(__name__)
class PlacementGroupCleanerCallback(ControllerCallback, WorkerGroupCallback):
"""Callback that manages a PlacementGroupCleaner for the training controller.
This callback ensures that placement groups are cleaned up even if the controller
dies ungracefully.
"""
def __init__(
self,
check_interval_s: float = 1.0,
get_actor_timeout_s: float = GET_ACTOR_TIMEOUT_S,
stop_timeout: Optional[float] = None,
):
"""Initialize the callback.
Args:
check_interval_s: How often (in seconds) the cleaner should check
if the controller is still alive.
get_actor_timeout_s: How long to wait when calling the get actor state api.
stop_timeout: How long to wait for the cleaner to stop.
"""
self._check_interval_s = check_interval_s
self._get_actor_timeout_s = get_actor_timeout_s
self._stop_timeout = stop_timeout
if self._stop_timeout is None:
self._stop_timeout = max(
2.0, self._check_interval_s * 2 + self._get_actor_timeout_s
)
self._cleaner: Optional[PlacementGroupCleaner] = None
self._controller_actor_id: Optional[str] = None
def after_controller_start(self, train_run_context: "TrainRunContext"):
"""Launch the detached PlacementGroupCleaner actor and start monitoring."""
core_context = ray.runtime_context.get_runtime_context()
self._controller_actor_id = core_context.get_actor_id()
try:
# Launch the cleaner as a detached actor so it survives controller death
cleaner_actor_cls = ray.remote(num_cpus=0)(PlacementGroupCleaner)
self._cleaner = cleaner_actor_cls.options(
lifetime="detached",
get_if_exists=False,
).remote(
controller_actor_id=self._controller_actor_id,
check_interval_s=self._check_interval_s,
get_actor_timeout_s=self._get_actor_timeout_s,
stop_timeout=self._stop_timeout,
)
logger.debug(
f"PlacementGroupCleaner launched for run_id={train_run_context.run_id}"
)
except Exception as e:
logger.warning(
f"Failed to launch PlacementGroupCleaner: {e}. "
"Placement groups may not be cleaned up if controller exits ungracefully."
)
self._cleaner = None
return
self._cleaner.start_monitoring.remote()
def after_worker_group_start(self, worker_group: "WorkerGroup"):
"""Register the worker group's placement group with the cleaner.
This is called after a worker group is successfully started.
"""
if not self._cleaner or not self._controller_actor_id:
logger.warning(
"PlacementGroupCleaner not available. "
"Placement groups may not be cleaned up if controller exits ungracefully."
)
return
worker_group_state = worker_group.get_worker_group_state()
placement_group = worker_group_state.placement_group_handle.placement_group
try:
ray.get(self._cleaner.register_placement_group.remote(placement_group))
except Exception as e:
logger.warning(
f"Failed to register placement group with cleaner: {e}. "
"Placement group may not be cleaned up if controller dies ungracefully."
)
return
logger.debug(
f"Registered placement group {placement_group.id} with PlacementGroupCleaner."
)
async def before_controller_shutdown(self):
self._stop_cleaner()
def before_controller_abort(self):
self._stop_cleaner()
def _stop_cleaner(self):
if not self._cleaner:
return
try:
# Stop the cleaner gracefully (it won't clean up the PG)
ray.get(self._cleaner.stop.remote(), timeout=self._stop_timeout)
except RayActorError:
logger.debug(
"PlacementGroupCleaner exited before stop completed; ignoring."
)
except Exception:
logger.exception("Failed to stop PlacementGroupCleaner gracefully.")
finally:
self._cleaner = None
@@ -0,0 +1,93 @@
import logging
import os
from typing import TYPE_CHECKING, Dict, List, Optional
import ray
from ray.actor import ActorHandle
from ray.train.v2._internal.constants import (
DEFAULT_PREEMPTION_POLL_INTERVAL_S,
PREEMPTION_POLL_INTERVAL_S_ENV_VAR,
)
from ray.train.v2._internal.execution.callback import WorkerGroupCallback
from ray.train.v2._internal.execution.preemption import PreemptionWatcher
if TYPE_CHECKING:
from ray.train.v2._internal.execution.worker_group import (
WorkerGroup,
WorkerGroupContext,
)
logger = logging.getLogger(__name__)
class PreemptionCallback(WorkerGroupCallback):
"""Manages a :class:`PreemptionWatcher` across worker-group lifecycles.
Spawns a fresh watcher in :meth:`after_worker_group_start` and stops it on
every teardown path (shutdown and abort). Each worker group gets its own
watcher and failure-domain map, so elastic resizes and restarts never
leak stale state.
"""
def __init__(self) -> None:
self._poll_interval_s: float = float(
os.getenv(
PREEMPTION_POLL_INTERVAL_S_ENV_VAR,
str(DEFAULT_PREEMPTION_POLL_INTERVAL_S),
)
)
self._watcher: Optional[ActorHandle] = None
def after_worker_group_start(self, worker_group: "WorkerGroup") -> None:
# Tear down any watcher from a previous worker group first. Worker-group
# startup can fail after this hook without running the shutdown hook, so
# this also prevents leaking an orphaned watcher across a reschedule.
self._stop_watcher()
# These handles are captured once per worker-group start. With the
# standard backend, any worker replacement goes through a full worker
# group restart (this hook runs again with fresh handles), so they
# never go stale.
# TODO(lehui): refresh worker handles on in-place replica replacement
# when adding preemption support for replica groups (TorchFT).
node_to_ranks: Dict[str, List[int]] = {}
worker_actors_by_rank: Dict[int, ActorHandle] = {}
for w in worker_group.get_workers():
rank = w.distributed_context.world_rank
node_to_ranks.setdefault(w.metadata.node_id, []).append(rank)
worker_actors_by_rank[rank] = w.actor
watcher_cls = ray.remote(num_cpus=0, max_restarts=-1)(PreemptionWatcher)
self._watcher = watcher_cls.remote(
node_to_ranks=node_to_ranks,
poll_interval_s=self._poll_interval_s,
worker_actors_by_rank=worker_actors_by_rank,
)
logger.debug(
"PreemptionCallback: started watcher for %d node(s).",
len(node_to_ranks),
)
def before_worker_group_shutdown(self, worker_group: "WorkerGroup") -> None:
self._stop_watcher()
def after_worker_group_abort(
self, worker_group_context: "WorkerGroupContext"
) -> None:
# abort() doesn't run the shutdown hook, so tear the watcher down here
# too — otherwise it keeps polling GCS until the cluster reaps it.
self._stop_watcher()
def _stop_watcher(self) -> None:
if self._watcher is None:
return
watcher = self._watcher
self._watcher = None
# Force-kill (non-blocking) rather than a synchronous graceful stop, so
# we never block the controller's event loop. The watcher's daemon poll
# thread dies with the actor process and holds no external resources.
try:
ray.kill(watcher)
except Exception:
logger.warning("Failed to kill PreemptionWatcher actor.", exc_info=True)
@@ -0,0 +1,221 @@
import importlib
import logging
from typing import TYPE_CHECKING, Dict, Optional
if TYPE_CHECKING:
from ray.data import Dataset
import ray
from ray.train.v2._internal.execution.callback import (
ControllerCallback,
WorkerGroupCallback,
)
from ray.train.v2._internal.execution.context import TrainRunContext
from ray.train.v2._internal.execution.controller.state import (
AbortedState,
ErroredState,
FinishedState,
ReschedulingState,
ResizingState,
RestartingState,
RunningState,
SchedulingState,
ShuttingDownState,
TrainControllerState,
)
from ray.train.v2._internal.execution.scaling_policy.scaling_policy import (
ResizeDecision,
)
from ray.train.v2._internal.execution.worker_group import (
WorkerGroup,
WorkerGroupContext,
WorkerGroupState,
)
from ray.train.v2._internal.execution.worker_group.poll import WorkerGroupPollStatus
from ray.train.v2._internal.logging.logging import (
get_train_application_controller_log_path,
)
from ray.train.v2._internal.state.state_manager import TrainStateManager
from ray.train.v2._internal.util import TrainingFramework
logger = logging.getLogger(__name__)
def _get_framework_version(framework: Optional[TrainingFramework]):
versions = {}
try:
import ray
versions["ray"] = ray.__version__
except ImportError:
logger.warning("Failed to collect ray version on worker.")
if framework is None:
return versions
for module_name in framework.module_names():
try:
module = importlib.import_module(module_name)
versions[module_name] = module.__version__
except ModuleNotFoundError:
# Module is not installed, skip without recording a version.
continue
except Exception:
logger.warning(f"Failed to collect {module_name} version on worker.")
continue
return versions
class StateManagerCallback(ControllerCallback, WorkerGroupCallback):
def __init__(self, datasets: Dict[str, "Dataset"]):
self._datasets = datasets
def after_controller_start(self, train_run_context: TrainRunContext):
self._state_manager = TrainStateManager()
self._run_name = train_run_context.get_run_config().name
self._run_id = train_run_context.run_id
# TODO: Should this be generated by the caller?
# NOTE: These must be called on the Controller.
# The Callback is first initialized on the Driver.
core_context = ray.runtime_context.get_runtime_context()
self._job_id = core_context.get_job_id()
self._controller_actor_id = core_context.get_actor_id()
controller_log_file_path = get_train_application_controller_log_path()
self._state_manager.create_train_run(
id=self._run_id,
name=self._run_name,
job_id=self._job_id,
controller_actor_id=self._controller_actor_id,
controller_log_file_path=controller_log_file_path,
run_config=train_run_context.run_config,
train_loop_config=train_run_context.train_loop_config,
scaling_config=train_run_context.scaling_config,
backend_config=train_run_context.backend_config,
datasets=self._datasets,
dataset_config=train_run_context.dataset_config,
)
def after_controller_state_update(
self,
previous_state: TrainControllerState,
current_state: TrainControllerState,
):
if previous_state._state_type == current_state._state_type:
return
logger.info(
f"[State Transition] {previous_state._state_type.state_name} -> "
f"{current_state._state_type.state_name}."
)
if isinstance(current_state, SchedulingState):
# TODO: This should probably always be ResizeDecision.
if isinstance(current_state.scaling_decision, ResizeDecision):
resize_decision = current_state.scaling_decision
else:
resize_decision = None
self._state_manager.update_train_run_scheduling(
run_id=self._run_id,
resize_decision=resize_decision,
)
elif isinstance(current_state, RunningState):
self._state_manager.update_train_run_running(
run_id=self._run_id,
)
elif isinstance(current_state, RestartingState):
self._state_manager.update_train_run_restarting(
run_id=self._run_id,
)
elif isinstance(current_state, ResizingState):
self._state_manager.update_train_run_resizing(
run_id=self._run_id,
)
elif isinstance(current_state, ErroredState):
self._state_manager.update_train_run_errored(
run_id=self._run_id,
status_detail=str(current_state.training_failed_error),
)
elif isinstance(current_state, FinishedState):
self._state_manager.update_train_run_finished(
run_id=self._run_id,
)
elif isinstance(current_state, AbortedState):
self._state_manager.update_train_run_aborted(
run_id=self._run_id,
)
elif isinstance(current_state, ReschedulingState):
# substate of SchedulingState
pass
elif isinstance(current_state, ShuttingDownState):
# substate of RunningState
pass
def before_worker_group_start(self, worker_group_context: WorkerGroupContext):
self._state_manager.create_train_run_attempt(
run_id=self._run_id,
attempt_id=worker_group_context.run_attempt_id,
num_workers=worker_group_context.num_workers,
resources_per_worker=worker_group_context.resources_per_worker,
)
def after_worker_group_start(self, worker_group: WorkerGroup):
worker_group_context: WorkerGroupContext = (
worker_group.get_worker_group_context()
)
worker_group_state: WorkerGroupState = worker_group.get_worker_group_state()
self._state_manager.update_train_run_attempt_running(
run_id=self._run_id,
attempt_id=worker_group_context.run_attempt_id,
workers=worker_group_state.workers,
)
# Update train run framework version
framework = self._state_manager.get_train_run_framework(self._run_id)
framework_versions = worker_group.execute_single(
0, _get_framework_version, framework
)
self._state_manager.update_train_run_framework_versions(
run_id=self._run_id,
framework_versions=framework_versions,
)
def before_worker_group_shutdown(self, worker_group: WorkerGroup):
worker_group_context: WorkerGroupContext = (
worker_group.get_worker_group_context()
)
# TODO: Consider passing error reason directly to the callback.
# Something along the lines of:
# WorkerGroup.shutdown(reason)
# -> WorkerGroupCallback.before_worker_group_shutdown(reason)
worker_group_poll_status: Optional[
WorkerGroupPollStatus
] = worker_group.get_latest_poll_status()
if worker_group_poll_status and worker_group_poll_status.errors:
self._state_manager.update_train_run_attempt_errored(
run_id=self._run_id,
attempt_id=worker_group_context.run_attempt_id,
status_detail=worker_group_poll_status.get_error_string(),
)
else:
self._state_manager.update_train_run_attempt_finished(
run_id=self._run_id,
attempt_id=worker_group_context.run_attempt_id,
)
def before_worker_group_abort(self, worker_group_context: WorkerGroupContext):
self._state_manager.update_train_run_attempt_aborted(
self._run_id,
worker_group_context.run_attempt_id,
)
@@ -0,0 +1,54 @@
from typing import Any, Dict, List
from ray.train.v2._internal.execution.callback import (
ReportCallback,
WorkerGroupCallback,
)
from ray.train.v2._internal.execution.context import TrainRunContext
from ray.train.v2._internal.execution.training_report import _TrainingReport
from ray.train.v2._internal.execution.worker_group import WorkerGroupPollStatus
from ray.train.v2.api.callback import UserCallback
class UserCallbackHandler(WorkerGroupCallback, ReportCallback):
"""Responsible for calling methods of subscribers implementing
the `UserCallback` interface.
"""
def __init__(
self, user_callbacks: List[UserCallback], train_run_context: TrainRunContext
):
self._user_callbacks = user_callbacks
self._train_run_context = train_run_context
# --------------------------
# ReportCallback
# --------------------------
def after_report(
self,
training_report: _TrainingReport,
metrics: List[Dict[str, Any]],
):
for user_callback in self._user_callbacks:
user_callback.after_report(
run_context=self._train_run_context,
metrics=metrics,
checkpoint=training_report.checkpoint,
)
# --------------------------
# WorkerGroupCallback
# --------------------------
def after_worker_group_poll_status(
self, worker_group_status: WorkerGroupPollStatus
):
if not worker_group_status.errors:
return
for user_callback in self._user_callbacks:
user_callback.after_exception(
run_context=self._train_run_context,
worker_exceptions=worker_group_status.errors,
)
@@ -0,0 +1,31 @@
import logging
import os
from ray.train.v2._internal.execution.callback import (
ReplicaGroupCallback,
WorkerGroupCallback,
)
from ray.train.v2._internal.execution.context import get_train_context
from ray.train.v2._internal.execution.worker_group import (
ExecutionGroup,
)
logger = logging.getLogger(__name__)
class WorkingDirectorySetupCallback(ReplicaGroupCallback, WorkerGroupCallback):
def after_execution_group_start(self, execution_group: ExecutionGroup):
"""Shared logic for setting up the working directory on an execution group."""
def chdir_to_working_dir() -> None:
"""Create the local working directory for the experiment."""
local_working_directory = (
get_train_context().get_storage().local_working_directory
)
os.makedirs(local_working_directory, exist_ok=True)
logger.debug(
f"Changing the working directory to: {local_working_directory}"
)
os.chdir(local_working_directory)
execution_group.execute(chdir_to_working_dir)
+159
View File
@@ -0,0 +1,159 @@
import os
from typing import Dict, Optional
from ray._common.constants import RAY_WARN_BLOCKING_GET_INSIDE_ASYNC_ENV_VAR
from ray._private.ray_constants import env_bool, env_set_by_user
# Unsupported configs can use this value to detect if the user has set it.
_UNSUPPORTED = "UNSUPPORTED"
_DEPRECATED = "DEPRECATED"
# The name of the file that is used to validate the storage.
VALIDATE_STORAGE_MARKER_FILENAME = ".validate_storage_marker"
# The name of the file that is used to store the checkpoint manager snapshot.
CHECKPOINT_MANAGER_SNAPSHOT_FILENAME = "checkpoint_manager_snapshot.json"
AWS_RETRYABLE_TOKENS = (
"AWS Error SLOW_DOWN",
"AWS Error INTERNAL_FAILURE",
"AWS Error SERVICE_UNAVAILABLE",
"AWS Error NETWORK_CONNECTION",
"AWS Error UNKNOWN",
)
# -----------------------------------------------------------------------
# Environment variables used in the controller, workers, and state actor.
#
# Be sure to update ENV_VARS_TO_PROPAGATE when adding new
# environment variables in this section.
# -----------------------------------------------------------------------
# Polling interval for the Train controller.
# This determines how many seconds the controller will wait between
# polling the worker group for its status.
HEALTH_CHECK_INTERVAL_S_ENV_VAR = "RAY_TRAIN_HEALTH_CHECK_INTERVAL_S"
DEFAULT_HEALTH_CHECK_INTERVAL_S: float = 2.0
# The time in seconds a worker health check must be hanging for
# before the controller marks the worker as dead and handles the failure.
WORKER_HEALTH_CHECK_TIMEOUT_S_ENV_VAR = "RAY_TRAIN_WORKER_HEALTH_CHECK_TIMEOUT_S"
DEFAULT_WORKER_HEALTH_CHECK_TIMEOUT_S: float = 10 * 60
# Timeout in seconds for the worker group to start.
WORKER_GROUP_START_TIMEOUT_S_ENV_VAR = "RAY_TRAIN_WORKER_GROUP_START_TIMEOUT_S"
DEFAULT_WORKER_GROUP_START_TIMEOUT_S: float = 60.0
# Time in seconds for collective operations before raising a timeout error.
COLLECTIVE_TIMEOUT_S_ENV_VAR = "RAY_TRAIN_COLLECTIVE_TIMEOUT_S"
# NOTE: Default to no timeout to avoid introducing more timeouts for users to configure.
# For example, users can already configure timeouts in torch distributed.
DEFAULT_COLLECTIVE_TIMEOUT_S: Optional[float] = None
# Interval in seconds to log a warning when waiting for a collective operation to complete.
COLLECTIVE_WARN_INTERVAL_S_ENV_VAR = "RAY_TRAIN_COLLECTIVE_WARN_INTERVAL_S"
DEFAULT_COLLECTIVE_WARN_INTERVAL_S: float = 60
# Interval in seconds to log a warning when waiting for a checkpoint upload fn operation to complete.
CHECKPOINT_UPLOAD_WARN_INTERVAL_S_ENV_VAR = (
"RAY_TRAIN_CHECKPOINT_UPLOAD_WARN_INTERVAL_S"
)
DEFAULT_CHECKPOINT_UPLOAD_WARN_INTERVAL_S: float = 60
# Feature flag for the preemption watcher. Default-on; provides a quick
# rollback path if the watcher actor misbehaves in a cluster.
ENABLE_PREEMPTION_WATCHER_ENV_VAR = "RAY_TRAIN_ENABLE_PREEMPTION_WATCHER"
DEFAULT_ENABLE_PREEMPTION_WATCHER: bool = True
# How often the preemption watcher polls Ray Core's drain state.
PREEMPTION_POLL_INTERVAL_S_ENV_VAR = "RAY_TRAIN_PREEMPTION_POLL_INTERVAL_S"
DEFAULT_PREEMPTION_POLL_INTERVAL_S: float = 5.0
# Environment variable to enable the print function patching.
ENABLE_PRINT_PATCH_ENV_VAR = "RAY_TRAIN_ENABLE_PRINT_PATCH"
DEFAULT_ENABLE_PRINT_PATCH = True
# V2 feature flag.
V2_ENABLED_ENV_VAR = "RAY_TRAIN_V2_ENABLED"
# Environment variables to enable/disable controller/worker structured logging.
ENABLE_CONTROLLER_STRUCTURED_LOGGING_ENV_VAR = (
"RAY_TRAIN_ENABLE_CONTROLLER_STRUCTURED_LOGGING"
)
ENABLE_WORKER_STRUCTURED_LOGGING_ENV_VAR = "RAY_TRAIN_ENABLE_WORKER_STRUCTURED_LOGGING"
DEFAULT_ENABLE_CONTROLLER_LOGGING = True
DEFAULT_ENABLE_WORKER_LOGGING = True
# Environment variables to configure reconciliation interval for Train state actor.
# This determines how many seconds the state actor will wait between
# polling the controller for its status.
ENABLE_STATE_ACTOR_RECONCILIATION_ENV_VAR = (
"RAY_TRAIN_ENABLE_STATE_ACTOR_RECONCILIATION"
)
DEFAULT_ENABLE_STATE_ACTOR_RECONCILIATION = True
STATE_ACTOR_RECONCILIATION_INTERVAL_S_ENV_VAR = (
"RAY_TRAIN_STATE_ACTOR_RECONCILIATION_INTERVAL_S"
)
DEFAULT_STATE_ACTOR_RECONCILIATION_INTERVAL_S: float = 30.0
# TODO: `ray.util.state.api.get_actor` typically takes 10-50ms but can take longer
# when there is high load on the cluster.
GET_ACTOR_TIMEOUT_S: int = 10
# GET_ACTOR_TIMEOUT_S * CONTROLLERS_TO_POLL_PER_ITERATION should be
# way less than STATE_ACTOR_RECONCILIATION_INTERVAL_S to give the state actor
# time to update live train run state.
CONTROLLERS_TO_POLL_PER_ITERATION: int = 1
# Environment variable for Train execution callbacks
RAY_TRAIN_CALLBACKS_ENV_VAR = "RAY_TRAIN_CALLBACKS"
# Ray Train does not warn by default when using blocking ray.get inside async actor.
DEFAULT_RAY_WARN_BLOCKING_GET_INSIDE_ASYNC_VALUE = "0"
# torchft lighthouse address
TORCHFT_LIGHTHOUSE_ADDR_ENV_VAR = "TORCHFT_LIGHTHOUSE"
# Environment variables to propagate from the driver to the controller,
# and then from the controller to the workers.
ENV_VARS_TO_PROPAGATE = {
V2_ENABLED_ENV_VAR,
HEALTH_CHECK_INTERVAL_S_ENV_VAR,
WORKER_HEALTH_CHECK_TIMEOUT_S_ENV_VAR,
WORKER_GROUP_START_TIMEOUT_S_ENV_VAR,
COLLECTIVE_TIMEOUT_S_ENV_VAR,
COLLECTIVE_WARN_INTERVAL_S_ENV_VAR,
CHECKPOINT_UPLOAD_WARN_INTERVAL_S_ENV_VAR,
ENABLE_PRINT_PATCH_ENV_VAR,
ENABLE_CONTROLLER_STRUCTURED_LOGGING_ENV_VAR,
ENABLE_WORKER_STRUCTURED_LOGGING_ENV_VAR,
ENABLE_STATE_ACTOR_RECONCILIATION_ENV_VAR,
STATE_ACTOR_RECONCILIATION_INTERVAL_S_ENV_VAR,
RAY_WARN_BLOCKING_GET_INSIDE_ASYNC_ENV_VAR,
TORCHFT_LIGHTHOUSE_ADDR_ENV_VAR,
ENABLE_PREEMPTION_WATCHER_ENV_VAR,
PREEMPTION_POLL_INTERVAL_S_ENV_VAR,
}
# ------------------------------------------------------------
# Environment variables used in the driver script only.
# ------------------------------------------------------------
# The environment variable to enable the Ray Train Metrics.
METRICS_ENABLED_ENV_VAR = "RAY_TRAIN_METRICS_ENABLED"
def is_v2_enabled() -> bool:
return env_bool(V2_ENABLED_ENV_VAR, True)
def get_env_vars_to_propagate() -> Dict[str, str]:
"""Returns a dictionary of environment variables that should be propagated
from the driver to the controller, and then from the controller
to each training worker.
This way, users only need to set environment variables in one place
when launching the script instead of needing to manually set a runtime environment.
"""
env_vars = {}
for env_var in ENV_VARS_TO_PROPAGATE:
if env_set_by_user(env_var):
env_vars[env_var] = os.environ[env_var]
return env_vars
@@ -0,0 +1,174 @@
import asyncio
import logging
from typing import TYPE_CHECKING, Dict, List
import ray
from ray.exceptions import GetTimeoutError
from ray.train.v2._internal.data_integration.interfaces import (
DatasetShardMetadata,
GenDataset,
)
if TYPE_CHECKING:
from ray.data import DataContext, DataIterator, Dataset, NodeIdStr
logger = logging.getLogger(__name__)
class DatasetManager:
"""Manages the dataset shards for datasets configured in the trainer."""
def __init__(
self,
datasets: Dict[str, GenDataset],
data_config: ray.train.DataConfig,
data_context: "DataContext",
world_size: int,
worker_node_ids: List["NodeIdStr"],
):
self._datasets = datasets
self._data_config = data_config
self._datasets_to_split = (
set(self._datasets.keys())
if data_config._datasets_to_split == "all"
else set(data_config._datasets_to_split)
)
self._world_size = world_size
self._worker_node_ids = worker_node_ids
self._coordinator_actors: List[ray.actor.ActorHandle] = []
# Maps dataset name to a list of cached `DataIterator`s corresponding to
# Train worker ranks.
self._dataset_iterators: Dict[str, List["DataIterator"]] = {}
# A condition variable to synchronize the calls to the async `get_dataset_shard` method.
self._condition = asyncio.Condition()
from ray.data import DataContext
DataContext._set_current(data_context)
def _create_dataset_iterators(
self, dataset_info: DatasetShardMetadata, base_dataset: "Dataset"
) -> List["DataIterator"]:
dataset_name = dataset_info.dataset_name
iterators_per_rank = self._data_config.configure(
datasets={dataset_name: base_dataset},
world_size=self._world_size,
worker_handles=None,
worker_node_ids=self._worker_node_ids,
)
assert len(iterators_per_rank) == self._world_size
# Convert the List[Dict[str, DataIterator]] to a List[DataIterator],
# since we only configured one dataset.
return [iterators_per_rank[i][dataset_name] for i in range(self._world_size)]
def _get_unsharded_dataset_iterator(
self, dataset_info: DatasetShardMetadata
) -> "DataIterator":
"""Returns the dataset iterator for a dataset that is excluded
from `DataConfig.datasets_to_split`.
Note that this method is NOT a barrier across workers and can be called
by any subset of workers and will return immediately.
"""
dataset_name = dataset_info.dataset_name
world_rank = dataset_info.world_rank
if dataset_name not in self._dataset_iterators:
self._dataset_iterators[dataset_name] = self._create_dataset_iterators(
dataset_info, self._datasets[dataset_name]
)
return self._dataset_iterators[dataset_name][world_rank]
async def _get_sharded_dataset_iterator(
self, dataset_info: DatasetShardMetadata
) -> "DataIterator":
"""Returns the dataset iterator for a dataset that is included
in `DataConfig.datasets_to_split`.
Note that this method is a barrier across workers,
and all workers must call this method before training.
"""
dataset_name = dataset_info.dataset_name
world_rank = dataset_info.world_rank
async with self._condition:
if dataset_name in self._dataset_iterators:
# If the dataset iterators have already been created, return the
# existing one.
iterator = self._dataset_iterators[dataset_name][world_rank]
elif world_rank == 0:
# In this case, the dataset iterators have not been created yet.
# The dataset only needs to be configured once globally for all workers.
# Do it only when the rank 0 worker calls this method.
iterators = self._create_dataset_iterators(
dataset_info, self._datasets[dataset_name]
)
iterator = iterators[world_rank]
# Cache the split coordinators for resource cleanup.
from ray.data._internal.iterator.stream_split_iterator import (
StreamSplitDataIterator,
)
if isinstance(iterator, StreamSplitDataIterator):
self._coordinator_actors.append(iterator._coord_actor)
# Cache the dataset iterators for future use.
self._dataset_iterators[dataset_name] = iterators
self._condition.notify_all()
else:
# Wait for the dataset iterators to be created by the rank 0 worker.
await self._condition.wait_for(
lambda: dataset_name in self._dataset_iterators
)
iterator = self._dataset_iterators[dataset_name][world_rank]
return iterator
async def get_dataset_shard(
self,
dataset_info: DatasetShardMetadata,
) -> "DataIterator":
"""Create and return the dataset shard iterator for a Ray Train worker's
call to `ray.train.get_dataset_shard`.
This method is a barrier that should be called by all Ray Train workers at once.
If the dataset iterators have already been created, return the existing ones.
Otherwise, create the dataset iterators and cache them.
Here's an example of how this method is used with 4 workers:
Rank 2 calls get_dataset_shard, waits on the condition variable.
Rank 1 calls get_dataset_shard, waits on the condition variable.
Rank 0 calls get_dataset_shard, creates the dataset iterators, caches them,
and notifies all workers hanging on the condition variable.
Rank 3 calls get_dataset_shard, returns the cached iterator.
"""
dataset_name = dataset_info.dataset_name
if dataset_name in self._datasets_to_split:
return await self._get_sharded_dataset_iterator(dataset_info)
else:
return self._get_unsharded_dataset_iterator(dataset_info)
def shutdown_data_executors(self) -> None:
"""
Attempts to shut down the data executors of each sharded dataset,
freeing resources allocated to data execution.
Note: The data executors for unsharded datasets are not managed by
SplitCoordinator actors and hence, are not accessible via the DatasetManager
so their cleanup is not handled by this method.
"""
try:
shutdown_refs = [
coord.shutdown_executor.remote() for coord in self._coordinator_actors
]
ray.get(shutdown_refs, timeout=5)
except GetTimeoutError:
logger.error("Ray Data executor shutdown task timed out after 5 seconds.")
except Exception:
logger.exception(
"Failed to gracefully terminate the Ray Data executor for each running dataset."
)
@@ -0,0 +1,31 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING, Callable, Protocol, Union
if TYPE_CHECKING:
from ray.data import DataIterator, Dataset
# A type representing either a ray.data.Dataset or a function that returns a
# ray.data.Dataset and accepts no arguments.
GenDataset = Union["Dataset", Callable[[], "Dataset"]]
@dataclass
class DatasetShardMetadata:
"""Metadata about a dataset shard used for lookup and configuration."""
dataset_name: str
world_rank: int
class DatasetShardProvider(Protocol):
def get_dataset_shard(self, dataset_info: DatasetShardMetadata) -> "DataIterator":
"""Get the dataset shard for the given dataset info.
Args:
dataset_info: The metadata of the shard to retrieve,
including the dataset name.
Returns:
The :class:`~ray.data.DataIterator` shard for the given dataset info.
Raises:
KeyError: If the dataset shard for the given dataset info is not found.
"""
...
+165
View File
@@ -0,0 +1,165 @@
import os
from typing import List, Optional
from ray.train.v2._internal.constants import (
COLLECTIVE_TIMEOUT_S_ENV_VAR,
DEFAULT_WORKER_GROUP_START_TIMEOUT_S,
DEFAULT_WORKER_HEALTH_CHECK_TIMEOUT_S,
WORKER_GROUP_START_TIMEOUT_S_ENV_VAR,
WORKER_HEALTH_CHECK_TIMEOUT_S_ENV_VAR,
)
# TODO: Distinguish between user and system exceptions.
class RayTrainError(Exception):
"""Base class for all Ray Train exceptions."""
class WorkerHealthCheckTimeoutError(RayTrainError):
"""Exception raised when a worker health check hangs for long enough."""
def __init__(self, message):
timeout = os.getenv(
WORKER_HEALTH_CHECK_TIMEOUT_S_ENV_VAR, DEFAULT_WORKER_HEALTH_CHECK_TIMEOUT_S
)
message += (
f"\nSet the {WORKER_HEALTH_CHECK_TIMEOUT_S_ENV_VAR} "
"environment variable to increase the timeout "
f"(current value: {timeout} seconds)."
)
super().__init__(message)
class WorkerHealthCheckFailedError(RayTrainError):
"""Exception raised when a worker health check fails."""
def __init__(self, message, failure: Exception):
super().__init__(message)
self._message = message
self.health_check_failure = failure
def __reduce__(self):
return (self.__class__, (self._message, self.health_check_failure))
def __str__(self):
return self._message + "\n" + str(self.health_check_failure)
class WorkerGroupStartupTimeoutError(RayTrainError):
"""Exception raised when the worker group startup times out.
Example scenario: 4 GPUs are detected in the cluster, but when the worker
are actually scheduled, one of the nodes goes down and only 3 GPUs are
available. One of the worker tasks may be stuck pending, until a timeout is reached.
"""
def __init__(self, num_workers: int):
timeout = float(
os.environ.get(
WORKER_GROUP_START_TIMEOUT_S_ENV_VAR,
DEFAULT_WORKER_GROUP_START_TIMEOUT_S,
)
)
self.num_workers = num_workers
super().__init__(
f"The worker group startup timed out after {timeout} seconds waiting "
f"for {num_workers} workers. "
"Potential causes include: "
"(1) temporary insufficient cluster resources while waiting for "
"autoscaling (ignore this warning in this case), "
"(2) infeasible resource request where the provided `ScalingConfig` "
"cannot be satisfied), "
"and (3) transient network issues. "
f"Set the {WORKER_GROUP_START_TIMEOUT_S_ENV_VAR} "
"environment variable to increase the timeout."
)
def __reduce__(self):
return (self.__class__, (self.num_workers,))
class WorkerGroupStartupFailedError(RayTrainError):
"""Exception raised when the worker group fails to start.
Example scenario: A worker is scheduled onto a node that dies while
the worker actor is initializing.
"""
class InsufficientClusterResourcesError(RayTrainError):
"""Exception raised when the cluster has insufficient resources.
Example scenario: A worker that requires 1 GPU is scheduled onto a cluster
that only has CPU worker node types.
"""
class CheckpointManagerInitializationError(RayTrainError):
"""Exception raised when the checkpoint manager fails to initialize from a snapshot.
Example scenarios:
1. The checkpoint manager snapshot version is old and
incompatible with the current version of Ray Train.
2. The checkpoint manager snapshot JSON file is corrupted.
3. The checkpoint manager snapshot references checkpoints that cannot be found
in the run storage path.
"""
class CollectiveTimeoutError(RayTrainError):
"""Exception raised when an internal Ray Train collective operation of
the worker group times out.
"""
class BroadcastCollectiveTimeoutError(CollectiveTimeoutError):
"""Exception raised when the broadcast operation times out.
There are two main timeout examples:
1. If not all workers call `ray.train.report`, the entire worker group will
hang until the timeout before raising. This prevents indefinite worker
group hangs.
2. If a worker is slow in the training loop and fails to reach the broadcast
time, the collective will time out.
"""
def __init__(
self, time_elapsed: Optional[float], missing_ranks: List[int], timeout_s: float
):
self._time_elapsed = time_elapsed
self._missing_ranks = missing_ranks
self._timeout_s = timeout_s
message = (
f"The collective operation timed out after {time_elapsed:.2f} seconds. "
f"The following ranks have not joined the collective operation: {missing_ranks}\n"
f"You can set the timeout with the {COLLECTIVE_TIMEOUT_S_ENV_VAR} "
f"environment variable (current value: {timeout_s:.2f} seconds). "
"Disable the timeout by setting the environment variable to `None`."
)
super().__init__(message)
def __reduce__(self):
return (
self.__class__,
(self._time_elapsed, self._missing_ranks, self._timeout_s),
)
class UserExceptionWithTraceback(RayTrainError):
"""This class wraps a user code exception raised on the worker
with its original traceback string, for logging and debugging purposes.
This is needed because the original exception traceback is not serialized
with the exception when it is *returned* back to the main process.
"""
def __init__(self, exc: BaseException, traceback_str: str):
self._base_exc = exc
self._traceback_str = traceback_str
def __reduce__(self):
return (self.__class__, (self._base_exc, self._traceback_str))
def __str__(self):
return self._traceback_str
@@ -0,0 +1,240 @@
from contextlib import contextmanager
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from ray.train.v2._internal.execution.training_report import _TrainingReport
from ray.train.v2.api.callback import RayTrainCallback
from ray.train.v2.api.config import ScalingConfig
from ray.util.annotations import DeveloperAPI
if TYPE_CHECKING:
from ray.train.v2._internal.execution.context import TrainRunContext
from ray.train.v2._internal.execution.controller import (
TrainControllerState,
)
from ray.train.v2._internal.execution.failure_handling import FailureDecision
from ray.train.v2._internal.execution.scaling_policy import ResizeDecision
from ray.train.v2._internal.execution.worker_group import (
ExecutionGroup,
ReplicaGroup,
Worker,
WorkerGroup,
WorkerGroupContext,
WorkerGroupPollStatus,
)
from ray.train.v2.api.result import Result
@DeveloperAPI
class ExecutionGroupCallback(RayTrainCallback):
"""Base callback for execution groups (worker groups and replica groups)."""
def before_init_train_context(
self, workers: List["Worker"]
) -> Dict[str, List[Any]]:
"""Called before initializing the TrainContext for an execution group.
Return:
A dictionary of additional arguments for TrainContext.
The key is the argument name and the value is a list of argument values
to pass to the TrainContext constructor of each worker in the group.
"""
return {}
def after_execution_group_start(self, execution_group: "ExecutionGroup"):
"""Called after an execution group is started or replaced.
All workers in the execution group should be ready to execute tasks."""
pass
def before_execution_group_shutdown(self, execution_group: "ExecutionGroup"):
"""Called before an execution group is shut down.
Workers may be dead at this point due to actor failures."""
pass
@DeveloperAPI
class WorkerGroupCallback(ExecutionGroupCallback):
@contextmanager
def on_worker_group_start(self):
yield
def before_worker_group_start(self, worker_group_context: "WorkerGroupContext"):
"""Called before the worker group actors are initialized."""
pass
def after_worker_group_start(self, worker_group: "WorkerGroup"):
"""Called after the worker group actors are initialized.
All workers should be ready to execute tasks."""
return self.after_execution_group_start(worker_group)
def after_worker_group_training_start(self, worker_group: "WorkerGroup"):
pass
@contextmanager
def on_worker_group_shutdown(self):
yield
def before_worker_group_shutdown(self, worker_group: "WorkerGroup"):
"""Called before the worker group is shut down.
Workers may be dead at this point due to actor failures, so this method
should catch and handle exceptions if attempting to execute tasks."""
return self.before_execution_group_shutdown(worker_group)
def after_worker_group_shutdown(self, worker_group_context: "WorkerGroupContext"):
"""Called after the worker group is shut down."""
pass
def after_worker_group_poll_status(
self, worker_group_status: "WorkerGroupPollStatus"
):
pass
def before_worker_group_abort(self, worker_group_context: "WorkerGroupContext"):
"""Called before the worker group is aborted."""
pass
def after_worker_group_abort(self, worker_group_context: "WorkerGroupContext"):
"""Called after the worker group is aborted."""
pass
@DeveloperAPI
class ReplicaGroupCallback(ExecutionGroupCallback):
"""Callback for replica group lifecycle events."""
def after_replica_group_start(self, replica_group: "ReplicaGroup"):
"""Called after a replica group is started or replaced.
All workers in the replica group should be ready to execute tasks."""
return self.after_execution_group_start(replica_group)
def before_replica_group_shutdown(self, replica_group: "ReplicaGroup"):
"""Called before a replica group is shut down.
Workers may be dead at this point due to actor failures."""
return self.before_execution_group_shutdown(replica_group)
@DeveloperAPI
class ControllerCallback(RayTrainCallback):
def after_controller_start(self, train_run_context: "TrainRunContext"):
"""Called immediately after `TrainController.run` is called,
before the control loop starts executing."""
pass
# TODO(matthewdeng): Revisit this callback interface for better extensibility.
# This hook was added for the specific use case of setting a `label_selector`
# for new worker groups (e.g., for TPU reservations). The current interface is
# tightly coupled to this purpose and limits its reuse for other use-cases.
def on_controller_start_worker_group(
self, *, scaling_config: ScalingConfig, num_workers: int
) -> Optional[Dict[str, str]]:
"""Called by the TrainController before the worker group is started.
This hook can be used to perform setup that modifies the worker group's
placement, such as reserving an accelerator slice.
Args:
scaling_config: The scaling configuration for the run.
num_workers: The number of workers to be started.
Returns:
An optional dictionary defining a `label_selector`
to gang schedule the worker group on the reserved TPU slice.
"""
return None
async def before_controller_shutdown(self):
"""Called before `TrainController.run` exits,
after the control loop has exited."""
pass
def after_controller_state_update(
self,
previous_state: "TrainControllerState",
current_state: "TrainControllerState",
):
"""Called whenever the controller state is updated."""
pass
def before_controller_execute_failure_decision(
self,
failure_decision: "FailureDecision",
):
"""Called before the controller executes a failure decision."""
pass
def before_controller_execute_resize_decision(
self,
resize_decision: "ResizeDecision",
):
"""Called before the controller executes a resize decision."""
pass
def after_controller_finish(self, result: "Result"):
"""Called after the training run completes, providing access to the final result.
Args:
result: The final training result containing metrics and checkpoint.
"""
pass
def before_controller_abort(self):
"""Called during `TrainController.abort` before the actor process exits."""
pass
# TODO: consider consolidating all metrics into one dict, possibly with UDF
@DeveloperAPI
class ReportCallback(RayTrainCallback):
def after_report(
self,
training_report: _TrainingReport,
metrics: List[Dict[str, Any]],
):
"""Called after all workers have reported a training result.
Note that this differs from `after_worker_group_poll_status`,
which may only contain a subset of workers that have reported.
For example, if only rank 0 is performing checkpointing, then
rank 0 would report a training result the slowest.
"""
pass
@DeveloperAPI
class WorkerCallback(RayTrainCallback):
"""
Callbacks that are hooked to the worker event.
These callbacks are created on the train driver process and then
copied and passed to all the workers.
The execution of these callbacks happens on each of the workers,
not on the train driver process.
"""
def after_init_train_context(self):
pass
def before_worker_shutdown(self):
pass
@DeveloperAPI
class TrainContextCallback(RayTrainCallback):
"""
Callbacks that are hooked to the train context event.
These callbacks are created on the train driver process and then
copied and passed to all the workers.
The execution of these callbacks happens on the train context of the workers.
"""
@contextmanager
def on_report(self):
yield
@contextmanager
def on_checkpoint_sync(self):
yield
@contextmanager
def on_checkpoint_transfer(self):
yield
@@ -0,0 +1,67 @@
import logging
from ray.train.v2.api.exceptions import ControllerError
logger = logging.getLogger(__name__)
class CallbackManager:
def __init__(self, callbacks):
self._callbacks = callbacks
def _get_method(self, callback, hook_name: str):
"""Look up a hook method on a callback, raising if missing."""
callback_name = type(callback).__name__
method = getattr(callback, hook_name, None)
if method is None or not callable(method):
raise ControllerError(
AttributeError(
f"Callback '{callback_name}' hook '{hook_name}' is missing "
"or not callable."
)
)
return method, callback_name
def invoke(self, hook_name: str, *args, **context) -> None:
for callback in self._callbacks:
method, callback_name = self._get_method(callback, hook_name)
try:
method(*args, **context)
except Exception as e:
# TODO: Enable configuration to suppress exceptions.
logger.exception(
f"Exception raised in callback hook '{hook_name}' from callback "
f"'{callback_name}'."
)
raise ControllerError(e) from e
async def async_invoke(self, hook_name: str, *args, **context) -> None:
for callback in self._callbacks:
method, callback_name = self._get_method(callback, hook_name)
try:
await method(*args, **context)
except Exception as e:
# TODO: Enable configuration to suppress exceptions.
logger.exception(
f"Exception raised in callback hook '{hook_name}' from callback "
f"'{callback_name}'."
)
raise ControllerError(e) from e
def invoke_best_effort(self, hook_name: str, *args, **context) -> None:
"""Invoke a hook on every callback, logging and suppressing errors.
Unlike ``invoke``, this does not fail fast — every callback is
attempted even if earlier ones raise. Used for cleanup hooks
(e.g. ``before_controller_abort``) where partial execution is
better than skipping remaining callbacks.
"""
for callback in self._callbacks:
method, callback_name = self._get_method(callback, hook_name)
try:
method(*args, **context)
except Exception as e:
logger.exception(
f"Error in callback hook '{hook_name}' from callback "
f"'{callback_name}': {e}"
)
@@ -0,0 +1,656 @@
import asyncio
import json
import logging
from typing import Any, Dict, List, Optional, Tuple, Union
import ray
from ray._common.pydantic_compat import BaseModel
from ray._private.ray_constants import env_float
from ray.air.config import CheckpointConfig
from ray.train._checkpoint import Checkpoint
from ray.train._internal.checkpoint_manager import (
_CheckpointManager,
_insert_into_sorted_list,
)
from ray.train._internal.session import _TrainingResult
from ray.train.v2._internal.constants import (
COLLECTIVE_WARN_INTERVAL_S_ENV_VAR,
DEFAULT_COLLECTIVE_WARN_INTERVAL_S,
)
from ray.train.v2._internal.exceptions import CheckpointManagerInitializationError
from ray.train.v2._internal.execution.callback import (
ReportCallback,
WorkerGroupCallback,
)
from ray.train.v2._internal.execution.context import StorageContext
from ray.train.v2._internal.execution.storage import _exists_at_fs_path, delete_fs_path
from ray.train.v2._internal.execution.training_report import _TrainingReport
from ray.train.v2._internal.execution.worker_group import Worker
from ray.train.v2._internal.util import wait_with_logging
from ray.train.v2.api.report_config import CheckpointConsistencyMode
from ray.train.v2.api.reported_checkpoint import (
ReportedCheckpoint,
ReportedCheckpointStatus,
)
from ray.train.v2.api.validation_config import ValidationTaskConfig
logger = logging.getLogger(__name__)
GET_ALL_REPORTED_CHECKPOINTS_PERIODIC_WARNING = """
`get_all_reported_checkpoints` has been waiting for all checkpoints to get to the {consistency_mode} state for {time_elapsed_s:.2f} s.
You can set the {warn_interval_env_var} environment variable to change the frequency of this warning (current value: {warn_interval_s} s).
"""
class _TrainingResultState(BaseModel):
# Increment version if the schema changes
version: int = 0
checkpoint_dir_name: str
metrics: dict
class _CheckpointManagerState(BaseModel):
ray_version: str = ray.__version__
checkpoint_results: List[_TrainingResultState]
checkpoint_report_indices: List[int]
latest_checkpoint_result: Optional[_TrainingResultState] = None
pending_training_results: List[_TrainingResultState]
pending_validation_specs: List[Union[bool, ValidationTaskConfig]]
current_report_index: int
# List of processed checkpoints based on if successfully validated,
# timed out or failed due to an error or canceled for some reason.
validated_checkpoint_dir_names: List[str]
timed_out_validation_checkpoint_dir_names: List[str]
failed_validation_checkpoint_dir_names: List[str]
def _get_training_result_from_state(
state: _TrainingResultState,
storage_context: StorageContext,
) -> _TrainingResult:
"""Get a TrainingResult object from a Pydantic state object."""
return _TrainingResult(
checkpoint=Checkpoint(
path=storage_context.build_checkpoint_path_from_name(
state.checkpoint_dir_name
),
filesystem=storage_context.storage_filesystem,
),
metrics=state.metrics,
)
def _get_state_from_training_result(
training_result: _TrainingResult,
storage_context: StorageContext,
) -> _TrainingResultState:
"""Get a Pydantic state object from a TrainingResult object."""
return _TrainingResultState(
checkpoint_dir_name=storage_context.extract_checkpoint_dir_name_from_path(
training_result.checkpoint.path
),
metrics=training_result.metrics,
)
class CheckpointManager(_CheckpointManager, ReportCallback, WorkerGroupCallback):
def __init__(
self,
checkpoint_config: CheckpointConfig,
storage_context: StorageContext,
):
self._storage_context = storage_context
self._checkpoint_config = checkpoint_config
# This tracks the number of report calls that have been processed
# for the current worker group.
self._current_report_index = 0
# Map from pending checkpoint to validation.
self._pending_training_results: Dict[
Checkpoint, Tuple[_TrainingResult, Union[bool, ValidationTaskConfig]]
] = {}
# Set of checkpoints that have successfully completed, been timed out
# or failed validation.
self._validated_checkpoints: set = set()
self._timed_out_validation_checkpoints: set = set()
self._failed_validation_checkpoints: set = set()
# Map from checkpoint to report index. Used to order checkpoints.
self._checkpoint_to_report_index = {}
self._condition = asyncio.Condition()
# Strong references to background tasks created via
# ``asyncio.create_task`` to prevent them from being garbage
# collected mid-execution. The event loop only keeps weak refs.
self._background_tasks: set = set()
self._collective_warn_interval_s = env_float(
COLLECTIVE_WARN_INTERVAL_S_ENV_VAR,
DEFAULT_COLLECTIVE_WARN_INTERVAL_S,
)
super().__init__(checkpoint_config)
# If the snapshot is found, the checkpoint manager will restore its state.
# TODO(xgui): CheckpointManager is used to save or restore the checkpoint manager state.
# We should sanity check if we should see old state in the storage folder.
self._maybe_load_state_from_storage()
def register_checkpoint(
self,
training_report: _TrainingReport,
):
"""Register new checkpoint and add to bookkeeping.
This method will register a new checkpoint and add it to the internal
bookkeeping logic. This means the checkpoint manager will decide if
this checkpoint should be kept, and if older or worse performing
checkpoints should be deleted.
Args:
training_report: Training report to register.
"""
checkpoint_result = _TrainingResult(
checkpoint=training_report.checkpoint,
metrics=training_report.metrics,
)
self._latest_checkpoint_result = checkpoint_result
self._checkpoint_to_report_index[
checkpoint_result.checkpoint
] = self._current_report_index
if self._checkpoint_config.checkpoint_score_attribute is not None:
# If we're ordering by a score, insert the checkpoint
# so that the list remains sorted.
_insert_into_sorted_list(
self._checkpoint_results,
checkpoint_result,
key=self._get_checkpoint_score,
checkpoint_to_report_index=self._checkpoint_to_report_index,
)
else:
# If no metric is provided, just append (ordering by time of registration).
self._checkpoint_results.append(checkpoint_result)
if training_report.validation:
self._pending_training_results[checkpoint_result.checkpoint] = (
checkpoint_result,
training_report.validation,
)
self._current_report_index += 1
self._save_state_and_delete_old_checkpoints()
self._notify()
def update_checkpoints_with_validation_result(
self,
checkpoint_updates: Dict[
Checkpoint, Tuple[Dict[str, Any], ReportedCheckpointStatus]
],
):
"""Finalize pending validations by recording terminal status and metrics.
* For VALIDATED checkpoints, metrics are merged into the checkpoint's
existing metrics and the checkpoint is re-sorted.
* For VALIDATION_TIMEOUT and VALIDATION_FAILED checkpoints, metrics are
left untouched and the checkpoint retains its original training-time
score position.
"""
for checkpoint, (metrics, status) in checkpoint_updates.items():
if checkpoint not in self._pending_training_results:
logger.warning(
f"Checkpoint {checkpoint} not found in pending training results. "
)
continue
checkpoint_result, _ = self._pending_training_results[checkpoint]
if checkpoint_result not in self._checkpoint_results:
raise ValueError(
f"Checkpoint {checkpoint} was in pending training results but not "
"checkpoint results. "
)
self._pending_training_results.pop(checkpoint)
if status == ReportedCheckpointStatus.VALIDATED:
# Update the metrics and sort into checkpoint_results
checkpoint_result.metrics.update(metrics)
self._checkpoint_results.remove(checkpoint_result)
_insert_into_sorted_list(
self._checkpoint_results,
checkpoint_result,
key=self._get_checkpoint_score,
checkpoint_to_report_index=self._checkpoint_to_report_index,
)
self._validated_checkpoints.add(checkpoint)
elif status == ReportedCheckpointStatus.VALIDATION_TIMEOUT:
self._timed_out_validation_checkpoints.add(checkpoint)
elif status == ReportedCheckpointStatus.VALIDATION_FAILED:
self._failed_validation_checkpoints.add(checkpoint)
else:
raise ValueError(
f"Unexpected terminal validation status {status} for "
f"checkpoint {checkpoint}."
)
self._save_state_and_delete_old_checkpoints()
self._notify()
def get_pending_training_results(
self,
) -> Dict[Checkpoint, Tuple[_TrainingResult, Union[bool, ValidationTaskConfig]]]:
"""Get the pending training results which includes their validation specs."""
return self._pending_training_results
def _notify(self):
"""Notify condition so all listeners know state has changed."""
async def async_notify():
async with self._condition:
self._condition.notify_all()
# Keep a strong reference to the task so it isn't garbage
# collected before completing, which would silently drop
# the notification and could leave listeners waiting forever.
task = asyncio.create_task(async_notify())
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
def _save_state_and_delete_old_checkpoints(self):
"""Delete the old checkpoints."""
# Get checkpoints to delete
results_to_delete = set()
if self._checkpoint_config.num_to_keep is not None:
# Delete the bottom (N - K) checkpoints
worst_results = set(
self._checkpoint_results[: -self._checkpoint_config.num_to_keep]
)
# Except for the latest checkpoint and pending checkpoints
results_to_delete = worst_results - {self._latest_checkpoint_result}
results_to_delete = results_to_delete - {
v for v, _ in self._pending_training_results.values()
}
# Update internal state before actually deleting them.
self._checkpoint_results = [
checkpoint_result
for checkpoint_result in self._checkpoint_results
if checkpoint_result not in results_to_delete
]
for checkpoint_result in results_to_delete:
del self._checkpoint_to_report_index[checkpoint_result.checkpoint]
# discard doesn't raise an error if the element isn't found
self._validated_checkpoints.discard(checkpoint_result.checkpoint)
self._timed_out_validation_checkpoints.discard(
checkpoint_result.checkpoint
)
self._failed_validation_checkpoints.discard(
checkpoint_result.checkpoint
)
# Save the checkpoint manager state to storage.
# Note: We save the state before deleting the old checkpoints.
# If deletion happens first and the process crashes, our snapshot
# may point to some stale checkpoints that are already deleted.
# TODO: Make this writing operation non-blocking.
self._write_state_to_storage()
# Delete the old checkpoints.
for checkpoint_result in results_to_delete:
checkpoint = checkpoint_result.checkpoint
logger.debug("Deleting checkpoint: %s", checkpoint)
delete_fs_path(fs=checkpoint.filesystem, fs_path=checkpoint.path)
# --------------------------
# CheckpointManager state
# --------------------------
def _save_state(self) -> str:
"""Save the checkpoint manager state to a JSON str."""
checkpoint_results = [
_get_state_from_training_result(checkpoint_result, self._storage_context)
for checkpoint_result in self._checkpoint_results
]
checkpoint_report_indices = [
self._checkpoint_to_report_index[checkpoint_result.checkpoint]
for checkpoint_result in self._checkpoint_results
]
latest_checkpoint_result = (
_get_state_from_training_result(
self._latest_checkpoint_result, self._storage_context
)
if self._latest_checkpoint_result is not None
else None
)
pending_training_results = [
_get_state_from_training_result(v, self._storage_context)
for v, _ in self._pending_training_results.values()
]
pending_validation_specs = [
v for _, v in self._pending_training_results.values()
]
validated_ckpt_dir_names = [
self._storage_context.extract_checkpoint_dir_name_from_path(checkpoint.path)
for checkpoint in self._validated_checkpoints
]
timed_out_validation_ckpt_dir_names = [
self._storage_context.extract_checkpoint_dir_name_from_path(checkpoint.path)
for checkpoint in self._timed_out_validation_checkpoints
]
failed_validation_ckpt_dir_names = [
self._storage_context.extract_checkpoint_dir_name_from_path(checkpoint.path)
for checkpoint in self._failed_validation_checkpoints
]
manager_snapshot = _CheckpointManagerState(
checkpoint_results=checkpoint_results,
checkpoint_report_indices=checkpoint_report_indices,
latest_checkpoint_result=latest_checkpoint_result,
pending_training_results=pending_training_results,
pending_validation_specs=pending_validation_specs,
current_report_index=self._current_report_index,
validated_checkpoint_dir_names=validated_ckpt_dir_names,
timed_out_validation_checkpoint_dir_names=timed_out_validation_ckpt_dir_names,
failed_validation_checkpoint_dir_names=failed_validation_ckpt_dir_names,
)
return manager_snapshot.json()
def _load_state(self, json_state: str):
"""Load the checkpoint manager state from a JSON str."""
json_dict = None
try:
json_dict = json.loads(json_state)
manager_snapshot = _CheckpointManagerState.parse_obj(json_dict)
except Exception as e:
if not json_dict:
error = e
elif "ray_version" not in json_dict:
error = (
"You are loading a checkpoint manager snapshot saved with an unknown Ray version "
f"but you are running Ray version {ray.__version__}. Please use the same Ray version "
"the checkpoint manager snapshot was saved with."
)
elif json_dict["ray_version"] != ray.__version__:
error = (
f"You are loading a checkpoint manager snapshot saved with Ray version "
f"{json_dict['ray_version']} but you are running Ray version "
f"{ray.__version__}. Please use the same Ray version the checkpoint "
"manager snapshot was saved with."
)
else:
error = e
raise CheckpointManagerInitializationError(error) from e
# Do this so we are using the same checkpoint and trainingresult objects.
# TODO: consider asserting that every checkpoint has a unique dir name
checkpoint_dir_name_to_checkpoint_result = {}
for training_result_state in manager_snapshot.checkpoint_results:
training_result = _get_training_result_from_state(
training_result_state, self._storage_context
)
checkpoint_dir_name_to_checkpoint_result[
training_result_state.checkpoint_dir_name
] = training_result
self._checkpoint_results.append(training_result)
self._assert_checkpoints_exist()
assert len(self._checkpoint_results) == len(
manager_snapshot.checkpoint_report_indices
)
self._checkpoint_to_report_index = {
checkpoint_result.checkpoint: report_index
for checkpoint_result, report_index in zip(
self._checkpoint_results, manager_snapshot.checkpoint_report_indices
)
}
self._latest_checkpoint_result = (
checkpoint_dir_name_to_checkpoint_result[
manager_snapshot.latest_checkpoint_result.checkpoint_dir_name
]
if manager_snapshot.latest_checkpoint_result is not None
else None
)
assert len(manager_snapshot.pending_training_results) == len(
manager_snapshot.pending_validation_specs
)
for training_result_state, validation_spec in zip(
manager_snapshot.pending_training_results,
manager_snapshot.pending_validation_specs,
):
training_result = checkpoint_dir_name_to_checkpoint_result[
training_result_state.checkpoint_dir_name
]
self._pending_training_results[training_result.checkpoint] = (
training_result,
validation_spec,
)
# Restore terminal validation statuses. Only checkpoints still in
# _checkpoint_results can be looked up; evicted checkpoints are irrelevant.
for dir_names, target_set in (
(
manager_snapshot.validated_checkpoint_dir_names,
self._validated_checkpoints,
),
(
manager_snapshot.timed_out_validation_checkpoint_dir_names,
self._timed_out_validation_checkpoints,
),
(
manager_snapshot.failed_validation_checkpoint_dir_names,
self._failed_validation_checkpoints,
),
):
for dir_name in dir_names:
if dir_name in checkpoint_dir_name_to_checkpoint_result:
target_set.add(
checkpoint_dir_name_to_checkpoint_result[dir_name].checkpoint
)
self._current_report_index = manager_snapshot.current_report_index
def _maybe_load_state_from_storage(self):
"""Load the checkpoint manager state from storage.
If no snapshot is found, start with a clean state.
"""
if not _exists_at_fs_path(
fs=self._storage_context.storage_filesystem,
fs_path=self._storage_context.checkpoint_manager_snapshot_path,
):
logger.debug(
"No checkpoint manager snapshot found. "
"No checkpoint will be available via `ray.train.get_checkpoint`, "
"so training will start from scratch."
)
return
with self._storage_context.storage_filesystem.open_input_stream(
self._storage_context.checkpoint_manager_snapshot_path
) as f:
logger.info(
"A run snapshot was found in storage folder at: "
f"'{self._storage_context.experiment_fs_path}'\n"
"This snapshot contains a list of checkpoints reported via "
"`ray.train.report` and will be loaded. "
"This allows the latest checkpoint found in the snapshot to be "
"accessible within your training function via "
"`ray.train.get_checkpoint`.\n"
"If you meant to start a brand new training job without any "
"information about previous checkpoints found in this directory, "
"please configure a new, unique `RunConfig(name)` or delete the "
f"existing folder at '{self._storage_context.experiment_fs_path}'."
)
json_state = f.read().decode("utf-8")
self._load_state(json_state)
def _write_state_to_storage(self):
"""Write the checkpoint manager state to storage."""
checkpoint_manager_snapshot = self._save_state()
with self._storage_context.storage_filesystem.open_output_stream(
self._storage_context.checkpoint_manager_snapshot_path
) as f:
f.write(checkpoint_manager_snapshot.encode("utf-8"))
def _assert_checkpoints_exist(self):
"""Validate the checkpoint manager state.
This method will validate the checkpoint manager state by checking if
the checkpoints specified in manager snapshot is compatible with the
checkpoint folders of the experiment storage filesystem.
Raises:
CheckpointManagerInitializationError: If the checkpoint manager snapshot
is not consistent with the stored checkpoints.
"""
for checkpoint_result in self._checkpoint_results:
checkpoint = checkpoint_result.checkpoint
assert checkpoint is not None
if not _exists_at_fs_path(
fs=checkpoint.filesystem, fs_path=checkpoint.path
):
raise CheckpointManagerInitializationError(
"The run snapshot contains a reference to a checkpoint "
f"that does not exist anymore ({checkpoint}). You are "
"running in a corrupted run directory `experiment_fs_path`. "
"Please configure a new, unique `RunConfig(name)` "
"or delete the existing folder at "
f"`{self._storage_context.experiment_fs_path}`."
)
# --------------------------
# ReportCallback
# --------------------------
def after_report(
self,
training_report: _TrainingReport,
metrics: List[Dict[str, Any]],
):
if not training_report.checkpoint:
self._current_report_index += 1
self._notify()
return
self.register_checkpoint(training_report)
# --------------------------
# WorkerGroupCallback
# --------------------------
def before_init_train_context(self, workers: List[Worker]) -> Dict[str, List[Any]]:
latest_checkpoint = (
self.latest_checkpoint_result.checkpoint
if self.latest_checkpoint_result
else None
)
train_context_args = {
"checkpoint": [latest_checkpoint] * len(workers),
"current_report_index": [self._current_report_index] * len(workers),
}
return train_context_args
# --------------------------------
# Get all reported checkpoints API
# --------------------------------
def _get_checkpoint_status(
self, checkpoint: Checkpoint
) -> ReportedCheckpointStatus:
"""Get ReportedCheckpoint's status."""
if checkpoint in self._pending_training_results:
return ReportedCheckpointStatus.PENDING_VALIDATION
elif checkpoint in self._timed_out_validation_checkpoints:
return ReportedCheckpointStatus.VALIDATION_TIMEOUT
elif checkpoint in self._failed_validation_checkpoints:
return ReportedCheckpointStatus.VALIDATION_FAILED
elif checkpoint in self._validated_checkpoints:
return ReportedCheckpointStatus.VALIDATED
else:
return ReportedCheckpointStatus.COMMITTED
def _generate_get_all_reported_checkpoints_periodic_warning(
self, start_time: float, consistency_mode: CheckpointConsistencyMode
) -> str:
"""Generates the warning message for the get_all_reported_checkpoints periodic warning."""
return GET_ALL_REPORTED_CHECKPOINTS_PERIODIC_WARNING.format(
consistency_mode=consistency_mode,
time_elapsed_s=asyncio.get_event_loop().time() - start_time,
warn_interval_env_var=COLLECTIVE_WARN_INTERVAL_S_ENV_VAR,
warn_interval_s=self._collective_warn_interval_s,
)
async def get_all_reported_checkpoints(
self,
current_report_index: int,
consistency_mode: CheckpointConsistencyMode = CheckpointConsistencyMode.VALIDATED,
timeout_s: Optional[float] = None,
) -> List[ReportedCheckpoint]:
"""Get all the reported checkpoints so far.
Args:
current_report_index: The current report index.
consistency_mode: Read semantics for checkpoint retrieval. Defaults to VALIDATED.
timeout_s: Timeout in seconds. Defaults to None to run forever.
Returns:
A list of ReportedCheckpoint objects that represent the checkpoints and
corresponding metrics reported by the workers.
"""
start_time = asyncio.get_event_loop().time()
if consistency_mode == CheckpointConsistencyMode.COMMITTED:
def predicate() -> bool:
return self._current_report_index == current_report_index
elif consistency_mode == CheckpointConsistencyMode.VALIDATED:
def predicate() -> bool:
return (
self._current_report_index == current_report_index
and not self._pending_training_results
)
else:
raise ValueError(
f"Unexpected CheckpointConsistencyMode: {consistency_mode}"
)
async with self._condition:
try:
await wait_with_logging(
self._condition,
predicate=predicate,
generate_warning_message=lambda: self._generate_get_all_reported_checkpoints_periodic_warning(
start_time, consistency_mode
),
warn_interval_s=self._collective_warn_interval_s,
timeout_s=timeout_s,
)
except (asyncio.TimeoutError, TimeoutError):
# Time out due to checkpoint upload or validation in progress
logger.debug(
"Timed out waiting for reported_checkpoint to become available."
)
# TODO: might be nice for CheckpointManager to manage ReportedCheckpoint
# instead of _TrainingResult but that is a large refactor.
return [
ReportedCheckpoint(
checkpoint=tr.checkpoint,
metrics=tr.metrics,
status=self._get_checkpoint_status(tr.checkpoint),
)
for tr in self._checkpoint_results
]
@@ -0,0 +1,129 @@
from collections import deque
from typing import Deque, List, Optional
from ray.train.v2._internal.execution.callback import (
ReplicaGroupCallback,
ReportCallback,
WorkerGroupCallback,
)
from ray.train.v2._internal.execution.training_report import _TrainingReport
from ray.train.v2._internal.execution.worker_group import (
WorkerGroup,
WorkerGroupPollStatus,
)
from ray.train.v2._internal.execution.worker_group.execution_group import ReplicaGroup
class ReportCallbackHandler(ReplicaGroupCallback, WorkerGroupCallback):
"""Consolidate training results from multiple workers and call
subscribers implementing the `ReportCallback` interface sequentially.
"""
def __init__(self, report_callbacks: List[ReportCallback]):
# We set the worker group after it has been started and remove it after it
# has been shut down.
self._worker_group: Optional[WorkerGroup] = None
# A list of queues holding training reports from workers.
self._training_report_queues: Optional[List[Deque[_TrainingReport]]] = None
self._report_callbacks = report_callbacks
def _assert_initialized(self):
assert (
self._worker_group and self._training_report_queues
), "Need to call initialize state with `after_worker_group_start` first."
# --------------------------
# WorkerGroupCallback
# --------------------------
def after_worker_group_poll_status(
self, worker_group_status: WorkerGroupPollStatus
) -> None:
"""Handle training results as they roll in from worker status polls.
Wait for all workers to report training results to collect
a consolidated training result.
"""
# Step 1: Assert that the worker group has been started and not shut down.
self._assert_initialized()
assert len(self._worker_group) == len(worker_group_status.worker_statuses), (
f"The number of workers in the worker group has changed unexpectedly. "
f"Expected: {len(self._worker_group)}, got: {len(worker_group_status.worker_statuses)}"
)
# Step 2: Update training_reports_queues with poll_results.
for i in range(len(self._worker_group)):
training_report = worker_group_status.worker_statuses[i].training_report
if training_report:
self._training_report_queues[i].append(training_report)
# Directly return if any of the worker result queues are empty.
if not all(self._training_report_queues):
return
training_reports = [q.popleft() for q in self._training_report_queues]
# Step 3: Consolidate a list of checkpoints to single checkpoint.
# Use the first checkpoint as the consolidated checkpoint.
checkpoint_results = [
tr for tr in training_reports if tr.checkpoint is not None
]
consolidated_checkpoint = None
validation = False
if checkpoint_results:
# Double check the storage path of the checkpoints in the training results.
unique_checkpoint_paths = {tr.checkpoint.path for tr in checkpoint_results}
if len(unique_checkpoint_paths) > 1:
# TODO: Support for inconsistent checkpoints path from workers
# instead of hard raising error. Maybe drop this iteration of
# training results and continue with the next iteration.
raise RuntimeError(
"The storage path of the checkpoints in the training results "
"is not the same. This means the checkpoints are not consistent."
"Got a mix of the following checkpoint paths: "
f"{unique_checkpoint_paths}\n"
"This is unexpected -- please file a Github issue."
)
consolidated_checkpoint = checkpoint_results[0].checkpoint
validation = checkpoint_results[0].validation
# Step 4: Invoke all dependent `ReportCallback`s.
metrics_per_worker = [
training_report.metrics for training_report in training_reports
]
for callback in self._report_callbacks:
callback.after_report(
training_report=_TrainingReport(
checkpoint=consolidated_checkpoint,
metrics=metrics_per_worker[0],
validation=validation,
),
metrics=metrics_per_worker,
)
def after_worker_group_start(self, worker_group: WorkerGroup) -> None:
"""Handle worker group start. Initialize internal states."""
self._worker_group = worker_group
self._training_report_queues = [deque() for _ in range(len(self._worker_group))]
def before_worker_group_shutdown(self, worker_group: WorkerGroup) -> None:
"""Handle worker group shutdown. Clear internal states.
None of the partial reported results are valid at this point.
"""
self._worker_group = None
self._training_report_queues = None
# --------------------------
# ReplicaGroupCallback
# --------------------------
def after_replica_group_start(self, replica_group: ReplicaGroup) -> None:
"""Handle replica group start. Initialize internal states."""
self._assert_initialized()
# TODO: it might be possible to reuse existing queues.
# For example, if 3/4 ddp workers reported a checkpoint, that checkpoint is usable.
self._training_report_queues = [deque() for _ in range(len(self._worker_group))]
@@ -0,0 +1,226 @@
import asyncio
import logging
from contextlib import asynccontextmanager
from typing import List, Optional, TypeVar
import ray
from ray.train.v2._internal.constants import (
COLLECTIVE_WARN_INTERVAL_S_ENV_VAR,
DEFAULT_COLLECTIVE_TIMEOUT_S,
DEFAULT_COLLECTIVE_WARN_INTERVAL_S,
)
from ray.train.v2._internal.exceptions import BroadcastCollectiveTimeoutError
from ray.train.v2._internal.util import wait_with_logging
T = TypeVar("T", bound=Optional[object])
logger = logging.getLogger(__name__)
class SynchronizationBarrierResetError(Exception):
"""Raised when the synchronization barrier is reset, e.g. due to a worker failure."""
pass
BROADCAST_PERIODIC_WARNING = """
`{caller_method_name}` has not been called by all {world_size} workers in the group.
The workers have been waiting for {max_time_elapsed_s:.2f} s for the following ranks to join the `{caller_method_name}` call: {missing_ranks}.
Also ensure that workers are not hanging on other operations, causing them to miss this synchronization barrier.
You can set the {warn_interval_env_var} environment variable to change the frequency of this warning (current value: {warn_interval_s} s).
"""
@ray.remote(num_cpus=0) # type: ignore
class SynchronizationActor:
"""A Ray actor that synchronizes the workers in a distributed training job.
This actor forms a synchronization barrier on a group of processes.
Every time a worker calls the broadcast_from_rank_zero method,
the counter is incremented. When the counter equals to the world size,
the actor notifies all the workers to continue.
"""
def __init__(
self,
timeout_s: Optional[float] = DEFAULT_COLLECTIVE_TIMEOUT_S,
warn_interval_s: float = DEFAULT_COLLECTIVE_WARN_INTERVAL_S,
):
self._counter: int = 0
self._world_size: int = 0
self._condition = asyncio.Condition()
self._reduced_data = None
self._reset = False
# The time when workers from different ranks
# enters the synchronization barrier.
self._sync_start_times: List[Optional[float]] = []
# The timeout in seconds for the synchronization barrier.
self._timeout_s: Optional[float] = timeout_s
# The interval in seconds to log a warning when waiting for the barrier.
self._warn_interval_s: float = warn_interval_s
def get_counter(self):
"""Returns the current value of the counter."""
return self._counter
def get_world_size(self):
"""Returns the current value of the world_size."""
return self._world_size
def get_reduced_data(self):
"""Returns the current value of the reduced_data."""
return self._reduced_data
def _clear_states(self):
"""Clears the states of the actor. When the last worker has
called the _clear_states method, the actor clears its states
"""
self._counter -= 1
if self._counter == 0:
self._reduced_data = None
self._world_size = 0
self._reset = False
self._condition.notify_all()
async def _setup_or_validate_collective_op(self, world_size: int):
"""The setup method for the synchronization actor if it is not setup yet.
It initializes the world size and the start times for the
synchronization barrier.
"""
# Wait for previous collective reset to finish.
await self._condition.wait_for(lambda: not self._reset)
if self._world_size == 0:
self._world_size = world_size
self._sync_start_times = [None] * world_size
elif world_size != self._world_size:
raise ValueError(
f"Expected all callers to provide the same world size. \
Got {world_size} and expected {self._world_size}."
)
@asynccontextmanager
async def _broadcast_collective_context_manager(
self, world_rank: int, world_size: int, data: T
):
"""A context manager that ensures the synchronization barrier is lifted
after the block of code is executed.
"""
try:
await self._setup_or_validate_collective_op(world_size)
if world_rank == 0:
self._reduced_data = data
if self._counter < self._world_size:
self._counter += 1
yield
finally:
self._clear_states()
def _get_time_elapsed(self) -> Optional[float]:
"""Return the time elapsed since the first worker entered the barrier.
If no workers have entered the barrier, returns None.
"""
start_times = [t for t in self._sync_start_times if t is not None]
if not start_times:
return None
return asyncio.get_event_loop().time() - min(start_times)
def _get_missing_ranks(self) -> List[int]:
"""Returns the ranks that have not entered the synchronization barrier."""
return [i for i, t in enumerate(self._sync_start_times) if t is None]
def _generate_broadcast_periodic_warning(self, caller_method_name: str) -> str:
"""Generates the warning message for the broadcast periodic warning."""
return BROADCAST_PERIODIC_WARNING.format(
caller_method_name=caller_method_name,
world_size=self._world_size,
max_time_elapsed_s=self._get_time_elapsed(),
missing_ranks=self._get_missing_ranks(),
warn_interval_env_var=COLLECTIVE_WARN_INTERVAL_S_ENV_VAR,
warn_interval_s=self._warn_interval_s,
)
async def reset(self):
"""Reset the synchronization barrier, unblocking any waiting workers.
If no workers are currently at the barrier, this is a no-op.
Waiting workers will raise SynchronizationBarrierResetError.
The actor remains alive and usable for subsequent barriers.
"""
async with self._condition:
if self._counter == 0:
return
self._reset = True
self._condition.notify_all()
async def broadcast_from_rank_zero(
self,
world_rank: int,
world_size: int,
data: T,
caller_method_name: str,
) -> T:
"""Broadcasts a data from the worker with rank 0 to all other workers.
This method is a coroutine that blocks until all workers have called this
method with the their data. The data from the worker with rank 0 will
be returned.
Args:
world_rank: The rank of the worker that calls this method.
world_size: The total number of workers in the group.
data: The data to broadcast.
caller_method_name: The name of the method that calls this method.
Returns:
The data broadcasted from the worker with rank 0.
"""
# TODO: resolve https://github.com/ray-project/ray/pull/54066#discussion_r2180657435
# We couldn't reproduce the issue but the asyncio docs don't say it can't happen.
# Ensures that all global states manipulation is done within the async context
# manager which makes the condition variable awaiting and the counter
# incrementing an atomic operation.
async with self._condition:
async with self._broadcast_collective_context_manager(
world_rank, world_size, data
):
# If the counter is equal to the world size, it means the last worker
# has called the broadcast_from_rank_zero method. The actor notifies
# all the workers to continue.
if self._counter == self._world_size:
self._condition.notify_all()
return self._reduced_data
# If the counter is less than the world size, the actor waits for the
# other workers to call the broadcast_from_rank_zero method.
try:
current_time = asyncio.get_event_loop().time()
self._sync_start_times[world_rank] = current_time
await wait_with_logging(
self._condition,
predicate=None,
generate_warning_message=(
lambda: self._generate_broadcast_periodic_warning(
caller_method_name
)
)
if world_rank == 0
else None,
warn_interval_s=self._warn_interval_s,
timeout_s=self._timeout_s,
)
if self._reset:
raise SynchronizationBarrierResetError(
"Synchronization barrier was reset, likely due "
"to a worker failure and replica group replacement."
)
return self._reduced_data
except (asyncio.TimeoutError, TimeoutError) as e:
raise BroadcastCollectiveTimeoutError(
time_elapsed=self._get_time_elapsed(),
missing_ranks=self._get_missing_ranks(),
timeout_s=self._timeout_s,
) from e
# TODO: Implement a general consensus_from_votes method that takes a callable
# reduce_fn and a list of votes from each worker. The method returns the consensus
@@ -0,0 +1,289 @@
import asyncio
import logging
import time
from collections import OrderedDict, deque
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union
import ray
from ray.train._checkpoint import Checkpoint
from ray.train.v2._internal.execution.callback import (
ControllerCallback,
ReportCallback,
WorkerGroupCallback,
)
from ray.train.v2._internal.execution.checkpoint.checkpoint_manager import (
CheckpointManager,
)
from ray.train.v2._internal.execution.training_report import (
_TrainingReport,
)
from ray.train.v2.api.reported_checkpoint import ReportedCheckpointStatus
from ray.train.v2.api.validation_config import ValidationConfig, ValidationTaskConfig
if TYPE_CHECKING:
from ray.train.v2._internal.execution.controller import TrainControllerState
from ray.train.v2._internal.execution.worker_group.worker import Worker
logger = logging.getLogger(__name__)
VALIDATION_TASK_POLL_INTERVAL_S = 1
MAX_IN_FLIGHT_VALIDATIONS = 1
@dataclass
class _PendingValidation:
checkpoint: Checkpoint
start_time: float
# None when no timeout applies.
timeout_s: Optional[float]
def __post_init__(self):
assert (
self.timeout_s is None or self.timeout_s > 0
), f"timeout_s needs to be None (for no timeout) or a positive value in seconds. Actual value: {self.timeout_s}"
@ray.remote
def run_validation_fn(
validation_config: ValidationConfig,
validation_task_config: Union[bool, ValidationTaskConfig],
checkpoint: Checkpoint,
) -> Dict:
"""Run the user-defined validation function.
Merges fn_kwargs from validation_config.task_config (defaults) with
fn_kwargs from validation_task_config (per-report overrides).
"""
# Merge kwargs: defaults from validation_config, overrides from validation_task_config
if validation_task_config is True:
merged_kwargs = validation_config.task_config.fn_kwargs
else:
merged_kwargs = {
**validation_config.task_config.fn_kwargs,
**validation_task_config.fn_kwargs,
}
metrics_dict = validation_config.fn(
checkpoint,
**merged_kwargs,
)
if not isinstance(metrics_dict, dict):
raise ValueError(
"The validation function must return a dictionary of metrics. "
f"Got {type(metrics_dict)} instead."
)
return metrics_dict
class ValidationManager(ControllerCallback, ReportCallback, WorkerGroupCallback):
def __init__(
self,
checkpoint_manager: CheckpointManager,
validation_config: ValidationConfig,
):
self._checkpoint_manager = checkpoint_manager
self._validation_config = validation_config
# _TrainingReports that we will validate
self._training_report_queue = deque()
# Map from in flight validation task to its pending-validation record
# (checkpoint + start_time + resolved timeout).
self._pending_validations: "OrderedDict[ray.ObjectRef, _PendingValidation]" = (
OrderedDict()
)
# Tasks that this manager proactively cancelled due to timeout. Used to
# distinguish timeout-cancels from controller-abort-cancels (both raise
# TaskCancelledError on ray.get).
self._timed_out_tasks: set = set()
# Map from validation task to checkpoint
# Finished validations that have yet to be processed
self._finished_validations: "OrderedDict[ray.ObjectRef, Checkpoint]" = (
OrderedDict()
)
self._requeue_incomplete_validations()
def _requeue_incomplete_validations(self):
"""Add _TrainingReports for incomplete validations to the queue."""
for checkpoint, (
training_result,
validation,
) in self._checkpoint_manager.get_pending_training_results().items():
if validation:
self._training_report_queue.append(
_TrainingReport(
metrics=training_result.metrics,
checkpoint=checkpoint,
validation=validation,
)
)
def after_report(
self,
training_report: _TrainingReport,
metrics: List[Dict[str, Any]],
):
if training_report.validation:
self._training_report_queue.append(training_report)
def _cancel_timed_out_validations(self):
"""Cancel any in-flight validation that has exceeded its timeout_s.
Cancelled tasks are moved directly from `_pending_validations` to
`_finished_validations` so the MAX_IN_FLIGHT slot is freed immediately
and the task flows through the normal finished-processing pipeline
without waiting for `ray.wait` to echo the cancellation.
"""
now = time.monotonic()
for task, pending in list(self._pending_validations.items()):
if (
pending.timeout_s is None
or now - pending.start_time < pending.timeout_s
):
continue
self._pending_validations.pop(task)
logger.warning(
f"Validation for checkpoint {pending.checkpoint} exceeded "
f"timeout_s={pending.timeout_s}s. Cancelling."
)
self._timed_out_tasks.add(task)
ray.cancel(task, force=True)
self._finished_validations[task] = pending.checkpoint
def _poll_validations(self) -> int:
"""Poll/process validations, update checkpoint manager, return num pending validations."""
self._cancel_timed_out_validations()
# Move pending validations to finished validations
validation_tasks = list(self._pending_validations.keys())
done, _ = ray.wait(
validation_tasks, timeout=0, num_returns=len(validation_tasks)
)
done_checkpoints = []
for task in done:
pending = self._pending_validations.pop(task)
done_checkpoints.append(pending.checkpoint)
self._finished_validations[task] = pending.checkpoint
if done_checkpoints:
logger.info(
f"Finished async validation task(s) for checkpoint(s): {done_checkpoints}.\n"
f"Running validations for checkpoint(s): {[p.checkpoint for p in self._pending_validations.values()]}.\n"
f"Staged validations for checkpoint(s): {[tr.checkpoint for tr in self._training_report_queue]}."
)
# Process finished validations (one at a time)
if self._finished_validations:
task, checkpoint = self._finished_validations.popitem(last=False)
update = self._process_finished_validation(task, checkpoint)
if update is not None:
self._checkpoint_manager.update_checkpoints_with_validation_result(
{checkpoint: update}
)
return len(self._pending_validations)
def _kick_off_validations(self) -> int:
"""Kick off validations and return the number of pending validations."""
# TODO: figure out where to place run_validation_fn task:
# TODO: provide option to run this on gpu?
num_validations_to_start = max(
MAX_IN_FLIGHT_VALIDATIONS - len(self._pending_validations), 0
)
num_validations_to_start = min(
num_validations_to_start, len(self._training_report_queue)
)
for _ in range(num_validations_to_start):
training_report = self._training_report_queue.popleft()
run_validation_fn_with_options = run_validation_fn.options(
**self._validation_config.ray_remote_kwargs,
)
validate_task = run_validation_fn_with_options.remote(
self._validation_config,
training_report.validation,
training_report.checkpoint,
)
if isinstance(training_report.validation, ValidationTaskConfig):
timeout_s = training_report.validation.timeout_s
else:
timeout_s = self._validation_config.task_config.timeout_s
self._pending_validations[validate_task] = _PendingValidation(
checkpoint=training_report.checkpoint,
start_time=time.monotonic(),
timeout_s=timeout_s,
)
logger.info(
f"Launched async validation task for checkpoint {training_report.checkpoint}"
)
return len(self._pending_validations)
def _process_finished_validation(
self, task: ray.ObjectRef, checkpoint: Checkpoint
) -> Optional[Tuple[Dict[str, Any], ReportedCheckpointStatus]]:
"""Process finished validation. Returns (metrics, status) or None.
Returns None when the task was cancelled by a controller abort (not a
timeout), leaving it pending so it re-queues on resumption.
"""
was_timed_out = task in self._timed_out_tasks
self._timed_out_tasks.discard(task)
if was_timed_out:
logger.info(
f"Validation for checkpoint {checkpoint} was cancelled due to timeout."
)
return {}, ReportedCheckpointStatus.VALIDATION_TIMEOUT
try:
metrics = ray.get(task)
return metrics, ReportedCheckpointStatus.VALIDATED
except ray.exceptions.TaskCancelledError:
logger.info(
f"Validation was cancelled for checkpoint {checkpoint}, likely because the train run was aborted. "
"It will be retried in the next train run with the same storage path if there is one."
)
return None
except ray.exceptions.RayTaskError:
logger.exception(f"Validation failed for checkpoint {checkpoint}")
return {}, ReportedCheckpointStatus.VALIDATION_FAILED
async def before_controller_shutdown(self):
while self._poll_validations() != 0 or self._kick_off_validations() != 0:
await asyncio.sleep(VALIDATION_TASK_POLL_INTERVAL_S)
checkpoint_updates: Dict[
Checkpoint, Tuple[Dict[str, Any], ReportedCheckpointStatus]
] = {}
tasks = list(self._finished_validations.keys())
for task in tasks:
checkpoint = self._finished_validations[task]
self._finished_validations.pop(task)
update = self._process_finished_validation(task, checkpoint)
if update is not None:
checkpoint_updates[checkpoint] = update
self._checkpoint_manager.update_checkpoints_with_validation_result(
checkpoint_updates
)
def before_controller_abort(self):
for task in self._pending_validations.keys():
ray.cancel(task)
def after_controller_state_update(
self,
previous_state: "TrainControllerState",
current_state: "TrainControllerState",
):
# TODO: figure out if there's a better place to poll validations
if current_state.is_terminal():
return
self._poll_validations()
self._kick_off_validations()
def before_init_train_context(
self, workers: List["Worker"]
) -> Dict[str, List[bool]]:
return {
"has_validation_fn": [True] * len(workers),
}
@@ -0,0 +1,56 @@
import logging
from typing import Any
import ray
import ray.cloudpickle as pickle
from ray.train.v2._internal.execution.context import get_train_context
# For reference, {1:1} is 19 bytes, {"1":"1"} is 21 bytes,
# and {"12345": "12345"} is 25 bytes.
_MAX_BROADCAST_SIZE_BYTES = 1000
logger = logging.getLogger(__name__)
def barrier() -> None:
"""
Create a barrier across all training workers.
"""
train_context = get_train_context()
sync_actor = train_context.get_synchronization_actor()
return ray.get(
sync_actor.broadcast_from_rank_zero.remote(
world_rank=train_context.get_world_rank(),
world_size=train_context.get_world_size(),
data=None,
caller_method_name="ray.train.collective.barrier",
)
)
def broadcast_from_rank_zero(data: Any) -> Any:
"""Broadcast data from the rank 0 worker to all other workers.
This method is used by the public API function :func:`ray.train.collective.broadcast_from_rank_zero`.
Users should typically call ``ray.train.collective.broadcast_from_rank_zero()`` instead of calling this method directly.
"""
# Validate data.
if data is not None:
data_bytes = len(pickle.dumps(data))
if data_bytes > _MAX_BROADCAST_SIZE_BYTES:
logger.warning(
f"Data size {data_bytes} bytes exceeds the maximum broadcast "
f"size of {_MAX_BROADCAST_SIZE_BYTES} bytes"
)
train_context = get_train_context()
sync_actor = train_context.get_synchronization_actor()
return ray.get(
sync_actor.broadcast_from_rank_zero.remote(
world_rank=train_context.get_world_rank(),
world_size=train_context.get_world_size(),
data=data,
caller_method_name="ray.train.collective.broadcast_from_rank_zero",
)
)
@@ -0,0 +1,559 @@
import logging
import sys
import threading
import uuid
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from pathlib import Path
from queue import Queue
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union
import ray
from ray._common.retry import retry
from ray._common.utils import env_float
from ray.actor import ActorHandle
from ray.train.v2._internal.constants import (
AWS_RETRYABLE_TOKENS,
CHECKPOINT_UPLOAD_WARN_INTERVAL_S_ENV_VAR,
DEFAULT_CHECKPOINT_UPLOAD_WARN_INTERVAL_S,
)
from ray.train.v2._internal.execution.checkpoint.sync_actor import (
SynchronizationActor,
SynchronizationBarrierResetError,
)
from ray.train.v2._internal.execution.preemption import PreemptionContext
from ray.train.v2._internal.execution.storage import StorageContext, delete_fs_path
from ray.train.v2._internal.execution.training_report import (
_TrainingReport,
)
from ray.train.v2._internal.util import (
construct_user_exception_with_traceback,
context_watchdog,
invoke_context_managers,
)
from ray.train.v2.api.config import RunConfig, ScalingConfig
from ray.train.v2.api.report_config import (
CheckpointConsistencyMode,
CheckpointUploadMode,
)
from ray.train.v2.api.validation_config import ValidationTaskConfig
if TYPE_CHECKING:
from ray.data import DataIterator
from ray.train import BackendConfig, Checkpoint, DataConfig
from ray.train.v2._internal.data_integration.interfaces import (
DatasetShardMetadata,
DatasetShardProvider,
)
from ray.train.v2._internal.execution.callback import TrainContextCallback
from ray.train.v2._internal.execution.worker_group.thread_runner import ThreadRunner
from ray.train.v2.api.reported_checkpoint import ReportedCheckpoint
logger = logging.getLogger(__file__)
# TODO: make this value manually or automatically configurable.
MAX_CHECKPOINT_UPLOAD_THREADS = 1
DEFAULT_CHECKPOINT_UPLOAD_WARN_MESSAGE = "Checkpoint upload for {checkpoint_dir_name} has been running for {elapsed}s (warning interval: {interval}s). This may indicate a network issue or slow storage backend. Consider specifying a different filesystem via RunConfig(storage_filesystem=...)."
CUSTOM_CHECKPOINT_UPLOAD_WARN_MESSAGE = "Custom checkpoint upload for {checkpoint_dir_name} has been running for {elapsed}s (warning interval: {interval}s). This may indicate an issue in your custom upload function passed to `ray.train.report(custom_upload_fn)`."
@dataclass(frozen=True)
class TrainRunContext:
"""Holds the metadata and context for the current training run."""
# The unique ID of the training run.
run_id: str = field(init=False, default_factory=lambda: uuid.uuid4().hex)
# The run configuration for the current training run.
run_config: RunConfig
# The configuration passed to the training function.
train_loop_config: Optional[Dict]
# The scaling configuration for the current training run.
scaling_config: ScalingConfig
# The configuration for the training backend (e.g., PyTorch, XGBoost).
backend_config: "BackendConfig"
# The configuration for dataset ingestion and sharding.
dataset_config: "DataConfig"
def get_run_config(self) -> RunConfig:
"""Returns the run config of the current training run."""
return self.run_config
@dataclass(frozen=True)
class DistributedContext:
world_rank: int
world_size: int
local_rank: int
local_world_size: int
node_rank: int
@dataclass(frozen=True)
class ExecutionContext:
"""Holds the execution context for the current worker process.
Every worker process has a single execution context accessed via the
`TrainContext`, which includes the training thread that is actually
running the user code.
"""
# A shared synchronization actor that helps broadcast data across ranks.
synchronization_actor: SynchronizationActor
# A queue that receives training results from the user training code.
# `ray.train.report` in user code populates this queue.
result_queue: Queue
# The thread launcher that runs the user training loop.
training_thread_runner: "ThreadRunner"
# The callbacks that are run in the worker train context.
train_context_callbacks: List["TrainContextCallback"]
@dataclass
class TrainContext:
train_run_context: TrainRunContext
distributed_context: DistributedContext
execution_context: ExecutionContext
storage_context: StorageContext
preemption_context: PreemptionContext
controller_actor: ActorHandle
dataset_shard_provider: "DatasetShardProvider"
has_validation_fn: Optional[bool] = None
# TODO: consolidate into CheckpointContext
checkpoint: Optional["Checkpoint"] = None
current_report_index: int = 0
report_call_index: int = 0
report_order_condition: threading.Condition = threading.Condition()
checkpoint_upload_threadpool: ThreadPoolExecutor = ThreadPoolExecutor(
max_workers=MAX_CHECKPOINT_UPLOAD_THREADS
)
def __post_init__(self):
# Ray train initializes worker with current report index
# report_call_index should start at the current report index
self.report_call_index = self.current_report_index
def get_experiment_name(self) -> str:
return self.train_run_context.run_config.name
def get_world_size(self) -> int:
return self.distributed_context.world_size
def get_world_rank(self) -> int:
return self.distributed_context.world_rank
def get_local_rank(self) -> int:
return self.distributed_context.local_rank
def get_local_world_size(self) -> int:
return self.distributed_context.local_world_size
def get_node_rank(self) -> int:
return self.distributed_context.node_rank
def get_storage(self):
return self.storage_context
# TODO: Don't allow these private methods to be called from user code.
def get_result_queue(self):
return self.execution_context.result_queue
def get_synchronization_actor(self):
return self.execution_context.synchronization_actor
def get_checkpoint(self):
with self.report_order_condition:
return self.checkpoint
def get_all_reported_checkpoints(
self,
consistency_mode: CheckpointConsistencyMode = CheckpointConsistencyMode.VALIDATED,
timeout_s: Optional[float] = None,
) -> List["ReportedCheckpoint"]:
return ray.get(
self.controller_actor.get_all_reported_checkpoints.remote(
self.report_call_index,
consistency_mode,
timeout_s,
)
)
def get_dataset_shard(self, dataset_info: "DatasetShardMetadata") -> "DataIterator":
"""Returns the :class:`ray.data.DataIterator` shard for this worker.
Call :meth:`~ray.data.DataIterator.iter_torch_batches` or
:meth:`~ray.data.DataIterator.to_tf` on this shard to convert it to the
appropriate framework-specific data type.
Args:
dataset_info: The shard metadata, including the dataset name and worker rank.
Returns:
The ``DataIterator`` shard with the given name for this worker.
Raises:
KeyError: If the dataset shard with the given name is not found.
"""
return self.dataset_shard_provider.get_dataset_shard(dataset_info)
def get_context_callbacks(self) -> List["TrainContextCallback"]:
return self.execution_context.train_context_callbacks
def _sync_checkpoint_dir_name_across_ranks(
self, checkpoint_dir_name: Optional[str] = None
) -> str:
"""Sync the checkpoint dir name across ranks.
Args:
checkpoint_dir_name: The checkpoint dir name to sync.
Returns:
The synced checkpoint dir name.
"""
# If checkpoint_dir_name is not set, use default checkpoint_dir_name
# created by the storage context.
checkpoint_dir_name = (
checkpoint_dir_name
or self.storage_context.make_default_checkpoint_dir_name()
)
# Get a consensus across ranks on the remote storage path, so distributed
# checkpoints will be stored to the same place.
sync_actor = self.get_synchronization_actor()
with invoke_context_managers(
[
callback.on_checkpoint_sync
for callback in self.execution_context.train_context_callbacks
]
):
return ray.get(
sync_actor.broadcast_from_rank_zero.remote(
world_rank=self.distributed_context.world_rank,
world_size=self.distributed_context.world_size,
data=checkpoint_dir_name,
caller_method_name="ray.train.report",
)
)
# TODO: make retry configurable
@retry(description="upload checkpoint", max_attempts=3, match=AWS_RETRYABLE_TOKENS)
def _upload_checkpoint(
self,
checkpoint_dir_name: str,
metrics: Dict[str, Any],
checkpoint: Optional["Checkpoint"] = None,
delete_local_checkpoint_after_upload: bool = False,
checkpoint_upload_fn: Optional[
Callable[["Checkpoint", str], "Checkpoint"]
] = None,
validation: Union[bool, ValidationTaskConfig] = False,
) -> _TrainingReport:
"""Save the checkpoint to remote storage.
Args:
checkpoint_dir_name: The checkpoint dir to persist to.
metrics: The metrics to report.
checkpoint: The checkpoint to report.
delete_local_checkpoint_after_upload: Whether to delete the checkpoint after it is uploaded.
checkpoint_upload_fn: A user defined function that will be called with the
checkpoint to upload it. If not provided, defaults to using the `pyarrow.fs.copy_files`
utility for copying to the destination `storage_path`.
validation: The validation configuration.
Returns:
The training result object containing the persisted checkpoint.
"""
if not checkpoint:
return _TrainingReport(checkpoint=None, metrics=metrics, validation=False)
def slow_upload_warning(stop_event: threading.Event, message: str):
# Log a warning for the checkpoint upload every `CHECKPOINT_UPLOAD_WARN_INTERVAL_S_ENV_VAR`
# seconds until `stop_event` is set.
elapsed = 0.0
interval = env_float(
CHECKPOINT_UPLOAD_WARN_INTERVAL_S_ENV_VAR,
DEFAULT_CHECKPOINT_UPLOAD_WARN_INTERVAL_S,
)
while not stop_event.wait(interval):
elapsed += interval
logger.warning(
message.format(
checkpoint_dir_name=checkpoint_dir_name,
elapsed=elapsed,
interval=interval,
)
)
# Records how long the checkpoint transfer took
warn_message = (
CUSTOM_CHECKPOINT_UPLOAD_WARN_MESSAGE
if checkpoint_upload_fn
else DEFAULT_CHECKPOINT_UPLOAD_WARN_MESSAGE
)
with invoke_context_managers(
[
callback.on_checkpoint_transfer
for callback in self.execution_context.train_context_callbacks
]
):
try:
with context_watchdog(slow_upload_warning, warn_message):
if checkpoint_upload_fn:
# Upload the checkpoint using the custom checkpoint_upload_fn
persisted_checkpoint = checkpoint_upload_fn(
checkpoint, checkpoint_dir_name
)
else:
# Upload the checkpoint using PyArrow
persisted_checkpoint = (
self.storage_context.persist_current_checkpoint(
checkpoint, checkpoint_dir_name
)
)
except FileNotFoundError:
logger.exception(
f"Failed to find local checkpoint ({checkpoint}) when attempting to upload it. "
"This could be caused by multiple workers on a node attempting to upload the "
"same directory, and then one of the workers deletes the directory before the "
"others finish."
)
raise
# Check that the checkpoint generated is a `ray.train.Checkpoint` instance
if checkpoint_upload_fn and not isinstance(
persisted_checkpoint, ray.train.Checkpoint
):
raise ValueError(
f"checkpoint_upload_fn must return a `ray.train.Checkpoint`. Actual type is {type(persisted_checkpoint)}"
)
# TODO: consider deleting local checkpoint as async callback instead
if delete_local_checkpoint_after_upload:
try:
delete_fs_path(checkpoint.filesystem, checkpoint.path)
except Exception:
logger.exception(
f"Failed to delete the local checkpoint after a successful upload: {checkpoint}"
)
return _TrainingReport(
checkpoint=persisted_checkpoint,
metrics=metrics,
validation=validation,
)
def _wait_then_report(
self, training_report: _TrainingReport, report_call_index: int
):
"""Thread waits for its turn before reporting training result to result queue.
It does this in order to guarantee the FIFO processing of checkpoints.
The queue size is set to 1 to avoid accumulating unprocessed results.
If the queue is full, the put operation blocks until a result is consumed.
TODO: Add a metric to track the blocking time waiting for the
training result to be consumed by the controller.
"""
with self.report_order_condition:
self.report_order_condition.wait_for(
lambda: self.current_report_index == report_call_index - 1
)
logger.info(
f"Reporting training result {report_call_index}: {training_report} "
f"from rank {self.get_world_rank()}"
)
# Update latest checkpoint as the persisted checkpoint.
if training_report.checkpoint:
self.checkpoint = training_report.checkpoint
self.get_result_queue().put(training_report)
self.current_report_index += 1
self.report_order_condition.notify_all()
def report(
self,
metrics: Dict[str, Any],
checkpoint: Optional["Checkpoint"] = None,
checkpoint_dir_name: Optional[str] = None,
checkpoint_upload_mode: CheckpointUploadMode = CheckpointUploadMode.SYNC,
delete_local_checkpoint_after_upload: Optional[bool] = None,
checkpoint_upload_fn: Optional[
Callable[["Checkpoint", str], "Checkpoint"]
] = None,
validation: Union[bool, ValidationTaskConfig] = False,
) -> None:
"""
Upload checkpoint to remote storage and put a training
result on the result queue of this worker process.
TODO: the report function should be implemented in the worker instead
of in the train context. The train context should only keep the train
related information and not the worker related actions. This refactor
would also require the `TrainContextCallback` to be updated as well.
"""
if "torch" in sys.modules:
from ray.air._internal.torch_utils import contains_tensor
if contains_tensor(metrics):
raise ValueError(
"Passing objects containing Torch tensors as metrics "
"is not supported as it will throw an exception on "
"deserialization. You can either convert the tensors "
"to Python objects (ex: `.numpy()`, `.item()`, etc.) "
"or save tensors as part of the checkpoint files instead."
)
if validation and not self.has_validation_fn:
raise ValueError(
"`validation_config` was not set on the trainer, but a validation was requested."
)
if delete_local_checkpoint_after_upload and checkpoint is not None:
experiment_path = Path(self.storage_context.experiment_fs_path)
checkpoint_path = Path(checkpoint.path)
# Resolve symlinks only for local (absolute) paths.
# Remote paths (S3, GCS, etc.) are relative after URI and resolve()
# would prepend CWD, producing a meaningless local path.
# Mixed absolute/relative paths return False
if experiment_path.is_absolute():
experiment_path = experiment_path.resolve()
if checkpoint_path.is_absolute():
checkpoint_path = checkpoint_path.resolve()
if experiment_path.is_relative_to(checkpoint_path):
raise ValueError(
f"Ray Train's experiment directory ({self.storage_context.experiment_fs_path}) "
f"is contained within the checkpoint path ({checkpoint.path}) "
f"and `ray.train.report(delete_local_checkpoint_after_upload=True)`. "
"As a result, this would delete the experiment directory. "
"Please write the checkpoint to a temporary directory, "
"a subdirectory of the experiment directory, "
"or use `delete_local_checkpoint_after_upload=False`."
)
with invoke_context_managers(
[
callback.on_report
for callback in self.execution_context.train_context_callbacks
]
):
self.report_call_index += 1
report_call_index = self.report_call_index
# Sync the checkpoint dir name across ranks.
try:
checkpoint_dir_name = self._sync_checkpoint_dir_name_across_ranks(
checkpoint_dir_name
)
except ray.exceptions.RayTaskError as e:
if not isinstance(e.cause, SynchronizationBarrierResetError):
raise e
logger.warning(
"Synchronization barrier was reset (likely due to a "
"worker failure). Skipping this report."
)
# Keep report indexes aligned across workers.
self.report_call_index -= 1
return
# Upload checkpoint, wait for turn, and report.
if checkpoint_upload_mode == CheckpointUploadMode.SYNC:
training_report = self._upload_checkpoint(
checkpoint_dir_name,
metrics,
checkpoint,
delete_local_checkpoint_after_upload,
checkpoint_upload_fn,
validation,
)
self._wait_then_report(training_report, report_call_index)
elif checkpoint_upload_mode == CheckpointUploadMode.NO_UPLOAD:
training_report = _TrainingReport(
checkpoint=checkpoint,
metrics=metrics,
validation=validation,
)
self._wait_then_report(training_report, report_call_index)
elif checkpoint_upload_mode == CheckpointUploadMode.ASYNC:
def _upload_checkpoint_and_report(
checkpoint_dir_name: str,
metrics: Dict[str, Any],
checkpoint: Optional["Checkpoint"],
report_call_index: int,
) -> None:
try:
training_report = self._upload_checkpoint(
checkpoint_dir_name,
metrics,
checkpoint,
delete_local_checkpoint_after_upload,
checkpoint_upload_fn,
validation,
)
self._wait_then_report(training_report, report_call_index)
except Exception as e:
# TODO: env var to disable eager raising
logger.exception(
"Checkpoint upload failed in the background thread. Raising eagerly "
"to avoid training in a corrupted state with more potential progress "
"lost due to checkpointing failures."
)
self.execution_context.training_thread_runner.get_exception_queue().put(
construct_user_exception_with_traceback(e)
)
self.checkpoint_upload_threadpool.submit(
_upload_checkpoint_and_report,
checkpoint_dir_name,
metrics,
checkpoint,
report_call_index,
)
else:
raise ValueError(
f"Invalid checkpoint upload mode: {checkpoint_upload_mode}"
)
# The global variable holding the current TrainContext
_train_context: Optional[TrainContext] = None
# Thread lock to protect the global TrainContext
_context_lock = threading.Lock()
def get_train_context() -> TrainContext:
"""Get the internal train context.
Note:
This should not be used directly by user-facing APIs. User-facing APIs should
call :class:`~ray.train.v2._internal.execution.train_fn_utils.TrainFnUtils`
or use :class:`~ray.train.v2.api.context.TrainContext` instead.
Returns:
The internal TrainContext for this worker.
"""
with _context_lock:
if _train_context is None:
raise RuntimeError("TrainContext has not been initialized.")
return _train_context
def set_train_context(context) -> None:
global _train_context
with _context_lock:
_train_context = context
@@ -0,0 +1,3 @@
from .controller import TrainController
__all__ = ["TrainController"]
@@ -0,0 +1,865 @@
import asyncio
import logging
import os
import uuid
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Callable, List, Optional
import pandas as pd
import ray
import ray._private.ray_constants as ray_constants
from ray.exceptions import AsyncioActorExit
from ray.train.v2._internal.constants import (
DEFAULT_ENABLE_CONTROLLER_LOGGING,
DEFAULT_ENABLE_PREEMPTION_WATCHER,
DEFAULT_HEALTH_CHECK_INTERVAL_S,
ENABLE_CONTROLLER_STRUCTURED_LOGGING_ENV_VAR,
ENABLE_PREEMPTION_WATCHER_ENV_VAR,
HEALTH_CHECK_INTERVAL_S_ENV_VAR,
)
from ray.train.v2._internal.execution.callback import (
ControllerCallback,
ReportCallback,
TrainContextCallback,
WorkerCallback,
WorkerGroupCallback,
)
from ray.train.v2._internal.execution.callback_manager import CallbackManager
from ray.train.v2._internal.execution.checkpoint.checkpoint_manager import (
CheckpointManager,
)
from ray.train.v2._internal.execution.checkpoint.report_handler import (
ReportCallbackHandler,
)
from ray.train.v2._internal.execution.checkpoint.validation_manager import (
ValidationManager,
)
from ray.train.v2._internal.execution.context import TrainRunContext
from ray.train.v2._internal.execution.controller.state import (
AbortedState,
ErroredState,
FinishedState,
InitializingState,
ReschedulingState,
ResizingState,
RestartingState,
RunningState,
SchedulingState,
ShuttingDownState,
TrainControllerState,
)
from ray.train.v2._internal.execution.failure_handling import (
FailureDecision,
FailurePolicy,
)
from ray.train.v2._internal.execution.scaling_policy import (
NoopDecision,
ResizeDecision,
ScalingPolicy,
)
from ray.train.v2._internal.execution.worker_group import (
WorkerGroup,
WorkerGroupContext,
WorkerGroupPollStatus,
)
from ray.train.v2._internal.logging import LoggingManager
from ray.train.v2._internal.util import ObjectRefWrapper, time_monotonic
from ray.train.v2.api.callback import RayTrainCallback
from ray.train.v2.api.exceptions import (
ControllerError,
TrainingFailedError,
)
from ray.train.v2.api.report_config import CheckpointConsistencyMode
from ray.train.v2.api.result import Result
from ray.train.v2.api.validation_config import ValidationConfig
if TYPE_CHECKING:
from ray.train.v2.api.reported_checkpoint import ReportedCheckpoint
from ray.util.tpu import get_tpu_num_slices_for_workers
logger = logging.getLogger(__name__)
@dataclass
class TrainControllerLoopIterationResult:
"""The result of a single iteration of the control loop."""
run_attempt_id: str
previous_state: TrainControllerState
next_state: TrainControllerState
training_failed_error: Optional[TrainingFailedError] = None
def __repr__(self) -> str:
return (
f"TrainControllerLoopIterationResult(\n"
f" run_attempt_id={self.run_attempt_id},\n"
f" previous_state={self.previous_state._state_type.state_name},\n"
f" next_state={self.next_state._state_type.state_name}\n"
f" training_failed_error={self.training_failed_error}\n"
f")"
)
class TrainController:
"""Manages the execution of a distributed training job.
Responsibilities include:
* Triggering the training function to run on the worker group.
* Monitoring the status of the worker group.
* Handling scaling decisions by restarting the worker group.
* Handling failure decisions by restarting the worker group or terminating training.
* Running callback logic on different hooks in the control loop.
"""
worker_group_cls = WorkerGroup
def __init__(
self,
train_fn_ref: ObjectRefWrapper[Callable[[], None]],
train_run_context: TrainRunContext,
scaling_policy: ScalingPolicy,
failure_policy: FailurePolicy,
callbacks: Optional[List[RayTrainCallback]] = None,
validation_config: Optional[ValidationConfig] = None,
):
self._train_run_context = train_run_context
if ray_constants.env_bool(
ENABLE_CONTROLLER_STRUCTURED_LOGGING_ENV_VAR,
DEFAULT_ENABLE_CONTROLLER_LOGGING,
):
LoggingManager.configure_controller_logger(self._train_run_context)
self._train_fn_ref = train_fn_ref
self._scaling_policy = scaling_policy
self._failure_policy = failure_policy
self._run_config = self._train_run_context.run_config
self._callbacks = callbacks or []
self._storage_context = self._train_run_context.run_config.storage_context
self._checkpoint_manager = CheckpointManager(
checkpoint_config=self._run_config.checkpoint_config,
storage_context=self._storage_context,
)
if validation_config:
validation_manager = ValidationManager(
checkpoint_manager=self._checkpoint_manager,
validation_config=validation_config,
)
else:
validation_manager = None
report_handler = ReportCallbackHandler(
report_callbacks=(
[self._checkpoint_manager]
+ ([validation_manager] if validation_manager else [])
+ [c for c in self._callbacks if isinstance(c, ReportCallback)]
)
)
# Group callbacks by the hooks they're subscribed to.
self._controller_callbacks = (
[
self._scaling_policy,
]
+ ([validation_manager] if validation_manager else [])
+ [c for c in self._callbacks if isinstance(c, ControllerCallback)]
)
self._controller_callback_manager = CallbackManager(self._controller_callbacks)
# Group callbacks that will be propagated to the worker group,
# train worker and the train context.
self._worker_group_callbacks_to_propagate = (
[report_handler]
+ ([validation_manager] if validation_manager else [])
+ [
c
for c in self._callbacks
if isinstance(
c, (WorkerGroupCallback, WorkerCallback, TrainContextCallback)
)
]
+ [self._checkpoint_manager]
)
self._health_check_interval_s = float(
os.getenv(HEALTH_CHECK_INTERVAL_S_ENV_VAR, DEFAULT_HEALTH_CHECK_INTERVAL_S)
)
self._manages_replica_groups = (
train_run_context.backend_config.backend_cls.has_replica_groups
if train_run_context.backend_config
else False
)
# Register the preemption-observability callback when not in TorchFT
# mode (replica groups handle peer loss via their own quorum).
enable_preemption_watcher = ray_constants.env_bool(
ENABLE_PREEMPTION_WATCHER_ENV_VAR,
DEFAULT_ENABLE_PREEMPTION_WATCHER,
)
if self._manages_replica_groups:
if enable_preemption_watcher and ray_constants.env_set_by_user(
ENABLE_PREEMPTION_WATCHER_ENV_VAR
):
logger.info(
"The preemption watcher is not compatible with replica "
"groups (e.g. TorchFT), which handle peer loss via their "
"own quorum; skipping it."
)
elif enable_preemption_watcher:
from ray.train.v2._internal.callbacks.preemption_callback import (
PreemptionCallback,
)
self._worker_group_callbacks_to_propagate.append(PreemptionCallback())
self._worker_group: Optional[WorkerGroup] = None
self._state = InitializingState()
self._return_value: Optional[Any] = None
# TODO: These can be attributes of a RunAttempt?
self._latest_poll_time = float("-inf")
# Generate an initial run attempt ID so that `_run_controller_hook`
# can reference it if a callback fails during `_start`.
self._generate_run_attempt_id()
self._start()
def _run_controller_hook(
self,
hook_name: str,
*args,
invoke_failure_decision_callbacks: bool = True,
**context,
) -> Optional["TrainControllerLoopIterationResult"]:
"""Invoke a named controller hook and catch any exceptions.
This method invokes all callbacks registered for the given controller hook.
If a callback raises an error, the error is routed through the failure policy
and may produce a ``TrainControllerLoopIterationResult``, indicating that the
current controller step should exit early with this failure result.
Args:
hook_name: The controller hook name to invoke.
*args: Positional arguments to pass to the hook.
invoke_failure_decision_callbacks: Whether to invoke failure-decision hooks
when handling a callback failure.
**context: Keyword arguments to pass to the hook.
Returns:
failure_result: A``TrainControllerLoopIterationResult`` if the hook execution results
in an early exit from the controller loop to raise the callback error,
or ``None`` if hook execution completes successfully.
"""
try:
self._controller_callback_manager.invoke(hook_name, *args, **context)
except ControllerError as error:
failure_decision = self._failure_policy.make_decision(
training_failed_error=error,
)
# Avoid re-entering controller callback hooks while handling a callback failure.
return self._execute_failure_decision(
failure_decision,
training_failed_error=error,
invoke_failure_decision_callbacks=invoke_failure_decision_callbacks,
)
return None
def _execute_resize_decision(
self, decision: ResizeDecision
) -> TrainControllerLoopIterationResult:
"""Executes resize decisions.
Errors from worker group shutdown, callbacks, or worker group startup
are allowed to propagate to the catch-all in ``run()``.
"""
failure_result = self._run_controller_hook(
"before_controller_execute_resize_decision", decision
)
if failure_result:
return failure_result
current_num_workers = (
len(self._worker_group.get_workers()) if self._worker_group else 0
)
poll_status = (
self._worker_group.get_latest_poll_status() if self._worker_group else None
)
failing_rgs = (
poll_status.failing_replica_group_indices if poll_status else set()
)
all_rgs = poll_status.all_replica_group_indices if poll_status else set()
if (
self._manages_replica_groups
and bool(failing_rgs)
and failing_rgs != all_rgs
and self._worker_group
# TODO: relax this after integrating replica groups with elastic training.
and decision.num_workers == current_num_workers
):
# Torchft: replace only failing replica groups.
self._replace_bad_workers(poll_status)
else:
# Standard: full restart.
if self._worker_group:
self._shutdown_worker_group()
self._start_worker_group(
num_workers=decision.num_workers,
resources_per_worker=decision.resources_per_worker,
)
return TrainControllerLoopIterationResult(
run_attempt_id=self._get_run_attempt_id(),
previous_state=self._state,
next_state=RunningState(),
)
def _replace_bad_workers(self, poll_status: WorkerGroupPollStatus):
"""Replace failing replica groups in the worker group.
Args:
poll_status: The poll status containing error information.
Returns:
None
"""
failing_rg_indices = poll_status.failing_replica_group_indices
if not failing_rg_indices:
logger.warning("No failing replica groups found in poll status.")
return
logger.info(f"Replacing failing replica groups: {failing_rg_indices}")
for rg_index in failing_rg_indices:
# TODO: parallelize this.
# TODO: also ensure that if earlier replacements succeed and later replacements fail,
# we don't redo the earlier replacements.
# See https://github.com/ray-project/ray/pull/61475#discussion_r3055217289
self._worker_group.replace_replica_group(rg_index)
def _get_retry_state(
self,
controller_state: TrainControllerState,
training_failed_error: TrainingFailedError,
) -> TrainControllerState:
if isinstance(controller_state, RunningState):
return RestartingState(training_failed_error=training_failed_error)
elif isinstance(controller_state, SchedulingState):
return ReschedulingState(training_failed_error=training_failed_error)
else:
# Cannot retry from this state (e.g. InitializingState,
# ShuttingDownState); force shutdown with error.
logger.warning(
"Cannot retry from state %s; forcing shutdown.",
type(controller_state).__name__,
)
return ShuttingDownState(
next_state=ErroredState(training_failed_error=training_failed_error)
)
def _execute_failure_decision(
self,
failure_decision: FailureDecision,
training_failed_error: TrainingFailedError,
invoke_failure_decision_callbacks: bool = True,
) -> TrainControllerLoopIterationResult:
"""Executes failure handling decisions for a scheduling or poll error."""
controller_state = self.get_state()
if invoke_failure_decision_callbacks:
failure_result = self._run_controller_hook(
"before_controller_execute_failure_decision",
failure_decision,
invoke_failure_decision_callbacks=False,
)
if failure_result:
return failure_result
# TODO: What should we do here?
# This currently never happens because there must be errors.
if failure_decision == FailureDecision.NOOP:
return TrainControllerLoopIterationResult(
run_attempt_id=self._get_run_attempt_id(),
previous_state=controller_state,
next_state=controller_state,
training_failed_error=training_failed_error,
)
if failure_decision == FailureDecision.RETRY:
return TrainControllerLoopIterationResult(
run_attempt_id=self._get_run_attempt_id(),
previous_state=controller_state,
next_state=self._get_retry_state(
controller_state, training_failed_error
),
)
elif failure_decision == FailureDecision.RAISE:
next_state = ShuttingDownState(
next_state=ErroredState(
training_failed_error=training_failed_error,
),
)
return TrainControllerLoopIterationResult(
run_attempt_id=self._get_run_attempt_id(),
previous_state=controller_state,
next_state=next_state,
training_failed_error=training_failed_error,
)
else:
raise ValueError(f"Unexpected failure decision: {failure_decision}")
async def _poll_workers(self) -> WorkerGroupPollStatus:
# Ensure that the time between polls is at least HEALTH_CHECK_INTERVAL_S.
time_since_last_poll = time_monotonic() - self._latest_poll_time
if time_since_last_poll < self._health_check_interval_s:
remaining_time = max(
self._health_check_interval_s - time_since_last_poll, 0
)
await asyncio.sleep(remaining_time)
if self.get_state().is_terminal():
logger.debug(
f"Controller is unexpectedly in terminal state {self.get_state()} after "
"sleeping and before polling workers. Exiting actor."
)
ray.actor.exit_actor()
status = self._worker_group.poll_status(timeout=self._health_check_interval_s)
self._latest_poll_time = time_monotonic()
return status
def _start_worker_group(self, num_workers: int, resources_per_worker: dict) -> None:
"""Start the worker group and launch the train function.
Args:
num_workers: The number of workers to start.
resources_per_worker: The resources per worker to start.
Raises:
Exception: If the worker group failed to start.
"""
placement_strategy = self._scaling_policy.scaling_config.placement_strategy
scaling_config = self._train_run_context.scaling_config
# Check for `label_selector` to influence WorkerGroup scheduling.
label_selector = scaling_config._label_selector_per_worker(num_workers)
for callback in self._controller_callbacks:
selector = callback.on_controller_start_worker_group(
scaling_config=scaling_config, num_workers=num_workers
)
if selector:
if label_selector:
logger.warning(
f"Overriding `ScalingConfig.label_selector` {label_selector} "
f"with label_selector returned by user-specified callback {selector}"
)
label_selector = [selector.copy() for _ in range(num_workers)]
# Calculate num_slices for the worker group if using TPU.
num_slices = 1
if scaling_config.use_tpu:
num_slices = get_tpu_num_slices_for_workers(
topology=scaling_config.topology,
accelerator_type=scaling_config.accelerator_type,
num_workers=num_workers,
resources_per_worker=resources_per_worker,
)
worker_group_context = WorkerGroupContext(
run_attempt_id=self._get_run_attempt_id(),
train_fn_ref=self._train_fn_ref,
num_workers=num_workers,
resources_per_worker=resources_per_worker,
placement_strategy=placement_strategy,
label_selector=label_selector,
num_slices=num_slices,
)
self._worker_group = self.worker_group_cls.create(
train_run_context=self._train_run_context,
worker_group_context=worker_group_context,
callbacks=self._worker_group_callbacks_to_propagate,
)
def _start(self):
failure_result = self._run_controller_hook(
"after_controller_start", self._train_run_context
)
if failure_result:
self._set_state(failure_result.next_state)
async def _shutdown(self) -> "TrainControllerLoopIterationResult":
"""Execute shutdown and return the final state transition.
Shutdown errors are never retried. If an error occurs during shutdown:
- If we're already shutting down after a training error
(next_state is ErroredState), the original error is preserved.
- Otherwise the shutdown error becomes the training failure.
"""
controller_state = self.get_state()
assert isinstance(controller_state, ShuttingDownState)
shutdown_error = None
# TODO: move to __del__ after https://github.com/ray-project/ray/issues/53169
if self._worker_group:
try:
self._shutdown_worker_group()
except Exception as e:
logger.exception("Error shutting down worker group.")
shutdown_error = ControllerError(e)
try:
await self._controller_callback_manager.async_invoke(
"before_controller_shutdown"
)
except ControllerError as e:
if shutdown_error:
logger.warning(
"An additional error occurred in the before_controller_shutdown "
"callback after a worker group shutdown error. "
"This error is being ignored to preserve the original "
"shutdown error. Error: %s",
e,
)
else:
shutdown_error = e
if shutdown_error:
if isinstance(controller_state.next_state, ErroredState):
logger.warning(
"Another error occurred during shutdown after a training error. "
"This error is being ignored to preserve the original "
"training error. Error: %s",
shutdown_error,
)
else:
return TrainControllerLoopIterationResult(
run_attempt_id=self._get_run_attempt_id(),
previous_state=controller_state,
next_state=ErroredState(training_failed_error=shutdown_error),
training_failed_error=shutdown_error,
)
return TrainControllerLoopIterationResult(
run_attempt_id=self._get_run_attempt_id(),
previous_state=controller_state,
next_state=controller_state.next_state,
)
def _shutdown_worker_group(self):
"""Shutdown the worker group and set the worker group to None."""
self._worker_group.shutdown()
self._worker_group = None
def get_worker_group(self) -> Optional[WorkerGroup]:
return self._worker_group
def get_state(self) -> TrainControllerState:
return self._state
def _set_state(self, state: TrainControllerState):
previous_state = self._state
self._state = state
failure_result = self._run_controller_hook(
"after_controller_state_update", previous_state, state
)
if failure_result:
# If we're transitioning into a terminal state, or if we're already in the shutdown path to an errored terminal state
# (ShuttingDownState -> ErroredState), preserve the original failure as the
# surfaced error. A failure in a state-update callback should not overwrite
# the underlying root-cause error.
if state.is_terminal() or (
isinstance(state, ShuttingDownState)
and isinstance(state.next_state, ErroredState)
):
logger.warning(
"A callback failed during a terminal state transition. "
"This failure is being ignored to preserve the original "
"training result. Error: %s",
failure_result.training_failed_error,
)
return
# NOTE: We intentionally do *not* re-invoke `after_controller_state_update`
# for this transition to avoid re-entering callback hooks while handling
# a callback failure.
self._state = failure_result.next_state
def _make_and_handle_scaling_decision_for_non_running_worker_group(
self,
controller_state: TrainControllerState,
) -> TrainControllerLoopIterationResult:
"""Make a scaling decision for a non-running worker group and return the appropriate next state.
This method should be called when entering a state that requires a scaling decision
for a non-running worker group.
This method handles the complete flow of:
1. Shutting down the non-running worker group if it still exists.
2. Getting a scaling decision for a non-running worker group
3. Determining the next state based on the decision type
4. Creating and returning the iteration result
Args:
controller_state: The current controller state
Returns:
TrainControllerLoopIterationResult with the appropriate next state
"""
scaling_decision = (
self._scaling_policy.make_decision_for_non_running_worker_group()
)
if isinstance(scaling_decision, NoopDecision):
next_state = controller_state
elif isinstance(scaling_decision, ResizeDecision):
next_state = SchedulingState(scaling_decision)
else:
raise ValueError(f"Unexpected scaling decision: {scaling_decision}")
return TrainControllerLoopIterationResult(
run_attempt_id=self._get_run_attempt_id(),
previous_state=controller_state,
next_state=next_state,
)
async def _step(self) -> TrainControllerLoopIterationResult:
"""Run a single iteration of the control loop.
Returns:
The result of the iteration.
"""
controller_state = self.get_state()
if isinstance(
controller_state, (InitializingState, RestartingState, ReschedulingState)
):
return self._make_and_handle_scaling_decision_for_non_running_worker_group(
controller_state
)
elif isinstance(controller_state, SchedulingState):
assert isinstance(controller_state.scaling_decision, ResizeDecision)
return self._execute_resize_decision(controller_state.scaling_decision)
elif isinstance(controller_state, RunningState):
worker_group_status: WorkerGroupPollStatus = await self._poll_workers()
if worker_group_status.finished and not worker_group_status.errors:
self._return_value = worker_group_status.worker_statuses[0].return_value
return TrainControllerLoopIterationResult(
run_attempt_id=self._get_run_attempt_id(),
previous_state=controller_state,
next_state=ShuttingDownState(
next_state=FinishedState(),
),
)
if worker_group_status.errors:
worker_group_error = worker_group_status.get_worker_group_error()
failure_decision = self._failure_policy.make_decision(
training_failed_error=worker_group_error,
)
return self._execute_failure_decision(
failure_decision, training_failed_error=worker_group_error
)
scaling_decision = (
self._scaling_policy.make_decision_for_running_worker_group(
worker_group_state=self.get_worker_group().get_worker_group_state(),
worker_group_status=worker_group_status,
)
)
if isinstance(scaling_decision, NoopDecision):
next_state = RunningState()
elif isinstance(scaling_decision, ResizeDecision):
next_state = ResizingState(
scaling_decision=scaling_decision,
)
else:
raise ValueError(f"Unexpected scaling decision: {scaling_decision}")
return TrainControllerLoopIterationResult(
run_attempt_id=self._get_run_attempt_id(),
previous_state=controller_state,
next_state=next_state,
)
elif isinstance(controller_state, ResizingState):
return TrainControllerLoopIterationResult(
run_attempt_id=self._get_run_attempt_id(),
previous_state=controller_state,
next_state=SchedulingState(
scaling_decision=controller_state.scaling_decision
),
)
elif isinstance(controller_state, ShuttingDownState):
return await self._shutdown()
else:
raise ValueError(f"Unexpected controller state: {controller_state}")
def _generate_run_attempt_id(self):
self._run_attempt_id = uuid.uuid4().hex
return self._run_attempt_id
def _get_run_attempt_id(self):
return self._run_attempt_id
async def _run_control_loop_iteration(self):
"""Run a single iteration of the control loop.
Steps:
1. Poll the worker group for status.
2. If the worker group is initializing or recovering from an error,
make a scaling decision and execute it.
3. If the worker group has finished, set the controller state to FINISHED.
4. If the worker group has errors, make a failure decision and execute it.
5. Otherwise, the worker group is running healthily.
Query the scaling policy for a scaling decision and execute it.
Errors raised by ``_step`` are caught and routed through the failure
policy (retry / raise). If the failure policy itself fails, the
controller is forced into ``ErroredState`` as a last resort.
``AsyncioActorExit`` is always re-raised so that the actor can shut
down cleanly.
"""
controller_state = self.get_state()
assert not controller_state.is_terminal()
if controller_state.needs_new_run_attempt():
self._generate_run_attempt_id()
try:
result = await self._step()
except AsyncioActorExit:
raise
except Exception as e:
# Preserve the original error type if it is already a
# TrainingFailedError (e.g. WorkerGroupError); otherwise
# wrap it in a ControllerError.
if isinstance(e, TrainingFailedError):
training_error = e
else:
# Log the full traceback only for unexpected errors.
logger.exception("Error in control loop iteration: %s", e)
training_error = ControllerError(e)
try:
failure_decision = self._failure_policy.make_decision(
training_failed_error=training_error,
)
result = self._execute_failure_decision(
failure_decision,
training_failed_error=training_error,
)
except Exception:
# Last resort: force into errored state, bypassing callbacks.
logger.exception(
"Failed to execute failure decision, forcing error state."
)
self._state = ErroredState(training_failed_error=training_error)
return
self._set_state(result.next_state)
async def run(self):
"""Run the main control loop. Exits when training is finished or errored."""
while not self.get_state().is_terminal():
await self._run_control_loop_iteration()
# Call after_controller_finish with the final result.
result = self._build_result()
failure_result = self._run_controller_hook(
"after_controller_finish", result, invoke_failure_decision_callbacks=False
)
# Since we are already in a terminal state, a callback failure should
# not overwrite the training outcome — log and preserve the result.
if failure_result:
logger.warning(
"A callback failed after training finished. "
"This failure is being ignored to preserve the original "
"training result. Error: %s",
failure_result.training_failed_error,
)
async def abort(self):
"""Trigger callback abort hooks and terminate the controller process."""
# Do not abort run if it's already finished.
if self.get_state().is_terminal():
return
self._controller_callback_manager.invoke_best_effort("before_controller_abort")
# Intentionally abort worker group before setting train run state because
# we only reconcile the states of live train runs.
try:
if self._worker_group:
self._worker_group.abort()
self._set_state(AbortedState())
except Exception as e:
logger.exception("Error aborting worker group: %s", e)
ray.actor.exit_actor()
def _build_result(self) -> Result:
storage = self._checkpoint_manager._storage_context
latest_checkpoint_result = self._checkpoint_manager.latest_checkpoint_result
latest_metrics = (
latest_checkpoint_result.metrics if latest_checkpoint_result else None
)
latest_checkpoint = (
latest_checkpoint_result.checkpoint if latest_checkpoint_result else None
)
best_checkpoints = [
(r.checkpoint, r.metrics)
for r in self._checkpoint_manager.best_checkpoint_results
]
# Provide the history of metrics attached to checkpoints as a dataframe.
metrics_dataframe = None
if best_checkpoints:
metrics_dataframe = pd.DataFrame([m for _, m in best_checkpoints])
return Result(
metrics=latest_metrics,
checkpoint=latest_checkpoint,
error=self.get_training_failed_error(),
path=storage.experiment_fs_path,
best_checkpoints=best_checkpoints,
metrics_dataframe=metrics_dataframe,
_storage_filesystem=storage.storage_filesystem,
return_value=self._return_value,
)
def get_result(self) -> Result:
"""Get the final training result from the TrainController."""
controller_state = self.get_state()
if not controller_state.is_terminal():
raise ValueError(
f"Cannot get result when controller is in state {controller_state}"
)
return self._build_result()
def get_training_failed_error(self) -> Optional[TrainingFailedError]:
"""Get the training failed error from the controller state.
Returns:
The training failed error if the controller is in an errored state,
None otherwise.
"""
controller_state = self.get_state()
if isinstance(controller_state, ErroredState):
return controller_state.training_failed_error
return None
async def get_all_reported_checkpoints(
self,
current_report_index: int,
consistency_mode: CheckpointConsistencyMode = CheckpointConsistencyMode.VALIDATED,
timeout_s: Optional[float] = None,
) -> List["ReportedCheckpoint"]:
return await self._checkpoint_manager.get_all_reported_checkpoints(
current_report_index, consistency_mode, timeout_s
)
@@ -0,0 +1,183 @@
import logging
import queue
import threading
from typing import Optional
import ray
from ray.train.v2._internal.state.util import is_actor_alive
from ray.util.placement_group import PlacementGroup, remove_placement_group
logger = logging.getLogger(__name__)
class PlacementGroupCleaner:
"""Detached helper that ensures PG cleanup if Ray Train Controller exits ungracefully.
This actor should be created with lifetime='detached' to avoid being
fate-shared with the Train controller.
"""
def __init__(
self,
controller_actor_id: str,
check_interval_s: float,
get_actor_timeout_s: float,
stop_timeout: Optional[float],
):
self._controller_actor_id = controller_actor_id
self._check_interval_s = check_interval_s
self._get_actor_timeout_s = get_actor_timeout_s
self._stop_timeout = stop_timeout
self._pg_queue: queue.Queue = queue.Queue()
self._stop_event = threading.Event()
self._monitor_thread: Optional[threading.Thread] = None
self._exiting: bool = False
def register_placement_group(self, placement_group: PlacementGroup):
logger.debug(
"PlacementGroupCleaner registered placement group %s for controller %s",
placement_group.id,
self._controller_actor_id,
)
# Send placement group update to the monitor thread via queue
self._pg_queue.put(placement_group)
def start_monitoring(self):
"""Start monitoring the controller and placement group."""
if self._monitor_thread is not None and self._monitor_thread.is_alive():
# Thread already running, just return True
logger.debug("Monitor thread already running")
return True
self._monitor_thread = threading.Thread(
target=self._monitor_loop,
name="PlacementGroupCleanerMonitor",
daemon=True,
)
self._monitor_thread.start()
logger.debug("PlacementGroupCleaner started monitoring in background thread")
return True
def _monitor_loop(self):
"""Monitor controller; remove PG when controller is gone.
This runs continuously until controller dies or stop() is called.
Uses a queue to receive placement group updates.
"""
curr_placement_group: Optional[PlacementGroup] = None
while not self._stop_event.is_set():
# Check for new placement group updates from queue
try:
pg = self._pg_queue.get(timeout=self._check_interval_s)
curr_placement_group = pg
logger.debug(f"Updated current placement group to {pg.id}")
except queue.Empty:
pass # continue to monitor current placement group
# Check if controller is still alive
try:
alive = is_actor_alive(
actor_id=self._controller_actor_id,
timeout=self._get_actor_timeout_s,
)
except ray.util.state.exception.RayStateApiException:
logger.warning(
"Failed to query Ray Train Controller actor state. "
"State API may be temporarily unavailable. Continuing to monitor."
)
continue
# Cleanup if controller is dead
if not alive:
# Drain any queued placement groups
while True:
try:
pg = self._pg_queue.get_nowait()
curr_placement_group = pg
except queue.Empty:
break
self._cleanup_placement_group(curr_placement_group)
break
# Exit the actor after cleanup since controller is dead
self._exit()
self._monitor_thread = None
def _cleanup_placement_group(self, placement_group: Optional[PlacementGroup]):
"""Clean up the current placement group if it hasn't been removed."""
if placement_group is None:
logger.debug("No placement group registered; skipping cleanup.")
return
if self._is_placement_group_removed(placement_group):
logger.debug(
"Controller actor died but placement group already removed; "
"skipping cleanup."
)
return
logger.warning(
f"Detected that the Ray Train controller actor ({self._controller_actor_id}) is dead. "
f"Cleaning up placement group = [{placement_group.id}] created by this run."
)
try:
remove_placement_group(placement_group)
except Exception as e:
logger.warning(f"Failed to clean up placement group: {e}")
return
logger.debug(
f"Placement group = [{placement_group.id}] cleaned up successfully"
)
def _stop_monitor_thread(self):
"""Stop the monitor thread and wait for it to exit.
Returns:
bool: True if the thread was stopped, False if there was no active thread.
"""
if self._monitor_thread is None or not self._monitor_thread.is_alive():
return False
# Signal stop and wait for thread to exit
self._stop_event.set()
self._monitor_thread.join(timeout=self._stop_timeout)
if self._monitor_thread.is_alive():
logger.warning(
"Monitor thread did not exit within %.2f seconds", self._stop_timeout
)
return False
self._monitor_thread = None
return True
def stop(self):
"""Request the cleaner to stop monitoring and exit."""
self._stop_monitor_thread()
self._exit()
def _is_placement_group_removed(self, placement_group: PlacementGroup) -> bool:
"""Check if a placement group has been removed."""
try:
table = ray.util.placement_group_table(placement_group)
except Exception as e:
logger.warning(
f"Failed to query placement group table: {e}. "
"Assuming placement group is not removed."
)
return False
if "state" not in table:
return True
return table["state"] == "REMOVED"
def _exit(self):
"""Exit the actor."""
if self._exiting:
return
self._exiting = True
try:
ray.actor.exit_actor()
except Exception as e:
# If exit fails for any reason, just log it.
logger.warning(f"Failed to exit actor: {e}")
@@ -0,0 +1,156 @@
from enum import Enum
from ray.train.v2._internal.execution.scaling_policy.scaling_policy import (
ScalingDecision,
)
from ray.train.v2.api.exceptions import TrainingFailedError
class TrainControllerStateType(Enum):
"""Enum representing different states of the train controller.
States:
INITIALIZING: The train controller is starting up. This is always the initial
state of the controller.
SCHEDULING: The train controller is in the process of scheduling a new worker
group.
RESCHEDULING: The train controller is in the process of rescheduling the worker
group.
RUNNING: The train controller is actively running training tasks.
RESTARTING: The train controller is in the process of recovering from an error.
RESIZING: The train controller is in the process of resizing a running worker
group.
SHUTTING_DOWN: The train controller has already shut down the worker group and
and is in the process of shutting itself down.
ERRORED: A terminal state indicating that training has encountered an error and
cannot continue.
FINISHED: A terminal state indicating that training has completed.
ABORTED: A terminal state indicating that training has been aborted.
Args:
state_name: The name of the state.
is_terminal: Whether this is a terminal state that should not be further processed.
needs_new_run_attempt: Whether this state requires starting a new run attempt, where
a run attempt is a logical unit that encompasses both scheduling workers and
executing training on those workers.
"""
INITIALIZING = ("INITIALIZING", False, True)
SCHEDULING = ("SCHEDULING", False, False)
RESCHEDULING = ("RESCHEDULING", False, False)
RUNNING = ("RUNNING", False, False)
RESTARTING = ("RESTARTING", False, True)
RESIZING = ("RESIZING", False, True)
SHUTTING_DOWN = ("SHUTTING_DOWN", False, False)
ERRORED = ("ERRORED", True, False)
FINISHED = ("FINISHED", True, False)
ABORTED = ("ABORTED", True, False)
def __init__(
self,
state_name: str,
is_terminal: bool,
needs_new_run_attempt: bool,
):
self.state_name = state_name
self.is_terminal = is_terminal
self.needs_new_run_attempt = needs_new_run_attempt
class TrainControllerState:
"""Base class for all train controller states.
Methods:
get_type() -> TrainControllerStateType: Returns the type of the state.
is_terminal() -> bool: Returns whether the state is terminal.
needs_new_run_attempt() -> bool: Returns whether a new run attempt is needed.
"""
def __init__(self, state_type: TrainControllerStateType):
self._state_type = state_type
def __repr__(self) -> str:
attrs = {
"type": self._state_type.name,
"is_terminal": self._state_type.is_terminal,
"needs_new_run_attempt": self._state_type.needs_new_run_attempt,
**{k: v for k, v in vars(self).items() if not k.startswith("_")},
}
attrs_str = "\n ".join(f"{k}={v}" for k, v in attrs.items())
return f"{self.__class__.__name__}(\n {attrs_str}\n)"
def is_terminal(self) -> bool:
return self._state_type.is_terminal
def needs_new_run_attempt(self) -> bool:
return self._state_type.needs_new_run_attempt
class InitializingState(TrainControllerState):
def __init__(self):
super().__init__(state_type=TrainControllerStateType.INITIALIZING)
class SchedulingState(TrainControllerState):
def __init__(self, scaling_decision: ScalingDecision):
super().__init__(state_type=TrainControllerStateType.SCHEDULING)
self.scaling_decision = scaling_decision
class ReschedulingState(TrainControllerState):
def __init__(
self,
training_failed_error: TrainingFailedError,
):
super().__init__(state_type=TrainControllerStateType.RESCHEDULING)
self.training_failed_error = training_failed_error
class RunningState(TrainControllerState):
# TODO: Split into multiple more granular states, or add more fields.
# For example, we may want to indicate if any health checks failed.
def __init__(self):
super().__init__(state_type=TrainControllerStateType.RUNNING)
class RestartingState(TrainControllerState):
def __init__(
self,
training_failed_error: TrainingFailedError,
):
super().__init__(state_type=TrainControllerStateType.RESTARTING)
self.training_failed_error = training_failed_error
class ResizingState(TrainControllerState):
def __init__(
self,
scaling_decision: ScalingDecision,
):
super().__init__(state_type=TrainControllerStateType.RESIZING)
self.scaling_decision = scaling_decision
class ShuttingDownState(TrainControllerState):
def __init__(self, next_state: "TrainControllerState"):
super().__init__(state_type=TrainControllerStateType.SHUTTING_DOWN)
self.next_state = next_state
class ErroredState(TrainControllerState):
def __init__(
self,
training_failed_error: TrainingFailedError,
):
super().__init__(state_type=TrainControllerStateType.ERRORED)
self.training_failed_error = training_failed_error
class FinishedState(TrainControllerState):
def __init__(self):
super().__init__(state_type=TrainControllerStateType.FINISHED)
class AbortedState(TrainControllerState):
def __init__(self):
super().__init__(state_type=TrainControllerStateType.ABORTED)
@@ -0,0 +1,16 @@
# isort: off
from .failure_policy import FailureDecision, FailurePolicy
from .default import DefaultFailurePolicy
from .factory import create_failure_policy
# isort: on
__all__ = [
"DefaultFailurePolicy",
"FailureDecision",
"FailurePolicy",
"create_failure_policy",
]
# DO NOT ADD ANYTHING AFTER THIS LINE.
@@ -0,0 +1,100 @@
import logging
from .failure_policy import FailureDecision, FailurePolicy
from ray.train.v2._internal.exceptions import (
WorkerGroupStartupFailedError,
WorkerGroupStartupTimeoutError,
)
from ray.train.v2.api.config import FailureConfig
from ray.train.v2.api.exceptions import (
ControllerError,
TrainingFailedError,
WorkerGroupError,
)
logger = logging.getLogger(__name__)
RETRYABLE_CONTROLLER_ERRORS = (
WorkerGroupStartupFailedError,
WorkerGroupStartupTimeoutError,
)
class DefaultFailurePolicy(FailurePolicy):
def __init__(self, failure_config: FailureConfig):
super().__init__(failure_config)
self._worker_group_failures = 0
self._controller_failures = 0
def _log_decision(
self,
decision: FailureDecision,
training_failed_error: TrainingFailedError,
error_count: int,
retry_limit: int,
):
if isinstance(training_failed_error, ControllerError):
error_source = "controller"
elif isinstance(training_failed_error, WorkerGroupError):
error_source = "worker group"
else:
raise ValueError(f"Unknown error type: {type(training_failed_error)}")
logger.info(
f"[FailurePolicy] {decision.value}\n"
f" Source: {error_source}\n"
f" Error count: {error_count} (max allowed: {retry_limit})\n"
f"Error: {training_failed_error}",
exc_info=(
type(training_failed_error),
training_failed_error,
training_failed_error.__traceback__,
),
)
def _is_retryable_error(self, training_failed_error: TrainingFailedError) -> bool:
if isinstance(training_failed_error, WorkerGroupError):
return True
elif isinstance(training_failed_error, ControllerError):
return isinstance(
training_failed_error.controller_failure, RETRYABLE_CONTROLLER_ERRORS
)
return False
def make_decision(
self,
training_failed_error: TrainingFailedError,
) -> FailureDecision:
if not self._is_retryable_error(training_failed_error):
decision = FailureDecision.RAISE
error_count = 1
retry_limit = 0
else:
if isinstance(training_failed_error, ControllerError):
self._controller_failures += 1
error_count = self._controller_failures
retry_limit = (
self.failure_config.controller_failure_limit
if self.failure_config.controller_failure_limit != -1
else float("inf")
)
elif isinstance(training_failed_error, WorkerGroupError):
self._worker_group_failures += 1
error_count = self._worker_group_failures
retry_limit = (
self.failure_config.max_failures
if self.failure_config.max_failures != -1
else float("inf")
)
else:
raise ValueError(f"Unknown error type: {type(training_failed_error)}")
if error_count > retry_limit:
decision = FailureDecision.RAISE
else:
decision = FailureDecision.RETRY
self._log_decision(decision, training_failed_error, error_count, retry_limit)
return decision
@@ -0,0 +1,13 @@
from ray.train import FailureConfig
from ray.train.v2._internal.execution.failure_handling import (
DefaultFailurePolicy,
FailurePolicy,
)
def create_failure_policy(failure_config: FailureConfig) -> FailurePolicy:
"""Create a failure policy from the given failure config.
Defaults to the `DefaultFailurePolicy` implementation.
"""
return DefaultFailurePolicy(failure_config=failure_config)
@@ -0,0 +1,29 @@
import abc
from enum import Enum
from ray.train.v2.api.config import FailureConfig
from ray.train.v2.api.exceptions import TrainingFailedError
class FailureDecision(Enum):
RETRY = "RETRY"
RAISE = "RAISE"
NOOP = "NOOP"
class FailurePolicy(abc.ABC):
"""A policy that determines how to handle user and system failures.
FailurePolicy will handle the controller failure and worker errors during training.
This can be used to implement fault tolerance and error recovery.
"""
def __init__(self, failure_config: FailureConfig):
self.failure_config = failure_config
@abc.abstractmethod
def make_decision(
self,
training_failed_error: TrainingFailedError,
) -> FailureDecision:
raise NotImplementedError
@@ -0,0 +1,93 @@
import logging
import os
from typing import Any, Callable
import torch
import torch.distributed as dist
from ray.train import Result
from ray.train.v2._internal.execution.local_mode.utils import LocalController
from ray.train.v2._internal.execution.train_fn_utils import (
LocalTrainFnUtils,
get_train_fn_utils,
set_train_fn_utils,
)
logger = logging.getLogger(__name__)
def has_torchrun_env() -> bool:
"""Return True if this process has torch.distributed env vars set.
For torch.distributed.init_process_group with init_method="env://", these variables are required:
- RANK: The rank of the current process
- LOCAL_RANK: The local rank of the current process
- WORLD_SIZE: Total number of processes participating in the job
- LOCAL_WORLD_SIZE: Total number of processes participating in the job on the current node
- MASTER_ADDR: The IP address or hostname of the master node (rank 0)
- MASTER_PORT: A free port on the master node for communication
"""
torch_dist_required_vars = {
"RANK",
"LOCAL_RANK",
"WORLD_SIZE",
"LOCAL_WORLD_SIZE",
"MASTER_ADDR",
"MASTER_PORT",
}
return torch_dist_required_vars.issubset(os.environ.keys())
class LocalTorchController(LocalController):
def _set_train_fn_utils(self) -> None:
world_size = 1
global_rank = 0
local_rank = 0
nproc_per_node = 1
node_rank = 0
if has_torchrun_env():
assert not dist.is_initialized(), "torch.distributed is already initialized"
torch.distributed.init_process_group(
backend="nccl" if torch.cuda.is_available() else "gloo"
)
world_size = torch.distributed.get_world_size()
global_rank = torch.distributed.get_rank()
local_rank = int(os.environ["LOCAL_RANK"])
if torch.cuda.is_available():
torch.cuda.set_device(local_rank)
nproc_per_node = int(os.environ.get("LOCAL_WORLD_SIZE"))
node_rank = global_rank // nproc_per_node
if world_size != 1:
assert (
self.datasets is None or len(self.datasets) == 0
), "Ray Data is not supported in local mode with multiple workers."
set_train_fn_utils(
LocalTrainFnUtils(
experiment_name=self.experiment_name,
world_size=world_size,
world_rank=global_rank,
local_rank=local_rank,
local_world_size=nproc_per_node,
node_rank=node_rank,
dataset_shards=self.datasets,
)
)
def run(self, train_func: Callable[[], Any]) -> Result:
self._set_train_fn_utils()
train_result = train_func()
train_fn_utils = get_train_fn_utils()
assert isinstance(train_fn_utils, LocalTrainFnUtils)
result = Result(
metrics=train_fn_utils._get_last_metrics(),
checkpoint=train_fn_utils.get_checkpoint(),
path=None,
error=None,
return_value=train_result,
)
if dist.is_initialized():
dist.destroy_process_group()
return result
@@ -0,0 +1,41 @@
import logging
from typing import Any, Callable, Dict, Optional
from ray.train import Result
from ray.train.trainer import GenDataset
from ray.train.v2._internal.execution.train_fn_utils import (
LocalTrainFnUtils,
get_train_fn_utils,
set_train_fn_utils,
)
logger = logging.getLogger(__name__)
class LocalController:
def __init__(
self, experiment_name: str, datasets: Optional[Dict[str, GenDataset]] = None
):
if datasets is not None:
datasets = {k: v() if callable(v) else v for k, v in datasets.items()}
self.datasets = datasets
self.experiment_name = experiment_name
def run(self, train_func: Callable[[], Any]) -> Result:
set_train_fn_utils(
LocalTrainFnUtils(
experiment_name=self.experiment_name,
dataset_shards=self.datasets,
)
)
result = train_func()
train_fn_utils = get_train_fn_utils()
assert isinstance(train_fn_utils, LocalTrainFnUtils)
return Result(
metrics=train_fn_utils._get_last_metrics(),
checkpoint=train_fn_utils.get_checkpoint(),
path=None,
error=None,
return_value=result,
)
@@ -0,0 +1,243 @@
import logging
import threading
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Dict, List, Optional, Set
import ray
from ray.actor import ActorHandle
from ray.train.v2._internal.constants import DEFAULT_PREEMPTION_POLL_INTERVAL_S
from ray.util.tpu import get_tpu_slice_name_from_node
if TYPE_CHECKING:
from ray.train.v2._internal.worker import RayTrainWorker
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class PreemptionInfo:
"""Information about an imminent preemption event.
Attributes:
deadline_ms: Earliest preemption deadline (UNIX time in milliseconds)
across all preempted nodes. ``None`` if no deadline was reported.
preempted_node_to_ranks: Map of preempted ``node_id`` to the worker ``world_rank``s affected when that node
is preempted.
"""
deadline_ms: Optional[int]
preempted_node_to_ranks: Dict[str, List[int]]
@property
def preempted_node_ids(self) -> List[str]:
"""Preempted node IDs, sorted lexicographically."""
return sorted(self.preempted_node_to_ranks)
@property
def preempted_ranks(self) -> List[int]:
"""All affected ranks across the preempted nodes, sorted ascending."""
return sorted(
{r for ranks in self.preempted_node_to_ranks.values() for r in ranks}
)
@dataclass
class PreemptionContext:
"""Thread-shared preemption signal for one worker actor."""
_preemption_info: Optional[PreemptionInfo] = field(default=None, init=False)
_lock: threading.Lock = field(default_factory=threading.Lock, init=False)
def set(self, info: PreemptionInfo) -> None:
with self._lock:
self._preemption_info = info
def get(self) -> Optional[PreemptionInfo]:
"""Return the current preemption signal, or ``None`` if none received."""
with self._lock:
return self._preemption_info
def _get_draining_nodes() -> Dict[str, int]:
"""Ray Core's draining nodes as ``{node_id_hex: deadline_ms}`` (0 = no deadline)."""
return ray._private.state.state.get_draining_nodes()
class PreemptionWatcher:
"""Polls Ray Core for node drains and logs detected preemption events.
One watcher per worker group, spawned as a ``num_cpus=0`` actor by
``PreemptionCallback``. The poll loop runs in a background thread. The
failure-domain map is built once on construction and is immutable for the
watcher's lifetime.
The failure-domain map records which of our ranks are affected if a node is
preempted: for a GPU node, the ranks on that node; for a TPU node, every
rank in the node's slice, since a TPU slice is preempted atomically.
Args:
node_to_ranks: Map ``node_id_hex -> [ranks on that node]``. Used both
as the set of nodes we care about (drains elsewhere are ignored)
and as the seed for failure-domain expansion.
poll_interval_s: Seconds between drain-state polls.
worker_actors_by_rank: Map ``world_rank -> worker actor handle``. On a
detected preemption, ``mark_preempt`` is called on every worker.
"""
def __init__(
self,
node_to_ranks: Dict[str, List[int]],
poll_interval_s: float = DEFAULT_PREEMPTION_POLL_INTERVAL_S,
worker_actors_by_rank: Optional[
Dict[int, ActorHandle["RayTrainWorker"]]
] = None,
):
self._node_to_ranks: Dict[str, List[int]] = {
nid: sorted(ranks) for nid, ranks in node_to_ranks.items()
}
self._poll_interval_s = poll_interval_s
self._worker_actors_by_rank: Dict[int, ActorHandle["RayTrainWorker"]] = (
worker_actors_by_rank or {}
)
self._failure_domain_map: Dict[str, List[int]] = self._build_failure_domain_map(
self._node_to_ranks
)
self._stop_event = threading.Event()
self._last_drained: Dict[str, int] = {}
self._latest_info: Optional[PreemptionInfo] = None
self._monitor_thread = threading.Thread(
target=self._watch_loop,
name="PreemptionWatcher",
daemon=True,
)
self._monitor_thread.start()
@staticmethod
def _build_failure_domain_map(
node_to_ranks: Dict[str, List[int]],
) -> Dict[str, List[int]]:
"""Map each node we host to all ranks in its failure domain.
- Non-TPU (e.g. GPU) clusters: the failure domain is the node itself,
so a drain on a node flags only the ranks this job runs there.
- TPU multislice: every host in a slice is reclaimed atomically, so a
drain on any host is fate-shared with the rest.
"""
per_node = {nid: sorted(set(ranks)) for nid, ranks in node_to_ranks.items()}
try:
all_nodes = ray.nodes()
# Slice label for each node we host (None for non-TPU nodes).
node_to_slice: Dict[str, Optional[str]] = {
node["NodeID"]: get_tpu_slice_name_from_node(node)
for node in all_nodes
if node["NodeID"] in node_to_ranks
}
# Union our ranks per slice.
slice_to_ranks: Dict[str, Set[int]] = {}
for node_id, ranks in node_to_ranks.items():
slice_label = node_to_slice.get(node_id)
if slice_label:
slice_to_ranks.setdefault(slice_label, set()).update(ranks)
# Non-TPU cluster (or none of our nodes are on a slice): per-node.
if not slice_to_ranks:
return per_node
result: Dict[str, List[int]] = {}
for node_id, ranks in node_to_ranks.items():
slice_label = node_to_slice.get(node_id)
if slice_label:
result[node_id] = sorted(slice_to_ranks[slice_label])
else:
result[node_id] = sorted(set(ranks))
return result
except Exception:
logger.debug(
"Could not build failure-domain map; falling back to per-node "
"domains (no TPU-slice expansion).",
exc_info=True,
)
return per_node
def get_latest_preemption_info(self) -> Optional[PreemptionInfo]:
"""Most recent :class:`PreemptionInfo` observed, or ``None``."""
return self._latest_info
def _watch_loop(self) -> None:
logger.debug(
"PreemptionWatcher polling %d node(s) every %.1fs.",
len(self._node_to_ranks),
self._poll_interval_s,
)
while not self._stop_event.is_set():
self._poll_once()
self._stop_event.wait(timeout=self._poll_interval_s)
logger.debug("PreemptionWatcher stopped.")
def _poll_once(self) -> None:
"""Poll the drain source once and dispatch on change.
Per-poll exceptions are caught and logged so a transient GCS hiccup
doesn't kill the watcher loop.
"""
try:
drained = _get_draining_nodes() or {}
# Keep only drains on this job's own nodes (others are ignored).
# That's complete for TPU — an SPMD job fully occupies its slice, so
# every fate-shared host is one of our nodes and a drain on any slice
# host appears here. For GPU, a drain on a host we don't run on is
# correctly irrelevant.
relevant = {
n: d for n, d in drained.items() if n in self._failure_domain_map
}
if relevant != self._last_drained:
self._on_drain_change(relevant)
self._last_drained = relevant
except Exception:
# TODO(lehui): consider exponential backoff when the drain API keeps
# failing, instead of retrying at the fixed poll interval.
logger.warning("PreemptionWatcher poll failed", exc_info=True)
def _on_drain_change(self, drained: Dict[str, int]) -> None:
"""Handle a change in the drained-node set.
``drained`` has already been narrowed to this job's nodes by the
caller (``_poll_once``).
"""
if not drained:
return
affected_node_ids = sorted(drained.keys())
preempted_node_to_ranks = {
node_id: self._failure_domain_map[node_id] for node_id in affected_node_ids
}
# Earliest deadline across the preempted nodes; None if none reported one
# (Ray Core uses 0 for "no deadline", which is falsy and filtered out).
reported_deadlines = [drained[n] for n in affected_node_ids if drained[n]]
deadline_ms = min(reported_deadlines) if reported_deadlines else None
info = PreemptionInfo(
deadline_ms=deadline_ms,
preempted_node_to_ranks=preempted_node_to_ranks,
)
self._latest_info = info
logger.warning(
"PreemptionWatcher: preemption detected — "
"preempted_node_ids=%s, preempted_ranks=%s, deadline_ms=%s",
info.preempted_node_ids,
info.preempted_ranks,
deadline_ms,
)
for rank, actor in self._worker_actors_by_rank.items():
actor.mark_preempt.remote(info)
# TODO(lehui): coalesce preemptions seen within one window into a single
# worker-group restart, so a staggered drain (node A at t, node B at
# t+60s) doesn't cause back-to-back restarts.
@@ -0,0 +1,29 @@
# isort: off
from .scaling_policy import ScalingDecision, ScalingPolicy, NoopDecision, ResizeDecision
from .scaling_policy import (
AUTOSCALING_REQUESTS_EXPIRE_TIME_S,
AUTOSCALING_REQUESTS_GET_TIMEOUT_S,
AUTOSCALING_REQUESTS_INTERVAL_S,
)
from .elastic import ElasticScalingPolicy
from .fixed import FixedScalingPolicy
from .factory import create_scaling_policy
# isort: on
__all__ = [
"AUTOSCALING_REQUESTS_EXPIRE_TIME_S",
"AUTOSCALING_REQUESTS_GET_TIMEOUT_S",
"AUTOSCALING_REQUESTS_INTERVAL_S",
"ScalingPolicy",
"ElasticScalingPolicy",
"FixedScalingPolicy",
"ScalingDecision",
"NoopDecision",
"ResizeDecision",
"create_scaling_policy",
]
# DO NOT ADD ANYTHING AFTER THIS LINE.
@@ -0,0 +1,291 @@
import logging
from typing import TYPE_CHECKING, Dict, List, Optional
import ray
from ray.train.v2._internal.execution.scaling_policy import (
NoopDecision,
ResizeDecision,
ScalingDecision,
ScalingPolicy,
)
from ray.train.v2._internal.execution.scaling_policy.scaling_policy import (
AUTOSCALING_REQUESTS_GET_TIMEOUT_S,
)
from ray.train.v2._internal.execution.worker_group import (
WorkerGroupPollStatus,
WorkerGroupState,
)
from ray.train.v2._internal.util import time_monotonic
from ray.train.v2.api.config import ScalingConfig
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from ray.data._internal.cluster_autoscaler.default_autoscaling_coordinator import (
ResourceDict,
)
class ElasticScalingPolicy(ScalingPolicy):
# Minimum interval in seconds between querying the AutoscalingCoordinator for allocated resources.
GET_ALLOCATED_RESOURCES_INTERVAL_S = 1
# Minimum interval in seconds between logging warnings about insufficient workers.
INSUFFICIENT_WORKERS_WARNING_INTERVAL_S = 30
def __init__(self, scaling_config: ScalingConfig):
super().__init__(scaling_config)
self._latest_monitor_time = float("-inf")
self._latest_insufficient_workers_warning_time = float("-inf")
self._latest_allocated_resources_query_time = float("-inf")
self._latest_allocated_resources: Optional[List["ResourceDict"]] = None
def _get_num_workers_for_resource_request(self) -> int:
return self.scaling_config.max_workers
def _count_possible_workers(
self, allocated_resources: List[Dict[str, float]]
) -> int:
"""Count the number of workers that can be started/restarted with the given
the list of node resources. The returned number is capped at the maximum
number of workers.
For GPUs, this divides raw allocated resources by per-worker requirements.
For TPUs, an additional check ensures workers align with physically intact
TPU slices (see ``_get_strict_tpu_worker_count``).
Args:
allocated_resources: The resources currently allocated by the AutoscalingCoordinator.
Returns:
The number of workers that can be started/restarted with the current resources.
"""
# TODO: Fractional resources do not work well here.
single_worker_resources = self.scaling_config._resources_per_worker_not_none
total_num_workers = 0
# If workers require no resources, we can run as many as we want.
if sum(single_worker_resources.values()) == 0:
return self.scaling_config.max_workers
for resources in allocated_resources:
num_workers = min(
[
resources.get(resource, 0.0) // single_worker_resources[resource]
for resource in single_worker_resources
if single_worker_resources[resource] > 0
]
)
total_num_workers += num_workers
total_num_workers = min(int(total_num_workers), self.scaling_config.max_workers)
# Multi-host TPUs are scheduled atomically in interconnected slices defined by a topology.
if (
self.scaling_config.use_tpu
and self.scaling_config.topology
and self.scaling_config.accelerator_type
):
total_num_workers = self._get_strict_tpu_worker_count(
total_num_workers=total_num_workers,
)
return total_num_workers
def _get_strict_tpu_worker_count(self, total_num_workers: int) -> int:
"""Calculate the number of workers that can run on intact TPU slices.
The Autoscaler's allocated resources might overestimate the number of
schedulable TPU workers because it counts raw resources. TPUs require
atomic, interconnected slices. This function checks the cluster for
physically intact slices to prevent scaling onto fractional/broken
topologies.
The worker count is: min(resource_based_slices, intact_slices) *
workers_per_slice, where resource_based_slices =
total_num_workers // workers_per_slice.
Args:
total_num_workers: The initial estimate of workers based on raw
allocated resources.
Returns:
The number of workers aligned to fully intact TPU slices.
"""
from ray.util.tpu import get_num_tpu_slices, get_tpu_worker_resources
single_worker_resources = self.scaling_config._resources_per_worker_not_none
try:
workers_per_slice, _ = get_tpu_worker_resources(
topology=self.scaling_config.topology,
accelerator_type=self.scaling_config.accelerator_type,
resources_per_unit=single_worker_resources,
num_slices=1,
)
if workers_per_slice == 0:
# A single worker requires more resources than exist in a
# full slice — impossible scheduling configuration for TPU.
return 0
num_slices_from_resources = total_num_workers // workers_per_slice
if num_slices_from_resources > 0:
try:
num_intact_slices = get_num_tpu_slices(
topology=self.scaling_config.topology,
accelerator_type=self.scaling_config.accelerator_type,
)
num_slices_from_resources = min(
num_slices_from_resources, num_intact_slices
)
except Exception as e:
logger.warning(
f"Failed to check cluster state for intact TPU slices: {e}"
)
return num_slices_from_resources * workers_per_slice
except Exception as e:
logger.warning(
f"Could not calculate TPU slice boundaries for elastic scaling: {e}. "
"Worker counts may not align with TPU topology."
)
return 0
def _get_resize_decision(self, num_workers: int) -> ResizeDecision:
return ResizeDecision(
num_workers=num_workers,
resources_per_worker=self.scaling_config._resources_per_worker_not_none,
)
def make_decision_for_non_running_worker_group(self) -> ScalingDecision:
self._maybe_send_resource_request()
allocated_resources = self._get_allocated_resources()
if allocated_resources is None:
return NoopDecision()
num_workers = self._count_possible_workers(allocated_resources)
if num_workers < self.scaling_config.min_workers:
now = time_monotonic()
# Only log this warning periodically to avoid spamming logs
if (
now - self._latest_insufficient_workers_warning_time
>= self.INSUFFICIENT_WORKERS_WARNING_INTERVAL_S
):
logger.info(
f"Detected ready resources for {num_workers} workers "
"in the cluster. "
"Deciding NOT to start/restart training due to the number of workers "
"falling below the minimum "
f"(min_workers={self.scaling_config.min_workers})."
)
self._latest_insufficient_workers_warning_time = now
return NoopDecision()
logger.info(
f"Detected ready resources for {num_workers} workers "
"in the cluster. "
"Deciding to start/restart training with this worker group size."
)
return self._get_resize_decision(num_workers)
def make_decision_for_running_worker_group(
self,
worker_group_state: WorkerGroupState,
worker_group_status: WorkerGroupPollStatus,
) -> ScalingDecision:
self._maybe_send_resource_request()
# Ensure that we don't make resizing decisions too frequently.
# The latest restart time and the latest monitor time (whichever is later)
# determine the time of the next resize consideration.
latest_consideration_time = max(
worker_group_state.start_time, self._latest_monitor_time
)
now = time_monotonic()
time_since_latest_consideration = now - latest_consideration_time
if (
time_since_latest_consideration
< self.scaling_config.elastic_resize_monitor_interval_s
):
logger.debug(
"Skipping resize decision due to the latest resizing consideration "
"happening too recently: "
"%.2f seconds < ScalingConfig(elastic_resize_monitor_interval_s=%.2f seconds).",
time_since_latest_consideration,
self.scaling_config.elastic_resize_monitor_interval_s,
)
return NoopDecision()
self._latest_monitor_time = now
allocated_resources = self._get_allocated_resources()
if allocated_resources is None:
return NoopDecision()
num_workers = self._count_possible_workers(allocated_resources)
if num_workers == worker_group_state.num_workers:
logger.info(
"Did not detect any changes in the cluster resources. "
"Training will continue with the same worker group size "
f"({num_workers})."
)
return NoopDecision()
elif num_workers < self.scaling_config.min_workers:
# This covers an edge case where allocated resources decrease to less
# than the minimum number of workers.
# This situation is rare, since cluster downsizing typically involves
# worker failures. However, this check is still useful to fully
# avoid entering an invalid state with fewer workers than the minimum.
return NoopDecision()
logger.info(
"Detected changes in the cluster resources. "
"Deciding to resize the worker group from "
f"{worker_group_state.num_workers} -> {num_workers} workers."
)
return self._get_resize_decision(num_workers)
# ---------------------------------------------------
# Methods for interacting with AutoscalingCoordinator
# ---------------------------------------------------
def _get_allocated_resources(self) -> Optional[List["ResourceDict"]]:
"""Get allocated resources from AutoscalingCoordinator.
Return None if there is an error."""
now = time_monotonic()
time_since_last_call = now - self._latest_allocated_resources_query_time
if time_since_last_call < self.GET_ALLOCATED_RESOURCES_INTERVAL_S:
return self._latest_allocated_resources
allocated_resources = None
try:
allocated_resources = ray.get(
self._autoscaling_coordinator.get_allocated_resources.remote(
self._requester_id
),
timeout=AUTOSCALING_REQUESTS_GET_TIMEOUT_S,
)
except Exception:
msg = (
f"Failed to get allocated resources for {self._requester_id}."
" Will not resize the worker group."
" If this only happens transiently during network partition or"
" CPU being overloaded, it's safe to ignore this error."
" If this error persists, file a GitHub issue."
)
logger.warning(msg, exc_info=True)
finally:
self._latest_allocated_resources_query_time = time_monotonic()
self._latest_allocated_resources = allocated_resources
return self._latest_allocated_resources
@@ -0,0 +1,16 @@
from ray.train.v2._internal.execution.scaling_policy import (
ElasticScalingPolicy,
FixedScalingPolicy,
ScalingPolicy,
)
from ray.train.v2.api.config import ScalingConfig
def create_scaling_policy(scaling_config: ScalingConfig) -> ScalingPolicy:
"""Create a scaling policy from the given scaling config.
Defaults to the `FixedScalingPolicy` implementation.
"""
if scaling_config.elasticity_enabled:
return ElasticScalingPolicy(scaling_config=scaling_config)
return FixedScalingPolicy(scaling_config=scaling_config)
@@ -0,0 +1,30 @@
from ray.train.v2._internal.execution.scaling_policy import (
NoopDecision,
ResizeDecision,
ScalingDecision,
ScalingPolicy,
)
from ray.train.v2._internal.execution.worker_group import (
WorkerGroupPollStatus,
WorkerGroupState,
)
class FixedScalingPolicy(ScalingPolicy):
def _get_num_workers_for_resource_request(self) -> int:
return self.scaling_config.num_workers
def make_decision_for_non_running_worker_group(self) -> ScalingDecision:
self._maybe_send_resource_request()
return ResizeDecision(
num_workers=self.scaling_config.num_workers,
resources_per_worker=self.scaling_config._resources_per_worker_not_none,
)
def make_decision_for_running_worker_group(
self,
worker_group_state: WorkerGroupState,
worker_group_status: WorkerGroupPollStatus,
) -> ScalingDecision:
self._maybe_send_resource_request()
return NoopDecision()
@@ -0,0 +1,183 @@
import abc
import logging
import uuid
from dataclasses import dataclass
from functools import cached_property
from typing import Dict
import ray
from ray.train.v2._internal.execution.callback import ControllerCallback
from ray.train.v2._internal.execution.context import TrainRunContext
from ray.train.v2._internal.execution.worker_group import (
WorkerGroupPollStatus,
WorkerGroupState,
)
from ray.train.v2._internal.util import time_monotonic
from ray.train.v2.api.config import ScalingConfig
logger = logging.getLogger(__name__)
# The time in seconds after which an autoscaling request will expire.
AUTOSCALING_REQUESTS_EXPIRE_TIME_S = 180
# Timeout in seconds for getting the result of a call to the AutoscalingCoordinator.
AUTOSCALING_REQUESTS_GET_TIMEOUT_S = 5
# Interval in seconds between resource requests to the AutoscalingCoordinator.
AUTOSCALING_REQUESTS_INTERVAL_S = 20
@dataclass
class ScalingDecision:
pass
@dataclass
class NoopDecision(ScalingDecision):
pass
@dataclass
class ResizeDecision(ScalingDecision):
num_workers: int
resources_per_worker: Dict[str, float]
class ScalingPolicy(abc.ABC, ControllerCallback):
"""A policy that determines when and how to scale a worker group.
This can be used to implement elasticity and fault tolerance.
Recovery decisions are made when workers are in an inactive or unhealthy state.
Upscale decisions are optional and are made when workers are healthy.
Note: When adding new scaling policies, revisit the shared defaults- particularly if:
- AutoscalingCoordinator integration is not needed or a different interface
becomes available
- Timeout/expiry constants need to diverge between policies
- _get_num_workers_for_resource_request() needs variable worker counts
- Controller lifecycle behavior diverges
"""
# TODO: Restructure these APIs to consider different TrainControllerStates
# instead of just running and non-running worker groups.
def __init__(self, scaling_config: ScalingConfig):
self.scaling_config = scaling_config
self._requester_id = "train-" + uuid.uuid4().hex
self._latest_autoscaling_request_time = float("-inf")
@abc.abstractmethod
def make_decision_for_non_running_worker_group(self) -> ScalingDecision:
"""Makes a scaling decision when the worker group is initializing
or recovering from an error."""
raise NotImplementedError
@abc.abstractmethod
def make_decision_for_running_worker_group(
self,
worker_group_state: WorkerGroupState,
worker_group_status: WorkerGroupPollStatus,
) -> ScalingDecision:
"""Makes a scaling decision when monitoring healthy, running workers."""
raise NotImplementedError
@abc.abstractmethod
def _get_num_workers_for_resource_request(self) -> int:
"""Return the number of workers to request resources for."""
raise NotImplementedError
# ---------------------------------------------------
# Methods for interacting with AutoscalingCoordinator
# ---------------------------------------------------
@cached_property
def _autoscaling_coordinator(self):
from ray.data._internal.cluster_autoscaler.default_autoscaling_coordinator import (
get_or_create_autoscaling_coordinator,
)
return get_or_create_autoscaling_coordinator()
def _maybe_send_resource_request(self):
"""Send a resource request to AutoscalingCoordinator,
if AUTOSCALING_REQUESTS_INTERVAL_S has passed since the last send."""
now = time_monotonic()
if (
now - self._latest_autoscaling_request_time
< AUTOSCALING_REQUESTS_INTERVAL_S
):
return
self._send_resource_request()
def _send_resource_request(self):
"""Register training resources with the AutoscalingCoordinator."""
resources_per_worker = self.scaling_config._resources_per_worker_not_none
num_workers = self._get_num_workers_for_resource_request()
label_selectors = self.scaling_config._label_selector_per_worker(num_workers)
try:
from ray.data._internal.cluster_autoscaler.default_autoscaling_coordinator import (
ResourceRequestPriority,
)
ray.get(
self._autoscaling_coordinator.request_resources.remote(
requester_id=self._requester_id,
resources=[resources_per_worker] * num_workers,
label_selectors=label_selectors,
expire_after_s=AUTOSCALING_REQUESTS_EXPIRE_TIME_S,
priority=ResourceRequestPriority.HIGH,
),
timeout=AUTOSCALING_REQUESTS_GET_TIMEOUT_S,
)
self._latest_autoscaling_request_time = time_monotonic()
except Exception:
msg = (
f"Failed to send resource request for {self._requester_id}."
" If this only happens transiently during network partition or"
" CPU being overloaded, it's safe to ignore this error."
" If this error persists, file a GitHub issue."
)
logger.warning(msg, exc_info=True)
def _cancel_resource_request(self):
"""Cancel the resource request to AutoscalingCoordinator."""
try:
ray.get(
self._autoscaling_coordinator.cancel_request.remote(
requester_id=self._requester_id,
),
timeout=AUTOSCALING_REQUESTS_GET_TIMEOUT_S,
)
except Exception:
msg = (
f"Failed to cancel resource request for {self._requester_id}."
" The request will still expire after the timeout of"
f" {AUTOSCALING_REQUESTS_EXPIRE_TIME_S} seconds."
)
logger.warning(msg, exc_info=True)
# --------------------------
# ControllerCallback
# --------------------------
def after_controller_start(self, train_run_context: TrainRunContext):
"""Register training resources with the AutoscalingCoordinator."""
self._requester_id = f"train-{train_run_context.run_id}"
resources_per_worker = self.scaling_config._resources_per_worker_not_none
num_workers = self._get_num_workers_for_resource_request()
label_selectors = self.scaling_config._label_selector_per_worker(num_workers)
if label_selectors:
logger.info(
f"Requesting resources: {resources_per_worker} * {num_workers} "
f"with label_selectors={label_selectors}"
)
else:
logger.info(f"Requesting resources: {resources_per_worker} * {num_workers}")
self._send_resource_request()
async def before_controller_shutdown(self):
"""Cancel the resource request when the controller shuts down."""
self._cancel_resource_request()
def before_controller_abort(self):
"""Cancel the resource request when the controller is aborted."""
self._cancel_resource_request()
@@ -0,0 +1,573 @@
# Try import ray[train] core requirements (defined in setup.py)
# isort: off
try:
import fsspec # noqa
from fsspec.implementations.local import LocalFileSystem
except (ImportError, ModuleNotFoundError) as e:
raise RuntimeError(
"fsspec is a required dependency of Ray Train and Ray Tune. "
"Please install with: `pip install fsspec`"
) from e
try:
import pyarrow
import pyarrow.fs
except (ImportError, ModuleNotFoundError) as e:
raise RuntimeError(
"pyarrow is a required dependency of Ray Train and Ray Tune. "
"Please install with: `pip install pyarrow`"
) from e
# isort: on
import fnmatch
import logging
import os
import shutil
from pathlib import Path
from typing import TYPE_CHECKING, Callable, List, Optional, Tuple, Type, Union
from ray.air._internal.filelock import TempFileLock
from ray.train.constants import _get_ray_train_session_dir
from ray.train.v2._internal.constants import (
CHECKPOINT_MANAGER_SNAPSHOT_FILENAME,
VALIDATE_STORAGE_MARKER_FILENAME,
)
from ray.train.v2._internal.util import date_str
from ray.util.annotations import DeveloperAPI
if TYPE_CHECKING:
from ray.train import Checkpoint
logger = logging.getLogger(__name__)
class _ExcludingLocalFilesystem(LocalFileSystem):
"""LocalFileSystem wrapper to exclude files according to patterns.
Args:
root_path: Root path to strip when matching with the exclude pattern.
Ex: root_path="/tmp/a/b/c", exclude=["*a*"], will exclude
/tmp/a/b/c/_a_.txt but not ALL of /tmp/a/*.
exclude: List of patterns that are applied to files returned by
``self.find()``. If a file path matches this pattern, it will
be excluded.
**kwargs: Additional keyword arguments forwarded to
``pyarrow.fs.LocalFileSystem``.
"""
def __init__(self, root_path: Path, exclude: List[str], **kwargs):
super().__init__(**kwargs)
self._exclude = exclude
self._root_path = root_path
@property
def fsid(self):
return "_excluding_local"
def _should_exclude(self, path: str) -> bool:
"""Return True if `path` (relative to `root_path`) matches any of the
`self._exclude` patterns."""
path = Path(path)
relative_path = path.relative_to(self._root_path).as_posix()
match_candidates = [relative_path]
if path.is_dir():
# Everything is in posix path format ('/')
match_candidates.append(relative_path + "/")
for excl in self._exclude:
if any(fnmatch.fnmatch(candidate, excl) for candidate in match_candidates):
return True
return False
def find(self, path, maxdepth=None, withdirs=False, detail=False, **kwargs):
"""Call parent find() and exclude from result."""
paths = super().find(
path, maxdepth=maxdepth, withdirs=withdirs, detail=detail, **kwargs
)
if detail:
return {
path: out
for path, out in paths.items()
if not self._should_exclude(path)
}
else:
return [path for path in paths if not self._should_exclude(path)]
def _pyarrow_fs_copy_files(
source, destination, source_filesystem=None, destination_filesystem=None, **kwargs
):
if isinstance(destination_filesystem, pyarrow.fs.S3FileSystem):
# Workaround multi-threading issue with pyarrow. Note that use_threads=True
# is safe for download, just not for uploads, see:
# https://github.com/apache/arrow/issues/32372
kwargs.setdefault("use_threads", False)
# Use a large chunk size to speed up large checkpoint transfers.
kwargs.setdefault("chunk_size", 64 * 1024 * 1024)
return pyarrow.fs.copy_files(
source,
destination,
source_filesystem=source_filesystem,
destination_filesystem=destination_filesystem,
**kwargs,
)
# TODO(justinvyu): Add unit tests for all these utils.
def delete_fs_path(fs: pyarrow.fs.FileSystem, fs_path: str):
"""Deletes (fs, fs_path) or raises FileNotFoundError if it doesn't exist."""
is_dir = _is_directory(fs, fs_path)
try:
if is_dir:
fs.delete_dir(fs_path)
else:
fs.delete_file(fs_path)
except Exception:
logger.exception(f"Caught exception when deleting path at ({fs}, {fs_path}):")
def _download_from_fs_path(
fs: pyarrow.fs.FileSystem,
fs_path: str,
local_path: str,
filelock: bool = True,
):
"""Downloads a directory or file from (fs, fs_path) to a local path.
If fs_path points to a directory:
- The full directory contents are downloaded directly into `local_path`,
rather than to a subdirectory of `local_path`.
If fs_path points to a file:
- The file is downloaded to `local_path`, which is expected to be a file path.
If the download fails, the `local_path` contents are
cleaned up before raising, if the directory did not previously exist.
NOTE: This method creates `local_path`'s parent directories if they do not
already exist. If the download fails, this does NOT clean up all the parent
directories that were created.
Args:
fs: The filesystem to download from.
fs_path: The filesystem path (either a directory or a file) to download.
local_path: The local path to download to.
filelock: Whether to require a file lock before downloading, useful for
multiple downloads to the same directory that may be happening in parallel.
Raises:
FileNotFoundError: if (fs, fs_path) doesn't exist.
"""
_local_path = Path(local_path).resolve()
exists_before = _local_path.exists()
if _is_directory(fs=fs, fs_path=fs_path):
_local_path.mkdir(parents=True, exist_ok=True)
else:
_local_path.parent.mkdir(parents=True, exist_ok=True)
try:
if filelock:
with TempFileLock(f"{os.path.normpath(local_path)}.lock"):
_pyarrow_fs_copy_files(fs_path, local_path, source_filesystem=fs)
else:
_pyarrow_fs_copy_files(fs_path, local_path, source_filesystem=fs)
except Exception as e:
# Clean up the directory if downloading was unsuccessful
if not exists_before:
shutil.rmtree(local_path, ignore_errors=True)
raise e
def _upload_to_fs_path(
local_path: str,
fs: pyarrow.fs.FileSystem,
fs_path: str,
exclude: Optional[List[str]] = None,
) -> None:
"""Uploads a local directory or file to (fs, fs_path).
NOTE: This will create all necessary parent directories at the destination.
Args:
local_path: The local path to upload.
fs: The filesystem to upload to.
fs_path: The filesystem path where the dir/file will be uploaded to.
exclude: A list of filename matches to exclude from upload. This includes
all files under subdirectories as well.
This pattern will match with the relative paths of all files under
`local_path`.
Ex: ["*.png"] to exclude all .png images.
"""
if not exclude:
# TODO(justinvyu): uploading a single file doesn't work
# (since we always create a directory at fs_path)
_create_directory(fs=fs, fs_path=fs_path)
_pyarrow_fs_copy_files(local_path, fs_path, destination_filesystem=fs)
return
_upload_to_uri_with_exclude_fsspec(
local_path=local_path, fs=fs, fs_path=fs_path, exclude=exclude
)
def _upload_to_uri_with_exclude_fsspec(
local_path: str, fs: "pyarrow.fs", fs_path: str, exclude: Optional[List[str]]
) -> None:
local_fs = _ExcludingLocalFilesystem(root_path=local_path, exclude=exclude)
handler = pyarrow.fs.FSSpecHandler(local_fs)
source_fs = pyarrow.fs.PyFileSystem(handler)
_create_directory(fs=fs, fs_path=fs_path)
_pyarrow_fs_copy_files(
local_path, fs_path, source_filesystem=source_fs, destination_filesystem=fs
)
def _list_at_fs_path(
fs: pyarrow.fs.FileSystem,
fs_path: str,
file_filter: Callable[[pyarrow.fs.FileInfo], bool] = lambda x: True,
) -> List[str]:
"""Returns the list of filenames at (fs, fs_path), similar to os.listdir.
If the path doesn't exist, returns an empty list.
"""
selector = pyarrow.fs.FileSelector(fs_path, allow_not_found=True, recursive=False)
return [
os.path.relpath(file_info.path.lstrip("/"), start=fs_path.lstrip("/"))
for file_info in fs.get_file_info(selector)
if file_filter(file_info)
]
def _exists_at_fs_path(fs: pyarrow.fs.FileSystem, fs_path: str) -> bool:
"""Returns True if (fs, fs_path) exists."""
valid = fs.get_file_info(fs_path)
return valid.type != pyarrow.fs.FileType.NotFound
def _is_directory(fs: pyarrow.fs.FileSystem, fs_path: str) -> bool:
"""Checks if (fs, fs_path) is a directory or a file.
Args:
fs: The filesystem to query.
fs_path: The path on the filesystem.
Returns:
``True`` if the path is a directory, ``False`` if it is a file.
Raises:
FileNotFoundError: if (fs, fs_path) doesn't exist.
"""
file_info = fs.get_file_info(fs_path)
if file_info.type == pyarrow.fs.FileType.NotFound:
raise FileNotFoundError(f"Path not found: ({fs}, {fs_path})")
return not file_info.is_file
def _create_directory(fs: pyarrow.fs.FileSystem, fs_path: str) -> None:
"""Create directory at (fs, fs_path).
Some external filesystems require directories to already exist, or at least
the `netloc` to be created (e.g. PyArrows ``mock://`` filesystem).
Generally this should be done before and outside of Ray applications. This
utility is thus primarily used in testing, e.g. of ``mock://` URIs.
"""
try:
fs.create_dir(fs_path)
except Exception:
logger.exception(
f"Caught exception when creating directory at ({fs}, {fs_path}):"
)
def get_fs_and_path(
storage_path: Union[str, os.PathLike],
storage_filesystem: Optional[pyarrow.fs.FileSystem] = None,
) -> Tuple[pyarrow.fs.FileSystem, str]:
"""Returns the fs and path from a storage path and an optional custom fs.
Args:
storage_path: A storage path or URI. (ex: s3://bucket/path or /tmp/ray_results)
storage_filesystem: A custom filesystem to use. If not provided,
this will be auto-resolved by pyarrow. If provided, the storage_path
is assumed to be prefix-stripped already, and must be a valid path
on the filesystem.
Returns:
A ``(filesystem, path)`` tuple.
"""
storage_path = str(storage_path)
if storage_filesystem:
return storage_filesystem, storage_path
return pyarrow.fs.FileSystem.from_uri(storage_path)
@DeveloperAPI
class StorageContext:
"""Shared context that holds the source of truth for all paths and
storage utilities, passed along from the driver to workers.
This object defines a few types of paths:
1. *_fs_path: A path on the `storage_filesystem`. This is a regular path
which has been prefix-stripped by pyarrow.fs.FileSystem.from_uri and
can be joined with `Path(...).as_posix()`.
2. *_driver_staging_path: The temporary staging directory on the local filesystem
where driver artifacts are saved to before persisting them to storage.
3. trial_working_directory: The local filesystem path that the remote
actors' working directories are moved to by default.
This is separated from the driver staging path so that driver syncing
does not implicitly upload the trial working directory, for trials on the
driver node.
Example with storage_path="mock:///bucket/path?param=1":
>>> import ray
>>> from ray.train._internal.storage import StorageContext
>>> import os
>>> _ = ray.init()
>>> storage = StorageContext(
... storage_path="mock://netloc/bucket/path?param=1",
... experiment_dir_name="exp_name",
... )
>>> storage.storage_filesystem # Auto-resolved # doctest: +ELLIPSIS
<pyarrow._fs._MockFileSystem object...
>>> storage.experiment_fs_path
'bucket/path/exp_name'
>>> storage.experiment_driver_staging_path # doctest: +ELLIPSIS
'/tmp/ray/session_.../artifacts/.../exp_name/driver_artifacts'
>>> storage.trial_dir_name = "trial_dir"
>>> storage.trial_fs_path
'bucket/path/exp_name/trial_dir'
>>> storage.trial_driver_staging_path # doctest: +ELLIPSIS
'/tmp/ray/session_.../artifacts/.../exp_name/driver_artifacts/trial_dir'
>>> storage.trial_working_directory # doctest: +ELLIPSIS
'/tmp/ray/session_.../artifacts/.../exp_name/working_dirs/trial_dir'
>>> ray.shutdown()
Example with storage_path="/tmp/ray_results":
>>> from ray.train._internal.storage import StorageContext
>>> storage = StorageContext(
... storage_path="/tmp/ray_results",
... experiment_dir_name="exp_name",
... )
>>> storage.storage_fs_path
'/tmp/ray_results'
>>> storage.experiment_fs_path
'/tmp/ray_results/exp_name'
>>> storage.storage_filesystem # Auto-resolved # doctest: +ELLIPSIS
<pyarrow._fs.LocalFileSystem object...
Internal Usage Examples:
- To copy files to the trial directory on the storage filesystem:
pyarrow.fs.copy_files(
local_dir,
Path(storage.trial_fs_path, "subdir").as_posix(),
destination_filesystem=storage.filesystem
)
.. warning::
This is an experimental developer API and is subject to change
without notice between versions.
"""
def __init__(
self,
storage_path: Union[str, os.PathLike],
experiment_dir_name: str,
storage_filesystem: Optional[pyarrow.fs.FileSystem] = None,
read_only: bool = False,
):
self.custom_fs_provided = storage_filesystem is not None
# Invariant: (`storage_filesystem`, `storage_path`) is the location where
# *all* results can be accessed.
self.experiment_dir_name = experiment_dir_name
self.storage_filesystem, self.storage_fs_path = get_fs_and_path(
storage_path, storage_filesystem
)
self.storage_fs_path = Path(self.storage_fs_path).as_posix()
self.read_only = read_only
if not self.read_only:
self._create_validation_file()
self._check_validation_file()
def __str__(self):
return (
"StorageContext<\n"
f" storage_filesystem='{self.storage_filesystem.type_name}',\n"
f" storage_fs_path='{self.storage_fs_path}',\n"
f" experiment_dir_name='{self.experiment_dir_name}',\n"
">"
)
def _create_validation_file(self):
"""On the creation of a storage context, create a validation file at the
storage path to verify that the storage path can be written to.
This validation file is also used to check whether the storage path is
accessible by all nodes in the cluster."""
valid_file = Path(
self.experiment_fs_path, VALIDATE_STORAGE_MARKER_FILENAME
).as_posix()
self.storage_filesystem.create_dir(self.experiment_fs_path)
with self.storage_filesystem.open_output_stream(valid_file):
pass
def _check_validation_file(self):
"""Checks that the validation file exists at the storage path."""
valid_file = Path(
self.experiment_fs_path, VALIDATE_STORAGE_MARKER_FILENAME
).as_posix()
if not _exists_at_fs_path(fs=self.storage_filesystem, fs_path=valid_file):
raise RuntimeError(
f"Unable to set up cluster storage with the following settings:\n{self}"
"\nCheck that all nodes in the cluster have read/write access "
"to the configured storage path. `RunConfig(storage_path)` should be "
"set to a cloud storage URI or a shared filesystem path accessible "
"by all nodes in your cluster ('s3://bucket' or '/mnt/nfs'). "
"A local path on the head node is not accessible by worker nodes. "
"See: https://docs.ray.io/en/latest/train/user-guides/persistent-storage.html" # noqa: E501
)
def persist_current_checkpoint(
self, checkpoint: "Checkpoint", checkpoint_dir_name: str
) -> "Checkpoint":
"""Persists a given checkpoint to the current checkpoint path on the filesystem.
This method copies the checkpoint files to the storage location.
It's up to the user to delete the original checkpoint files if desired.
For example, the original directory is typically a local temp directory.
Args:
checkpoint: The checkpoint to persist to
(fs, experiment_fs_path / checkpoint_dir_name).
checkpoint_dir_name: Name of the destination directory for the
checkpoint, relative to ``experiment_fs_path``.
Returns:
Checkpoint: A Checkpoint pointing to the persisted checkpoint location.
"""
if self.read_only:
raise RuntimeError(
"Cannot perform write/validation operations as the StorageContext is read-only."
)
# TODO(justinvyu): Fix this cyclical import.
from ray.train import Checkpoint
checkpoint_fs_path = self.build_checkpoint_path_from_name(checkpoint_dir_name)
logger.debug(
"Copying checkpoint files to storage path:\n"
"({source_fs}, {source}) -> ({dest_fs}, {destination})".format(
source=checkpoint.path,
destination=checkpoint_fs_path,
source_fs=checkpoint.filesystem,
dest_fs=self.storage_filesystem,
)
)
# Raise an error if the storage path is not accessible when
# attempting to upload a checkpoint from a remote worker.
# Ex: If storage_path is a local path, then a validation marker
# will only exist on the head node but not the worker nodes.
self._check_validation_file()
self.storage_filesystem.create_dir(checkpoint_fs_path)
_pyarrow_fs_copy_files(
source=checkpoint.path,
destination=checkpoint_fs_path,
source_filesystem=checkpoint.filesystem,
destination_filesystem=self.storage_filesystem,
)
persisted_checkpoint = Checkpoint(
filesystem=self.storage_filesystem,
path=checkpoint_fs_path,
)
logger.info(f"Checkpoint successfully created at: {persisted_checkpoint}")
return persisted_checkpoint
@property
def experiment_fs_path(self) -> str:
"""The path on the `storage_filesystem` to the experiment directory.
NOTE: This does not have a URI prefix anymore, since it has been stripped
by pyarrow.fs.FileSystem.from_uri already. The URI scheme information is
kept in `storage_filesystem` instead.
"""
return Path(self.storage_fs_path, self.experiment_dir_name).as_posix()
@property
def local_working_directory(self) -> str:
"""Every ray train worker will set this directory as its working directory."""
if self.experiment_dir_name is None:
raise RuntimeError(
"Cannot access `local_working_directory` without "
"setting `experiment_dir_name`"
)
return Path(_get_ray_train_session_dir(), self.experiment_dir_name).as_posix()
@property
def checkpoint_manager_snapshot_path(self) -> str:
"""The path to the checkpoint manager snapshot file."""
return Path(
self.experiment_fs_path, CHECKPOINT_MANAGER_SNAPSHOT_FILENAME
).as_posix()
@staticmethod
def get_experiment_dir_name(run_obj: Union[str, Callable, Type]) -> str:
from ray.tune.experiment import Experiment
run_identifier = Experiment.get_trainable_name(run_obj)
if bool(int(os.environ.get("TUNE_DISABLE_DATED_SUBDIR", 0))):
dir_name = run_identifier
else:
dir_name = "{}_{}".format(run_identifier, date_str())
return dir_name
@staticmethod
def make_default_checkpoint_dir_name():
"""Get the name of the checkpoint directory by timestamp."""
return f"checkpoint_{date_str(include_ms=True)}"
def extract_checkpoint_dir_name_from_path(self, checkpoint_path: str) -> str:
"""Get the checkpoint name from the checkpoint path.
The parent directory of the checkpoint path should be the experiment directory.
"""
# TODO: Use Pathlib to extract the name when supports at least Python 3.9
experiment_fs_path = self.experiment_fs_path + "/"
if not checkpoint_path.startswith(experiment_fs_path):
raise ValueError(
f"Checkpoint path {checkpoint_path} is not under the experiment "
f"directory {self.experiment_fs_path}."
)
return checkpoint_path[len(experiment_fs_path) :]
def build_checkpoint_path_from_name(self, checkpoint_name: str) -> str:
"""Get the checkpoint path from the checkpoint name.
The parent directory of the checkpoint path should be the experiment directory.
"""
return Path(self.experiment_fs_path, checkpoint_name).as_posix()
@@ -0,0 +1,297 @@
import logging
import threading
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Union
from ray.train.v2._internal.data_integration.interfaces import DatasetShardMetadata
from ray.train.v2._internal.execution import collective_impl
from ray.train.v2._internal.execution.context import (
get_train_context as get_internal_train_context,
)
from ray.train.v2.api.context import (
DistributedTrainContext,
LocalTrainContext,
TrainContext as ExternalTrainContext,
)
from ray.train.v2.api.report_config import (
CheckpointConsistencyMode,
CheckpointUploadMode,
)
from ray.train.v2.api.validation_config import ValidationTaskConfig
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from ray.data import DataIterator
from ray.train import Checkpoint
from ray.train.v2.api.reported_checkpoint import ReportedCheckpoint
class TrainFnUtils(ABC):
"""Utility class providing an abstraction layer between user-facing APIs
and :class:`~ray.train.v2.api.context.TrainContext`.
It should be set before the users' training function is called.
This class can be patched if new user APIs behaviors is wanted.
"""
@abstractmethod
def report(
self,
metrics: Dict[str, Any],
checkpoint: Optional["Checkpoint"] = None,
checkpoint_dir_name: Optional[str] = None,
checkpoint_upload_mode: CheckpointUploadMode = CheckpointUploadMode.SYNC,
delete_local_checkpoint_after_upload: Optional[bool] = None,
checkpoint_upload_fn: Optional[
Callable[["Checkpoint", str], "Checkpoint"]
] = None,
validation: Union[bool, ValidationTaskConfig] = False,
) -> None:
"""Upload checkpoint to remote storage and put a training result on the result queue.
Args:
metrics: The metrics to report.
checkpoint: The checkpoint to report.
checkpoint_dir_name: The name of the checkpoint dir
in this iteration. Note: If not set, the checkpoint will
be stored in the default storage path. If set, make sure
this value is unique for each iteration.
checkpoint_upload_mode: The manner in which we want to upload the checkpoint.
Defaults to uploading the checkpoint synchronously.
This works when no checkpoint is provided but is not useful in that case.
delete_local_checkpoint_after_upload: Whether to delete the checkpoint after it is uploaded.
checkpoint_upload_fn: A user defined function that will be called with the
checkpoint to upload it. If not provided, defaults to using the `pyarrow.fs.copy_files`
utility for copying to the destination `storage_path`.
validation: [Alpha] If True, triggers validation with default kwargs from validation_config.
If a ValidationTaskConfig, validation is run using fn_kwargs merged with validation_config
defaults, with fn_kwargs taking precedence on conflicts. If False, no validation.
"""
pass
@abstractmethod
def get_checkpoint(self) -> Optional["Checkpoint"]:
"""Get the latest checkpoint to resume training from.
Returns:
The latest checkpoint if available, None otherwise.
"""
pass
@abstractmethod
def get_all_reported_checkpoints(
self,
consistency_mode: CheckpointConsistencyMode = CheckpointConsistencyMode.VALIDATED,
timeout_s: Optional[float] = None,
) -> List["ReportedCheckpoint"]:
"""Get all the checkpoints reported by the workers.
Args:
consistency_mode: Read semantics for checkpoint retrieval. Defaults to VALIDATED.
timeout_s: Timeout in seconds for reading checkpoints and validation data.
Defaults to ``None`` to not time out.
Returns:
A list of ReportedCheckpoint objects that represent the checkpoints and
corresponding metrics reported by the workers.
"""
pass
@abstractmethod
def get_dataset_shard(self, dataset_info: DatasetShardMetadata) -> "DataIterator":
"""Get the dataset shard for this training process.
Args:
dataset_info: The metadata of the dataset to get the shard for.
Returns:
The DataIterator shard for this worker.
"""
pass
@abstractmethod
def get_context(self) -> ExternalTrainContext:
"""Get the TrainContext for this training process.
The specific type of TrainContext returned depends on the implementation of TrainFnUtils.
Returns:
The train context for this training process.
"""
pass
@abstractmethod
def is_distributed(self) -> bool:
pass
@abstractmethod
def barrier(self) -> None:
"""Create a barrier across all workers.
All workers must call this method before the training function can continue.
This method is used by the public API function :func:`ray.train.collective.barrier`.
Users should typically call ``ray.train.collective.barrier()`` instead of calling this method directly.
"""
pass
@abstractmethod
def broadcast_from_rank_zero(self, data: Any) -> Any:
"""Broadcast data from the rank 0 worker to all other workers.
This method is used by the public API function :func:`ray.train.collective.broadcast_from_rank_zero`.
Users should typically call ``ray.train.collective.broadcast_from_rank_zero()`` instead of calling this method directly.
"""
pass
class DistributedTrainFnUtils(TrainFnUtils):
def report(
self,
metrics: Dict[str, Any],
checkpoint: Optional["Checkpoint"] = None,
checkpoint_dir_name: Optional[str] = None,
checkpoint_upload_mode: CheckpointUploadMode = CheckpointUploadMode.SYNC,
delete_local_checkpoint_after_upload: Optional[bool] = None,
checkpoint_upload_fn: Optional[
Callable[["Checkpoint", str], "Checkpoint"]
] = None,
validation: Union[bool, ValidationTaskConfig] = False,
) -> None:
return get_internal_train_context().report(
metrics,
checkpoint,
checkpoint_dir_name,
checkpoint_upload_mode,
delete_local_checkpoint_after_upload,
checkpoint_upload_fn,
validation,
)
def get_checkpoint(self):
return get_internal_train_context().get_checkpoint()
def get_dataset_shard(self, dataset_info: DatasetShardMetadata) -> "DataIterator":
return get_internal_train_context().get_dataset_shard(dataset_info)
def get_context(self) -> DistributedTrainContext:
return DistributedTrainContext()
def is_distributed(self) -> bool:
return True
def barrier(self) -> None:
return collective_impl.barrier()
def broadcast_from_rank_zero(self, data: Any) -> Any:
return collective_impl.broadcast_from_rank_zero(data)
def get_all_reported_checkpoints(
self,
consistency_mode: CheckpointConsistencyMode = CheckpointConsistencyMode.VALIDATED,
timeout_s: Optional[float] = None,
) -> List["ReportedCheckpoint"]:
return get_internal_train_context().get_all_reported_checkpoints(
consistency_mode=consistency_mode, timeout_s=timeout_s
)
class LocalTrainFnUtils(TrainFnUtils):
def __init__(
self,
experiment_name: str,
dataset_shards: Optional[Dict[str, "DataIterator"]] = None,
world_size: int = 1,
world_rank: int = 0,
local_rank: int = 0,
local_world_size: int = 1,
node_rank: int = 0,
):
self._context = LocalTrainContext(
experiment_name=experiment_name,
world_size=world_size,
world_rank=world_rank,
local_rank=local_rank,
local_world_size=local_world_size,
node_rank=node_rank,
)
self._dataset_shards = dataset_shards
self._last_metrics = None
self._last_checkpoint = None
def report(
self,
metrics: Dict[str, Any],
checkpoint: Optional["Checkpoint"] = None,
checkpoint_dir_name: Optional[str] = None,
checkpoint_upload_mode: CheckpointUploadMode = CheckpointUploadMode.SYNC,
delete_local_checkpoint_after_upload: Optional[bool] = None,
checkpoint_upload_fn: Optional[
Callable[["Checkpoint", str], "Checkpoint"]
] = None,
validation: Union[bool, ValidationTaskConfig] = False,
) -> None:
self._last_metrics = metrics
self._last_checkpoint = checkpoint
logger.info(f"Reported metrics: {metrics}")
def get_checkpoint(self) -> Optional["Checkpoint"]:
return self._last_checkpoint
def get_dataset_shard(self, dataset_info: DatasetShardMetadata) -> "DataIterator":
dataset_name = dataset_info.dataset_name
assert (
self._dataset_shards is not None and dataset_name in self._dataset_shards
), f"Dataset shard {dataset_name} not found."
return self._dataset_shards[dataset_name]
def get_context(self) -> LocalTrainContext:
return self._context
def is_distributed(self) -> bool:
return False
def barrier(self) -> None:
pass
def broadcast_from_rank_zero(self, data: Any) -> Any:
return data
def _get_last_metrics(self) -> Optional[Dict[str, Any]]:
"""Return the last metrics reported by the training function.
This function should only be called by LocalController
"""
return self._last_metrics
def get_all_reported_checkpoints(
self,
consistency_mode: CheckpointConsistencyMode = CheckpointConsistencyMode.VALIDATED,
timeout_s: Optional[float] = None,
) -> List["ReportedCheckpoint"]:
return []
_train_fn_utils: Optional[TrainFnUtils] = None
_train_fn_utils_lock = threading.Lock()
def get_train_fn_utils() -> TrainFnUtils:
"""Return the Ray Train function utilities.
Returns:
The TrainFnUtils instance for the current worker.
Raises:
RuntimeError: If the Ray Train function utilities are not initialized.
"""
global _train_fn_utils
with _train_fn_utils_lock:
if _train_fn_utils is None:
raise RuntimeError("Ray Train function utilities not initialized.")
return _train_fn_utils
def set_train_fn_utils(train_fn_utils) -> None:
global _train_fn_utils
with _train_fn_utils_lock:
_train_fn_utils = train_fn_utils
@@ -0,0 +1,22 @@
from typing import TYPE_CHECKING, Any, Dict, Optional, Union
if TYPE_CHECKING:
from ray.train import Checkpoint
from ray.train.v2.api.validation_config import ValidationTaskConfig
class _TrainingReport:
"""Checkpoint and metrics reported by user, as well as optional validation configuration."""
def __init__(
self,
checkpoint: Optional["Checkpoint"],
metrics: Dict[str, Any],
validation: Union[bool, "ValidationTaskConfig"],
):
self.checkpoint = checkpoint
self.metrics = metrics
self.validation = validation
def __repr__(self) -> str:
return f"TrainingReport(checkpoint={self.checkpoint}, metrics={self.metrics}, validation={self.validation})"
@@ -0,0 +1,31 @@
from .execution_group import ExecutionGroup, ReplicaGroup
from .placement_group_handle import (
DefaultPlacementGroupHandle,
PlacementGroupHandle,
SlicePlacementGroupHandle,
)
from .poll import WorkerGroupPollStatus, WorkerStatus
from .state import (
WorkerGroupContext,
WorkerGroupState,
WorkerGroupStateBuilder,
)
from .worker import ActorMetadata, RayTrainWorker, Worker
from .worker_group import WorkerGroup
__all__ = [
"ActorMetadata",
"DefaultPlacementGroupHandle",
"ExecutionGroup",
"PlacementGroupHandle",
"RayTrainWorker",
"ReplicaGroup",
"SlicePlacementGroupHandle",
"Worker",
"WorkerGroup",
"WorkerGroupContext",
"WorkerGroupPollStatus",
"WorkerGroupState",
"WorkerGroupStateBuilder",
"WorkerStatus",
]
@@ -0,0 +1,117 @@
import abc
from typing import TYPE_CHECKING, Callable, List, Optional, TypeVar
import ray
from ray.train._internal.base_worker_group import BaseWorkerGroup
from ray.train.v2._internal.execution.worker_group.state import _shutdown_workers
from ray.train.v2._internal.execution.worker_group.worker import Worker
from ray.types import ObjectRef
if TYPE_CHECKING:
from ray.train.v2._internal.execution.callback import ReplicaGroupCallback
from ray.train.v2._internal.execution.worker_group.state import WorkerGroupContext
T = TypeVar("T")
class ExecutionGroup(BaseWorkerGroup):
"""Base class for groups that can execute functions on workers.
Provides concrete implementations of the 4 execution methods and __len__
based on two abstract primitives: _assert_active() and get_workers().
"""
@abc.abstractmethod
def _assert_active(self):
"""Assert that this execution group is active."""
pass
@abc.abstractmethod
def get_workers(self) -> List[Worker]:
"""Return the list of workers in this group."""
pass
def execute_async(self, fn: Callable, *fn_args, **fn_kwargs) -> List[ObjectRef]:
self._assert_active()
workers = self.get_workers()
return [worker.execute_async(fn, *fn_args, **fn_kwargs) for worker in workers]
def execute(self, fn: Callable[..., T], *fn_args, **fn_kwargs) -> List[T]:
return ray.get(self.execute_async(fn, *fn_args, **fn_kwargs))
def execute_single_async(
self, rank: int, fn: Callable[..., T], *fn_args, **fn_kwargs
) -> ObjectRef:
self._assert_active()
workers = self.get_workers()
if rank >= len(workers):
raise ValueError(
f"The provided {rank=} is " f"not valid for {len(workers)} workers."
)
return workers[rank].execute_async(fn, *fn_args, **fn_kwargs)
def execute_single(
self, rank: int, fn: Callable[..., T], *fn_args, **fn_kwargs
) -> T:
return ray.get(self.execute_single_async(rank, fn, *fn_args, **fn_kwargs))
def __len__(self) -> int:
self._assert_active()
return len(self.get_workers())
class ReplicaGroup(ExecutionGroup):
"""A group representing a subset of workers from a WorkerGroup.
Used to pass a replica's workers to backend methods (on_start, etc.)
as if they were a standalone worker group.
"""
def __init__(
self,
workers: List[Worker],
resources_per_worker: dict,
callbacks: Optional[List["ReplicaGroupCallback"]] = None,
):
self._workers = workers
self._resources_per_worker = resources_per_worker
self._callbacks = callbacks or []
# An inactive ReplicaGroup still needs to keep track of workers
# so we can replace them later.
self._active = True
def _assert_active(self):
if not self.is_active():
raise ValueError("ReplicaGroup has been shut down.")
def is_active(self) -> bool:
return self._active
def get_workers(self) -> List[Worker]:
return self._workers
def get_resources_per_worker(self) -> dict:
return self._resources_per_worker
def shutdown(self):
"""Shutdown all workers in this replica group and clear state."""
if self.is_active():
for cb in self._callbacks:
cb.before_replica_group_shutdown(self)
_shutdown_workers(self._workers)
self._active = False
def start_training(self, worker_group_context: "WorkerGroupContext"):
"""Start training on all workers in this replica group."""
for cb in self._callbacks:
cb.after_replica_group_start(self)
ray.get(
[
worker.actor.run_train_fn.remote(worker_group_context.train_fn_ref)
for worker in self._workers
]
)
@@ -0,0 +1,106 @@
import logging
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Union
from ray.types import ObjectRef
from ray.util.placement_group import PlacementGroup, remove_placement_group
if TYPE_CHECKING:
from ray.util.tpu import SlicePlacementGroup
logger = logging.getLogger(__name__)
class PlacementGroupHandle(ABC):
"""Unified interface for placement groups in Ray Train.
This abstract base class provides a common interface for both standard
PlacementGroup and SlicePlacementGroup, allowing WorkerGroup to handle
them uniformly without conditional logic.
"""
@property
@abstractmethod
def placement_group(self) -> PlacementGroup:
"""The underlying PlacementGroup for worker scheduling."""
...
@abstractmethod
def ready(self) -> ObjectRef:
"""Returns an ObjectRef to check if the placement group is ready.
Compatible with ray.get() and ray.wait().
"""
...
@abstractmethod
def wait(self, timeout_seconds: Union[float, int] = 30) -> bool:
"""Wait for the placement group to be ready within the specified time.
Args:
timeout_seconds: Timeout in seconds.
Returns:
True if the placement group is created. False otherwise.
"""
...
@abstractmethod
def shutdown(self) -> None:
"""Release all resources associated with this placement group.
After calling this method, the placement group should no longer be used.
"""
...
class DefaultPlacementGroupHandle(PlacementGroupHandle):
"""Wrapper for standard PlacementGroup."""
def __init__(self, pg: PlacementGroup):
self._pg = pg
@property
def placement_group(self) -> PlacementGroup:
return self._pg
def ready(self) -> ObjectRef:
return self._pg.ready()
def wait(self, timeout_seconds: Union[float, int] = 30) -> bool:
try:
return self._pg.wait(timeout_seconds)
except Exception:
logger.warning(
"Placement group wait failed; treating as not ready.",
exc_info=True,
)
return False
def shutdown(self) -> None:
remove_placement_group(self._pg)
class SlicePlacementGroupHandle(PlacementGroupHandle):
"""Wrapper for SlicePlacementGroup that delegates to its underlying PlacementGroup."""
def __init__(self, spg: "SlicePlacementGroup"):
self._spg = spg
@property
def placement_group(self) -> PlacementGroup:
return self._spg.placement_group
def ready(self) -> ObjectRef:
return self._spg.placement_group.ready()
def wait(self, timeout_seconds: Union[float, int] = 30) -> bool:
try:
return self._spg.placement_group.wait(timeout_seconds)
except Exception:
logger.warning(
"Slice placement group wait failed; treating as not ready.",
exc_info=True,
)
return False
def shutdown(self) -> None:
self._spg.shutdown()
@@ -0,0 +1,152 @@
import re
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Any, Dict, Optional, Set
from ray._private.ray_logging import NUMBERS
from ray.train.v2._internal.exceptions import (
UserExceptionWithTraceback,
WorkerHealthCheckFailedError,
)
from ray.train.v2._internal.execution.preemption import PreemptionInfo
from ray.train.v2._internal.execution.training_report import _TrainingReport
from ray.train.v2.api.exceptions import WorkerGroupError
from ray.types import ObjectRef
ERR_CHAR_LIMIT = 1000
def _normalize_error_string(error_str: str) -> str:
# Replace numbers with <NUM> based on NUMBERS regex
normalized = re.sub(NUMBERS, "<NUM>", error_str)
return normalized
def _truncate_error_string(error_str: str) -> str:
"""
Truncates error strings to include the first ERR_CHAR_LIMIT // 2
characters and the last ERR_CHAR_LIMIT // 2 characters.
"""
if len(error_str) >= ERR_CHAR_LIMIT:
return (
error_str[: ERR_CHAR_LIMIT // 2]
+ "...\n... (Output truncated. See individual worker logs for full details) ...\n"
+ error_str[len(error_str) - ERR_CHAR_LIMIT // 2 :]
)
return error_str
@dataclass
class WorkerStatus:
running: bool
error: Optional[Exception] = None
training_report: Optional[_TrainingReport] = None
return_value: Any = field(default=None)
preemption_info: Optional[PreemptionInfo] = None
@dataclass(frozen=True)
class WorkerGroupPollStatus:
worker_statuses: Dict[int, WorkerStatus]
worker_rank_to_replica_group_rank: Optional[Dict[int, int]] = None
@property
def all_replica_group_indices(self) -> Set[int]:
"""Return the set of all replica group indices."""
if self.worker_rank_to_replica_group_rank is None:
return set()
return set(self.worker_rank_to_replica_group_rank.values())
@property
def failing_replica_group_indices(self) -> Set[int]:
"""Return the set of replica group indices that have failing workers."""
if self.worker_rank_to_replica_group_rank is None:
return set()
return {
self.worker_rank_to_replica_group_rank[rank]
for rank in self.errors
if rank in self.worker_rank_to_replica_group_rank
}
@property
def errors(self) -> Dict[int, Exception]:
errors = {}
for world_rank, status in self.worker_statuses.items():
if status.error is not None:
error = status.error
if isinstance(error, UserExceptionWithTraceback):
error = error._base_exc
errors[world_rank] = error
return errors
def get_worker_group_error(self) -> WorkerGroupError:
return WorkerGroupError(
error_message=self.get_error_string(),
worker_failures=self.errors,
)
@property
def finished(self) -> bool:
return self.worker_statuses and all(
not status.running for status in self.worker_statuses.values()
)
def get_error_string(self) -> str:
"""
Returns a string representation of worker group errors.
Groups similar errors (ignoring numbers) and shows original error examples.
"""
# Group errors by normalized strings (ignoring numbers)
normalized_error_to_ranks = defaultdict(list)
normalized_error_to_original = {}
show_full_error = set()
for world_rank, status in self.worker_statuses.items():
if status.error:
error_str = str(status.error)
normalized_error = _normalize_error_string(error_str)
normalized_error_to_ranks[normalized_error].append(str(world_rank))
# Store the first original error for this normalized group
if normalized_error not in normalized_error_to_original:
normalized_error_to_original[normalized_error] = error_str
# Fully show errors for non-graceful worker failures or running workers
if (
isinstance(status.error, WorkerHealthCheckFailedError)
or status.running
):
show_full_error.add(normalized_error)
errors = []
for normalized_error, ranks in normalized_error_to_ranks.items():
# Show the original error
orig_error = normalized_error_to_original[normalized_error]
# Convert rank list to comma-separated strings
ranks_str = ",".join(ranks)
if normalized_error in show_full_error:
errors.append(f"[Rank {ranks_str} Error Snippet]:\n{orig_error}")
else:
errors.append(
f"[Rank {ranks_str} Error Snippet]:\n{_truncate_error_string(orig_error)}"
)
error_str = "\n".join(errors)
return error_str
@dataclass(frozen=True)
class PollTask:
"""Represents a poll task for a worker.
Attributes:
start_time: The time when the poll task was started.
task: The ObjectRef representing the poll task.
"""
start_time: float
task: ObjectRef
@@ -0,0 +1,170 @@
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, Callable, Dict, List, Optional
import ray
from ray.actor import ActorHandle
from ray.train.v2._internal.execution.checkpoint.sync_actor import SynchronizationActor
from ray.train.v2._internal.execution.worker_group.worker import Worker
from ray.train.v2._internal.util import ObjectRefWrapper, time_monotonic
if TYPE_CHECKING:
from ray.train.v2._internal.execution.worker_group.placement_group_handle import (
PlacementGroupHandle,
)
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class WorkerGroupContext:
"""Context for a worker group.
This stores the context that is shared when starting a worker group.
Attributes:
run_attempt_id: The ID of the run attempt.
train_fn_ref: An object store reference to the training function to execute.
num_workers: The number of workers in the worker group.
resources_per_worker: The resources per worker.
placement_strategy: Strategy for placing workers.
label_selector: Optional label selectors to apply per-bundle for workers.
num_slices: The number of TPU slices (if using TPU). Defaults to 1.
"""
run_attempt_id: str
train_fn_ref: ObjectRefWrapper[Callable[[], None]]
num_workers: int
resources_per_worker: Dict[str, float]
placement_strategy: str = "PACK"
label_selector: Optional[List[Dict[str, str]]] = None
num_slices: int = 1
@dataclass(frozen=True)
class WorkerGroupState:
"""Ongoing state of an active worker group.
Attributes:
start_time: The time when the worker group was started.
workers: The workers in the worker group.
These should always be in sorted order by world rank.
placement_group_handle: The placement group handle for the worker group.
sync_actor: The synchronization actor for the worker group.
"""
start_time: float
placement_group_handle: "PlacementGroupHandle"
workers: List[Worker]
sync_actor: ActorHandle
@property
def num_workers(self) -> int:
return len(self.workers)
def replace_workers(
self, old_workers: List[Worker], new_workers: List[Worker]
) -> "WorkerGroupState":
"""Return a new WorkerGroupState with old_workers replaced by new_workers."""
current_workers = list(self.workers)
for old_w, new_w in zip(old_workers, new_workers):
idx = current_workers.index(old_w)
current_workers[idx] = new_w
return WorkerGroupState(
start_time=self.start_time,
placement_group_handle=self.placement_group_handle,
workers=current_workers,
sync_actor=self.sync_actor,
)
def shutdown(self):
_shutdown_workers(self.workers)
_shutdown_sync_actor(self.sync_actor)
self.placement_group_handle.shutdown()
class WorkerGroupStateBuilder:
"""Builder for WorkerGroupState.
Example usage:
```python
builder = WorkerGroupStateBuilder()
builder.with_placement_group_handle(placement_group_handle)
builder.with_workers(workers)
builder.with_sync_actor(sync_actor)
state = builder.build()
builder.shutdown(patience_s=10)
```
"""
def __init__(self):
self.placement_group_handle = None
self.workers = None
self.sync_actor = None
def with_placement_group_handle(
self, placement_group_handle: "PlacementGroupHandle"
) -> "WorkerGroupStateBuilder":
self.placement_group_handle = placement_group_handle
return self
def with_workers(self, workers: List[Worker]) -> "WorkerGroupStateBuilder":
self.workers = workers
return self
def with_sync_actor(
self, sync_actor: SynchronizationActor
) -> "WorkerGroupStateBuilder":
self.sync_actor = sync_actor
return self
def build(self) -> WorkerGroupState:
required_attrs = {
"placement_group_handle": self.placement_group_handle,
"workers": self.workers,
"sync_actor": self.sync_actor,
}
missing = [name for name, attr in required_attrs.items() if attr is None]
if missing:
raise ValueError(
f"Cannot build incomplete state. Missing: {', '.join(missing)}"
)
return WorkerGroupState(
start_time=time_monotonic(),
placement_group_handle=self.placement_group_handle,
workers=self.workers,
sync_actor=self.sync_actor,
)
def shutdown(self):
if self.workers:
_shutdown_workers(self.workers)
self.workers = None
if self.sync_actor:
_shutdown_sync_actor(self.sync_actor)
self.sync_actor = None
if self.placement_group_handle:
self.placement_group_handle.shutdown()
self.placement_group_handle = None
def _shutdown_workers(workers: List[Worker], patience_s: float = 5):
"""Shuts down workers after allowing a maximum of patience_s seconds for shutdown hooks to run."""
if patience_s < 0:
raise ValueError("Invalid patience_s: must be non-negative")
done_refs = [w.actor.shutdown.remote() for w in workers]
logger.debug(f"Shutting down {len(workers)} workers.")
ray.wait(done_refs, num_returns=len(done_refs), timeout=patience_s)
for worker in workers:
ray.kill(worker.actor)
def _shutdown_sync_actor(sync_actor: SynchronizationActor):
ray.kill(sync_actor)
@@ -0,0 +1,93 @@
import logging
import queue
import threading
from typing import Callable, Optional, TypeVar
from ray.train.v2._internal.exceptions import UserExceptionWithTraceback
from ray.train.v2._internal.util import (
construct_user_exception_with_traceback,
get_callable_name,
)
T = TypeVar("T")
logger = logging.getLogger(__name__)
class ThreadRunner:
"""Utility to run a user function as a thread and capture its return value
or exception.
"""
def __init__(self):
self._ret: Optional[T] = None
self._exc: Optional[UserExceptionWithTraceback] = None
self._thread: Optional[threading.Thread] = None
self._monitor_thread: Optional[threading.Thread] = None
self._lock = threading.Lock()
self._exc_queue: queue.SimpleQueue[Optional[Exception]] = queue.SimpleQueue()
def run(self, target: Callable[[], T]) -> None:
if self._thread is not None:
raise RuntimeError("Thread is already running.")
def _run_target():
try:
result = target()
with self._lock:
self._ret = result
self._exc_queue.put(None)
except BaseException as e:
# Exclude the first 3 frames from the traceback, which are
# the `ThreadRunner._run_target`, `construct_train_func`, and
# train_fn_with_final_checkpoint_flush calls.
self._exc_queue.put(
construct_user_exception_with_traceback(e, exclude_frames=3)
)
# Join the monitor thread. This ensures that a queued exception
# is processed before the target function is considered done.
self._monitor_thread.join()
self._monitor_thread = threading.Thread(
target=self._monitor_target,
daemon=True,
name=f"MonitoringThread({get_callable_name(target)})",
)
self._monitor_thread.start()
self._thread = threading.Thread(
target=_run_target,
daemon=True,
name=f"TrainingThread({get_callable_name(target)})",
)
self._thread.start()
def _monitor_target(self):
"""Monitor the exception queue and set the exception if an exception is found.
This should run as a daemon thread and exit when None is put into the exception queue.
"""
exc: Optional[UserExceptionWithTraceback] = self._exc_queue.get()
if exc is None:
return
with self._lock:
self._exc = exc
def is_running(self) -> bool:
"""Returns whether the target function is still running."""
return self._thread is not None and self._thread.is_alive()
def get_error(self) -> Optional[BaseException]:
with self._lock:
return self._exc
def get_return_value(self) -> Optional[T]:
with self._lock:
return self._ret
def get_exception_queue(self) -> queue.SimpleQueue:
"""Returns a queue that nested threads can add exceptions to."""
return self._exc_queue
@@ -0,0 +1,319 @@
import logging
import os
import queue
import socket
import sys
from dataclasses import dataclass
from functools import cached_property
from typing import TYPE_CHECKING, Callable, Dict, List, Optional, TypeVar, Union
import ray
import ray._private.ray_constants as ray_constants
from .thread_runner import ThreadRunner
from ray.actor import ActorHandle
from ray.train import Checkpoint
from ray.train.v2._internal.constants import (
DEFAULT_ENABLE_WORKER_LOGGING,
ENABLE_WORKER_STRUCTURED_LOGGING_ENV_VAR,
)
from ray.train.v2._internal.execution.callback import (
TrainContextCallback,
WorkerCallback,
)
from ray.train.v2._internal.execution.context import (
DistributedContext,
ExecutionContext,
TrainContext,
TrainRunContext,
get_train_context,
set_train_context,
)
from ray.train.v2._internal.execution.preemption import (
PreemptionContext,
PreemptionInfo,
)
from ray.train.v2._internal.execution.storage import StorageContext
from ray.train.v2._internal.execution.train_fn_utils import (
DistributedTrainFnUtils,
set_train_fn_utils,
)
from ray.train.v2._internal.execution.worker_group.poll import WorkerStatus
from ray.train.v2._internal.logging.logging import LoggingManager
from ray.train.v2._internal.logging.patch_print import patch_print_function
from ray.train.v2._internal.util import ObjectRefWrapper
from ray.types import ObjectRef
if TYPE_CHECKING:
from ray.train.v2._internal.data_integration.interfaces import DatasetShardProvider
T = TypeVar("T")
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ActorMetadata:
hostname: str
node_id: str
node_ip: str
pid: int
accelerator_ids: Dict[str, List[Union[int, str]]]
@property
def gpu_ids(self) -> List[Union[int, str]]:
return self.accelerator_ids.get("GPU", [])
@cached_property
def _repr(self) -> str:
indent = " "
repr_lines = [
"ActorMetadata(",
f"{indent}hostname={repr(self.hostname)},",
f"{indent}node_id={repr(self.node_id)},",
f"{indent}node_ip={repr(self.node_ip)},",
f"{indent}pid={repr(self.pid)},",
]
non_empty_accelerator_ids = {k: v for k, v in self.accelerator_ids.items() if v}
if non_empty_accelerator_ids:
repr_lines.append(f"{indent}accelerator_ids={non_empty_accelerator_ids},")
repr_lines.append(")")
return "\n".join(repr_lines)
def __repr__(self) -> str:
return self._repr
@dataclass
class Worker:
actor: ActorHandle
metadata: ActorMetadata
resources: Dict[str, float]
distributed_context: Optional[DistributedContext] = None
log_file_path: Optional[str] = None
placement_group_bundle_index: Optional[int] = None
@cached_property
def _repr(self) -> str:
indent = " "
metadata_repr = repr(self.metadata).replace("\n", f"\n{indent}")
context_repr = repr(self.distributed_context).replace("\n", f"\n{indent}")
repr_lines = [
"Worker(",
f"{indent}actor={repr(self.actor)},",
f"{indent}metadata={metadata_repr},",
f"{indent}distributed_context={context_repr},",
f"{indent}log_file_path={repr(self.log_file_path)},",
")",
]
return "\n".join(repr_lines)
def __repr__(self) -> str:
return self._repr
def execute_async(self, fn: Callable[..., T], *fn_args, **fn_kwargs) -> ObjectRef:
"""Execute ``func`` on worker.
Args:
fn: The function to execute on the worker.
*fn_args: Positional arguments to forward to ``fn``.
**fn_kwargs: Keyword arguments to forward to ``fn``.
Returns:
(ObjectRef) An ObjectRef representing the output of func.
"""
return self.actor.execute.options(name=f"execute.{fn.__name__}").remote(
fn, *fn_args, **fn_kwargs
)
class RayTrainWorker:
def __init__(self):
self._callbacks: List[WorkerCallback] = []
def execute(self, fn: Callable[..., T], *fn_args, **fn_kwargs) -> T:
return fn(*fn_args, **fn_kwargs)
def run_train_fn(self, train_fn_ref: ObjectRefWrapper[Callable[[], None]]):
"""Run the training function in a separate thread.
This function should return immediately, freeing up the main actor thread
to perform other tasks such as polling the status.
"""
try:
train_fn = train_fn_ref.get()
except Exception as e:
logger.error(f"Error deserializing the training function: {e}")
raise
def train_fn_with_final_checkpoint_flush():
result = train_fn()
get_train_context().checkpoint_upload_threadpool.shutdown()
if "torch" in sys.modules:
from ray.air._internal.torch_utils import contains_tensor
if contains_tensor(result):
raise ValueError(
"Returning objects containing Torch tensors from the "
"training function is not supported as it will throw an "
"exception on deserialization. You can either convert "
"the tensors to Python objects (ex: `.numpy()`, "
"`.item()`, etc.) or save tensors as part of the "
"checkpoint files instead."
)
return result
# Create and start the training thread.
logger.debug(
f"Rank {get_train_context().get_world_rank()}: Launching training function."
)
get_train_context().execution_context.training_thread_runner.run(
train_fn_with_final_checkpoint_flush
)
def get_metadata(self) -> ActorMetadata:
return ActorMetadata(
hostname=socket.gethostname(),
node_id=ray.get_runtime_context().get_node_id(),
node_ip=ray.util.get_node_ip_address(),
pid=os.getpid(),
accelerator_ids=ray.get_runtime_context().get_accelerator_ids(),
)
def mark_preempt(self, info: PreemptionInfo) -> None:
"""Store an incoming preemption signal for the UDF to read.
Called by the PreemptionWatcher on every worker when a preemption
affecting the worker group is detected.
"""
train_context = get_train_context()
rank = train_context.get_world_rank()
train_context.preemption_context.set(info)
logger.info(
"Rank %d received preemption signal "
"(this_worker_preempted=%s, preempted_ranks=%s, deadline_ms=%s).",
rank,
rank in info.preempted_ranks,
info.preempted_ranks,
info.deadline_ms,
)
def poll_status(self) -> WorkerStatus:
train_context = get_train_context()
execution_context = train_context.execution_context
# TODO: We can implement two phase commit here.
# Only mark the task done when the result has been processed by the controller.
try:
training_report = execution_context.result_queue.get_nowait()
execution_context.result_queue.task_done()
except queue.Empty:
training_report = None
error = execution_context.training_thread_runner.get_error()
# TODO: The running state should not be conflated with queue flushing.
# Running should only be true if the user code is still running.
# This relies on `worker_group_status.finished` returning False
# until all training results have been flushed.
running = execution_context.training_thread_runner.is_running() or bool(
training_report
)
return_value = (
execution_context.training_thread_runner.get_return_value()
if not running
else None
)
return WorkerStatus(
running=running,
error=error,
training_report=training_report,
return_value=return_value,
preemption_info=train_context.preemption_context.get(),
)
def clear_result_queue(self) -> bool:
"""Drain the result queue, discarding any pending training reports.
Returns:
True if the queue had at least one result, False if it was empty.
"""
execution_context = get_train_context().execution_context
had_result = False
while True:
try:
execution_context.result_queue.get_nowait()
execution_context.result_queue.task_done()
had_result = True
except queue.Empty:
break
return had_result
def shutdown(self):
"""Shutdown the worker.
This method is not doing the real shutdown, but it is used by the worker
group to signal the worker to stop running the training function.
Any shutdown worker callbacks can hook on this method to implement the
corresponding shutdown logic. Note that the shutdown logic needs to be
thread-safe if it is running in a separate thread.
"""
for callback in self._callbacks:
callback.before_worker_shutdown()
def init_train_context(
self,
train_run_context: TrainRunContext,
distributed_context: DistributedContext,
synchronization_actor: ActorHandle,
storage_context: StorageContext,
worker_callbacks: List[Union[WorkerCallback, TrainContextCallback]],
controller_actor: ActorHandle,
dataset_shard_provider: Optional["DatasetShardProvider"] = None,
checkpoint: Optional[Checkpoint] = None,
has_validation_fn: Optional[bool] = None,
current_report_index: int = 0,
):
self._callbacks = [c for c in worker_callbacks if isinstance(c, WorkerCallback)]
context_callbacks_to_propagate = [
c for c in worker_callbacks if isinstance(c, TrainContextCallback)
]
context = TrainContext(
train_run_context=train_run_context,
distributed_context=distributed_context,
execution_context=ExecutionContext(
synchronization_actor=synchronization_actor,
# Make the queue size 1 to avoid building up too
# many unprocessed results.
result_queue=queue.Queue(maxsize=1),
training_thread_runner=ThreadRunner(),
train_context_callbacks=context_callbacks_to_propagate,
),
storage_context=storage_context,
preemption_context=PreemptionContext(),
controller_actor=controller_actor,
checkpoint=checkpoint,
dataset_shard_provider=dataset_shard_provider,
has_validation_fn=has_validation_fn,
current_report_index=current_report_index,
)
# Configure the train and root logger for the worker processes.
if ray_constants.env_bool(
ENABLE_WORKER_STRUCTURED_LOGGING_ENV_VAR, DEFAULT_ENABLE_WORKER_LOGGING
):
LoggingManager.configure_worker_logger(context)
patch_print_function()
# Set the train context global variable for the worker.
set_train_context(context)
# user facing train fn utils
set_train_fn_utils(DistributedTrainFnUtils())
for callback in self._callbacks:
callback.after_init_train_context()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,3 @@
from .logging import LoggingManager
__all__ = ["LoggingManager"]
@@ -0,0 +1,340 @@
import logging.config
import os
from enum import Enum
from typing import Optional, Union
import ray
from ray._common.filters import CoreContextFilter
from ray._common.formatters import JSONFormatter
from ray._private.log import PlainRayHandler
from ray.train.v2._internal.execution.context import TrainContext, TrainRunContext
from ray.train.v2._internal.util import get_module_name
class TrainContextFilter(logging.Filter):
"""Add Ray Train metadata to the log records.
This filter is applied to Ray Train controller and worker processes.
"""
# Log keys for Ray Train controller and worker processes.
class LogKey(str, Enum):
RUN_NAME = "run_name"
COMPONENT = "component"
WORLD_RANK = "world_rank"
LOCAL_RANK = "local_rank"
NODE_RANK = "node_rank"
# Ray Train Component by process types
class TrainComponent(str, Enum):
CONTROLLER = "controller"
WORKER = "worker"
def __init__(self, context: Union[TrainRunContext, TrainContext]):
self._is_worker: bool = isinstance(context, TrainContext)
if self._is_worker:
self._run_name: str = context.train_run_context.get_run_config().name
self._world_rank: int = context.get_world_rank()
self._local_rank: int = context.get_local_rank()
self._node_rank: int = context.get_node_rank()
self._component: str = TrainContextFilter.TrainComponent.WORKER
else:
self._run_name: str = context.get_run_config().name
self._component: str = TrainContextFilter.TrainComponent.CONTROLLER
def controller_filter(self, record):
# Add the run_id and component to Ray Train controller processes.
setattr(record, TrainContextFilter.LogKey.RUN_NAME, self._run_name)
setattr(record, TrainContextFilter.LogKey.COMPONENT, self._component)
return True
def worker_filter(self, record):
# Add the run_id and component to Ray Train worker processes.
setattr(record, TrainContextFilter.LogKey.RUN_NAME, self._run_name)
setattr(record, TrainContextFilter.LogKey.COMPONENT, self._component)
# Add all the rank related information to the log record for worker processes.
setattr(record, TrainContextFilter.LogKey.WORLD_RANK, self._world_rank)
setattr(record, TrainContextFilter.LogKey.LOCAL_RANK, self._local_rank)
setattr(record, TrainContextFilter.LogKey.NODE_RANK, self._node_rank)
return True
def filter(self, record):
if self._is_worker:
return self.worker_filter(record)
else:
return self.controller_filter(record)
class TrainLogLevelFilter(logging.Filter):
"""Filter that applies log level filtering only to ray.train log records."""
def __init__(self, log_level: str = "INFO"):
super().__init__()
self._log_level = getattr(logging, log_level)
def filter(self, record):
if record.name == "ray.train" or record.name.startswith("ray.train."):
return record.levelno >= self._log_level
return True
class SessionFileHandler(logging.Handler):
"""A handler that writes to a log file in the Ray session directory.
The Ray session directory isn't available until Ray is initialized, so any logs
emitted before Ray is initialized will be lost.
This handler will not create the file handler until you emit a log record.
Args:
filename: The name of the log file. The file is created in the 'logs/train'
directory of the Ray session directory.
"""
# TODO (hpguo): This handler class is shared by both Ray Train and ray data. We
# should move this to ray core and make it available to both libraries.
def __init__(self, filename: str):
super().__init__()
self._filename = filename
self._handler = None
self._formatter = None
self._path = None
def emit(self, record):
if self._handler is None:
self._try_create_handler()
if self._handler is not None:
self._handler.emit(record)
def setFormatter(self, fmt: logging.Formatter) -> None:
if self._handler is not None:
self._handler.setFormatter(fmt)
self._formatter = fmt
def get_log_file_path(self) -> Optional[str]:
if self._handler is None:
self._try_create_handler()
return self._path
def _try_create_handler(self):
assert self._handler is None
# Get the Ray Train log directory. If not in a Ray session, return.
# This handler will only be created within a Ray session.
log_directory = LoggingManager.get_log_directory()
if log_directory is None:
return
os.makedirs(log_directory, exist_ok=True)
# Create the log file.
self._path = os.path.join(log_directory, self._filename)
self._handler = logging.FileHandler(self._path)
if self._formatter is not None:
self._handler.setFormatter(self._formatter)
class LoggingManager:
"""
A utility class for managing the logging configuration of Ray Train.
"""
@staticmethod
def _get_base_logger_config_dict(
context: Union[TrainRunContext, TrainContext],
) -> dict:
"""Return the base logging configuration dictionary."""
log_level = LoggingManager._resolve_log_level(context)
# Using Ray worker ID as the file identifier where logs are written to.
file_identifier = ray.get_runtime_context().get_worker_id()
# Return the base logging configuration as a Python dictionary.
return {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"ray_json": {"class": get_module_name(JSONFormatter)},
},
"filters": {
"core_context_filter": {"()": CoreContextFilter},
"train_context_filter": {"()": TrainContextFilter, "context": context},
"train_log_level_filter": {
"()": TrainLogLevelFilter,
"log_level": log_level,
},
},
"handlers": {
"console": {
"class": get_module_name(PlainRayHandler),
"filters": ["train_log_level_filter"],
},
"file_train_sys_controller": {
"class": get_module_name(SessionFileHandler),
"formatter": "ray_json",
"filename": f"ray-train-sys-controller-{file_identifier}.log",
"filters": ["core_context_filter", "train_context_filter"],
},
"file_train_app_controller": {
"class": get_module_name(SessionFileHandler),
"formatter": "ray_json",
"filename": f"ray-train-app-controller-{file_identifier}.log",
"filters": [
"core_context_filter",
"train_context_filter",
"train_log_level_filter",
],
},
"file_train_sys_worker": {
"class": get_module_name(SessionFileHandler),
"formatter": "ray_json",
"filename": f"ray-train-sys-worker-{file_identifier}.log",
"filters": ["core_context_filter", "train_context_filter"],
},
"file_train_app_worker": {
"class": get_module_name(SessionFileHandler),
"formatter": "ray_json",
"filename": f"ray-train-app-worker-{file_identifier}.log",
"filters": [
"core_context_filter",
"train_context_filter",
"train_log_level_filter",
],
},
},
"loggers": {},
}
@staticmethod
def _resolve_log_level(
context: Union[TrainRunContext, TrainContext],
) -> str:
"""Returns the log level from RunConfig's LoggingConfig."""
if isinstance(context, TrainContext):
run_config = context.train_run_context.get_run_config()
else:
run_config = context.get_run_config()
return run_config.logging_config.log_level
@staticmethod
def _get_controller_logger_config_dict(context: TrainRunContext) -> dict:
"""Return the controller logger configuration dictionary.
On the controller process, only the `ray.train` logger is configured.
It is broadly set to level DEBUG, with downstream processing by log handlers.
This logger emits logs to the following three locations:
- `file_train_sys_controller`: Ray Train system logs.
- `file_train_app_controller`: Ray Train application logs.
- `console`: Logs to the console.
"""
config_dict = LoggingManager._get_base_logger_config_dict(context)
config_dict["loggers"]["ray.train"] = {
"level": "DEBUG",
"handlers": [
"file_train_sys_controller",
"file_train_app_controller",
"console",
],
"propagate": False,
}
return config_dict
@staticmethod
def _get_worker_logger_config_dict(context: TrainContext) -> dict:
"""Return the worker loggers configuration dictionary.
On the worker process, there are two loggers being configured:
First, the `ray.train` logger is configured and emits logs to the
following three locations:
- `file_train_sys_worker`: Ray Train system logs.
- `file_train_app_worker`: Ray Train application logs.
- `console`: Logs to the console.
It is broadly set to level DEBUG, with downstream processing by log handlers.
Second, the root logger is configured and emits logs to the following
two locations:
- `console`: Logs to the console.
- `file_train_app_worker`: Ray Train application logs.
The root logger will not emit Ray Train system logs and thus not writing to
`file_train_sys_worker` file handler.
"""
config_dict = LoggingManager._get_base_logger_config_dict(context)
config_dict["loggers"]["ray.train"] = {
"level": "DEBUG",
"handlers": ["file_train_sys_worker", "file_train_app_worker", "console"],
"propagate": False,
}
config_dict["root"] = {
"level": "INFO",
"handlers": ["file_train_app_worker", "console"],
}
return config_dict
@staticmethod
def configure_controller_logger(context: TrainRunContext) -> None:
"""
Configure the logger on the controller process, which is the `ray.train` logger.
"""
config = LoggingManager._get_controller_logger_config_dict(context)
logging.config.dictConfig(config)
# TODO: Return the controller log file path.
@staticmethod
def configure_worker_logger(context: TrainContext) -> None:
"""
Configure the loggers on the worker process, which contains the
`ray.train` logger and the root logger.
"""
config = LoggingManager._get_worker_logger_config_dict(context)
logging.config.dictConfig(config)
# TODO: Return the worker log file path.
@staticmethod
def get_log_directory() -> Optional[str]:
"""Return the directory where Ray Train writes log files.
If not in a Ray session, return None.
This path looks like: "/tmp/ray/session_xxx/logs/train/"
"""
global_node = ray._private.worker._global_node
if global_node is None:
return None
root_dir = global_node.get_session_dir_path()
return os.path.join(root_dir, "logs", "train")
def get_train_application_controller_log_path() -> Optional[str]:
"""
Return the path to the file train application controller log file.
"""
# TODO: This is a temporary solution. We should return the log file path in
# the `configure_controller_logger` function.
logger = logging.getLogger("ray.train")
for handler in logger.handlers:
if (
isinstance(handler, SessionFileHandler)
and "ray-train-app-controller" in handler._filename
):
return handler.get_log_file_path()
return None
def get_train_application_worker_log_path() -> Optional[str]:
"""
Return the path to the file train application worker log file.
"""
# TODO: This is a temporary solution. We should return the log file path in
# the `configure_worker_logger` function.
logger = logging.getLogger("ray.train")
for handler in logger.handlers:
if (
isinstance(handler, SessionFileHandler)
and "ray-train-app-worker" in handler._filename
):
return handler.get_log_file_path()
return None
@@ -0,0 +1,76 @@
import builtins
import contextlib
import logging
import sys
from typing import Callable
from ray._private.ray_constants import env_bool
from ray.train.v2._internal.constants import (
DEFAULT_ENABLE_PRINT_PATCH,
ENABLE_PRINT_PATCH_ENV_VAR,
)
# Save the original print function
_original_print = builtins.print
@contextlib.contextmanager
def print_context_manager(print_fn: Callable):
"""Context manager to set the builtin print function as print_fn."""
current_print = builtins.print
builtins.print = print_fn
yield
builtins.print = current_print
def redirected_print(*objects, sep=" ", end="\n", file=None, flush=False):
"""Implement python's print function to redirect logs to Train's logger.
If the file is set to anything other than stdout, stderr, or None, call the
builtin print. Else, construct the message and redirect to Train's logger.
This makes sure that print to customized file in user defined function will not
be overwritten by the redirected print function.
See https://docs.python.org/3/library/functions.html#print
"""
# TODO (hpguo): This handler class is shared by both ray train and ray serve. We
# should move this to ray core and make it available to both libraries.
if file not in [sys.stdout, sys.stderr, None]:
_original_print(*objects, sep=sep, end=end, file=file, flush=flush)
return
# If sys.stdout/stderr has been redirected (e.g. contextlib.redirect_stdout(),
# or wrapping by libraries like wandb / MLflow / colorama / IPython), tee to
# the original print so the redirect target also receives the output. The
# logger still gets the message below, so structured logs aren't silently
# dropped when a third-party library wraps the stream.
if (file in (sys.stdout, None) and sys.stdout is not sys.__stdout__) or (
file is sys.stderr and sys.stderr is not sys.__stderr__
):
_original_print(*objects, sep=sep, end=end, file=file, flush=flush)
root_logger = logging.getLogger()
message = sep.join(map(str, objects))
# Use the original `print` method for the scope of the logger call, in order to
# avoid infinite recursion errors if any exceptions get raised (since exception
# handling involves another `print(..., file=sys.stderr)`.
# Note that an exception being raised here is not expected (e.g. it would be a
# bug in our own logging code), so this is just to keep the error logs sane
# during development.
with print_context_manager(_original_print):
# We want this log to be associated with the line of code where user calls
# `print`, which is stacklevel 2.
# Frame [stacklevel]:
# User's call to print [2] -> `redirected_print` [1] -> root_logger.log [0]
root_logger.log(logging.INFO, message, stacklevel=2)
def patch_print_function() -> None:
"""
Patch the print function to redirect logs to Train's logger.
Only patch the print function if the environment variable is set to "1"
"""
if env_bool(ENABLE_PRINT_PATCH_ENV_VAR, DEFAULT_ENABLE_PRINT_PATCH):
builtins.print = redirected_print
@@ -0,0 +1,166 @@
from abc import ABC, abstractmethod
from enum import Enum
from typing import Dict, Generic, Optional, Tuple, TypeVar
from ray.util.metrics import Gauge
RUN_NAME_TAG_KEY = "ray_train_run_name"
RUN_ID_TAG_KEY = "ray_train_run_id"
T = TypeVar("T")
E = TypeVar("E", bound=Enum)
class Metric(ABC):
def __init__(
self,
name: str,
default: T,
description: str,
base_tags: Dict[str, str],
):
"""
Initialize a new metric.
Args:
name: The name of the metric.
default: The default value of the metric.
description: The description of the metric.
base_tags: The base tags for the metric.
"""
self._default = default
self._base_tags = base_tags
self._gauge = Gauge(
name,
description=description,
tag_keys=self._get_tag_keys(),
)
@abstractmethod
def record(self, value: T):
"""Update the metric value.
Args:
value: The value to update the metric with.
"""
pass
@abstractmethod
def get_value(self) -> T:
"""Get the value of the metric.
Returns:
The value of the metric. If the metric has not been recorded,
the default value is returned.
"""
pass
@abstractmethod
def reset(self):
"""Reset values and clean up resources."""
pass
def _get_tag_keys(self) -> Tuple[str, ...]:
return tuple(self._base_tags.keys())
class TimeMetric(Metric):
"""A metric for tracking elapsed time."""
def __init__(
self,
name: str,
description: str,
base_tags: Dict[str, str],
):
self._current_value = 0.0
super().__init__(
name=name,
default=0.0,
description=description,
base_tags=base_tags,
)
def record(self, value: float):
"""Update the time metric value by accumulating the time.
Args:
value: The time value to increment the metric by.
"""
self._current_value += value
self._gauge.set(self._current_value, self._base_tags)
def get_value(self) -> float:
return self._current_value
def reset(self):
self._current_value = self._default
self._gauge.set(self._default, self._base_tags)
class EnumMetric(Metric, Generic[E]):
"""A metric for tracking enum values."""
DEFAULT_VALUE = 0
RECORDED_VALUE = 1
def __init__(
self,
name: str,
description: str,
base_tags: Dict[str, str],
enum_tag_key: str,
):
self._enum_tag_key = enum_tag_key
self._current_value: Optional[E] = None
super().__init__(
name=name,
default=self.DEFAULT_VALUE,
description=description,
base_tags=base_tags,
)
def record(self, enum_value: E) -> None:
"""Record a specific enum value.
The metric will be reset to 0 for the previous value and set to 1 for the new value.
Args:
enum_value: The enum value to record for.
"""
if enum_value == self._current_value:
return
if self._current_value is not None:
previous_tags = self._get_tags(self._current_value)
self._gauge.set(self._default, previous_tags)
current_tags = self._get_tags(enum_value)
self._gauge.set(self.RECORDED_VALUE, current_tags)
self._current_value = enum_value
def get_value(self, enum_value: E) -> int:
"""Get the value for a specific enum value.
Args:
enum_value: The enum value to get the value for
Returns:
The value for the enum value
"""
return int(enum_value == self._current_value)
def reset(self):
if self._current_value is not None:
tags = self._get_tags(self._current_value)
self._gauge.set(self._default, tags)
self._current_value = None
def _get_tag_keys(self) -> Tuple[str, ...]:
return tuple(self._base_tags.keys()) + (self._enum_tag_key,)
def _get_tags(self, enum_value: E) -> Dict[str, str]:
tags = self._base_tags.copy()
tags[self._enum_tag_key] = enum_value.name
return tags
@@ -0,0 +1,66 @@
from typing import Dict, Union
from ray.train.v2._internal.execution.controller.state import TrainControllerStateType
from ray.train.v2._internal.metrics.base import (
RUN_ID_TAG_KEY,
RUN_NAME_TAG_KEY,
EnumMetric,
TimeMetric,
)
class ControllerMetrics:
"""Factory for creating controller-specific metrics.
This class defines all metrics used to track the state and performance of the
training controller. Each metric is defined with its name, type, default value,
description, and required tags.
"""
# ===== Metric Names =====
CONTROLLER_STATE = "train_controller_state"
WORKER_GROUP_START_TOTAL_TIME_S = "train_worker_group_start_total_time_s"
WORKER_GROUP_SHUTDOWN_TOTAL_TIME_S = "train_worker_group_shutdown_total_time_s"
# ===== Tag Keys =====
CONTROLLER_STATE_TAG_KEY = "ray_train_controller_state"
@classmethod
def _create_time_metric(
cls, name: str, description: str, base_tags: Dict[str, str]
) -> TimeMetric:
return TimeMetric(
name=name,
description=description,
base_tags=base_tags,
)
@classmethod
def _create_controller_state_metric(
cls, base_tags: Dict[str, str]
) -> EnumMetric[TrainControllerStateType]:
return EnumMetric[TrainControllerStateType](
name=cls.CONTROLLER_STATE,
description="Current state of the Ray Train controller",
base_tags=base_tags,
enum_tag_key=cls.CONTROLLER_STATE_TAG_KEY,
)
@classmethod
def get_controller_metrics(
cls, run_name: str, run_id: str
) -> Dict[str, Union[TimeMetric, EnumMetric[TrainControllerStateType]]]:
base_tags = {RUN_NAME_TAG_KEY: run_name, RUN_ID_TAG_KEY: run_id}
return {
cls.WORKER_GROUP_START_TOTAL_TIME_S: cls._create_time_metric(
cls.WORKER_GROUP_START_TOTAL_TIME_S,
"Total time taken to start the worker group",
base_tags,
),
cls.WORKER_GROUP_SHUTDOWN_TOTAL_TIME_S: cls._create_time_metric(
cls.WORKER_GROUP_SHUTDOWN_TOTAL_TIME_S,
"Total time taken to shutdown the worker group",
base_tags,
),
cls.CONTROLLER_STATE: cls._create_controller_state_metric(base_tags),
}
@@ -0,0 +1,64 @@
from typing import Dict
from ray.train.v2._internal.metrics.base import (
RUN_ID_TAG_KEY,
RUN_NAME_TAG_KEY,
TimeMetric,
)
WORKER_WORLD_RANK_TAG_KEY = "ray_train_worker_world_rank"
WORKER_ACTOR_ID_TAG_KEY = "ray_train_worker_actor_id"
class WorkerMetrics:
"""Factory for creating worker-specific metrics.
This class defines all metrics used to track the state and performance of the
training workers. Each metric is defined with its name, type, default value,
description, and required tags.
"""
# ===== Metric Names =====
REPORT_TOTAL_BLOCKED_TIME_S = "train_report_total_blocked_time_s"
CHECKPOINT_SYNC_TOTAL_TIME_S = "train_checkpoint_sync_total_time_s"
CHECKPOINT_TRANSFER_TOTAL_TIME_S = "train_checkpoint_transfer_total_time_s"
@classmethod
def _create_time_metric(
cls, name: str, description: str, base_tags: Dict[str, str]
) -> TimeMetric:
"""Create a time-based metric."""
return TimeMetric(
name=name,
description=description,
base_tags=base_tags,
)
@classmethod
def get_worker_metrics(
cls, run_name: str, run_id: str, world_rank: int, worker_actor_id: str
) -> Dict[str, TimeMetric]:
"""Get all worker metrics."""
base_tags = {
RUN_NAME_TAG_KEY: run_name,
RUN_ID_TAG_KEY: run_id,
WORKER_WORLD_RANK_TAG_KEY: str(world_rank),
WORKER_ACTOR_ID_TAG_KEY: worker_actor_id,
}
return {
cls.REPORT_TOTAL_BLOCKED_TIME_S: cls._create_time_metric(
cls.REPORT_TOTAL_BLOCKED_TIME_S,
"Cumulative time in seconds to report a checkpoint to the storage.",
base_tags,
),
cls.CHECKPOINT_SYNC_TOTAL_TIME_S: cls._create_time_metric(
cls.CHECKPOINT_SYNC_TOTAL_TIME_S,
"Cumulative time in seconds spent synchronizing the checkpoint directory name across all ranks.",
base_tags,
),
cls.CHECKPOINT_TRANSFER_TOTAL_TIME_S: cls._create_time_metric(
cls.CHECKPOINT_TRANSFER_TOTAL_TIME_S,
"Cumulative time in seconds spent transferring checkpoint files to storage.",
base_tags,
),
}
@@ -0,0 +1,70 @@
from ray.train.constants import V2_MIGRATION_GUIDE_MESSAGE
FAIL_FAST_DEPRECATION_MESSAGE = (
"`ray.train.FailureConfig(fail_fast)` is deprecated since it is "
"only relevant in the context of multiple trials running in Ray Tune. "
"This parameter is still available in `ray.tune.FailureConfig` "
"for passing into a `ray.tune.Tuner`. "
f"{V2_MIGRATION_GUIDE_MESSAGE}"
)
TRAINER_RESOURCES_DEPRECATION_MESSAGE = (
"`ray.train.ScalingConfig(trainer_resources)` is deprecated. "
"This parameter was an advanced configuration that specified "
"resources for the Ray Train driver actor, which doesn't "
"need to reserve logical resources because it doesn't perform "
"any heavy computation. "
"Only the `resources_per_worker` parameter should be used "
"to specify resources for the training workers. "
f"{V2_MIGRATION_GUIDE_MESSAGE}"
)
VERBOSE_DEPRECATION_MESSAGE = (
"`ray.train.RunConfig(verbose)` is deprecated. "
"This parameter controls Ray Tune logging verbosity, "
"and is only relevant when using Ray Tune. "
"This parameter is still available in `ray.tune.RunConfig` "
"for passing into a `ray.tune.Tuner`. "
f"{V2_MIGRATION_GUIDE_MESSAGE}"
)
LOG_TO_FILE_DEPRECATION_MESSAGE = (
"`ray.train.RunConfig(log_to_file)` is deprecated. "
"The Ray Train driver actor and the training worker actors "
"already log stdout/stderr as part of Ray's logging system. "
f"{V2_MIGRATION_GUIDE_MESSAGE}"
)
STOP_DEPRECATION_MESSAGE = (
"`ray.train.RunConfig(stop)` is deprecated. "
"This parameter is only relevant when using Ray Tune "
"and is still available in `ray.tune.RunConfig` "
"for passing into a `ray.tune.Tuner`. "
f"{V2_MIGRATION_GUIDE_MESSAGE}"
)
CALLBACKS_DEPRECATION_MESSAGE = (
"`ray.train.RunConfig(callbacks: List[ray.tune.Callback])` is deprecated. "
"Ray Train no longer accepts Ray Tune callbacks, since the Ray Train "
"execution backend is being separated from Ray Tune. "
f"{V2_MIGRATION_GUIDE_MESSAGE}"
)
PROGRESS_REPORTER_DEPRECATION_MESSAGE = (
"`ray.train.RunConfig(progress_reporter)` is deprecated. "
"This parameter controls the Ray Tune console output reporter, "
"and is only relevant when using Ray Tune. "
"This parameter is still available in `ray.tune.RunConfig` "
"for passing into a `ray.tune.Tuner`. "
f"{V2_MIGRATION_GUIDE_MESSAGE}"
)
SYNC_CONFIG_DEPRECATION_MESSAGE = (
"`ray.train.RunConfig(sync_config)` is deprecated. "
"This configuration controls advanced syncing behavior, "
"which is either not supported or not relevant in the reworked Ray Train. "
"This parameter is still available in `ray.tune.RunConfig` "
"for passing into a `ray.tune.Tuner`. "
"The `SyncConfig` class has been moved to `ray.tune.SyncConfig`. "
f"{V2_MIGRATION_GUIDE_MESSAGE}"
)
@@ -0,0 +1,313 @@
import logging
from google.protobuf.struct_pb2 import Struct
from ray.core.generated.export_train_state_pb2 import (
ExportTrainRunAttemptEventData as ProtoTrainRunAttempt,
ExportTrainRunEventData as ProtoTrainRun,
)
from ray.dashboard.modules.metrics.dashboards.common import Panel
from ray.dashboard.modules.metrics.dashboards.train_dashboard_panels import (
TRAIN_RUN_PANELS,
TRAIN_WORKER_PANELS,
)
from ray.train.v2._internal.state.schema import (
ActorStatus,
BackendConfig,
DataConfig,
ExecutionOptions,
RunAttemptStatus,
RunConfig,
RunSettings,
RunStatus,
ScalingConfig,
TrainRun,
TrainRunAttempt,
TrainWorker,
)
from ray.train.v2._internal.util import TrainingFramework
# Increment each time the exported Train schema changes (proto, pydantic, or
# exported json) so downstream consumers can distinguish schema versions.
TRAIN_SCHEMA_VERSION = 4
RAY_TRAIN_VERSION = 2
# Status mapping dictionaries
_ACTOR_STATUS_MAP = {
ActorStatus.ALIVE: ProtoTrainRunAttempt.ActorStatus.ALIVE,
ActorStatus.DEAD: ProtoTrainRunAttempt.ActorStatus.DEAD,
}
_RUN_ATTEMPT_STATUS_MAP = {
RunAttemptStatus.PENDING: ProtoTrainRunAttempt.RunAttemptStatus.PENDING,
RunAttemptStatus.RUNNING: ProtoTrainRunAttempt.RunAttemptStatus.RUNNING,
RunAttemptStatus.FINISHED: ProtoTrainRunAttempt.RunAttemptStatus.FINISHED,
RunAttemptStatus.ERRORED: ProtoTrainRunAttempt.RunAttemptStatus.ERRORED,
RunAttemptStatus.ABORTED: ProtoTrainRunAttempt.RunAttemptStatus.ABORTED,
}
_RUN_STATUS_MAP = {
RunStatus.INITIALIZING: ProtoTrainRun.RunStatus.INITIALIZING,
RunStatus.SCHEDULING: ProtoTrainRun.RunStatus.SCHEDULING,
RunStatus.RUNNING: ProtoTrainRun.RunStatus.RUNNING,
RunStatus.RESTARTING: ProtoTrainRun.RunStatus.RESTARTING,
RunStatus.RESIZING: ProtoTrainRun.RunStatus.RESIZING,
RunStatus.FINISHED: ProtoTrainRun.RunStatus.FINISHED,
RunStatus.ERRORED: ProtoTrainRun.RunStatus.ERRORED,
RunStatus.ABORTED: ProtoTrainRun.RunStatus.ABORTED,
}
_TRAINING_FRAMEWORK_MAP = {
None: ProtoTrainRun.BackendConfig.TrainingFramework.TRAINING_FRAMEWORK_UNSPECIFIED,
TrainingFramework.TORCH: ProtoTrainRun.BackendConfig.TrainingFramework.TORCH,
TrainingFramework.JAX: ProtoTrainRun.BackendConfig.TrainingFramework.JAX,
TrainingFramework.TENSORFLOW: ProtoTrainRun.BackendConfig.TrainingFramework.TENSORFLOW,
TrainingFramework.XGBOOST: ProtoTrainRun.BackendConfig.TrainingFramework.XGBOOST,
TrainingFramework.LIGHTGBM: ProtoTrainRun.BackendConfig.TrainingFramework.LIGHTGBM,
}
logger = logging.getLogger(__name__)
def _dict_to_struct(d: dict) -> Struct:
"""Returns a protobuf Struct from a dictionary."""
s = Struct()
s.update(d)
return s
def _to_proto_resources(resources: dict) -> ProtoTrainRunAttempt.TrainResources:
"""Convert resources dictionary to protobuf TrainResources."""
return ProtoTrainRunAttempt.TrainResources(resources=resources)
def _to_proto_worker(worker: TrainWorker) -> ProtoTrainRunAttempt.TrainWorker:
"""Convert TrainWorker to protobuf format."""
status = None
if worker.status is not None:
status = _ACTOR_STATUS_MAP[worker.status]
return ProtoTrainRunAttempt.TrainWorker(
world_rank=worker.world_rank,
local_rank=worker.local_rank,
node_rank=worker.node_rank,
actor_id=bytes.fromhex(worker.actor_id),
node_id=bytes.fromhex(worker.node_id),
node_ip=worker.node_ip,
pid=worker.pid,
gpu_ids=worker.gpu_ids,
status=status,
resources=_to_proto_resources(worker.resources.resources),
log_file_path=worker.log_file_path,
)
# Main conversion functions
def train_run_attempt_to_proto(attempt: TrainRunAttempt) -> ProtoTrainRunAttempt:
"""Convert TrainRunAttempt to protobuf format."""
proto_attempt = ProtoTrainRunAttempt(
schema_version=TRAIN_SCHEMA_VERSION,
ray_train_version=RAY_TRAIN_VERSION,
run_id=attempt.run_id,
attempt_id=attempt.attempt_id,
status=_RUN_ATTEMPT_STATUS_MAP[attempt.status],
status_detail=attempt.status_detail,
start_time_ns=attempt.start_time_ns,
end_time_ns=attempt.end_time_ns,
resources=[_to_proto_resources(r.resources) for r in attempt.resources],
workers=[_to_proto_worker(w) for w in attempt.workers],
)
return proto_attempt
def _to_proto_dashboard_panel(panel: Panel) -> ProtoTrainRun.DashboardPanelMetadata:
"""Convert Dashboard Panel to protobuf format."""
proto_panel = ProtoTrainRun.DashboardPanelMetadata(
id=str(panel.id),
title=panel.title,
)
return proto_panel
def to_proto_backend_config(
backend_config: BackendConfig,
) -> ProtoTrainRun.BackendConfig:
"""Convert BackendConfig to protobuf format."""
proto_backend_config = ProtoTrainRun.BackendConfig(
framework=_TRAINING_FRAMEWORK_MAP[backend_config.framework],
)
proto_backend_config.config.CopyFrom(_dict_to_struct(backend_config.config))
return proto_backend_config
def to_proto_scaling_config(
scaling_config: ScalingConfig,
) -> ProtoTrainRun.ScalingConfig:
"""Convert ScalingConfig to protobuf format."""
proto_scaling_config = ProtoTrainRun.ScalingConfig(
use_gpu=scaling_config.use_gpu,
placement_strategy=scaling_config.placement_strategy,
use_tpu=scaling_config.use_tpu,
)
if isinstance(scaling_config.num_workers, tuple):
proto_scaling_config.num_workers_range.CopyFrom(
ProtoTrainRun.ScalingConfig.IntRange(
min=scaling_config.num_workers[0],
max=scaling_config.num_workers[1],
)
)
else:
proto_scaling_config.num_workers_fixed = scaling_config.num_workers
if scaling_config.resources_per_worker is not None:
proto_scaling_config.resources_per_worker.values.update(
scaling_config.resources_per_worker
)
if scaling_config.accelerator_type is not None:
proto_scaling_config.accelerator_type = scaling_config.accelerator_type
if scaling_config.topology is not None:
proto_scaling_config.topology = scaling_config.topology
if scaling_config.bundle_label_selector is not None:
selectors = scaling_config.bundle_label_selector
if isinstance(selectors, dict):
proto_scaling_config.label_selector_single.values.update(selectors)
else:
proto_scaling_config.label_selector_list.values.extend(
[ProtoTrainRun.ScalingConfig.StringMap(values=s) for s in selectors]
)
return proto_scaling_config
def _to_proto_execution_options(
execution_options: ExecutionOptions,
) -> ProtoTrainRun.ExecutionOptions:
"""Convert a single ExecutionOptions schema model to protobuf."""
return ProtoTrainRun.ExecutionOptions(
resource_limits=_dict_to_struct(execution_options.resource_limits),
exclude_resources=_dict_to_struct(execution_options.exclude_resources),
preserve_order=execution_options.preserve_order,
actor_locality_enabled=execution_options.actor_locality_enabled,
verbose_progress=execution_options.verbose_progress,
)
def to_proto_data_config(data_config: DataConfig) -> ProtoTrainRun.DataConfig:
"""Convert DataConfig to protobuf format."""
data_execution_options = data_config.data_execution_options
proto_data_config = ProtoTrainRun.DataConfig(
enable_shard_locality=data_config.enable_shard_locality,
data_execution_options=ProtoTrainRun.DataExecutionOptions(
default=_to_proto_execution_options(data_execution_options.default),
per_dataset_execution_options={
name: _to_proto_execution_options(opts)
for name, opts in data_execution_options.per_dataset_execution_options.items()
},
),
)
if data_config.datasets_to_split == "all":
proto_data_config.all.SetInParent()
else:
proto_data_config.datasets.values.extend(data_config.datasets_to_split)
return proto_data_config
def _to_proto_failure_config(run_config: RunConfig) -> ProtoTrainRun.FailureConfig:
"""Convert RunConfig.failure_config to protobuf format."""
return ProtoTrainRun.FailureConfig(
max_failures=run_config.failure_config.max_failures,
controller_failure_limit=run_config.failure_config.controller_failure_limit,
)
def _to_proto_checkpoint_config(
run_config: RunConfig,
) -> ProtoTrainRun.CheckpointConfig:
"""Convert RunConfig.checkpoint_config to protobuf format."""
checkpoint_score_order = ProtoTrainRun.CheckpointConfig.CheckpointScoreOrder.Value(
run_config.checkpoint_config.checkpoint_score_order.upper()
)
proto_checkpoint_config = ProtoTrainRun.CheckpointConfig(
checkpoint_score_order=checkpoint_score_order
)
if run_config.checkpoint_config.num_to_keep is not None:
proto_checkpoint_config.num_to_keep = run_config.checkpoint_config.num_to_keep
if run_config.checkpoint_config.checkpoint_score_attribute is not None:
proto_checkpoint_config.checkpoint_score_attribute = (
run_config.checkpoint_config.checkpoint_score_attribute
)
return proto_checkpoint_config
def to_proto_run_config(run_config: RunConfig) -> ProtoTrainRun.RunConfig:
"""Convert RunConfig to protobuf format."""
proto_run_config = ProtoTrainRun.RunConfig(
name=run_config.name,
failure_config=_to_proto_failure_config(run_config),
worker_runtime_env=_dict_to_struct(run_config.worker_runtime_env),
checkpoint_config=_to_proto_checkpoint_config(run_config),
storage_path=run_config.storage_path,
)
if run_config.storage_filesystem is not None:
proto_run_config.storage_filesystem = run_config.storage_filesystem
return proto_run_config
def _to_proto_run_settings(run_settings: RunSettings) -> ProtoTrainRun.RunSettings:
"""Convert RunSettings to protobuf format."""
proto_run_settings = ProtoTrainRun.RunSettings(
backend_config=to_proto_backend_config(run_settings.backend_config),
scaling_config=to_proto_scaling_config(run_settings.scaling_config),
datasets=run_settings.datasets,
data_config=to_proto_data_config(run_settings.data_config),
run_config=to_proto_run_config(run_settings.run_config),
)
if run_settings.train_loop_config is not None:
proto_run_settings.train_loop_config.CopyFrom(
_dict_to_struct(run_settings.train_loop_config)
)
return proto_run_settings
def train_run_to_proto(run: TrainRun) -> ProtoTrainRun:
"""Convert TrainRun to protobuf format."""
proto_train_run_panels = [_to_proto_dashboard_panel(p) for p in TRAIN_RUN_PANELS]
proto_train_worker_panels = [
_to_proto_dashboard_panel(p) for p in TRAIN_WORKER_PANELS
]
proto_train_run = ProtoTrainRun(
schema_version=TRAIN_SCHEMA_VERSION,
ray_train_version=RAY_TRAIN_VERSION,
id=run.id,
name=run.name,
job_id=bytes.fromhex(run.job_id),
controller_actor_id=bytes.fromhex(run.controller_actor_id),
status=_RUN_STATUS_MAP[run.status],
status_detail=run.status_detail,
start_time_ns=run.start_time_ns,
end_time_ns=run.end_time_ns,
controller_log_file_path=run.controller_log_file_path,
train_run_panels=proto_train_run_panels,
train_worker_panels=proto_train_worker_panels,
framework_versions=run.framework_versions,
run_settings=_to_proto_run_settings(run.run_settings),
)
return proto_train_run
@@ -0,0 +1,532 @@
import math
from collections.abc import Mapping
from enum import Enum
from typing import Any, Dict, List, Literal, Optional, Tuple, Union
from pydantic import field_validator
from ray._common.pydantic_compat import BaseModel, Field
from ray.dashboard.modules.job.pydantic_models import JobDetails
from ray.train.v2._internal.util import TrainingFramework
from ray.util.annotations import DeveloperAPI
MAX_ERROR_STACK_TRACE_LENGTH = 50000
def _to_json_serializable_value(value: Any, *, max_depth: int = 3) -> Any:
"""Recursively coerce a value into a human-readable, JSON serializable representation.
If ``value`` is a list or dict, this function walks through it and replaces non-JSON
serializable fields (e.g. custom objects, modules, tensors, callables, etc.) with a
human-readable string representation.
Args:
value: Any Python value. Primitives pass through; collections recurse;
other types are stringified.
max_depth: Truncates dicts nested beyond ``max_depth`` to ``"..."``.
Lists do not consume depth.
Returns:
The JSON serializable representation of the value.
"""
if max_depth <= 0:
raise ValueError("max_depth must be greater than 0")
def _safe_str(v):
try:
return str(v)
except Exception:
return type(v).__name__
def _walk(value, depth):
if value is None or isinstance(value, (bool, int, str)):
return value
if isinstance(value, float):
return str(value) if not math.isfinite(value) else value
if isinstance(value, Mapping):
if depth <= 0:
return "..."
try:
items = list(value.items())
except Exception:
# Custom Mapping subclass with a broken `.items()`.
return type(value).__name__
return {_safe_str(k): _walk(v, depth - 1) for k, v in items}
# Tuples, sets, and frozensets all become lists in JSON.
if isinstance(value, (list, tuple, set, frozenset)):
return [_walk(v, depth) for v in value]
cls = type(value)
# Use class name if no custom string representation is defined.
if cls.__str__ is object.__str__ and cls.__repr__ is object.__repr__:
return cls.__name__
return _safe_str(value)
return _walk(value, max_depth)
@DeveloperAPI
class RunStatus(str, Enum):
"""Enumeration of the possible statuses for a Train run."""
# ====== Active States ======
# The Train run is currently in the process of initializing.
INITIALIZING = "INITIALIZING"
# The Train run is waiting to be scheduled.
SCHEDULING = "SCHEDULING"
# The Train run is currently in progress.
RUNNING = "RUNNING"
# The Train run is recovering from a failure or restart.
RESTARTING = "RESTARTING"
# The Train run is resizing.
RESIZING = "RESIZING"
# ===== Terminal States ======
# The Train run completed successfully.
FINISHED = "FINISHED"
# The Train run failed due to an error in the training workers.
ERRORED = "ERRORED"
# The Train run was terminated due to system or controller errors.
ABORTED = "ABORTED"
def is_terminal(self) -> bool:
return self in [RunStatus.FINISHED, RunStatus.ERRORED, RunStatus.ABORTED]
@DeveloperAPI
class RunAttemptStatus(str, Enum):
"""Enumeration of the possible statuses for a Train run attempt."""
# ====== Active States ======
# The run attempt is waiting to be scheduled.
PENDING = "PENDING"
# The run attempt is currently in progress.
RUNNING = "RUNNING"
# ===== Terminal States =====
# The run attempt completed successfully.
FINISHED = "FINISHED"
# The run attempt failed due to an error in the training workers.
ERRORED = "ERRORED"
# The run attempt was terminated due to system or controller errors.
ABORTED = "ABORTED"
def is_terminal(self) -> bool:
return self in [
RunAttemptStatus.FINISHED,
RunAttemptStatus.ERRORED,
RunAttemptStatus.ABORTED,
]
@DeveloperAPI
class ActorStatus(str, Enum):
"""Enumeration of the statuses for a Train worker actor."""
# The actor is currently active.
ALIVE = "ALIVE"
# The actor is no longer active.
DEAD = "DEAD"
@DeveloperAPI
class TrainResources(BaseModel):
"""Resources allocated for a Train worker or run."""
resources: Dict[str, float] = Field(
description="A dictionary specifying the types and amounts of resources "
"allocated (e.g., CPU, GPU)."
)
@DeveloperAPI
class TrainWorker(BaseModel):
"""Metadata about a Ray Train worker."""
world_rank: int = Field(
description="The global rank of the worker in the training cluster."
)
local_rank: int = Field(description="The local rank of the worker on its node.")
node_rank: int = Field(description="The rank of the worker's node in the cluster.")
actor_id: str = Field(description="The unique ID of the worker's actor.")
node_id: str = Field(
description="The unique ID of the node where the worker is running."
)
node_ip: str = Field(
description="The IP address of the node where the worker is running."
)
pid: int = Field(description="The process ID of the worker.")
gpu_ids: List[int] = Field(description="A list of GPU IDs allocated to the worker.")
status: Optional[ActorStatus] = Field(
None, description="The current status of the worker actor."
)
resources: TrainResources = Field(
description="The resources allocated to this Train worker."
)
log_file_path: Optional[str] = Field(
None, description="The path to the log file for the Train worker."
)
@DeveloperAPI
class MemoryInfo(BaseModel):
"""Memory usage information for a process."""
rss: int = Field(description="The resident set size (RSS) memory usage in bytes.")
vms: int = Field(description="The virtual memory size (VMS) usage in bytes.")
pfaults: Optional[int] = Field(None, description="The number of page faults.")
pageins: Optional[int] = Field(None, description="The number of page-ins.")
@DeveloperAPI
class ProcessStats(BaseModel):
"""CPU and memory statistics for a process."""
cpuPercent: float = Field(description="The percentage of CPU usage.")
mem: Optional[List[int]] = Field(
None,
description="Memory statistics, including total memory, free memory, "
"and memory usage ratio.",
)
memoryInfo: MemoryInfo = Field(description="Detailed memory usage information.")
class ProcessGPUUsage(BaseModel):
"""GPU usage statistics for a process."""
pid: int = Field(description="The process ID.")
gpuMemoryUsage: int = Field(description="The GPU memory usage in bytes.")
@DeveloperAPI
class GPUStats(BaseModel):
"""Statistics for a GPU."""
uuid: str = Field(description="The unique identifier of the GPU.")
index: int = Field(description="The index of the GPU.")
name: str = Field(description="The name of the GPU.")
utilizationGpu: Optional[float] = Field(
None, description="The percentage utilization of the GPU."
)
memoryUsed: float = Field(description="The amount of GPU memory used in bytes.")
memoryTotal: float = Field(description="The total amount of GPU memory in bytes.")
processInfo: ProcessGPUUsage = Field(
description="GPU usage statistics for the associated process."
)
@DeveloperAPI
class DecoratedTrainWorker(TrainWorker):
"""Detailed metadata for a Ray Train worker, including process and GPU stats."""
processStats: Optional[ProcessStats] = Field(
None, description="CPU and memory statistics for the worker process."
)
gpus: List[GPUStats] = Field(
default_factory=list,
description="A list of GPUs used by the worker process,"
" with detailed statistics.",
)
@DeveloperAPI
class TrainRunAttempt(BaseModel):
"""Metadata for an individual attempt to execute a Train run."""
run_id: str = Field(description="Unique identifier for the parent Train run.")
attempt_id: str = Field(
description="Unique identifier for this specific Train run attempt."
)
status: RunAttemptStatus = Field(
description="The current execution status of the Train run attempt."
)
status_detail: Optional[str] = Field(
None,
description="Additional details about the status,"
" including error messages if applicable.",
)
start_time_ns: int = Field(
description="The UNIX timestamp (in nanoseconds)"
" when the Train run attempt started."
)
end_time_ns: Optional[int] = Field(
None,
description="The UNIX timestamp (in nanoseconds)"
" when the Train run attempt ended. "
"If null, the attempt is still ongoing.",
)
resources: List[TrainResources] = Field(
description="The resources (e.g., CPU, GPU) allocated to the Train run attempt."
)
workers: List[TrainWorker] = Field(
description="List of Train workers participating in this attempt, "
"sorted by global ranks."
)
@DeveloperAPI
class DecoratedTrainRunAttempt(TrainRunAttempt):
"""Detailed metadata for a Train run attempt, including decorated worker data."""
workers: List[DecoratedTrainWorker] = Field(
description="A list of Train workers with detailed statistics, "
"sorted by global ranks."
)
@DeveloperAPI
class ExecutionOptions(BaseModel):
"""ExecutionOptions for a single Ray Data ingest pipeline."""
resource_limits: Dict[str, Any] = Field(
description="The resource limits applied to the Ray Data execution plan."
)
exclude_resources: Dict[str, Any] = Field(
description="The resources excluded from the Ray Data execution plan "
"(e.g. resources reserved by Ray Train workers)."
)
@field_validator("resource_limits", "exclude_resources", mode="before")
@classmethod
def _sanitize_dict(cls, v):
return _to_json_serializable_value(v)
preserve_order: bool = Field(
description="Whether to preserve the order of outputs across operators."
)
actor_locality_enabled: bool = Field(
description="Whether actor-based locality optimizations are enabled."
)
verbose_progress: bool = Field(
description="Whether verbose progress reporting is enabled."
)
@DeveloperAPI
class DataExecutionOptions(BaseModel):
"""ExecutionOptions for a Ray Train run, split into defaults and per-dataset overrides."""
default: ExecutionOptions = Field(
description="Execution options applied to any dataset without a per-dataset override."
)
per_dataset_execution_options: Dict[str, ExecutionOptions] = Field(
default_factory=dict,
description="Per-dataset execution option overrides, keyed by dataset name.",
)
@DeveloperAPI
class DataConfig(BaseModel):
"""Configuration for dataset splitting and execution options within Ray Train."""
datasets_to_split: Union[Literal["all"], List[str]] = Field(
description="Which datasets to split; either 'all' or a list of dataset names."
)
execution_options: Optional[Dict] = Field(
default=None,
deprecated="DEPRECATED: Use data_execution_options instead.",
)
data_execution_options: DataExecutionOptions = Field(
description="Data execution options"
)
enable_shard_locality: bool = Field(
description="Whether to enable shard locality optimization."
)
@DeveloperAPI
class ScalingConfig(BaseModel):
"""Scaling config for a Train run."""
num_workers: Union[int, Tuple[int, int]] = Field(
description="The number of workers for the Train run."
)
use_gpu: bool = Field(description="Whether to use GPUs for the Train run.")
resources_per_worker: Optional[Dict[str, float]] = Field(
None, description="The resources per worker for a Train run."
)
placement_strategy: str = Field(
description="The placement strategy for the Train run."
)
accelerator_type: Optional[str] = Field(
None, description="The accelerator type for the Train run."
)
use_tpu: bool = Field(description="Whether to use TPUs for the Train run.")
topology: Optional[str] = Field(None, description="The topology for the Train run.")
bundle_label_selector: Optional[
Union[Dict[str, str], List[Dict[str, str]]]
] = Field(None, description="The bundle label selector for the Train run.")
@DeveloperAPI
class FailureConfig(BaseModel):
"""Failure config for a Train run."""
max_failures: int = Field(
description="The maximum number of failures for a Train run."
)
controller_failure_limit: int = Field(
description="The maximum number of controller failures to tolerate."
)
@DeveloperAPI
class CheckpointConfig(BaseModel):
"""Checkpoint config for a Train run."""
num_to_keep: Optional[int] = Field(
None,
description="The number of most recent checkpoints to keep. Older checkpoints may be deleted.",
)
checkpoint_score_attribute: Optional[str] = Field(
None,
description="Attribute used to score and rank checkpoints; can be a metric key or attribute.",
)
checkpoint_score_order: Literal["max", "min"] = Field(
description="Order to rank checkpoint scores, 'max' for higher-is-better, 'min' for lower-is-better.",
)
@DeveloperAPI
class RunConfig(BaseModel):
"""Run configuration parameters for a Train run, encompassing failure,
runtime environment, checkpoint settings, and storage path."""
name: str = Field(description="The name of the Train run.")
failure_config: FailureConfig = Field(
description="The failure config for a Train run."
)
worker_runtime_env: Dict[str, Any] = Field(
description="The worker runtime env for a Train run."
)
@field_validator("worker_runtime_env", mode="before")
@classmethod
def _sanitize_worker_runtime_env(cls, v):
return _to_json_serializable_value(v)
checkpoint_config: CheckpointConfig = Field(
description="The checkpoint config for a Train run."
)
storage_path: str = Field(description="The storage path for a Train run.")
storage_filesystem: Optional[str] = Field(
None, description="The storage filesystem for a Train run."
)
@field_validator("storage_filesystem", mode="before")
@classmethod
def _sanitize_storage_filesystem(cls, v):
return _to_json_serializable_value(v)
@DeveloperAPI
class BackendConfig(BaseModel):
"""Backend config for a Train run."""
framework: Optional[TrainingFramework] = Field(
None, description="The training framework for this backend config."
)
config: Dict[str, Any] = Field(
description="Training framework-specific configuration fields."
)
@field_validator("config", mode="before")
@classmethod
def _sanitize_config(cls, v):
return _to_json_serializable_value(v)
@DeveloperAPI
class RunSettings(BaseModel):
"""Settings for a Train run, primarily consisting of configs set before a train run starts.
This includes the train loop config, backend config, scaling config, dataset configs,
and runtime configuration.
"""
train_loop_config: Optional[Dict] = Field(
None, description="The user defined train loop config for a Train run."
)
@field_validator("train_loop_config", mode="before")
@classmethod
def _sanitize_train_loop_config(cls, v):
return _to_json_serializable_value(v)
backend_config: BackendConfig = Field(
description="The backend config for a Train run. Can vary with the framework (e.g. TorchConfig)"
)
scaling_config: ScalingConfig = Field(
description="The scaling config for this Train run."
)
datasets: List[str] = Field(
description="A list of dataset names for a Train run.",
)
data_config: DataConfig = Field(
description="The data config for a Train run.",
)
run_config: RunConfig = Field(
description="Run configuration for this Train run, including failure, runtime environment, checkpoint settings, and storage path."
)
@DeveloperAPI
class TrainRun(BaseModel):
"""Metadata for a Ray Train run, including its details and status."""
id: str = Field(description="Unique identifier for the Train run.")
name: str = Field(description="Human-readable name assigned to the Train run.")
job_id: str = Field(description="The Ray Job ID associated with this Train run.")
controller_actor_id: str = Field(
description="Unique ID of the actor managing the Train run."
)
status: RunStatus = Field(
description="The current execution status of the Train run."
)
status_detail: Optional[str] = Field(
None,
description="Additional details about the current status, "
"including error messages if applicable.",
)
start_time_ns: int = Field(
description="The UNIX timestamp (in nanoseconds) when the Train run started."
)
end_time_ns: Optional[int] = Field(
None,
description="The UNIX timestamp (in nanoseconds) when the Train run ended. "
"If null, the run is still in progress.",
)
controller_log_file_path: Optional[str] = Field(
None, description="The path to the log file for the Train run controller."
)
framework_versions: Dict[str, str] = Field(
description="The relevant framework versions for this Train run,"
"including the Ray version and training framework version."
)
run_settings: RunSettings = Field(
description="The run settings for this Train run, including train loop config, "
"backend config, scaling config, dataset details, and runtime configuration."
)
@DeveloperAPI
class DecoratedTrainRun(TrainRun):
"""Detailed metadata for a Ray Train run, including attempts and job details."""
attempts: List[DecoratedTrainRunAttempt] = Field(
description="A list of attempts made to execute the Train run."
)
job_details: Optional[JobDetails] = Field(
None,
description="Detailed information about the job that initiated this Train run.",
)
@DeveloperAPI
class TrainRunsResponse(BaseModel):
"""Response containing a list of decorated Train runs."""
train_runs: List[DecoratedTrainRun] = Field(
description="A list of Train runs with detailed metadata."
)
@@ -0,0 +1,301 @@
import copy
import logging
import os
import threading
import time
from collections import OrderedDict, defaultdict
from typing import Dict, Optional
import ray
from ray._private import ray_constants
from ray._private.event.export_event_logger import (
EventLogType,
check_export_api_enabled,
get_export_event_logger,
)
from ray.actor import ActorHandle
from ray.train.v2._internal.constants import (
CONTROLLERS_TO_POLL_PER_ITERATION,
DEFAULT_ENABLE_STATE_ACTOR_RECONCILIATION,
DEFAULT_STATE_ACTOR_RECONCILIATION_INTERVAL_S,
ENABLE_STATE_ACTOR_RECONCILIATION_ENV_VAR,
GET_ACTOR_TIMEOUT_S,
STATE_ACTOR_RECONCILIATION_INTERVAL_S_ENV_VAR,
)
from ray.train.v2._internal.state.schema import (
TrainRun,
TrainRunAttempt,
)
from ray.train.v2._internal.state.util import (
is_actor_alive,
update_train_run_aborted,
update_train_run_attempt_aborted,
)
from ray.train.v2._internal.util import time_monotonic
logger = logging.getLogger(__name__)
class TrainStateActor:
def __init__(
self,
# TODO: group into single config if we need to do similar polling elsewhere
enable_state_actor_reconciliation: bool = False,
reconciliation_interval_s: float = 30,
get_actor_timeout_s: int = GET_ACTOR_TIMEOUT_S,
controllers_to_poll_per_iteration: int = CONTROLLERS_TO_POLL_PER_ITERATION,
):
# NOTE: All runs and attempts are stored in memory.
# This may be a memory issue for large runs.
# TODO: consider cleaning up runs over time.
self._runs: Dict[str, TrainRun] = OrderedDict()
# {run_id: {attempt_id: TrainRunAttempt}}
self._run_attempts: Dict[str, OrderedDict[str, TrainRunAttempt]] = defaultdict(
OrderedDict
)
(
self._export_logger,
self._is_train_run_export_api_enabled,
self._is_train_run_attempt_export_api_enabled,
) = self._init_export_logger()
# TODO: consider row level locking if loop takes too long.
self._runs_lock = threading.RLock()
self._run_attempts_lock = threading.RLock()
# Set env vars related to reconciling train run/attempt state.
if enable_state_actor_reconciliation:
self._reconciliation_interval_s = reconciliation_interval_s
self._controllers_to_poll_per_iteration = controllers_to_poll_per_iteration
self._get_actor_timeout_s = get_actor_timeout_s
self._start_run_state_reconciliation_thread()
def _abort_live_runs_with_dead_controllers(
self, last_poll_run_id: Optional[str]
) -> str:
aborted_run_ids = []
with self._runs_lock:
runs = list(self._runs.values())
# Start iterating from poll index.
starting_poll_index = 0
if last_poll_run_id is not None:
for poll_index, run in enumerate(runs):
if run.id == last_poll_run_id:
starting_poll_index = (poll_index + 1) % len(runs)
break
# Abort runs.
num_polled_runs = 0
poll_index = starting_poll_index
while (
poll_index < starting_poll_index + len(runs)
and num_polled_runs < self._controllers_to_poll_per_iteration
):
run = runs[poll_index % len(runs)]
poll_index += 1
last_poll_run_id = run.id
if run.status.is_terminal():
continue
try:
if not is_actor_alive(
run.controller_actor_id, self._get_actor_timeout_s
):
update_train_run_aborted(run, False)
self.create_or_update_train_run(run)
aborted_run_ids.append(run.id)
except ray.util.state.exception.RayStateApiException:
logger.exception(
"State API unavailable when checking if actor is alive. "
"Will check again on next poll."
)
num_polled_runs += 1
# Abort run attempts.
with self._run_attempts_lock:
for run_id in aborted_run_ids:
latest_run_attempt = self._get_latest_run_attempt(run_id)
if latest_run_attempt and not latest_run_attempt.status.is_terminal():
update_train_run_attempt_aborted(latest_run_attempt, False)
self.create_or_update_train_run_attempt(latest_run_attempt)
return last_poll_run_id
def _start_run_state_reconciliation_thread(self) -> None:
def _reconciliation_loop():
last_poll_run_id = None
latest_poll_time = float("-inf")
while True:
# Wait for the poll interval to elapse.
time_since_last_poll = time_monotonic() - latest_poll_time
if time_since_last_poll < self._reconciliation_interval_s:
remaining_time = (
self._reconciliation_interval_s - time_since_last_poll
)
time.sleep(remaining_time)
last_poll_run_id = self._abort_live_runs_with_dead_controllers(
last_poll_run_id
)
latest_poll_time = time_monotonic()
threading.Thread(target=_reconciliation_loop, daemon=True).start()
def _get_latest_run_attempt(self, run_id: str) -> Optional[TrainRunAttempt]:
with self._run_attempts_lock:
# NOTE: run_attempts is OrderedDict from attempt_id to TrainRunAttempt.
run_attempts = self._run_attempts.get(run_id, {})
if not run_attempts:
return None
return next(reversed(run_attempts.values()))
def create_or_update_train_run(self, run: TrainRun) -> None:
with self._runs_lock:
self._runs[run.id] = run
run_copy = copy.deepcopy(run)
self._maybe_export_train_run(run_copy)
def create_or_update_train_run_attempt(self, run_attempt: TrainRunAttempt) -> None:
with self._run_attempts_lock:
self._run_attempts[run_attempt.run_id][run_attempt.attempt_id] = run_attempt
run_attempt_copy = copy.deepcopy(run_attempt)
self._maybe_export_train_run_attempt(run_attempt_copy)
def get_train_runs(self) -> Dict[str, TrainRun]:
with self._runs_lock:
return self._runs
def get_train_run_attempts(self) -> Dict[str, Dict[str, TrainRunAttempt]]:
with self._run_attempts_lock:
return self._run_attempts
# ============================
# Export API
# ============================
def is_export_api_enabled(self) -> bool:
return self._export_logger is not None
def _init_export_logger(self) -> tuple[Optional[logging.Logger], bool, bool]:
"""Initialize the export logger and check if the export API is enabled.
Returns:
A tuple containing:
- The export logger (or None if export API is not enabled).
- A boolean indicating if the export API is enabled for train runs.
- A boolean indicating if the export API is enabled for train run attempts.
"""
# Proto schemas should be imported within the scope of TrainStateActor to
# prevent serialization errors.
from ray.core.generated.export_event_pb2 import ExportEvent
is_train_run_export_api_enabled = check_export_api_enabled(
ExportEvent.SourceType.EXPORT_TRAIN_RUN
)
is_train_run_attempt_export_api_enabled = check_export_api_enabled(
ExportEvent.SourceType.EXPORT_TRAIN_RUN_ATTEMPT
)
export_api_enabled = (
is_train_run_export_api_enabled or is_train_run_attempt_export_api_enabled
)
if not export_api_enabled:
return None, False, False
log_directory = os.path.join(
ray._private.worker._global_node.get_session_dir_path(), "logs"
)
logger = None
try:
logger = get_export_event_logger(
EventLogType.TRAIN_STATE,
log_directory,
)
except Exception:
logger.exception(
"Unable to initialize the export event logger, so no Train export "
"events will be written."
)
if logger is None:
return None, False, False
return (
logger,
is_train_run_export_api_enabled,
is_train_run_attempt_export_api_enabled,
)
def _maybe_export_train_run(self, run: TrainRun) -> None:
if not self._is_train_run_export_api_enabled:
return
from ray.train.v2._internal.state.export import train_run_to_proto
run_proto = train_run_to_proto(run)
self._export_logger.send_event(run_proto)
def _maybe_export_train_run_attempt(self, run_attempt: TrainRunAttempt) -> None:
if not self._is_train_run_attempt_export_api_enabled:
return
from ray.train.v2._internal.state.export import train_run_attempt_to_proto
run_attempt_proto = train_run_attempt_to_proto(run_attempt)
self._export_logger.send_event(run_attempt_proto)
TRAIN_STATE_ACTOR_NAME = "train_v2_state_actor"
TRAIN_STATE_ACTOR_NAMESPACE = "_train_state_actor"
_state_actor_lock: threading.RLock = threading.RLock()
def get_or_create_state_actor() -> ActorHandle:
"""Get or create the Ray Train state actor singleton.
This is a long-living, detached actor living on the head node
that gets initialized when the first Train run happens on the
Ray cluster.
"""
with _state_actor_lock:
state_actor = (
ray.remote(TrainStateActor)
.options(
num_cpus=0,
name=TRAIN_STATE_ACTOR_NAME,
namespace=TRAIN_STATE_ACTOR_NAMESPACE,
get_if_exists=True,
lifetime="detached",
resources={"node:__internal_head__": 0.001},
# Escape from the parent's placement group
scheduling_strategy="DEFAULT",
max_restarts=-1,
max_task_retries=-1,
)
.remote(
enable_state_actor_reconciliation=ray_constants.env_bool(
ENABLE_STATE_ACTOR_RECONCILIATION_ENV_VAR,
DEFAULT_ENABLE_STATE_ACTOR_RECONCILIATION,
),
reconciliation_interval_s=float(
os.getenv(
STATE_ACTOR_RECONCILIATION_INTERVAL_S_ENV_VAR,
DEFAULT_STATE_ACTOR_RECONCILIATION_INTERVAL_S,
)
),
)
)
return state_actor
def get_state_actor() -> Optional[ActorHandle]:
"""Get the `TrainStateActor` if exists, otherwise return None."""
try:
return ray.get_actor(
name=TRAIN_STATE_ACTOR_NAME,
namespace=TRAIN_STATE_ACTOR_NAMESPACE,
)
except ValueError:
return None
@@ -0,0 +1,326 @@
import logging
from collections import defaultdict
from typing import Dict, List, Optional
import ray
from ray.actor import ActorHandle
from ray.train import BackendConfig
from ray.train._internal.data_config import DataConfig
from ray.train.v2._internal.execution.context import DistributedContext
from ray.train.v2._internal.execution.scaling_policy.scaling_policy import (
ResizeDecision,
)
from ray.train.v2._internal.execution.worker_group import ActorMetadata, Worker
from ray.train.v2._internal.state.schema import (
ActorStatus,
BackendConfig as BackendConfigSchema,
CheckpointConfig as CheckpointConfigSchema,
FailureConfig as FailureConfigSchema,
RunAttemptStatus,
RunConfig as RunConfigSchema,
RunSettings,
RunStatus,
ScalingConfig as ScalingConfigSchema,
TrainResources,
TrainRun,
TrainRunAttempt,
TrainWorker,
)
from ray.train.v2._internal.state.state_actor import get_or_create_state_actor
from ray.train.v2._internal.state.util import (
construct_data_config,
current_time_ns,
mark_workers_dead,
update_train_run_aborted,
update_train_run_attempt_aborted,
)
from ray.train.v2._internal.util import TrainingFramework
from ray.train.v2.api.config import RunConfig, ScalingConfig
logger = logging.getLogger(__name__)
class TrainStateManager:
"""Manages the state of a train run and run attempts."""
def __init__(self) -> None:
self._state_actor = get_or_create_state_actor()
# NOTE: All runs and attempts are stored in memory.
# This may be a memory issue for large runs.
self._runs: Dict[str, TrainRun] = {}
# {run_id: {attempt_id: TrainRunAttempt}}
self._run_attempts: Dict[str, Dict[str, TrainRunAttempt]] = defaultdict(dict)
def create_train_run(
self,
id: str,
name: str,
job_id: str,
controller_actor_id: str,
controller_log_file_path: str,
run_config: RunConfig,
train_loop_config: Optional[Dict],
scaling_config: ScalingConfig,
backend_config: BackendConfig,
datasets: Dict[str, ray.data.Dataset],
dataset_config: DataConfig,
) -> None:
run_config_schema = RunConfigSchema(
name=run_config.name,
failure_config=FailureConfigSchema(
max_failures=run_config.failure_config.max_failures,
controller_failure_limit=run_config.failure_config.controller_failure_limit,
),
worker_runtime_env=run_config.worker_runtime_env,
checkpoint_config=CheckpointConfigSchema(
num_to_keep=run_config.checkpoint_config.num_to_keep,
checkpoint_score_attribute=run_config.checkpoint_config.checkpoint_score_attribute,
checkpoint_score_order=run_config.checkpoint_config.checkpoint_score_order,
),
storage_path=run_config.storage_path,
storage_filesystem=run_config.storage_filesystem,
)
scaling_config_schema = ScalingConfigSchema(
num_workers=scaling_config.num_workers,
use_gpu=scaling_config.use_gpu,
resources_per_worker=scaling_config.resources_per_worker,
placement_strategy=scaling_config.placement_strategy,
accelerator_type=scaling_config.accelerator_type,
use_tpu=scaling_config.use_tpu,
topology=scaling_config.topology,
bundle_label_selector=scaling_config.label_selector,
)
backend_config_schema = BackendConfigSchema(
framework=backend_config.framework,
config=backend_config.to_dict(),
)
run_settings = RunSettings(
train_loop_config=train_loop_config,
backend_config=backend_config_schema,
scaling_config=scaling_config_schema,
datasets=list(datasets.keys()),
data_config=construct_data_config(dataset_config),
run_config=run_config_schema,
)
run = TrainRun(
id=id,
name=name,
job_id=job_id,
status=RunStatus.INITIALIZING,
status_detail=None,
controller_actor_id=controller_actor_id,
start_time_ns=current_time_ns(),
end_time_ns=None,
controller_log_file_path=controller_log_file_path,
framework_versions={"ray": ray.__version__},
run_settings=run_settings,
)
self._runs[run.id] = run
# Block so the initial run state isn't lost if the controller exits
# right after. Without this, the .remote() task could still be in the
# caller's outbound queue when the controller dies, leaving the state
# actor with no record of the run.
self._create_or_update_train_run(run, block=True)
def update_train_run_scheduling(
self,
run_id: str,
resize_decision: Optional[ResizeDecision] = None,
) -> None:
if resize_decision is not None:
status_detail = _get_scheduling_status_detail(
resize_decision.num_workers, resize_decision.resources_per_worker
)
else:
status_detail = None
run = self._runs[run_id]
run.status = RunStatus.SCHEDULING
run.status_detail = status_detail
self._create_or_update_train_run(run)
def update_train_run_running(
self,
run_id: str,
) -> None:
run = self._runs[run_id]
run.status = RunStatus.RUNNING
run.status_detail = None
self._create_or_update_train_run(run)
def update_train_run_restarting(
self,
run_id: str,
) -> None:
run = self._runs[run_id]
run.status = RunStatus.RESTARTING
run.status_detail = None
self._create_or_update_train_run(run)
def update_train_run_resizing(
self,
run_id: str,
) -> None:
run = self._runs[run_id]
run.status = RunStatus.RESIZING
run.status_detail = None
self._create_or_update_train_run(run)
def update_train_run_finished(
self,
run_id: str,
):
run = self._runs[run_id]
run.status = RunStatus.FINISHED
run.status_detail = None
run.end_time_ns = current_time_ns()
# Block on terminal status so the final state isn't lost if the controller exits right after.
self._create_or_update_train_run(run, block=True)
def update_train_run_errored(
self,
run_id: str,
status_detail: str,
):
run = self._runs[run_id]
run.status = RunStatus.ERRORED
run.status_detail = status_detail
run.end_time_ns = current_time_ns()
# Block on terminal status so the final state isn't lost if the controller exits right after.
self._create_or_update_train_run(run, block=True)
def update_train_run_aborted(
self,
run_id: str,
):
run = self._runs[run_id]
update_train_run_aborted(run=run, graceful=True)
# Block on terminal status so the final state isn't lost if the controller exits right after.
self._create_or_update_train_run(run, block=True)
def update_train_run_framework_versions(
self, run_id: str, framework_versions: Dict[str, str]
):
run = self._runs[run_id]
run.framework_versions = framework_versions
self._create_or_update_train_run(run)
def create_train_run_attempt(
self,
run_id: str,
attempt_id: str,
num_workers: int,
resources_per_worker: Dict[str, float],
) -> None:
status_detail = _get_scheduling_status_detail(num_workers, resources_per_worker)
resources = [
TrainResources(resources=resources_per_worker) for _ in range(num_workers)
]
run_attempt = TrainRunAttempt(
run_id=run_id,
attempt_id=attempt_id,
start_time_ns=current_time_ns(),
status=RunAttemptStatus.PENDING,
status_detail=status_detail,
resources=resources,
workers=[], # Not started yet.
)
self._run_attempts[run_id][attempt_id] = run_attempt
self._create_or_update_train_run_attempt(run_attempt)
def update_train_run_attempt_running(
self, run_id: str, attempt_id: str, workers: List[Worker]
) -> None:
def _convert_worker(worker: Worker) -> TrainWorker:
actor: ActorHandle = worker.actor
distributed_context: DistributedContext = worker.distributed_context
actor_metadata: ActorMetadata = worker.metadata
return TrainWorker(
world_rank=distributed_context.world_rank,
local_rank=distributed_context.local_rank,
node_rank=distributed_context.node_rank,
actor_id=actor._actor_id.hex(),
node_id=actor_metadata.node_id,
node_ip=actor_metadata.node_ip,
pid=actor_metadata.pid,
gpu_ids=actor_metadata.gpu_ids,
status=ActorStatus.ALIVE,
resources=TrainResources(resources=worker.resources),
log_file_path=worker.log_file_path,
)
workers: List[TrainWorker] = [_convert_worker(worker) for worker in workers]
run_attempt = self._run_attempts[run_id][attempt_id]
run_attempt.status = RunAttemptStatus.RUNNING
run_attempt.status_detail = None
run_attempt.workers = workers
self._create_or_update_train_run_attempt(run_attempt)
def update_train_run_attempt_finished(
self,
run_id: str,
attempt_id: str,
):
run_attempt = self._run_attempts[run_id][attempt_id]
run_attempt.status = RunAttemptStatus.FINISHED
run_attempt.status_detail = None
run_attempt.end_time_ns = current_time_ns()
mark_workers_dead(run_attempt)
# Block to avoid case where controller is dead but attempt is not terminal.
self._create_or_update_train_run_attempt(run_attempt, block=True)
def update_train_run_attempt_errored(
self,
run_id: str,
attempt_id: str,
status_detail: str,
):
run_attempt = self._run_attempts[run_id][attempt_id]
run_attempt.status = RunAttemptStatus.ERRORED
run_attempt.status_detail = status_detail
run_attempt.end_time_ns = current_time_ns()
mark_workers_dead(run_attempt)
# Block to avoid case where controller is dead but attempt is not terminal.
self._create_or_update_train_run_attempt(run_attempt, block=True)
def update_train_run_attempt_aborted(
self,
run_id: str,
attempt_id: str,
):
run_attempt = self._run_attempts[run_id][attempt_id]
update_train_run_attempt_aborted(run_attempt=run_attempt, graceful=True)
# Block to avoid case where controller is dead but attempt is not terminal.
self._create_or_update_train_run_attempt(run_attempt, block=True)
def get_train_run_framework(self, run_id: str) -> Optional[TrainingFramework]:
run = self._runs[run_id]
return run.run_settings.backend_config.framework
def _create_or_update_train_run(
self, run: TrainRun, *, block: bool = False
) -> None:
ref = self._state_actor.create_or_update_train_run.remote(run)
if block:
ray.get(ref)
def _create_or_update_train_run_attempt(
self, run_attempt: TrainRunAttempt, *, block: bool = False
) -> None:
ref = self._state_actor.create_or_update_train_run_attempt.remote(run_attempt)
if block:
ray.get(ref)
def _get_scheduling_status_detail(
num_workers: int, resources_per_worker: Dict[str, float]
) -> str:
return f"Scheduling {num_workers} workers, each requiring: {resources_per_worker}."
@@ -0,0 +1,97 @@
import time
from ray.data._internal.execution.interfaces.execution_options import ExecutionOptions
from ray.train._internal.data_config import DataConfig
from ray.train.v2._internal.state.schema import (
ActorStatus,
DataConfig as DataConfigSchema,
DataExecutionOptions,
ExecutionOptions as ExecutionOptionsSchema,
RunAttemptStatus,
RunStatus,
TrainRun,
TrainRunAttempt,
)
from ray.util.state import get_actor
_GRACEFUL_ABORT_STATUS_DETAIL = "Run aborted due to user interrupt (SIGINT)."
_DEAD_CONTROLLER_ABORT_STATUS_DETAIL = (
"Run aborted because the driver process exited unexpectedly."
)
def update_train_run_aborted(run: TrainRun, graceful: bool) -> None:
run.status = RunStatus.ABORTED
if graceful:
run.status_detail = _GRACEFUL_ABORT_STATUS_DETAIL
else:
run.status_detail = _DEAD_CONTROLLER_ABORT_STATUS_DETAIL
run.end_time_ns = current_time_ns()
def update_train_run_attempt_aborted(
run_attempt: TrainRunAttempt, graceful: bool
) -> None:
if graceful:
run_attempt.status_detail = _GRACEFUL_ABORT_STATUS_DETAIL
else:
run_attempt.status_detail = _DEAD_CONTROLLER_ABORT_STATUS_DETAIL
run_attempt.status = RunAttemptStatus.ABORTED
run_attempt.end_time_ns = current_time_ns()
mark_workers_dead(run_attempt)
def mark_workers_dead(run_attempt: TrainRunAttempt) -> None:
for worker in run_attempt.workers:
worker.status = ActorStatus.DEAD
def current_time_ns() -> int:
return time.time_ns()
def is_actor_alive(actor_id: str, timeout: int) -> bool:
"""Returns whether actor is alive."""
actor_state = get_actor(actor_id, timeout=timeout)
return actor_state and actor_state.state != "DEAD"
def construct_data_config(data_config: DataConfig) -> DataConfigSchema:
"""Serialize a user-facing DataConfig into the exportable schema.
Note: This function assumes data_config._execution_options (a defaultdict)
hasn't been read between initialization of the field and this function call.
Any read materializes a dataset key and affects the data config shape,
wrongly capturing a per dataset execution options even if the user only
provided a default.
"""
exec_options = data_config._execution_options
per_dataset_execution_options = {}
if exec_options:
per_dataset_execution_options = {
ds_name: execution_options_to_model(opts)
for ds_name, opts in exec_options.items()
}
return DataConfigSchema(
datasets_to_split=data_config._datasets_to_split,
data_execution_options=DataExecutionOptions(
default=execution_options_to_model(exec_options.default_factory()),
per_dataset_execution_options=per_dataset_execution_options,
),
enable_shard_locality=data_config._enable_shard_locality,
)
def execution_options_to_model(
execution_options: ExecutionOptions,
) -> ExecutionOptionsSchema:
"""Convert a ray.data ExecutionOptions object into the export schema model."""
return ExecutionOptionsSchema(
resource_limits=execution_options.resource_limits.to_resource_dict(),
exclude_resources=execution_options.exclude_resources.to_resource_dict(),
preserve_order=execution_options.preserve_order,
actor_locality_enabled=execution_options.actor_locality_enabled,
verbose_progress=execution_options.verbose_progress,
)
+369
View File
@@ -0,0 +1,369 @@
import asyncio
import contextlib
import functools
import logging
import threading
import time
import traceback
from datetime import datetime
from enum import Enum
from typing import (
Any,
Callable,
ContextManager,
Dict,
Generator,
Generic,
Iterator,
List,
Optional,
TypeVar,
Union,
)
import ray
from ray.train._internal.utils import count_required_parameters
from ray.train.v2._internal.exceptions import UserExceptionWithTraceback
logger = logging.getLogger(__name__)
T = TypeVar("T")
def bundle_to_remote_args(bundle: dict) -> dict:
"""Convert a bundle of resources to Ray actor/task arguments.
>>> bundle_to_remote_args({"GPU": 1, "memory": 1, "custom": 0.1})
{'num_cpus': 0, 'num_gpus': 1, 'memory': 1, 'resources': {'custom': 0.1}}
"""
bundle = bundle.copy()
args = {
"num_cpus": bundle.pop("CPU", 0),
"num_gpus": bundle.pop("GPU", 0),
"memory": bundle.pop("memory", 0),
}
if bundle:
args["resources"] = bundle
return args
def construct_train_func(
train_func: Union[Callable[[], T], Callable[[Dict[str, Any]], T]],
config: Optional[Dict[str, Any]],
train_func_context: ContextManager,
fn_arg_name: Optional[str] = "train_loop_per_worker",
) -> Callable[[], T]:
"""Validates and constructs the training function to execute.
Args:
train_func: The training function to execute.
This can either take in no arguments or a ``config`` dict.
config: Configurations to pass into ``train_func``. If None then an
empty Dict will be created.
train_func_context: Context manager for user's `train_func`, which executes
backend-specific logic before and after the training function.
fn_arg_name: The name of training function to use for error messages.
Returns:
A valid training function.
Raises:
ValueError: if the input ``train_func`` is invalid.
"""
num_required_params = count_required_parameters(train_func)
if num_required_params > 1:
err_msg = (
f"{fn_arg_name} should take in 0 or 1 required arguments, but it accepts "
f"{num_required_params} required arguments instead."
)
raise ValueError(err_msg)
if num_required_params == 1:
config = config or {}
@functools.wraps(train_func)
def train_fn():
with train_func_context():
return train_func(config)
else: # num_params == 0
@functools.wraps(train_func)
def train_fn():
with train_func_context():
return train_func()
return train_fn
class TrainingFramework(Enum):
TORCH = "torch"
JAX = "jax"
TENSORFLOW = "tensorflow"
XGBOOST = "xgboost"
LIGHTGBM = "lightgbm"
def module_names(self) -> tuple[str, ...]:
"""Returns the relevant module names for the training framework.
These module names are used by Train state version collection (see
`_get_framework_version`) to gather versions of key framework-related packages.
Note: If adding a new module, make sure to use the module name rather than
the distribution name. (e.g. sklearn instead of scikit-learn)
"""
if self is TrainingFramework.TORCH:
return ("torch",)
if self is TrainingFramework.JAX:
return ("jax", "jaxlib")
if self is TrainingFramework.TENSORFLOW:
return ("tensorflow", "keras")
if self is TrainingFramework.XGBOOST:
return ("xgboost",)
if self is TrainingFramework.LIGHTGBM:
return ("lightgbm",)
return (self.value,)
class ObjectRefWrapper(Generic[T]):
"""Thin wrapper around ray.put to manually control dereferencing."""
def __init__(self, obj: T):
self._ref = ray.put(obj)
def get(self) -> T:
return ray.get(self._ref)
def date_str(include_ms: bool = False):
pattern = "%Y-%m-%d_%H-%M-%S"
if include_ms:
pattern += ".%f"
return datetime.today().strftime(pattern)
def time_monotonic():
return time.monotonic()
def _copy_doc(copy_func):
def wrapped(func):
func.__doc__ = copy_func.__doc__
return func
return wrapped
@contextlib.contextmanager
def invoke_context_managers(
context_managers: List[ContextManager],
) -> Generator[None, None, None]:
"""
Utility to invoke a list of context managers and yield sequentially.
Args:
context_managers: List of context managers to invoke.
"""
with contextlib.ExitStack() as stack:
for context_manager in context_managers:
stack.enter_context(context_manager())
yield
def get_module_name(obj: object) -> str:
"""Returns the full module name of the given object, including its qualified name.
Args:
obj: The object (class, function, etc.) whose module name is required.
Returns:
Full module and qualified name as a string.
"""
return f"{obj.__module__}.{obj.__qualname__}"
def get_callable_name(fn: Callable) -> str:
"""Returns a readable name for any callable.
Args:
fn: The callable to extract a name from.
Returns:
A human-readable name for the callable.
Examples:
>>> get_callable_name(lambda x: x)
'<lambda>'
>>> def foo(a, b): pass
>>> get_callable_name(foo)
'foo'
>>> from functools import partial
>>> bar = partial(partial(foo, a=1), b=2)
>>> get_callable_name(bar)
'foo'
>>> class Dummy:
... def __call__(self, a, b): pass
>>> get_callable_name(Dummy())
'Dummy'
"""
if isinstance(fn, functools.partial):
return get_callable_name(fn.func)
# Use __name__ for regular functions and lambdas
if hasattr(fn, "__name__"):
return fn.__name__
# Fallback to the class name for objects that implement __call__
return fn.__class__.__name__
def construct_user_exception_with_traceback(
e: BaseException, exclude_frames: int = 0
) -> UserExceptionWithTraceback:
"""Construct a UserExceptionWithTraceback from a base exception.
Args:
e: The base exception to construct a UserExceptionWithTraceback from.
exclude_frames: The number of frames to exclude from the beginnning of
the traceback.
Returns:
A UserExceptionWithTraceback object.
"""
# TODO(justinvyu): This is brittle and may break if the call stack
# changes. Figure out a more robust way to exclude these frames.
exc_traceback_str = traceback.format_exc(
limit=-(len(traceback.extract_tb(e.__traceback__)) - exclude_frames)
)
logger.error(f"Error in training function:\n{exc_traceback_str}")
return UserExceptionWithTraceback(e, traceback_str=exc_traceback_str)
def _in_ray_train_worker() -> bool:
"""Check if the current process is a Ray Train V2 worker."""
from ray.train.v2._internal.execution.train_fn_utils import get_train_fn_utils
try:
get_train_fn_utils()
return True
except RuntimeError:
return False
def requires_train_worker(raise_in_tune_session: bool = False) -> Callable:
"""Check that the caller is a Ray Train worker spawned by Ray Train,
with access to training function utilities.
Args:
raise_in_tune_session: Whether to raise a specific error message if the caller
is in a Tune session. If True, will raise a DeprecationWarning.
Returns:
A decorator that performs this check, which raises an error if the caller
is not a Ray Train worker.
"""
def _wrap(fn: Callable) -> Callable:
@functools.wraps(fn)
def _wrapped_fn(*args, **kwargs):
from ray.tune.trainable.trainable_fn_utils import _in_tune_session
if raise_in_tune_session and _in_tune_session():
raise DeprecationWarning(
f"`ray.train.{fn.__name__}` is deprecated when running in a function "
"passed to Ray Tune. Please use the equivalent `ray.tune` API instead. "
"See this issue for more context: "
"https://github.com/ray-project/ray/issues/49454"
)
if not _in_ray_train_worker():
raise RuntimeError(
f"`{fn.__name__}` cannot be used outside of a Ray Train training function. "
"You are calling this API from the driver or another non-training process. "
"These utilities are only available within a function launched by `trainer.fit()`."
)
return fn(*args, **kwargs)
return _wrapped_fn
return _wrap
async def wait_with_logging(
condition: asyncio.Condition,
predicate: Optional[Callable[[], bool]] = None,
generate_warning_message: Optional[Callable[[], str]] = None,
warn_interval_s: float = 60,
timeout_s: Optional[float] = None,
):
"""Waits for condition to be notified, logging warnings and eventually timing out.
You must acquire the condition before calling this function.
Args:
condition: The condition to wait for.
predicate: Wait until this predicate is True. If None, wait until the condition
is notified.
generate_warning_message: A function that generates the warning message to log.
If None, no warning is logged.
warn_interval_s: The interval in seconds to log a warning.
timeout_s: The timeout in seconds. Defaults to``None`` to not time out.
"""
async def _wait_loop():
while True:
try:
await asyncio.wait_for(
condition.wait()
if predicate is None
else condition.wait_for(predicate),
timeout=warn_interval_s,
)
return
# asyncio.wait_for() raises `asyncio.TimeoutError` for asyncio<=3.10
# and raises `TimeoutError` for asyncio>=3.11
# https://docs.python.org/3/library/asyncio-task.html#asyncio.wait_for
except (asyncio.TimeoutError, TimeoutError):
if generate_warning_message is not None:
warning_message = generate_warning_message()
logger.warning(warning_message)
await asyncio.wait_for(
_wait_loop(),
timeout=timeout_s,
)
@contextlib.contextmanager
def context_watchdog(fn: Callable, *args: Any) -> Iterator[None]:
"""Run a function in a background thread for the duration of the context.
The function is started in a daemon thread on entry. On exit, a
threading.Event is set to signal the thread to stop. The function is
responsible for checking the event and returning promptly once it is set.
Args:
fn: A function whose first argument is a threading.Event stop signal.
The function should return when stop_event.is_set() or
stop_event.wait(...) returns True.
*args: Additional arguments forwarded to fn after the stop event.
Yields:
None: Control is yielded to the caller while the watchdog thread runs.
"""
stop_event = threading.Event()
thread = threading.Thread(
target=fn,
args=(stop_event, *args),
daemon=True, # thread will end even if the finally is bypassed by an abnormal exit
)
thread.start()
try:
yield
finally:
stop_event.set()
thread.join()